aiobmsble 0.2.0__py3-none-any.whl → 0.2.2__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 +5 -1
  2. aiobmsble/__main__.py +5 -1
  3. aiobmsble/basebms.py +10 -1
  4. aiobmsble/bms/__init__.py +5 -0
  5. aiobmsble/bms/abc_bms.py +168 -0
  6. aiobmsble/bms/ant_bms.py +200 -0
  7. aiobmsble/bms/braunpwr_bms.py +171 -0
  8. aiobmsble/bms/cbtpwr_bms.py +172 -0
  9. aiobmsble/bms/cbtpwr_vb_bms.py +188 -0
  10. aiobmsble/bms/daly_bms.py +168 -0
  11. aiobmsble/bms/dpwrcore_bms.py +211 -0
  12. aiobmsble/bms/dummy_bms.py +93 -0
  13. aiobmsble/bms/ecoworthy_bms.py +155 -0
  14. aiobmsble/bms/ective_bms.py +181 -0
  15. aiobmsble/bms/ej_bms.py +237 -0
  16. aiobmsble/bms/felicity_bms.py +143 -0
  17. aiobmsble/bms/jbd_bms.py +207 -0
  18. aiobmsble/bms/jikong_bms.py +305 -0
  19. aiobmsble/bms/neey_bms.py +218 -0
  20. aiobmsble/bms/ogt_bms.py +218 -0
  21. aiobmsble/bms/pro_bms.py +148 -0
  22. aiobmsble/bms/redodo_bms.py +131 -0
  23. aiobmsble/bms/renogy_bms.py +152 -0
  24. aiobmsble/bms/renogy_pro_bms.py +109 -0
  25. aiobmsble/bms/roypow_bms.py +190 -0
  26. aiobmsble/bms/seplos_bms.py +249 -0
  27. aiobmsble/bms/seplos_v2_bms.py +209 -0
  28. aiobmsble/bms/tdt_bms.py +203 -0
  29. aiobmsble/bms/tianpwr_bms.py +142 -0
  30. aiobmsble/utils.py +16 -6
  31. {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.2.dist-info}/METADATA +3 -2
  32. aiobmsble-0.2.2.dist-info/RECORD +36 -0
  33. aiobmsble-0.2.0.dist-info/RECORD +0 -10
  34. {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.2.dist-info}/WHEEL +0 -0
  35. {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.2.dist-info}/entry_points.txt +0 -0
  36. {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.2.dist-info}/licenses/LICENSE +0 -0
  37. {aiobmsble-0.2.0.dist-info → aiobmsble-0.2.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,211 @@
1
+ """Module to support D-powercore Smart BMS.
2
+
3
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
4
+ License: Apache-2.0, http://www.apache.org/licenses/
5
+ """
6
+
7
+ from enum import IntEnum
8
+ from string import hexdigits
9
+ from typing import Final
10
+
11
+ from bleak.backends.characteristic import BleakGATTCharacteristic
12
+ from bleak.backends.device import BLEDevice
13
+ from bleak.uuids import normalize_uuid_str
14
+
15
+ from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
16
+ from aiobmsble.basebms import BaseBMS
17
+
18
+
19
+ class Cmd(IntEnum):
20
+ """BMS operation codes."""
21
+
22
+ UNLOCKACC = 0x32
23
+ UNLOCKREJ = 0x33
24
+ LEGINFO1 = 0x60
25
+ LEGINFO2 = 0x61
26
+ CELLVOLT = 0x62
27
+ UNLOCK = 0x64
28
+ UNLOCKED = 0x65
29
+ GETINFO = 0xA0
30
+
31
+
32
+ class BMS(BaseBMS):
33
+ """D-powercore Smart BMS class implementation."""
34
+
35
+ _PAGE_LEN: Final[int] = 20
36
+ _MAX_CELLS: Final[int] = 32
37
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
38
+ BMSdp("voltage", 6, 2, False, lambda x: x / 10, Cmd.LEGINFO1),
39
+ BMSdp("current", 8, 2, True, lambda x: x, Cmd.LEGINFO1),
40
+ BMSdp("battery_level", 14, 1, False, lambda x: x, Cmd.LEGINFO1),
41
+ BMSdp("cycle_charge", 12, 2, False, lambda x: x / 1000, Cmd.LEGINFO1),
42
+ BMSdp(
43
+ "temperature",
44
+ 12,
45
+ 2,
46
+ False,
47
+ lambda x: round(x * 0.1 - 273.15, 1),
48
+ Cmd.LEGINFO2,
49
+ ),
50
+ BMSdp(
51
+ "cell_count", 6, 1, False, lambda x: min(x, BMS._MAX_CELLS), Cmd.CELLVOLT
52
+ ),
53
+ BMSdp("cycles", 8, 2, False, lambda x: x, Cmd.LEGINFO2),
54
+ BMSdp("problem_code", 15, 1, False, lambda x: x & 0xFF, Cmd.LEGINFO1),
55
+ )
56
+ _CMDS: Final[set[Cmd]] = {Cmd(field.idx) for field in _FIELDS}
57
+
58
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
59
+ """Intialize private BMS members."""
60
+ super().__init__(ble_device, reconnect)
61
+ assert self._ble_device.name is not None # required for unlock
62
+ self._data_final: dict[int, bytearray] = {}
63
+
64
+ @staticmethod
65
+ def matcher_dict_list() -> list[MatcherPattern]:
66
+ """Provide BluetoothMatcher definition."""
67
+ return [
68
+ {
69
+ "local_name": pattern,
70
+ "service_uuid": BMS.uuid_services()[0],
71
+ "connectable": True,
72
+ }
73
+ for pattern in ("DXB-*", "TBA-*")
74
+ ]
75
+
76
+ @staticmethod
77
+ def device_info() -> dict[str, str]:
78
+ """Return device information for the battery management system."""
79
+ return {"manufacturer": "D-powercore", "model": "Smart BMS"}
80
+
81
+ @staticmethod
82
+ def uuid_services() -> list[str]:
83
+ """Return list of 128-bit UUIDs of services required by BMS."""
84
+ return [normalize_uuid_str("fff0")]
85
+
86
+ @staticmethod
87
+ def uuid_rx() -> str:
88
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
89
+ return "fff4"
90
+
91
+ @staticmethod
92
+ def uuid_tx() -> str:
93
+ """Return 16-bit UUID of characteristic that provides write property."""
94
+ return "fff3"
95
+
96
+ @staticmethod
97
+ def _calc_values() -> frozenset[BMSvalue]:
98
+ return frozenset(
99
+ {
100
+ "battery_charging",
101
+ "cycle_capacity",
102
+ "delta_voltage",
103
+ "power",
104
+ "runtime",
105
+ }
106
+ )
107
+
108
+ async def _notification_handler(
109
+ self, _sender: BleakGATTCharacteristic, data: bytearray
110
+ ) -> None:
111
+ self._log.debug("RX BLE data: %s", data)
112
+
113
+ if len(data) != BMS._PAGE_LEN:
114
+ self._log.debug("invalid page length (%i)", len(data))
115
+ return
116
+
117
+ # ignore ACK responses
118
+ if data[0] & 0x80:
119
+ self._log.debug("ignore acknowledge message")
120
+ return
121
+
122
+ # acknowledge received frame
123
+ await self._await_reply(
124
+ bytes([data[0] | 0x80]) + data[1:], wait_for_notify=False
125
+ )
126
+
127
+ size: Final[int] = data[0]
128
+ page: Final[int] = data[1] >> 4
129
+ maxpg: Final[int] = data[1] & 0xF
130
+
131
+ if page == 1:
132
+ self._data.clear()
133
+
134
+ self._data += data[2 : size + 2]
135
+
136
+ self._log.debug("(%s): %s", "start" if page == 1 else "cnt.", data)
137
+
138
+ if page == maxpg:
139
+ if (crc := BMS._crc(self._data[3:-4])) != int.from_bytes(
140
+ self._data[-4:-2], byteorder="big"
141
+ ):
142
+ self._log.debug(
143
+ "incorrect checksum: 0x%X != 0x%X",
144
+ int.from_bytes(self._data[-4:-2], byteorder="big"),
145
+ crc,
146
+ )
147
+ self._data.clear()
148
+ self._data_final = {} # reset invalid data
149
+ return
150
+
151
+ self._data_final[self._data[3]] = self._data.copy()
152
+ self._data_event.set()
153
+
154
+ @staticmethod
155
+ def _crc(data: bytearray) -> int:
156
+ return sum(data) + 8
157
+
158
+ @staticmethod
159
+ def _cmd(cmd: Cmd, data: bytes) -> bytes:
160
+ frame: bytearray = bytearray([cmd.value, 0x00, 0x00]) + data
161
+ checksum: Final[int] = BMS._crc(frame)
162
+ frame = (
163
+ bytearray([0x3A, 0x03, 0x05])
164
+ + frame
165
+ + bytes([(checksum >> 8) & 0xFF, checksum & 0xFF, 0x0D, 0x0A])
166
+ )
167
+ frame = bytearray([len(frame) + 2, 0x11]) + frame
168
+ frame += bytes(BMS._PAGE_LEN - len(frame))
169
+
170
+ return bytes(frame)
171
+
172
+ async def _init_connection(
173
+ self, char_notify: BleakGATTCharacteristic | int | str | None = None
174
+ ) -> None:
175
+ """Connect to the BMS and setup notification if not connected."""
176
+ await super()._init_connection()
177
+
178
+ # unlock BMS if not TBA version
179
+ if self.name.startswith("TBA-"):
180
+ return
181
+
182
+ if not all(c in hexdigits for c in self.name[-4:]):
183
+ self._log.debug("unable to unlock BMS")
184
+ return
185
+
186
+ pwd = int(self.name[-4:], 16)
187
+ await self._await_reply(
188
+ BMS._cmd(
189
+ Cmd.UNLOCK,
190
+ bytes([(pwd >> 8) & 0xFF, pwd & 0xFF]),
191
+ ),
192
+ wait_for_notify=False,
193
+ )
194
+
195
+ async def _async_update(self) -> BMSsample:
196
+ """Update battery status information."""
197
+ for request in BMS._CMDS:
198
+ await self._await_reply(self._cmd(request, b""))
199
+
200
+ if not BMS._CMDS.issubset(set(self._data_final.keys())):
201
+ raise ValueError("incomplete response set")
202
+
203
+ result: BMSsample = BMS._decode_data(BMS._FIELDS, self._data_final)
204
+ result["cell_voltages"] = BMS._cell_voltages(
205
+ self._data_final[Cmd.CELLVOLT],
206
+ cells=result.get("cell_count", 0),
207
+ start=7,
208
+ )
209
+
210
+ self._data_final.clear()
211
+ return result
@@ -0,0 +1,93 @@
1
+ """Module to support Dummy BMS.
2
+
3
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
4
+ License: Apache-2.0, http://www.apache.org/licenses/
5
+ """
6
+
7
+ from bleak.backends.characteristic import BleakGATTCharacteristic
8
+ from bleak.backends.device import BLEDevice
9
+ from bleak.uuids import normalize_uuid_str
10
+
11
+ from aiobmsble import BMSsample, BMSvalue, MatcherPattern
12
+ from aiobmsble.basebms import BaseBMS
13
+
14
+
15
+ class BMS(BaseBMS):
16
+ """Dummy BMS implementation."""
17
+
18
+ # _HEAD: Final[bytes] = b"\x55" # beginning of frame
19
+ # _TAIL: Final[bytes] = b"\xAA" # end of frame
20
+ # _FRAME_LEN: Final[int] = 10 # length of frame, including SOF and checksum
21
+
22
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
23
+ """Initialize BMS."""
24
+ super().__init__(ble_device, reconnect)
25
+
26
+ @staticmethod
27
+ def matcher_dict_list() -> list[MatcherPattern]:
28
+ """Provide BluetoothMatcher definition."""
29
+ return [{"local_name": "dummy", "connectable": True}] # TODO
30
+
31
+ @staticmethod
32
+ def device_info() -> dict[str, str]:
33
+ """Return device information for the battery management system."""
34
+ return {"manufacturer": "Dummy Manufacturer", "model": "dummy model"} # TODO
35
+
36
+ @staticmethod
37
+ def uuid_services() -> list[str]:
38
+ """Return list of 128-bit UUIDs of services required by BMS."""
39
+ return [normalize_uuid_str("0000")] # TODO: change service UUID here!
40
+
41
+ @staticmethod
42
+ def uuid_rx() -> str:
43
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
44
+ return "0000" # TODO: change RX characteristic UUID here!
45
+
46
+ @staticmethod
47
+ def uuid_tx() -> str:
48
+ """Return 16-bit UUID of characteristic that provides write property."""
49
+ return "0000" # TODO: change TX characteristic UUID here!
50
+
51
+ @staticmethod
52
+ def _calc_values() -> frozenset[BMSvalue]:
53
+ return frozenset(
54
+ {"power", "battery_charging"}
55
+ ) # calculate further values from BMS provided set ones
56
+
57
+ def _notification_handler(
58
+ self, _sender: BleakGATTCharacteristic, data: bytearray
59
+ ) -> None:
60
+ """Handle the RX characteristics notify event (new data arrives)."""
61
+ # self._log.debug("RX BLE data: %s", data)
62
+
63
+ # *******************************************************
64
+ # # TODO: Do things like checking correctness of frame here
65
+ # # and store it into a instance variable, e.g. self._data
66
+ # # Below are some examples of how to do it
67
+ # # Have a look at the BMS base class for function to use,
68
+ # # take a look at other implementations for more details
69
+ # *******************************************************
70
+
71
+ # if not data.startswith(BMS._HEAD):
72
+ # self._log.debug("incorrect SOF")
73
+ # return
74
+
75
+ # if (crc := crc_sum(self._data[:-1])) != self._data[-1]:
76
+ # self._log.debug("invalid checksum 0x%X != 0x%X", self._data[-1], crc)
77
+ # return
78
+
79
+ # self._data = data.copy()
80
+ # self._data_event.set()
81
+
82
+ async def _async_update(self) -> BMSsample:
83
+ """Update battery status information."""
84
+ self._log.debug("replace with command to UUID %s", BMS.uuid_tx())
85
+ # await self._await_reply(b"<some_command>")
86
+
87
+ # # TODO: parse data from self._data here
88
+
89
+ return {
90
+ "voltage": 12,
91
+ "current": 1.5,
92
+ "temperature": 27.182,
93
+ } # TODO: fixed values, replace parsed data
@@ -0,0 +1,155 @@
1
+ """Module to support ECO-WORTHY BMS.
2
+
3
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
4
+ License: Apache-2.0, http://www.apache.org/licenses/
5
+ """
6
+
7
+ import asyncio
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, crc_modbus
16
+
17
+
18
+ class BMS(BaseBMS):
19
+ """ECO-WORTHY BMS implementation."""
20
+
21
+ _HEAD: Final[tuple] = (b"\xa1", b"\xa2")
22
+ _CELL_POS: Final[int] = 14
23
+ _TEMP_POS: Final[int] = 80
24
+ _FIELDS_V1: Final[tuple[BMSdp, ...]] = (
25
+ BMSdp("battery_level", 16, 2, False, lambda x: x, 0xA1),
26
+ BMSdp("voltage", 20, 2, False, lambda x: x / 100, 0xA1),
27
+ BMSdp("current", 22, 2, True, lambda x: x / 100, 0xA1),
28
+ BMSdp("problem_code", 51, 2, False, lambda x: x, 0xA1),
29
+ BMSdp("design_capacity", 26, 2, False, lambda x: x // 100, 0xA1),
30
+ BMSdp("cell_count", _CELL_POS, 2, False, lambda x: x, 0xA2),
31
+ BMSdp("temp_sensors", _TEMP_POS, 2, False, lambda x: x, 0xA2),
32
+ # ("cycles", 0xA1, 8, 2, False, lambda x: x),
33
+ )
34
+ _FIELDS_V2: Final[tuple[BMSdp, ...]] = tuple(
35
+ BMSdp(
36
+ *field[:-2],
37
+ (lambda x: x / 10) if field.key == "current" else field.fct,
38
+ field.idx,
39
+ )
40
+ for field in _FIELDS_V1
41
+ )
42
+
43
+ _CMDS: Final[set[int]] = set({field.idx for field in _FIELDS_V1})
44
+
45
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
46
+ """Initialize BMS."""
47
+ super().__init__(ble_device, reconnect)
48
+ self._mac_head: Final[tuple] = tuple(
49
+ int(self._ble_device.address.replace(":", ""), 16).to_bytes(6) + head
50
+ for head in BMS._HEAD
51
+ )
52
+ self._data_final: dict[int, bytearray] = {}
53
+
54
+ @staticmethod
55
+ def matcher_dict_list() -> list[MatcherPattern]:
56
+ """Provide BluetoothMatcher definition."""
57
+ return [MatcherPattern(local_name="ECO-WORTHY 02_*", connectable=True)] + [
58
+ MatcherPattern(
59
+ local_name=pattern,
60
+ service_uuid=BMS.uuid_services()[0],
61
+ connectable=True,
62
+ )
63
+ for pattern in ("DCHOUSE*", "ECO-WORTHY*")
64
+ ]
65
+
66
+ @staticmethod
67
+ def device_info() -> dict[str, str]:
68
+ """Return device information for the battery management system."""
69
+ return {"manufacturer": "ECO-WORTHY", "model": "BW02"}
70
+
71
+ @staticmethod
72
+ def uuid_services() -> list[str]:
73
+ """Return list of 128-bit UUIDs of services required by BMS."""
74
+ return [normalize_uuid_str("fff0")]
75
+
76
+ @staticmethod
77
+ def uuid_rx() -> str:
78
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
79
+ return "fff1"
80
+
81
+ @staticmethod
82
+ def uuid_tx() -> str:
83
+ """Return 16-bit UUID of characteristic that provides write property."""
84
+ raise NotImplementedError
85
+
86
+ @staticmethod
87
+ def _calc_values() -> frozenset[BMSvalue]:
88
+ return frozenset(
89
+ {
90
+ "battery_charging",
91
+ "cycle_charge",
92
+ "cycle_capacity",
93
+ "delta_voltage",
94
+ "power",
95
+ "runtime",
96
+ "temperature",
97
+ }
98
+ ) # calculate further values from BMS provided set ones
99
+
100
+ def _notification_handler(
101
+ self, _sender: BleakGATTCharacteristic, data: bytearray
102
+ ) -> None:
103
+ """Handle the RX characteristics notify event (new data arrives)."""
104
+ self._log.debug("RX BLE data: %s", data)
105
+
106
+ if not data.startswith(BMS._HEAD + self._mac_head):
107
+ self._log.debug("invalid frame type: '%s'", data[0:1].hex())
108
+ return
109
+
110
+ if (crc := crc_modbus(data[:-2])) != int.from_bytes(data[-2:], "little"):
111
+ self._log.debug(
112
+ "invalid checksum 0x%X != 0x%X",
113
+ int.from_bytes(data[-2:], "little"),
114
+ crc,
115
+ )
116
+ self._data = bytearray()
117
+ return
118
+
119
+ # copy final data without message type and adapt to protocol type
120
+ shift: Final[bool] = data.startswith(self._mac_head)
121
+ self._data_final[data[6 if shift else 0]] = (
122
+ bytearray(2 if shift else 0) + data.copy()
123
+ )
124
+ if BMS._CMDS.issubset(self._data_final.keys()):
125
+ self._data_event.set()
126
+
127
+ async def _async_update(self) -> BMSsample:
128
+ """Update battery status information."""
129
+
130
+ self._data_final.clear()
131
+ self._data_event.clear() # clear event to ensure new data is acquired
132
+ await asyncio.wait_for(self._wait_event(), timeout=BMS.TIMEOUT)
133
+
134
+ result: BMSsample = BMS._decode_data(
135
+ (
136
+ BMS._FIELDS_V1
137
+ if self._data_final[0xA1].startswith(BMS._HEAD)
138
+ else BMS._FIELDS_V2
139
+ ),
140
+ self._data_final,
141
+ )
142
+
143
+ result["cell_voltages"] = BMS._cell_voltages(
144
+ self._data_final[0xA2],
145
+ cells=result.get("cell_count", 0),
146
+ start=BMS._CELL_POS + 2,
147
+ )
148
+ result["temp_values"] = BMS._temp_values(
149
+ self._data_final[0xA2],
150
+ values=result.get("temp_sensors", 0),
151
+ start=BMS._TEMP_POS + 2,
152
+ divider=10,
153
+ )
154
+
155
+ return result
@@ -0,0 +1,181 @@
1
+ """Module to support Ective BMS.
2
+
3
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
4
+ License: Apache-2.0, http://www.apache.org/licenses/
5
+ """
6
+
7
+ import asyncio
8
+ from string import hexdigits
9
+ from typing import Final, Literal
10
+
11
+ from bleak.backends.characteristic import BleakGATTCharacteristic
12
+ from bleak.backends.device import BLEDevice
13
+ from bleak.uuids import normalize_uuid_str
14
+
15
+ from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
16
+ from aiobmsble.basebms import BaseBMS
17
+
18
+
19
+ class BMS(BaseBMS):
20
+ """Ective BMS implementation."""
21
+
22
+ _HEAD_RSP: Final[tuple[bytes, ...]] = (b"\x5e", b"\x83") # header for responses
23
+ _MAX_CELLS: Final[int] = 16
24
+ _INFO_LEN: Final[int] = 113
25
+ _CRC_LEN: Final[int] = 4
26
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
27
+ BMSdp("voltage", 1, 8, False, lambda x: x / 1000),
28
+ BMSdp("current", 9, 8, True, lambda x: x / 1000),
29
+ BMSdp("battery_level", 29, 4, False, lambda x: x),
30
+ BMSdp("cycle_charge", 17, 8, False, lambda x: x / 1000),
31
+ BMSdp("cycles", 25, 4, False, lambda x: x),
32
+ BMSdp("temperature", 33, 4, False, lambda x: round(x * 0.1 - 273.15, 1)),
33
+ BMSdp("problem_code", 37, 2, False, lambda x: x),
34
+ )
35
+
36
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
37
+ """Initialize BMS."""
38
+ super().__init__(ble_device, reconnect)
39
+ self._data_final: bytearray = bytearray()
40
+
41
+ @staticmethod
42
+ def matcher_dict_list() -> list[MatcherPattern]:
43
+ """Provide BluetoothMatcher definition."""
44
+ return [
45
+ {
46
+ "service_uuid": BMS.uuid_services()[0],
47
+ "connectable": True,
48
+ "manufacturer_id": m_id,
49
+ }
50
+ for m_id in (0, 0xFFFF)
51
+ ]
52
+
53
+ @staticmethod
54
+ def device_info() -> dict[str, str]:
55
+ """Return device information for the battery management system."""
56
+ return {"manufacturer": "Ective", "model": "Smart BMS"}
57
+
58
+ @staticmethod
59
+ def uuid_services() -> list[str]:
60
+ """Return list of 128-bit UUIDs of services required by BMS."""
61
+ return [normalize_uuid_str("ffe0")]
62
+
63
+ @staticmethod
64
+ def uuid_rx() -> str:
65
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
66
+ return "ffe4"
67
+
68
+ @staticmethod
69
+ def uuid_tx() -> str:
70
+ """Return 16-bit UUID of characteristic that provides write property."""
71
+ raise NotImplementedError
72
+
73
+ @staticmethod
74
+ def _calc_values() -> frozenset[BMSvalue]:
75
+ return frozenset(
76
+ {
77
+ "battery_charging",
78
+ "cycle_capacity",
79
+ "cycle_charge",
80
+ "delta_voltage",
81
+ "power",
82
+ "runtime",
83
+ }
84
+ ) # calculate further values from BMS provided set ones
85
+
86
+ def _notification_handler(
87
+ self, _sender: BleakGATTCharacteristic, data: bytearray
88
+ ) -> None:
89
+ """Handle the RX characteristics notify event (new data arrives)."""
90
+
91
+ if (
92
+ start := next(
93
+ (i for i, b in enumerate(data) if bytes([b]) in BMS._HEAD_RSP), -1
94
+ )
95
+ ) != -1: # check for beginning of frame
96
+ data = data[start:]
97
+ self._data.clear()
98
+
99
+ self._data += data
100
+ self._log.debug(
101
+ "RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
102
+ )
103
+
104
+ if len(self._data) < BMS._INFO_LEN:
105
+ return
106
+
107
+ self._data = self._data[: BMS._INFO_LEN] # cut off exceeding data
108
+
109
+ if not (
110
+ self._data.startswith(BMS._HEAD_RSP)
111
+ and set(self._data.decode(errors="replace")[1:]).issubset(hexdigits)
112
+ ):
113
+ self._log.debug("incorrect frame coding: %s", self._data)
114
+ self._data.clear()
115
+ return
116
+
117
+ if (crc := BMS._crc(self._data[1 : -BMS._CRC_LEN])) != int(
118
+ self._data[-BMS._CRC_LEN :], 16
119
+ ):
120
+ self._log.debug(
121
+ "invalid checksum 0x%X != 0x%X",
122
+ int(self._data[-BMS._CRC_LEN :], 16),
123
+ crc,
124
+ )
125
+ self._data.clear()
126
+ return
127
+
128
+ self._data_final = self._data.copy()
129
+ self._data_event.set()
130
+
131
+ @staticmethod
132
+ def _crc(data: bytearray) -> int:
133
+ return sum(int(data[idx : idx + 2], 16) for idx in range(0, len(data), 2))
134
+
135
+ @staticmethod
136
+ def _cell_voltages(
137
+ data: bytearray,
138
+ *,
139
+ cells: int,
140
+ start: int,
141
+ size: int = 2,
142
+ byteorder: Literal["little", "big"] = "big",
143
+ divider: int = 1000,
144
+ ) -> list[float]:
145
+ """Parse cell voltages from status message."""
146
+ return [
147
+ (value / divider)
148
+ for idx in range(cells)
149
+ if (
150
+ value := BMS._conv_int(
151
+ data[start + idx * size : start + (idx + 1) * size]
152
+ )
153
+ )
154
+ ]
155
+
156
+ @staticmethod
157
+ def _conv_int(data: bytearray, sign: bool = False) -> int:
158
+ return int.from_bytes(
159
+ bytes.fromhex(data.decode("ascii", errors="strict")),
160
+ byteorder="little",
161
+ signed=sign,
162
+ )
163
+
164
+ @staticmethod
165
+ def _conv_data(data: bytearray) -> BMSsample:
166
+ result: BMSsample = {}
167
+ for field in BMS._FIELDS:
168
+ result[field.key] = field.fct(
169
+ BMS._conv_int(data[field.pos : field.pos + field.size], field.signed)
170
+ )
171
+ return result
172
+
173
+ async def _async_update(self) -> BMSsample:
174
+ """Update battery status information."""
175
+
176
+ await asyncio.wait_for(self._wait_event(), timeout=BMS.TIMEOUT)
177
+ return self._conv_data(self._data_final) | {
178
+ "cell_voltages": BMS._cell_voltages(
179
+ self._data_final, cells=BMS._MAX_CELLS, start=45, size=4
180
+ )
181
+ }