aiobmsble 0.2.0__tar.gz → 0.2.1__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.1}/PKG-INFO +3 -2
  2. aiobmsble-0.2.1/aiobmsble/bms/__init__.py +1 -0
  3. aiobmsble-0.2.1/aiobmsble/bms/abc_bms.py +164 -0
  4. aiobmsble-0.2.1/aiobmsble/bms/ant_bms.py +196 -0
  5. aiobmsble-0.2.1/aiobmsble/bms/braunpwr_bms.py +167 -0
  6. aiobmsble-0.2.1/aiobmsble/bms/cbtpwr_bms.py +168 -0
  7. aiobmsble-0.2.1/aiobmsble/bms/cbtpwr_vb_bms.py +184 -0
  8. aiobmsble-0.2.1/aiobmsble/bms/daly_bms.py +164 -0
  9. aiobmsble-0.2.1/aiobmsble/bms/dpwrcore_bms.py +207 -0
  10. aiobmsble-0.2.1/aiobmsble/bms/dummy_bms.py +89 -0
  11. aiobmsble-0.2.1/aiobmsble/bms/ecoworthy_bms.py +151 -0
  12. aiobmsble-0.2.1/aiobmsble/bms/ective_bms.py +177 -0
  13. aiobmsble-0.2.1/aiobmsble/bms/ej_bms.py +233 -0
  14. aiobmsble-0.2.1/aiobmsble/bms/felicity_bms.py +139 -0
  15. aiobmsble-0.2.1/aiobmsble/bms/jbd_bms.py +203 -0
  16. aiobmsble-0.2.1/aiobmsble/bms/jikong_bms.py +301 -0
  17. aiobmsble-0.2.1/aiobmsble/bms/neey_bms.py +214 -0
  18. aiobmsble-0.2.1/aiobmsble/bms/ogt_bms.py +214 -0
  19. aiobmsble-0.2.1/aiobmsble/bms/pro_bms.py +144 -0
  20. aiobmsble-0.2.1/aiobmsble/bms/redodo_bms.py +127 -0
  21. aiobmsble-0.2.1/aiobmsble/bms/renogy_bms.py +149 -0
  22. aiobmsble-0.2.1/aiobmsble/bms/renogy_pro_bms.py +105 -0
  23. aiobmsble-0.2.1/aiobmsble/bms/roypow_bms.py +186 -0
  24. aiobmsble-0.2.1/aiobmsble/bms/seplos_bms.py +245 -0
  25. aiobmsble-0.2.1/aiobmsble/bms/seplos_v2_bms.py +205 -0
  26. aiobmsble-0.2.1/aiobmsble/bms/tdt_bms.py +199 -0
  27. aiobmsble-0.2.1/aiobmsble/bms/tianpwr_bms.py +138 -0
  28. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/aiobmsble/utils.py +1 -1
  29. {aiobmsble-0.2.0 → aiobmsble-0.2.1/aiobmsble.egg-info}/PKG-INFO +3 -2
  30. aiobmsble-0.2.1/aiobmsble.egg-info/SOURCES.txt +46 -0
  31. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/aiobmsble.egg-info/requires.txt +2 -1
  32. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/pyproject.toml +4 -9
  33. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/tests/test_basebms.py +2 -11
  34. aiobmsble-0.2.1/tests/test_fuzzing.py +61 -0
  35. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/tests/test_plugins.py +4 -18
  36. aiobmsble-0.2.0/aiobmsble.egg-info/SOURCES.txt +0 -19
  37. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/LICENSE +0 -0
  38. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/MANIFEST.in +0 -0
  39. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/README.md +0 -0
  40. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/aiobmsble/__init__.py +0 -0
  41. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/aiobmsble/__main__.py +0 -0
  42. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/aiobmsble/basebms.py +0 -0
  43. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/aiobmsble.egg-info/dependency_links.txt +0 -0
  44. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/aiobmsble.egg-info/entry_points.txt +0 -0
  45. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/aiobmsble.egg-info/top_level.txt +0 -0
  46. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/setup.cfg +0 -0
  47. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/tests/test_examples.py +0 -0
  48. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/tests/test_main.py +0 -0
  49. {aiobmsble-0.2.0 → aiobmsble-0.2.1}/tests/test_utils.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.1
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
@@ -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
+ }
@@ -0,0 +1,196 @@
1
+ """Module to support ANT BMS."""
2
+
3
+ from typing import Final
4
+
5
+ from bleak.backends.characteristic import BleakGATTCharacteristic
6
+ from bleak.backends.device import BLEDevice
7
+ from bleak.uuids import normalize_uuid_str
8
+
9
+ from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
10
+ from aiobmsble.basebms import BaseBMS, crc_modbus
11
+
12
+
13
+ class BMS(BaseBMS):
14
+ """ANT BMS implementation."""
15
+
16
+ _HEAD: Final[bytes] = b"\x7e\xa1"
17
+ _TAIL: Final[bytes] = b"\xaa\x55"
18
+ _MIN_LEN: Final[int] = 10 # frame length without data
19
+ _CMD_STAT: Final[int] = 0x01
20
+ _CMD_DEV: Final[int] = 0x02
21
+ _TEMP_POS: Final[int] = 8
22
+ _MAX_TEMPS: Final[int] = 6
23
+ _CELL_COUNT: Final[int] = 9
24
+ _CELL_POS: Final[int] = 34
25
+ _MAX_CELLS: Final[int] = 32
26
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
27
+ BMSdp("voltage", 38, 2, False, lambda x: x / 100),
28
+ BMSdp("current", 40, 2, True, lambda x: x / 10),
29
+ BMSdp("design_capacity", 50, 4, False, lambda x: x // 1e6),
30
+ BMSdp("battery_level", 42, 2, False, lambda x: x),
31
+ BMSdp(
32
+ "problem_code",
33
+ 46,
34
+ 2,
35
+ False,
36
+ lambda x: ((x & 0xF00) if (x >> 8) not in (0x1, 0x4, 0xB, 0xF) else 0)
37
+ | ((x & 0xF) if (x & 0xF) not in (0x1, 0x4, 0xB, 0xC, 0xF) else 0),
38
+ ),
39
+ BMSdp("cycle_charge", 54, 4, False, lambda x: x / 1e6),
40
+ BMSdp("delta_voltage", 82, 2, False, lambda x: x / 1000),
41
+ BMSdp("power", 62, 4, True, lambda x: x / 1),
42
+ )
43
+
44
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
45
+ """Initialize BMS."""
46
+ super().__init__(ble_device, reconnect)
47
+ self._data_final: bytearray = bytearray()
48
+ self._valid_reply: int = BMS._CMD_STAT | 0x10 # valid reply mask
49
+ self._exp_len: int = BMS._MIN_LEN
50
+
51
+ @staticmethod
52
+ def matcher_dict_list() -> list[MatcherPattern]:
53
+ """Provide BluetoothMatcher definition."""
54
+ return [
55
+ {
56
+ "local_name": "ANT-BLE*",
57
+ "service_uuid": BMS.uuid_services()[0],
58
+ "manufacturer_id": 0x2313,
59
+ "connectable": True,
60
+ }
61
+ ]
62
+
63
+ @staticmethod
64
+ def device_info() -> dict[str, str]:
65
+ """Return device information for the battery management system."""
66
+ return {"manufacturer": "ANT", "model": "Smart BMS"}
67
+
68
+ @staticmethod
69
+ def uuid_services() -> list[str]:
70
+ """Return list of 128-bit UUIDs of services required by BMS."""
71
+ return [normalize_uuid_str("ffe0")] # change service UUID here!
72
+
73
+ @staticmethod
74
+ def uuid_rx() -> str:
75
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
76
+ return "ffe1"
77
+
78
+ @staticmethod
79
+ def uuid_tx() -> str:
80
+ """Return 16-bit UUID of characteristic that provides write property."""
81
+ return "ffe1"
82
+
83
+ @staticmethod
84
+ def _calc_values() -> frozenset[BMSvalue]:
85
+ return frozenset(
86
+ {"cycle_capacity", "temperature"}
87
+ ) # calculate further values from BMS provided set ones
88
+
89
+ async def _init_connection(
90
+ self, char_notify: BleakGATTCharacteristic | int | str | None = None
91
+ ) -> None:
92
+ """Initialize RX/TX characteristics and protocol state."""
93
+ await super()._init_connection(char_notify)
94
+ self._exp_len = BMS._MIN_LEN
95
+ self._valid_reply = BMS._CMD_DEV | 0x10
96
+ await self._await_reply(BMS._cmd(BMS._CMD_DEV, 0x026C, 0x20)) # TODO: parse
97
+ self._valid_reply = BMS._CMD_STAT | 0x10
98
+
99
+ def _notification_handler(
100
+ self, _sender: BleakGATTCharacteristic, data: bytearray
101
+ ) -> None:
102
+ """Handle the RX characteristics notify event (new data arrives)."""
103
+
104
+ if (
105
+ data.startswith(BMS._HEAD)
106
+ and len(self._data) >= self._exp_len
107
+ and len(data) >= BMS._MIN_LEN
108
+ ):
109
+ self._data = bytearray()
110
+ self._exp_len = data[5] + BMS._MIN_LEN
111
+
112
+ self._data += data
113
+ self._log.debug(
114
+ "RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
115
+ )
116
+
117
+ if len(self._data) < self._exp_len:
118
+ return
119
+
120
+ if self._data[2] != self._valid_reply:
121
+ self._log.debug("unexpected response (type 0x%X)", self._data[2])
122
+ return
123
+
124
+ if len(self._data) != self._exp_len and self._data[2] != BMS._CMD_DEV | 0x10:
125
+ # length of CMD_DEV is incorrect, so we ignore the length check here
126
+ self._log.debug(
127
+ "invalid frame length %d != %d", len(self._data), self._exp_len
128
+ )
129
+ return
130
+
131
+ if not self._data.endswith(BMS._TAIL):
132
+ self._log.debug("invalid frame end")
133
+ return
134
+
135
+ if (crc := crc_modbus(self._data[1 : self._exp_len - 4])) != int.from_bytes(
136
+ self._data[self._exp_len - 4 : self._exp_len - 2], "little"
137
+ ):
138
+ self._log.debug(
139
+ "invalid checksum 0x%X != 0x%X",
140
+ int.from_bytes(
141
+ self._data[self._exp_len - 4 : self._exp_len - 2], "little"
142
+ ),
143
+ crc,
144
+ )
145
+ return
146
+
147
+ self._data_final = self._data.copy()
148
+ self._data_event.set()
149
+
150
+ @staticmethod
151
+ def _cmd(cmd: int, adr: int, value: int) -> bytes:
152
+ """Assemble a ANT BMS command."""
153
+ frame: bytearray = (
154
+ bytearray([*BMS._HEAD, cmd & 0xFF])
155
+ + adr.to_bytes(2, "little")
156
+ + int.to_bytes(value & 0xFF, 1)
157
+ )
158
+ frame.extend(int.to_bytes(crc_modbus(frame[1:]), 2, "little"))
159
+ return bytes(frame) + BMS._TAIL
160
+
161
+ @staticmethod
162
+ def _temp_sensors(data: bytearray, sensors: int, offs: int) -> list[float]:
163
+ return [
164
+ float(int.from_bytes(data[idx : idx + 2], byteorder="little", signed=True))
165
+ for idx in range(offs, offs + sensors * 2, 2)
166
+ ]
167
+
168
+ async def _async_update(self) -> BMSsample:
169
+ """Update battery status information."""
170
+ await self._await_reply(BMS._cmd(BMS._CMD_STAT, 0, 0xBE))
171
+
172
+ result: BMSsample = {}
173
+ result["battery_charging"] = self._data_final[7] == 0x2
174
+ result["cell_count"] = min(self._data_final[BMS._CELL_COUNT], BMS._MAX_CELLS)
175
+ result["cell_voltages"] = BMS._cell_voltages(
176
+ self._data_final,
177
+ cells=result["cell_count"],
178
+ start=BMS._CELL_POS,
179
+ byteorder="little",
180
+ )
181
+ result["temp_sensors"] = min(self._data_final[BMS._TEMP_POS], BMS._MAX_TEMPS)
182
+ result["temp_values"] = BMS._temp_sensors(
183
+ self._data_final,
184
+ result["temp_sensors"] + 2, # + MOSFET, balancer temperature
185
+ BMS._CELL_POS + result["cell_count"] * 2,
186
+ )
187
+ result.update(
188
+ BMS._decode_data(
189
+ BMS._FIELDS,
190
+ self._data_final,
191
+ byteorder="little",
192
+ offset=(result["temp_sensors"] + result["cell_count"]) * 2,
193
+ )
194
+ )
195
+
196
+ return result
@@ -0,0 +1,167 @@
1
+ """Module to support Braun Power BMS."""
2
+
3
+ from typing import Final
4
+
5
+ from bleak.backends.characteristic import BleakGATTCharacteristic
6
+ from bleak.backends.device import BLEDevice
7
+ from bleak.uuids import normalize_uuid_str
8
+
9
+ from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
10
+ from aiobmsble.basebms import BaseBMS
11
+
12
+
13
+ class BMS(BaseBMS):
14
+ """Braun Power BMS class implementation."""
15
+
16
+ _HEAD: Final[bytes] = b"\x7b" # header for responses
17
+ _TAIL: Final[int] = 0x7D # tail for command
18
+ _MIN_LEN: Final[int] = 4 # minimum frame size
19
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
20
+ BMSdp("cell_count", 3, 1, False, lambda x: x, 0x2),
21
+ BMSdp("temp_sensors", 3, 1, False, lambda x: x, 0x3),
22
+ BMSdp("voltage", 5, 2, False, lambda x: x / 100, 0x1),
23
+ BMSdp("current", 13, 2, True, lambda x: x / 100, 0x1),
24
+ BMSdp("battery_level", 4, 1, False, lambda x: x, 0x1),
25
+ BMSdp("cycle_charge", 15, 2, False, lambda x: x / 100, 0x1),
26
+ BMSdp("design_capacity", 17, 2, False, lambda x: x // 100, 0x1),
27
+ BMSdp("cycles", 23, 2, False, lambda x: x, 0x1),
28
+ BMSdp("problem_code", 31, 2, False, lambda x: x, 0x1),
29
+ )
30
+ _CMDS: Final[set[int]] = {field.idx for field in _FIELDS}
31
+ _INIT_CMDS: Final[set[int]] = {
32
+ 0x74, # SW version
33
+ 0xF4, # BMS program version
34
+ 0xF5, # BMS boot version
35
+ }
36
+
37
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
38
+ """Intialize private BMS members."""
39
+ super().__init__(ble_device, reconnect)
40
+ self._data_final: dict[int, bytearray] = {}
41
+ self._exp_reply: tuple[int] = (0x01,)
42
+
43
+ @staticmethod
44
+ def matcher_dict_list() -> list[MatcherPattern]:
45
+ """Provide BluetoothMatcher definition."""
46
+ return [
47
+ MatcherPattern(
48
+ local_name=pattern,
49
+ service_uuid=BMS.uuid_services()[0],
50
+ manufacturer_id=0x7B,
51
+ connectable=True,
52
+ )
53
+ for pattern in ("HSKS-*", "BL-*")
54
+ ]
55
+
56
+ @staticmethod
57
+ def device_info() -> dict[str, str]:
58
+ """Return device information for the battery management system."""
59
+ return {"manufacturer": "Braun Power", "model": "Smart BMS"}
60
+
61
+ @staticmethod
62
+ def uuid_services() -> list[str]:
63
+ """Return list of 128-bit UUIDs of services required by BMS."""
64
+ return [normalize_uuid_str("ff00")]
65
+
66
+ @staticmethod
67
+ def uuid_rx() -> str:
68
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
69
+ return "ff01"
70
+
71
+ @staticmethod
72
+ def uuid_tx() -> str:
73
+ """Return 16-bit UUID of characteristic that provides write property."""
74
+ return "ff02"
75
+
76
+ @staticmethod
77
+ def _calc_values() -> frozenset[BMSvalue]:
78
+ return frozenset(
79
+ {
80
+ "power",
81
+ "battery_charging",
82
+ "cycle_capacity",
83
+ "runtime",
84
+ "delta_voltage",
85
+ "temperature",
86
+ }
87
+ )
88
+
89
+ def _notification_handler(
90
+ self, _sender: BleakGATTCharacteristic, data: bytearray
91
+ ) -> None:
92
+ # check if answer is a heading of valid response type
93
+ if (
94
+ data.startswith(BMS._HEAD)
95
+ and len(self._data) >= BMS._MIN_LEN
96
+ and data[1] in {*BMS._CMDS, *BMS._INIT_CMDS}
97
+ and len(self._data) >= BMS._MIN_LEN + self._data[2]
98
+ ):
99
+ self._data = bytearray()
100
+
101
+ self._data += data
102
+ self._log.debug(
103
+ "RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
104
+ )
105
+
106
+ # verify that data is long enough
107
+ if (
108
+ len(self._data) < BMS._MIN_LEN
109
+ or len(self._data) < BMS._MIN_LEN + self._data[2]
110
+ ):
111
+ return
112
+
113
+ # check correct frame ending
114
+ if self._data[-1] != BMS._TAIL:
115
+ self._log.debug("incorrect frame end (length: %i).", len(self._data))
116
+ self._data.clear()
117
+ return
118
+
119
+ if self._data[1] not in self._exp_reply:
120
+ self._log.debug("unexpected command 0x%02X", self._data[1])
121
+ self._data.clear()
122
+ return
123
+
124
+ # check if response length matches expected length
125
+ if len(self._data) != BMS._MIN_LEN + self._data[2]:
126
+ self._log.debug("wrong data length (%i): %s", len(self._data), self._data)
127
+ self._data.clear()
128
+ return
129
+
130
+ self._data_final[self._data[1]] = self._data
131
+ self._data_event.set()
132
+
133
+ @staticmethod
134
+ def _cmd(cmd: int, data: bytes = b"") -> bytes:
135
+ """Assemble a Braun Power BMS command."""
136
+ assert len(data) <= 255, "data length must be a single byte."
137
+ return bytes([*BMS._HEAD, cmd, len(data), *data, BMS._TAIL])
138
+
139
+ async def _init_connection(
140
+ self, char_notify: BleakGATTCharacteristic | int | str | None = None
141
+ ) -> None:
142
+ """Connect to the BMS and setup notification if not connected."""
143
+ await super()._init_connection()
144
+ for cmd in BMS._INIT_CMDS:
145
+ self._exp_reply = (cmd,)
146
+ await self._await_reply(BMS._cmd(cmd))
147
+
148
+ async def _async_update(self) -> BMSsample:
149
+ """Update battery status information."""
150
+ self._data_final.clear()
151
+ for cmd in BMS._CMDS:
152
+ self._exp_reply = (cmd,)
153
+ await self._await_reply(BMS._cmd(cmd))
154
+
155
+ data: BMSsample = BMS._decode_data(BMS._FIELDS, self._data_final)
156
+ data["cell_voltages"] = BMS._cell_voltages(
157
+ self._data_final[0x2], cells=data.get("cell_count", 0), start=4
158
+ )
159
+ data["temp_values"] = BMS._temp_values(
160
+ self._data_final[0x3],
161
+ values=data.get("temp_sensors", 0),
162
+ start=4,
163
+ offset=2731,
164
+ divider=10,
165
+ )
166
+
167
+ return data