aiobmsble 0.2.0__tar.gz → 0.2.2__tar.gz

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 (49) hide show
  1. {aiobmsble-0.2.0/aiobmsble.egg-info → aiobmsble-0.2.2}/PKG-INFO +3 -2
  2. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/aiobmsble/__init__.py +5 -1
  3. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/aiobmsble/__main__.py +5 -1
  4. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/aiobmsble/basebms.py +10 -1
  5. aiobmsble-0.2.2/aiobmsble/bms/__init__.py +5 -0
  6. aiobmsble-0.2.2/aiobmsble/bms/abc_bms.py +168 -0
  7. aiobmsble-0.2.2/aiobmsble/bms/ant_bms.py +200 -0
  8. aiobmsble-0.2.2/aiobmsble/bms/braunpwr_bms.py +171 -0
  9. aiobmsble-0.2.2/aiobmsble/bms/cbtpwr_bms.py +172 -0
  10. aiobmsble-0.2.2/aiobmsble/bms/cbtpwr_vb_bms.py +188 -0
  11. aiobmsble-0.2.2/aiobmsble/bms/daly_bms.py +168 -0
  12. aiobmsble-0.2.2/aiobmsble/bms/dpwrcore_bms.py +211 -0
  13. aiobmsble-0.2.2/aiobmsble/bms/dummy_bms.py +93 -0
  14. aiobmsble-0.2.2/aiobmsble/bms/ecoworthy_bms.py +155 -0
  15. aiobmsble-0.2.2/aiobmsble/bms/ective_bms.py +181 -0
  16. aiobmsble-0.2.2/aiobmsble/bms/ej_bms.py +237 -0
  17. aiobmsble-0.2.2/aiobmsble/bms/felicity_bms.py +143 -0
  18. aiobmsble-0.2.2/aiobmsble/bms/jbd_bms.py +207 -0
  19. aiobmsble-0.2.2/aiobmsble/bms/jikong_bms.py +305 -0
  20. aiobmsble-0.2.2/aiobmsble/bms/neey_bms.py +218 -0
  21. aiobmsble-0.2.2/aiobmsble/bms/ogt_bms.py +218 -0
  22. aiobmsble-0.2.2/aiobmsble/bms/pro_bms.py +148 -0
  23. aiobmsble-0.2.2/aiobmsble/bms/redodo_bms.py +131 -0
  24. aiobmsble-0.2.2/aiobmsble/bms/renogy_bms.py +152 -0
  25. aiobmsble-0.2.2/aiobmsble/bms/renogy_pro_bms.py +109 -0
  26. aiobmsble-0.2.2/aiobmsble/bms/roypow_bms.py +190 -0
  27. aiobmsble-0.2.2/aiobmsble/bms/seplos_bms.py +249 -0
  28. aiobmsble-0.2.2/aiobmsble/bms/seplos_v2_bms.py +209 -0
  29. aiobmsble-0.2.2/aiobmsble/bms/tdt_bms.py +203 -0
  30. aiobmsble-0.2.2/aiobmsble/bms/tianpwr_bms.py +142 -0
  31. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/aiobmsble/utils.py +16 -6
  32. {aiobmsble-0.2.0 → aiobmsble-0.2.2/aiobmsble.egg-info}/PKG-INFO +3 -2
  33. aiobmsble-0.2.2/aiobmsble.egg-info/SOURCES.txt +46 -0
  34. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/aiobmsble.egg-info/requires.txt +2 -1
  35. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/pyproject.toml +4 -9
  36. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/tests/test_basebms.py +9 -11
  37. aiobmsble-0.2.2/tests/test_fuzzing.py +61 -0
  38. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/tests/test_plugins.py +4 -18
  39. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/tests/test_utils.py +4 -5
  40. aiobmsble-0.2.0/aiobmsble.egg-info/SOURCES.txt +0 -19
  41. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/LICENSE +0 -0
  42. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/MANIFEST.in +0 -0
  43. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/README.md +0 -0
  44. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/aiobmsble.egg-info/dependency_links.txt +0 -0
  45. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/aiobmsble.egg-info/entry_points.txt +0 -0
  46. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/aiobmsble.egg-info/top_level.txt +0 -0
  47. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/setup.cfg +0 -0
  48. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/tests/test_examples.py +0 -0
  49. {aiobmsble-0.2.0 → aiobmsble-0.2.2}/tests/test_main.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiobmsble
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Asynchronous Python library to query battery management systems via Bluetooth Low Energy.
5
5
  Author: Patrick Loschmidt
6
6
  Maintainer: Patrick Loschmidt
@@ -19,7 +19,7 @@ Requires-Python: >=3.12
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: bleak~=1.1.0
22
- Requires-Dist: bleak-retry-connector~=4.0.1
22
+ Requires-Dist: bleak-retry-connector~=4.0.2
23
23
  Requires-Dist: asyncio
24
24
  Requires-Dist: logging
25
25
  Requires-Dist: statistics
@@ -29,6 +29,7 @@ Requires-Dist: pytest; extra == "dev"
29
29
  Requires-Dist: pytest-asyncio; extra == "dev"
30
30
  Requires-Dist: pytest-cov; extra == "dev"
31
31
  Requires-Dist: pytest-xdist; extra == "dev"
32
+ Requires-Dist: hypothesis; extra == "dev"
32
33
  Requires-Dist: mypy; extra == "dev"
33
34
  Requires-Dist: ruff; extra == "dev"
34
35
  Dynamic: license-file
@@ -1,4 +1,8 @@
1
- """Package for battery management systems (BMS) via Bluetooth LE."""
1
+ """Package for battery management systems (BMS) via Bluetooth LE (aiobmsble).
2
+
3
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
4
+ License: Apache-2.0, http://www.apache.org/licenses/
5
+ """
2
6
 
3
7
  from collections.abc import Callable
4
8
  from enum import IntEnum
@@ -1,4 +1,8 @@
1
- """Example function for package usage."""
1
+ """Example script for aiobmsble package usage.
2
+
3
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
4
+ License: Apache-2.0, http://www.apache.org/licenses/
5
+ """
2
6
 
3
7
  import argparse
4
8
  import asyncio
@@ -1,4 +1,8 @@
1
- """Base class defintion for battery management systems (BMS)."""
1
+ """Base class defintion for battery management systems (BMS).
2
+
3
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
4
+ License: Apache-2.0, http://www.apache.org/licenses/
5
+ """
2
6
 
3
7
  from abc import ABC, abstractmethod
4
8
  import asyncio
@@ -79,6 +83,11 @@ class BaseBMS(ABC):
79
83
  self._data: bytearray = bytearray()
80
84
  self._data_event: Final[asyncio.Event] = asyncio.Event()
81
85
 
86
+ @classmethod
87
+ def get_bms_module(cls) -> str:
88
+ """Return BMS module name, e.g. aiobmsble.bms.dummy_bms."""
89
+ return cls.__module__
90
+
82
91
  @staticmethod
83
92
  @abstractmethod
84
93
  def matcher_dict_list() -> list[MatcherPattern]:
@@ -0,0 +1,5 @@
1
+ """Package for battery management systems (BMS) plugins.
2
+
3
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
4
+ License: Apache-2.0, http://www.apache.org/licenses/
5
+ """
@@ -0,0 +1,168 @@
1
+ """Module to support ABC BMS.
2
+
3
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
4
+ License: Apache-2.0, http://www.apache.org/licenses/
5
+ """
6
+
7
+ import contextlib
8
+ from typing import Final
9
+
10
+ from bleak.backends.characteristic import BleakGATTCharacteristic
11
+ from bleak.backends.device import BLEDevice
12
+ from bleak.uuids import normalize_uuid_str
13
+
14
+ from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
15
+ from aiobmsble.basebms import BaseBMS, crc8
16
+
17
+
18
+ class BMS(BaseBMS):
19
+ """ABC BMS implementation."""
20
+
21
+ _HEAD_CMD: Final[int] = 0xEE
22
+ _HEAD_RESP: Final[bytes] = b"\xcc"
23
+ _INFO_LEN: Final[int] = 0x14
24
+ _EXP_REPLY: Final[dict[int, set[int]]] = { # wait for these replies
25
+ 0xC0: {0xF1},
26
+ 0xC1: {0xF0, 0xF2},
27
+ 0xC2: {0xF0, 0xF3, 0xF4}, # 4 cells per F4 message
28
+ 0xC3: {0xF5, 0xF6, 0xF7, 0xF8, 0xFA},
29
+ 0xC4: {0xF9},
30
+ }
31
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
32
+ BMSdp("temp_sensors", 4, 1, False, lambda x: x, 0xF2),
33
+ BMSdp("voltage", 2, 3, False, lambda x: x / 1000, 0xF0),
34
+ BMSdp("current", 5, 3, True, lambda x: x / 1000, 0xF0),
35
+ # ("design_capacity", 8, 3, False, lambda x: x / 1000, 0xF0),
36
+ BMSdp("battery_level", 16, 1, False, lambda x: x, 0xF0),
37
+ BMSdp("cycle_charge", 11, 3, False, lambda x: x / 1000, 0xF0),
38
+ BMSdp("cycles", 14, 2, False, lambda x: x, 0xF0),
39
+ BMSdp( # only first bit per byte is used
40
+ "problem_code",
41
+ 2,
42
+ 16,
43
+ False,
44
+ lambda x: sum(((x >> (i * 8)) & 1) << i for i in range(16)),
45
+ 0xF9,
46
+ ),
47
+ )
48
+ _RESPS: Final[set[int]] = {field.idx for field in _FIELDS} | {0xF4} # cell voltages
49
+
50
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
51
+ """Initialize BMS."""
52
+ super().__init__(ble_device, reconnect)
53
+ self._data_final: dict[int, bytearray] = {}
54
+ self._exp_reply: set[int] = set()
55
+
56
+ @staticmethod
57
+ def matcher_dict_list() -> list[MatcherPattern]:
58
+ """Provide BluetoothMatcher definition."""
59
+ return [
60
+ {
61
+ "local_name": pattern,
62
+ "service_uuid": normalize_uuid_str("fff0"),
63
+ "connectable": True,
64
+ }
65
+ for pattern in ("ABC-*", "SOK-*") # "NB-*", "Hoover",
66
+ ]
67
+
68
+ @staticmethod
69
+ def device_info() -> dict[str, str]:
70
+ """Return device information for the battery management system."""
71
+ return {"manufacturer": "Chunguang Song", "model": "ABC BMS"}
72
+
73
+ @staticmethod
74
+ def uuid_services() -> list[str]:
75
+ """Return list of 128-bit UUIDs of services required by BMS."""
76
+ return [normalize_uuid_str("ffe0")]
77
+
78
+ @staticmethod
79
+ def uuid_rx() -> str:
80
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
81
+ return "ffe1"
82
+
83
+ @staticmethod
84
+ def uuid_tx() -> str:
85
+ """Return 16-bit UUID of characteristic that provides write property."""
86
+ return "ffe2"
87
+
88
+ @staticmethod
89
+ def _calc_values() -> frozenset[BMSvalue]:
90
+ return frozenset(
91
+ {
92
+ "battery_charging",
93
+ "cycle_capacity",
94
+ "delta_voltage",
95
+ "power",
96
+ "runtime",
97
+ "temperature",
98
+ }
99
+ ) # calculate further values from BMS provided set ones
100
+
101
+ def _notification_handler(
102
+ self, _sender: BleakGATTCharacteristic, data: bytearray
103
+ ) -> None:
104
+ """Handle the RX characteristics notify event (new data arrives)."""
105
+ self._log.debug("RX BLE data: %s", data)
106
+
107
+ if not data.startswith(BMS._HEAD_RESP):
108
+ self._log.debug("Incorrect frame start")
109
+ return
110
+
111
+ if len(data) != BMS._INFO_LEN:
112
+ self._log.debug("Incorrect frame length")
113
+ return
114
+
115
+ if (crc := crc8(data[:-1])) != data[-1]:
116
+ self._log.debug("invalid checksum 0x%X != 0x%X", data[-1], crc)
117
+ return
118
+
119
+ if data[1] == 0xF4 and 0xF4 in self._data_final:
120
+ # expand cell voltage frame with all parts
121
+ self._data_final[0xF4] = bytearray(self._data_final[0xF4][:-2] + data[2:])
122
+ else:
123
+ self._data_final[data[1]] = data.copy()
124
+
125
+ self._exp_reply.discard(data[1])
126
+
127
+ if not self._exp_reply: # check if all expected replies are received
128
+ self._data_event.set()
129
+
130
+ @staticmethod
131
+ def _cmd(cmd: bytes) -> bytes:
132
+ """Assemble a ABC BMS command."""
133
+ frame = bytearray([BMS._HEAD_CMD, cmd[0], 0x00, 0x00, 0x00])
134
+ frame.append(crc8(frame))
135
+ return bytes(frame)
136
+
137
+ async def _async_update(self) -> BMSsample:
138
+ """Update battery status information."""
139
+ self._data_final.clear()
140
+ for cmd in (0xC1, 0xC2, 0xC4):
141
+ self._exp_reply.update(BMS._EXP_REPLY[cmd])
142
+ with contextlib.suppress(TimeoutError):
143
+ await self._await_reply(BMS._cmd(bytes([cmd])))
144
+
145
+ # check all repsonses are here, 0xF9 is not mandatory (not all BMS report it)
146
+ self._data_final.setdefault(0xF9, bytearray())
147
+ if not BMS._RESPS.issubset(set(self._data_final.keys())):
148
+ self._log.debug("Incomplete data set %s", self._data_final.keys())
149
+ raise TimeoutError("BMS data incomplete.")
150
+
151
+ result: BMSsample = BMS._decode_data(
152
+ BMS._FIELDS, self._data_final, byteorder="little"
153
+ )
154
+ return result | {
155
+ "cell_voltages": BMS._cell_voltages( # every second value is the cell idx
156
+ self._data_final[0xF4],
157
+ cells=(len(self._data_final[0xF4]) - 4) // 2,
158
+ start=3,
159
+ byteorder="little",
160
+ size=2,
161
+ )[::2],
162
+ "temp_values": BMS._temp_values(
163
+ self._data_final[0xF2],
164
+ start=5,
165
+ values=result.get("temp_sensors", 0),
166
+ byteorder="little",
167
+ ),
168
+ }
@@ -0,0 +1,200 @@
1
+ """Module to support ANT BMS.
2
+
3
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
4
+ License: Apache-2.0, http://www.apache.org/licenses/
5
+ """
6
+
7
+ from typing import Final
8
+
9
+ from bleak.backends.characteristic import BleakGATTCharacteristic
10
+ from bleak.backends.device import BLEDevice
11
+ from bleak.uuids import normalize_uuid_str
12
+
13
+ from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
14
+ from aiobmsble.basebms import BaseBMS, crc_modbus
15
+
16
+
17
+ class BMS(BaseBMS):
18
+ """ANT BMS implementation."""
19
+
20
+ _HEAD: Final[bytes] = b"\x7e\xa1"
21
+ _TAIL: Final[bytes] = b"\xaa\x55"
22
+ _MIN_LEN: Final[int] = 10 # frame length without data
23
+ _CMD_STAT: Final[int] = 0x01
24
+ _CMD_DEV: Final[int] = 0x02
25
+ _TEMP_POS: Final[int] = 8
26
+ _MAX_TEMPS: Final[int] = 6
27
+ _CELL_COUNT: Final[int] = 9
28
+ _CELL_POS: Final[int] = 34
29
+ _MAX_CELLS: Final[int] = 32
30
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
31
+ BMSdp("voltage", 38, 2, False, lambda x: x / 100),
32
+ BMSdp("current", 40, 2, True, lambda x: x / 10),
33
+ BMSdp("design_capacity", 50, 4, False, lambda x: x // 1e6),
34
+ BMSdp("battery_level", 42, 2, False, lambda x: x),
35
+ BMSdp(
36
+ "problem_code",
37
+ 46,
38
+ 2,
39
+ False,
40
+ lambda x: ((x & 0xF00) if (x >> 8) not in (0x1, 0x4, 0xB, 0xF) else 0)
41
+ | ((x & 0xF) if (x & 0xF) not in (0x1, 0x4, 0xB, 0xC, 0xF) else 0),
42
+ ),
43
+ BMSdp("cycle_charge", 54, 4, False, lambda x: x / 1e6),
44
+ BMSdp("delta_voltage", 82, 2, False, lambda x: x / 1000),
45
+ BMSdp("power", 62, 4, True, lambda x: x / 1),
46
+ )
47
+
48
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
49
+ """Initialize BMS."""
50
+ super().__init__(ble_device, reconnect)
51
+ self._data_final: bytearray = bytearray()
52
+ self._valid_reply: int = BMS._CMD_STAT | 0x10 # valid reply mask
53
+ self._exp_len: int = BMS._MIN_LEN
54
+
55
+ @staticmethod
56
+ def matcher_dict_list() -> list[MatcherPattern]:
57
+ """Provide BluetoothMatcher definition."""
58
+ return [
59
+ {
60
+ "local_name": "ANT-BLE*",
61
+ "service_uuid": BMS.uuid_services()[0],
62
+ "manufacturer_id": 0x2313,
63
+ "connectable": True,
64
+ }
65
+ ]
66
+
67
+ @staticmethod
68
+ def device_info() -> dict[str, str]:
69
+ """Return device information for the battery management system."""
70
+ return {"manufacturer": "ANT", "model": "Smart BMS"}
71
+
72
+ @staticmethod
73
+ def uuid_services() -> list[str]:
74
+ """Return list of 128-bit UUIDs of services required by BMS."""
75
+ return [normalize_uuid_str("ffe0")] # change service UUID here!
76
+
77
+ @staticmethod
78
+ def uuid_rx() -> str:
79
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
80
+ return "ffe1"
81
+
82
+ @staticmethod
83
+ def uuid_tx() -> str:
84
+ """Return 16-bit UUID of characteristic that provides write property."""
85
+ return "ffe1"
86
+
87
+ @staticmethod
88
+ def _calc_values() -> frozenset[BMSvalue]:
89
+ return frozenset(
90
+ {"cycle_capacity", "temperature"}
91
+ ) # calculate further values from BMS provided set ones
92
+
93
+ async def _init_connection(
94
+ self, char_notify: BleakGATTCharacteristic | int | str | None = None
95
+ ) -> None:
96
+ """Initialize RX/TX characteristics and protocol state."""
97
+ await super()._init_connection(char_notify)
98
+ self._exp_len = BMS._MIN_LEN
99
+ self._valid_reply = BMS._CMD_DEV | 0x10
100
+ await self._await_reply(BMS._cmd(BMS._CMD_DEV, 0x026C, 0x20)) # TODO: parse
101
+ self._valid_reply = BMS._CMD_STAT | 0x10
102
+
103
+ def _notification_handler(
104
+ self, _sender: BleakGATTCharacteristic, data: bytearray
105
+ ) -> None:
106
+ """Handle the RX characteristics notify event (new data arrives)."""
107
+
108
+ if (
109
+ data.startswith(BMS._HEAD)
110
+ and len(self._data) >= self._exp_len
111
+ and len(data) >= BMS._MIN_LEN
112
+ ):
113
+ self._data = bytearray()
114
+ self._exp_len = data[5] + BMS._MIN_LEN
115
+
116
+ self._data += data
117
+ self._log.debug(
118
+ "RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
119
+ )
120
+
121
+ if len(self._data) < self._exp_len:
122
+ return
123
+
124
+ if self._data[2] != self._valid_reply:
125
+ self._log.debug("unexpected response (type 0x%X)", self._data[2])
126
+ return
127
+
128
+ if len(self._data) != self._exp_len and self._data[2] != BMS._CMD_DEV | 0x10:
129
+ # length of CMD_DEV is incorrect, so we ignore the length check here
130
+ self._log.debug(
131
+ "invalid frame length %d != %d", len(self._data), self._exp_len
132
+ )
133
+ return
134
+
135
+ if not self._data.endswith(BMS._TAIL):
136
+ self._log.debug("invalid frame end")
137
+ return
138
+
139
+ if (crc := crc_modbus(self._data[1 : self._exp_len - 4])) != int.from_bytes(
140
+ self._data[self._exp_len - 4 : self._exp_len - 2], "little"
141
+ ):
142
+ self._log.debug(
143
+ "invalid checksum 0x%X != 0x%X",
144
+ int.from_bytes(
145
+ self._data[self._exp_len - 4 : self._exp_len - 2], "little"
146
+ ),
147
+ crc,
148
+ )
149
+ return
150
+
151
+ self._data_final = self._data.copy()
152
+ self._data_event.set()
153
+
154
+ @staticmethod
155
+ def _cmd(cmd: int, adr: int, value: int) -> bytes:
156
+ """Assemble a ANT BMS command."""
157
+ frame: bytearray = (
158
+ bytearray([*BMS._HEAD, cmd & 0xFF])
159
+ + adr.to_bytes(2, "little")
160
+ + int.to_bytes(value & 0xFF, 1)
161
+ )
162
+ frame.extend(int.to_bytes(crc_modbus(frame[1:]), 2, "little"))
163
+ return bytes(frame) + BMS._TAIL
164
+
165
+ @staticmethod
166
+ def _temp_sensors(data: bytearray, sensors: int, offs: int) -> list[float]:
167
+ return [
168
+ float(int.from_bytes(data[idx : idx + 2], byteorder="little", signed=True))
169
+ for idx in range(offs, offs + sensors * 2, 2)
170
+ ]
171
+
172
+ async def _async_update(self) -> BMSsample:
173
+ """Update battery status information."""
174
+ await self._await_reply(BMS._cmd(BMS._CMD_STAT, 0, 0xBE))
175
+
176
+ result: BMSsample = {}
177
+ result["battery_charging"] = self._data_final[7] == 0x2
178
+ result["cell_count"] = min(self._data_final[BMS._CELL_COUNT], BMS._MAX_CELLS)
179
+ result["cell_voltages"] = BMS._cell_voltages(
180
+ self._data_final,
181
+ cells=result["cell_count"],
182
+ start=BMS._CELL_POS,
183
+ byteorder="little",
184
+ )
185
+ result["temp_sensors"] = min(self._data_final[BMS._TEMP_POS], BMS._MAX_TEMPS)
186
+ result["temp_values"] = BMS._temp_sensors(
187
+ self._data_final,
188
+ result["temp_sensors"] + 2, # + MOSFET, balancer temperature
189
+ BMS._CELL_POS + result["cell_count"] * 2,
190
+ )
191
+ result.update(
192
+ BMS._decode_data(
193
+ BMS._FIELDS,
194
+ self._data_final,
195
+ byteorder="little",
196
+ offset=(result["temp_sensors"] + result["cell_count"]) * 2,
197
+ )
198
+ )
199
+
200
+ return result
@@ -0,0 +1,171 @@
1
+ """Module to support Braun Power BMS.
2
+
3
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
4
+ License: Apache-2.0, http://www.apache.org/licenses/
5
+ """
6
+
7
+ from typing import Final
8
+
9
+ from bleak.backends.characteristic import BleakGATTCharacteristic
10
+ from bleak.backends.device import BLEDevice
11
+ from bleak.uuids import normalize_uuid_str
12
+
13
+ from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
14
+ from aiobmsble.basebms import BaseBMS
15
+
16
+
17
+ class BMS(BaseBMS):
18
+ """Braun Power BMS class implementation."""
19
+
20
+ _HEAD: Final[bytes] = b"\x7b" # header for responses
21
+ _TAIL: Final[int] = 0x7D # tail for command
22
+ _MIN_LEN: Final[int] = 4 # minimum frame size
23
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
24
+ BMSdp("cell_count", 3, 1, False, lambda x: x, 0x2),
25
+ BMSdp("temp_sensors", 3, 1, False, lambda x: x, 0x3),
26
+ BMSdp("voltage", 5, 2, False, lambda x: x / 100, 0x1),
27
+ BMSdp("current", 13, 2, True, lambda x: x / 100, 0x1),
28
+ BMSdp("battery_level", 4, 1, False, lambda x: x, 0x1),
29
+ BMSdp("cycle_charge", 15, 2, False, lambda x: x / 100, 0x1),
30
+ BMSdp("design_capacity", 17, 2, False, lambda x: x // 100, 0x1),
31
+ BMSdp("cycles", 23, 2, False, lambda x: x, 0x1),
32
+ BMSdp("problem_code", 31, 2, False, lambda x: x, 0x1),
33
+ )
34
+ _CMDS: Final[set[int]] = {field.idx for field in _FIELDS}
35
+ _INIT_CMDS: Final[set[int]] = {
36
+ 0x74, # SW version
37
+ 0xF4, # BMS program version
38
+ 0xF5, # BMS boot version
39
+ }
40
+
41
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
42
+ """Intialize private BMS members."""
43
+ super().__init__(ble_device, reconnect)
44
+ self._data_final: dict[int, bytearray] = {}
45
+ self._exp_reply: tuple[int] = (0x01,)
46
+
47
+ @staticmethod
48
+ def matcher_dict_list() -> list[MatcherPattern]:
49
+ """Provide BluetoothMatcher definition."""
50
+ return [
51
+ MatcherPattern(
52
+ local_name=pattern,
53
+ service_uuid=BMS.uuid_services()[0],
54
+ manufacturer_id=0x7B,
55
+ connectable=True,
56
+ )
57
+ for pattern in ("HSKS-*", "BL-*")
58
+ ]
59
+
60
+ @staticmethod
61
+ def device_info() -> dict[str, str]:
62
+ """Return device information for the battery management system."""
63
+ return {"manufacturer": "Braun Power", "model": "Smart BMS"}
64
+
65
+ @staticmethod
66
+ def uuid_services() -> list[str]:
67
+ """Return list of 128-bit UUIDs of services required by BMS."""
68
+ return [normalize_uuid_str("ff00")]
69
+
70
+ @staticmethod
71
+ def uuid_rx() -> str:
72
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
73
+ return "ff01"
74
+
75
+ @staticmethod
76
+ def uuid_tx() -> str:
77
+ """Return 16-bit UUID of characteristic that provides write property."""
78
+ return "ff02"
79
+
80
+ @staticmethod
81
+ def _calc_values() -> frozenset[BMSvalue]:
82
+ return frozenset(
83
+ {
84
+ "power",
85
+ "battery_charging",
86
+ "cycle_capacity",
87
+ "runtime",
88
+ "delta_voltage",
89
+ "temperature",
90
+ }
91
+ )
92
+
93
+ def _notification_handler(
94
+ self, _sender: BleakGATTCharacteristic, data: bytearray
95
+ ) -> None:
96
+ # check if answer is a heading of valid response type
97
+ if (
98
+ data.startswith(BMS._HEAD)
99
+ and len(self._data) >= BMS._MIN_LEN
100
+ and data[1] in {*BMS._CMDS, *BMS._INIT_CMDS}
101
+ and len(self._data) >= BMS._MIN_LEN + self._data[2]
102
+ ):
103
+ self._data = bytearray()
104
+
105
+ self._data += data
106
+ self._log.debug(
107
+ "RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
108
+ )
109
+
110
+ # verify that data is long enough
111
+ if (
112
+ len(self._data) < BMS._MIN_LEN
113
+ or len(self._data) < BMS._MIN_LEN + self._data[2]
114
+ ):
115
+ return
116
+
117
+ # check correct frame ending
118
+ if self._data[-1] != BMS._TAIL:
119
+ self._log.debug("incorrect frame end (length: %i).", len(self._data))
120
+ self._data.clear()
121
+ return
122
+
123
+ if self._data[1] not in self._exp_reply:
124
+ self._log.debug("unexpected command 0x%02X", self._data[1])
125
+ self._data.clear()
126
+ return
127
+
128
+ # check if response length matches expected length
129
+ if len(self._data) != BMS._MIN_LEN + self._data[2]:
130
+ self._log.debug("wrong data length (%i): %s", len(self._data), self._data)
131
+ self._data.clear()
132
+ return
133
+
134
+ self._data_final[self._data[1]] = self._data
135
+ self._data_event.set()
136
+
137
+ @staticmethod
138
+ def _cmd(cmd: int, data: bytes = b"") -> bytes:
139
+ """Assemble a Braun Power BMS command."""
140
+ assert len(data) <= 255, "data length must be a single byte."
141
+ return bytes([*BMS._HEAD, cmd, len(data), *data, BMS._TAIL])
142
+
143
+ async def _init_connection(
144
+ self, char_notify: BleakGATTCharacteristic | int | str | None = None
145
+ ) -> None:
146
+ """Connect to the BMS and setup notification if not connected."""
147
+ await super()._init_connection()
148
+ for cmd in BMS._INIT_CMDS:
149
+ self._exp_reply = (cmd,)
150
+ await self._await_reply(BMS._cmd(cmd))
151
+
152
+ async def _async_update(self) -> BMSsample:
153
+ """Update battery status information."""
154
+ self._data_final.clear()
155
+ for cmd in BMS._CMDS:
156
+ self._exp_reply = (cmd,)
157
+ await self._await_reply(BMS._cmd(cmd))
158
+
159
+ data: BMSsample = BMS._decode_data(BMS._FIELDS, self._data_final)
160
+ data["cell_voltages"] = BMS._cell_voltages(
161
+ self._data_final[0x2], cells=data.get("cell_count", 0), start=4
162
+ )
163
+ data["temp_values"] = BMS._temp_values(
164
+ self._data_final[0x3],
165
+ values=data.get("temp_sensors", 0),
166
+ start=4,
167
+ offset=2731,
168
+ divider=10,
169
+ )
170
+
171
+ return data