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
@@ -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
@@ -0,0 +1,168 @@
1
+ """Module to support CBT Power Smart 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_sum
11
+
12
+
13
+ class BMS(BaseBMS):
14
+ """CBT Power Smart BMS class implementation."""
15
+
16
+ HEAD: Final[bytes] = bytes([0xAA, 0x55])
17
+ TAIL_RX: Final[bytes] = bytes([0x0D, 0x0A])
18
+ TAIL_TX: Final[bytes] = bytes([0x0A, 0x0D])
19
+ MIN_FRAME: Final[int] = len(HEAD) + len(TAIL_RX) + 3 # CMD, LEN, CRC, 1 Byte each
20
+ CRC_POS: Final[int] = -len(TAIL_RX) - 1
21
+ LEN_POS: Final[int] = 3
22
+ CMD_POS: Final[int] = 2
23
+ CELL_VOLTAGE_CMDS: Final[list[int]] = [0x5, 0x6, 0x7, 0x8]
24
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
25
+ BMSdp("voltage", 4, 4, False, lambda x: x / 1000, 0x0B),
26
+ BMSdp("current", 8, 4, True, lambda x: x / 1000, 0x0B),
27
+ BMSdp("temperature", 4, 2, True, lambda x: x, 0x09),
28
+ BMSdp("battery_level", 4, 1, False, lambda x: x, 0x0A),
29
+ BMSdp("design_capacity", 4, 2, False, lambda x: x, 0x15),
30
+ BMSdp("cycles", 6, 2, False, lambda x: x, 0x15),
31
+ BMSdp("runtime", 14, 2, False, lambda x: x * BMS._HRS_TO_SECS / 100, 0x0C),
32
+ BMSdp("problem_code", 4, 4, False, lambda x: x, 0x21),
33
+ )
34
+ _CMDS: Final[list[int]] = list({field.idx for field in _FIELDS})
35
+
36
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
37
+ """Intialize private BMS members."""
38
+ super().__init__(ble_device, reconnect)
39
+
40
+ @staticmethod
41
+ def matcher_dict_list() -> list[MatcherPattern]:
42
+ """Provide BluetoothMatcher definition."""
43
+ return [
44
+ {"service_uuid": BMS.uuid_services()[0], "connectable": True},
45
+ { # Creabest
46
+ "service_uuid": normalize_uuid_str("fff0"),
47
+ "manufacturer_id": 0,
48
+ "connectable": True,
49
+ },
50
+ {
51
+ "service_uuid": normalize_uuid_str("03c1"),
52
+ "manufacturer_id": 0x5352,
53
+ "connectable": True,
54
+ },
55
+ ]
56
+
57
+ @staticmethod
58
+ def device_info() -> dict[str, str]:
59
+ """Return device information for the battery management system."""
60
+ return {"manufacturer": "CBT Power", "model": "Smart BMS"}
61
+
62
+ @staticmethod
63
+ def uuid_services() -> list[str]:
64
+ """Return list of services required by BMS."""
65
+ return [normalize_uuid_str("ffe5"), normalize_uuid_str("ffe0")]
66
+
67
+ @staticmethod
68
+ def uuid_rx() -> str:
69
+ """Return characteristic that provides notification/read property."""
70
+ return "ffe4"
71
+
72
+ @staticmethod
73
+ def uuid_tx() -> str:
74
+ """Return characteristic that provides write property."""
75
+ return "ffe9"
76
+
77
+ @staticmethod
78
+ def _calc_values() -> frozenset[BMSvalue]:
79
+ return frozenset(
80
+ {
81
+ "power",
82
+ "battery_charging",
83
+ "delta_voltage",
84
+ "cycle_capacity",
85
+ "temperature",
86
+ }
87
+ )
88
+
89
+ def _notification_handler(
90
+ self, _sender: BleakGATTCharacteristic, data: bytearray
91
+ ) -> None:
92
+ """Retrieve BMS data update."""
93
+ self._log.debug("RX BLE data: %s", data)
94
+
95
+ # verify that data is long enough
96
+ if len(data) < BMS.MIN_FRAME or len(data) != BMS.MIN_FRAME + data[BMS.LEN_POS]:
97
+ self._log.debug("incorrect frame length (%i): %s", len(data), data)
98
+ return
99
+
100
+ if not data.startswith(BMS.HEAD) or not data.endswith(BMS.TAIL_RX):
101
+ self._log.debug("incorrect frame start/end: %s", data)
102
+ return
103
+
104
+ if (crc := crc_sum(data[len(BMS.HEAD) : len(data) + BMS.CRC_POS])) != data[
105
+ BMS.CRC_POS
106
+ ]:
107
+ self._log.debug(
108
+ "invalid checksum 0x%X != 0x%X",
109
+ data[len(data) + BMS.CRC_POS],
110
+ crc,
111
+ )
112
+ return
113
+
114
+ self._data = data
115
+ self._data_event.set()
116
+
117
+ @staticmethod
118
+ def _cmd(cmd: bytes, value: list[int] | None = None) -> bytes:
119
+ """Assemble a CBT Power BMS command."""
120
+ value = [] if value is None else value
121
+ assert len(value) <= 255
122
+ frame = bytearray([*BMS.HEAD, cmd[0], len(value), *value])
123
+ frame.append(crc_sum(frame[len(BMS.HEAD) :]))
124
+ frame.extend(BMS.TAIL_TX)
125
+ return bytes(frame)
126
+
127
+ async def _async_update(self) -> BMSsample:
128
+ """Update battery status information."""
129
+ resp_cache: dict[int, bytearray] = {} # avoid multiple queries
130
+ for cmd in BMS._CMDS:
131
+ self._log.debug("request command 0x%X.", cmd)
132
+ try:
133
+ await self._await_reply(BMS._cmd(cmd.to_bytes(1)))
134
+ except TimeoutError:
135
+ continue
136
+ if cmd != self._data[BMS.CMD_POS]:
137
+ self._log.debug(
138
+ "incorrect response 0x%X to command 0x%X",
139
+ self._data[BMS.CMD_POS],
140
+ cmd,
141
+ )
142
+ resp_cache[self._data[BMS.CMD_POS]] = self._data.copy()
143
+
144
+ voltages: list[float] = []
145
+ for cmd in BMS.CELL_VOLTAGE_CMDS:
146
+ try:
147
+ await self._await_reply(BMS._cmd(cmd.to_bytes(1)))
148
+ except TimeoutError:
149
+ break
150
+ cells: list[float] = BMS._cell_voltages(
151
+ self._data, cells=5, start=4, byteorder="little"
152
+ )
153
+ voltages.extend(cells)
154
+ if len(voltages) % 5 or len(cells) == 0:
155
+ break
156
+
157
+ data: BMSsample = BMS._decode_data(BMS._FIELDS, resp_cache, byteorder="little")
158
+
159
+ # get cycle charge from design capacity and SoC
160
+ if data.get("design_capacity") and data.get("battery_level"):
161
+ data["cycle_charge"] = (
162
+ data.get("design_capacity", 0) * data.get("battery_level", 0) / 100
163
+ )
164
+ # remove runtime if not discharging
165
+ if data.get("current", 0) >= 0:
166
+ data.pop("runtime", None)
167
+
168
+ return data | {"cell_voltages": voltages}