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,218 @@
1
+ """Module to support Offgridtec Smart Pro BMS.
2
+
3
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
4
+ License: Apache-2.0, http://www.apache.org/licenses/
5
+ """
6
+
7
+ from collections.abc import Callable
8
+ from string import digits, hexdigits
9
+ from typing import Any, Final, NamedTuple
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 BMSsample, BMSvalue, MatcherPattern
16
+ from aiobmsble.basebms import BaseBMS
17
+
18
+
19
+ class BMS(BaseBMS):
20
+ """Offgridtec LiFePO4 Smart Pro type A and type B BMS implementation."""
21
+
22
+ _IDX_NAME: Final = 0
23
+ _IDX_LEN: Final = 1
24
+ _IDX_FCT: Final = 2
25
+ # magic crypt sequence of length 16
26
+ _CRYPT_SEQ: Final[list[int]] = [2, 5, 4, 3, 1, 4, 1, 6, 8, 3, 7, 2, 5, 8, 9, 3]
27
+
28
+ class _Response(NamedTuple):
29
+ valid: bool
30
+ reg: int
31
+ value: int
32
+
33
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
34
+ """Intialize private BMS members."""
35
+ super().__init__(ble_device, reconnect)
36
+ self._type: str = (
37
+ self.name[9]
38
+ if len(self.name) >= 10 and set(self.name[10:]).issubset(digits)
39
+ else "?"
40
+ )
41
+ self._key: int = (
42
+ sum(BMS._CRYPT_SEQ[int(c, 16)] for c in (f"{int(self.name[10:]):0>4X}"))
43
+ if self._type in "AB"
44
+ else 0
45
+ ) + (5 if (self._type == "A") else 8)
46
+ self._log.info(
47
+ "%s type: %c, ID: %s, key: 0x%X",
48
+ self.device_id(),
49
+ self._type,
50
+ self.name[10:],
51
+ self._key,
52
+ )
53
+ self._exp_reply: int = 0x0
54
+ self._response: BMS._Response = BMS._Response(False, 0, 0)
55
+ self._REGISTERS: dict[int, tuple[BMSvalue, int, Callable[[int], Any]]]
56
+ if self._type == "A":
57
+ self._REGISTERS = {
58
+ # SOC (State of Charge)
59
+ 2: ("battery_level", 1, lambda x: x),
60
+ 4: ("cycle_charge", 3, lambda x: x / 1000),
61
+ 8: ("voltage", 2, lambda x: x / 1000),
62
+ # MOS temperature
63
+ 12: ("temperature", 2, lambda x: round(x * 0.1 - 273.15, 1)),
64
+ # 3rd byte of current is 0 (should be 1 as for B version)
65
+ 16: ("current", 3, lambda x: x / 100),
66
+ 24: ("runtime", 2, lambda x: x * 60),
67
+ 44: ("cycles", 2, lambda x: x),
68
+ # Type A batteries have no cell voltage registers
69
+ }
70
+ self._HEADER = "+RAA"
71
+ elif self._type == "B":
72
+ self._REGISTERS = {
73
+ # MOS temperature
74
+ 8: ("temperature", 2, lambda x: round(x * 0.1 - 273.15, 1)),
75
+ 9: ("voltage", 2, lambda x: x / 1000),
76
+ 10: ("current", 3, lambda x: x / 1000),
77
+ # SOC (State of Charge)
78
+ 13: ("battery_level", 1, lambda x: x),
79
+ 15: ("cycle_charge", 3, lambda x: x / 1000),
80
+ 18: ("runtime", 2, lambda x: x * 60),
81
+ 23: ("cycles", 2, lambda x: x),
82
+ }
83
+ # add cell voltage registers, note: need to be last!
84
+ self._HEADER = "+R16"
85
+ else:
86
+ self._REGISTERS = {}
87
+ self._log.exception("unkown device type '%c'", self._type)
88
+
89
+ @staticmethod
90
+ def matcher_dict_list() -> list[MatcherPattern]:
91
+ """Return a list of Bluetooth matchers."""
92
+ return [
93
+ {
94
+ "local_name": "SmartBat-[AB]*",
95
+ "service_uuid": BMS.uuid_services()[0],
96
+ "connectable": True,
97
+ }
98
+ ]
99
+
100
+ @staticmethod
101
+ def device_info() -> dict[str, str]:
102
+ """Return a dictionary of device information."""
103
+ return {"manufacturer": "Offgridtec", "model": "LiFePo4 Smart Pro"}
104
+
105
+ @staticmethod
106
+ def uuid_services() -> list[str]:
107
+ """Return list of 128-bit UUIDs of services required by BMS."""
108
+ return [normalize_uuid_str("fff0")]
109
+
110
+ @staticmethod
111
+ def uuid_rx() -> str:
112
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
113
+ return "fff4"
114
+
115
+ @staticmethod
116
+ def uuid_tx() -> str:
117
+ """Return 16-bit UUID of characteristic that provides write property."""
118
+ return "fff6"
119
+
120
+ @staticmethod
121
+ def _calc_values() -> frozenset[BMSvalue]:
122
+ return frozenset(
123
+ {"cycle_capacity", "power", "battery_charging", "delta_voltage"}
124
+ )
125
+
126
+ def _notification_handler(
127
+ self, _sender: BleakGATTCharacteristic, data: bytearray
128
+ ) -> None:
129
+ self._log.debug("RX BLE data: %s", data)
130
+
131
+ self._response = self._ogt_response(data)
132
+
133
+ # check that descrambled message is valid
134
+ if not self._response.valid:
135
+ self._log.debug("response data is invalid")
136
+ return
137
+
138
+ if self._response.reg not in (-1, self._exp_reply):
139
+ self._log.debug("wrong register response")
140
+ return
141
+
142
+ self._exp_reply = -1
143
+ self._data_event.set()
144
+
145
+ def _ogt_response(self, resp: bytearray) -> _Response:
146
+ """Descramble a response from the BMS."""
147
+
148
+ try:
149
+ msg: Final[str] = bytearray(
150
+ (resp[x] ^ self._key) for x in range(len(resp))
151
+ ).decode(encoding="ascii")
152
+ except UnicodeDecodeError:
153
+ return BMS._Response(False, -1, 0)
154
+
155
+ self._log.debug("response: %s", msg.rstrip("\r\n"))
156
+ # verify correct response
157
+ if len(msg) < 8 or not msg.startswith("+RD,"):
158
+ return BMS._Response(False, -1, 0)
159
+ if msg[4:7] == "Err":
160
+ return BMS._Response(True, -1, 0)
161
+ if not msg.endswith("\r\n") or not all(c in hexdigits for c in msg[4:-2]):
162
+ return BMS._Response(False, -1, 0)
163
+
164
+ # 16-bit value in network order (plus optional multiplier for 24-bit values)
165
+ # multiplier has 1 as minimum due to current value in A type battery
166
+ signed: bool = len(msg) > 12
167
+ value: int = int.from_bytes(
168
+ bytes.fromhex(msg[6:10]), byteorder="little", signed=signed
169
+ ) * (max(int(msg[10:12], 16), 1) if signed else 1)
170
+ return BMS._Response(True, int(msg[4:6], 16), value)
171
+
172
+ def _ogt_command(self, reg: int, length: int) -> bytes:
173
+ """Put together an scambled query to the BMS."""
174
+
175
+ cmd: Final[str] = f"{self._HEADER}{reg:0>2X}{length:0>2X}"
176
+ self._log.debug("command: %s", cmd)
177
+
178
+ return bytes(ord(cmd[i]) ^ self._key for i in range(len(cmd)))
179
+
180
+ async def _async_update(self) -> BMSsample:
181
+ """Update battery status information."""
182
+ result: BMSsample = {}
183
+
184
+ for reg in list(self._REGISTERS):
185
+ self._exp_reply = reg
186
+ await self._await_reply(
187
+ data=self._ogt_command(reg, self._REGISTERS[reg][BMS._IDX_LEN])
188
+ )
189
+ if self._response.reg < 0:
190
+ raise TimeoutError
191
+
192
+ name, _length, func = self._REGISTERS[self._response.reg]
193
+ result[name] = func(self._response.value)
194
+ self._log.debug(
195
+ "decoded data: reg: %s (#%i), raw: %i, value: %f",
196
+ name,
197
+ reg,
198
+ self._response.value,
199
+ result.get(name),
200
+ )
201
+
202
+ # read cell voltages for type B battery
203
+ if self._type == "B":
204
+ for cell_reg in range(16):
205
+ self._exp_reply = 63 - cell_reg
206
+ await self._await_reply(data=self._ogt_command(63 - cell_reg, 2))
207
+ if self._response.reg < 0:
208
+ self._log.debug("cell count: %i", cell_reg)
209
+ break
210
+ result.setdefault("cell_voltages", []).append(
211
+ self._response.value / 1000
212
+ )
213
+
214
+ # remove remaining runtime if battery is charging
215
+ if result.get("runtime") == 0xFFFF * 60:
216
+ del result["runtime"]
217
+
218
+ return result
@@ -0,0 +1,148 @@
1
+ """Module to support Pro 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
16
+
17
+
18
+ class BMS(BaseBMS):
19
+ """Pro BMS Smart Shunt class implementation."""
20
+
21
+ _HEAD: Final[bytes] = bytes([0x55, 0xAA])
22
+ _MIN_LEN: Final[int] = 5
23
+ _INIT_RESP: Final[int] = 0x03
24
+ _RT_DATA: Final[int] = 0x04
25
+
26
+ # Commands from btsnoop capture
27
+ _CMD_INIT: Final[bytes] = bytes.fromhex("55aa0a0101558004077f648e682b")
28
+ _CMD_ACK: Final[bytes] = bytes.fromhex("55aa070101558040000095")
29
+ _CMD_DATA_STREAM: Final[bytes] = bytes.fromhex("55aa070101558042000097")
30
+ # command that triggers data streaming (Function 0x43)
31
+ _CMD_TRIGGER_DATA: Final[bytes] = bytes.fromhex("55aa0901015580430000120084")
32
+
33
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
34
+ BMSdp("voltage", 8, 2, False, lambda x: x / 100),
35
+ BMSdp(
36
+ "current",
37
+ 12,
38
+ 4,
39
+ False,
40
+ lambda x: ((x & 0xFFFF) / 1000) * (-1 if (x >> 24) & 0x80 else 1),
41
+ ),
42
+ BMSdp("problem_code", 15, 4, False, lambda x: x & 0x7F),
43
+ BMSdp(
44
+ "temperature",
45
+ 16,
46
+ 3,
47
+ False,
48
+ lambda x: ((x & 0xFFFF) / 10) * (-1 if x >> 16 else 1),
49
+ ),
50
+ BMSdp("cycle_charge", 20, 4, False, lambda x: x / 100),
51
+ BMSdp("battery_level", 24, 1, False, lambda x: x),
52
+ BMSdp("power", 32, 4, False, lambda x: x / 100),
53
+ )
54
+
55
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
56
+ """Initialize private BMS members."""
57
+ super().__init__(ble_device, reconnect)
58
+ self._valid_reply: int = BMS._RT_DATA
59
+
60
+ @staticmethod
61
+ def matcher_dict_list() -> list[MatcherPattern]:
62
+ """Provide BluetoothMatcher definition."""
63
+ return [
64
+ MatcherPattern(
65
+ local_name="Pro BMS",
66
+ service_uuid=BMS.uuid_services()[0],
67
+ connectable=True,
68
+ )
69
+ ]
70
+
71
+ @staticmethod
72
+ def device_info() -> dict[str, str]:
73
+ """Return device information for the battery management system."""
74
+ return {"manufacturer": "Pro BMS", "model": "Smart Shunt"}
75
+
76
+ @staticmethod
77
+ def uuid_services() -> list[str]:
78
+ """Return list of 128-bit UUIDs of services required by BMS."""
79
+ return [normalize_uuid_str("fff0")]
80
+
81
+ @staticmethod
82
+ def uuid_rx() -> str:
83
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
84
+ return "fff4"
85
+
86
+ @staticmethod
87
+ def uuid_tx() -> str:
88
+ """Return 16-bit UUID of characteristic that provides write property."""
89
+ return "fff3"
90
+
91
+ @staticmethod
92
+ def _calc_values() -> frozenset[BMSvalue]:
93
+ return frozenset({"battery_charging", "cycle_capacity", "runtime"})
94
+
95
+ def _notification_handler(
96
+ self, _sender: BleakGATTCharacteristic, data: bytearray
97
+ ) -> None:
98
+ self._log.debug("RX BLE data: %s", data)
99
+
100
+ if len(data) < BMS._MIN_LEN or not data.startswith(BMS._HEAD):
101
+ self._log.debug("Invalid packet header")
102
+ return
103
+
104
+ if data[3] != self._valid_reply:
105
+ self._log.debug("unexpected response (type 0x%X)", data[3])
106
+ return
107
+
108
+ if len(data) != data[2] + BMS._MIN_LEN:
109
+ self._log.debug("incorrect frame length: %i).", len(self._data))
110
+ return
111
+
112
+ self._data = data
113
+ self._data_event.set()
114
+
115
+ async def _init_connection(
116
+ self, char_notify: BleakGATTCharacteristic | int | str | None = None
117
+ ) -> None:
118
+ """Initialize RX/TX characteristics and protocol state."""
119
+ await super()._init_connection()
120
+ self._valid_reply = BMS._INIT_RESP
121
+
122
+ # Step 1: Send initialization command and await response
123
+ await self._await_reply(BMS._CMD_INIT)
124
+
125
+ # Step 2: Send ACK command
126
+ # Step 3: Send data stream command
127
+ # Step 4: Send trigger data command 0x43 - start RT data stream
128
+ for cmd in (BMS._CMD_ACK, BMS._CMD_DATA_STREAM, BMS._CMD_TRIGGER_DATA):
129
+ await self._await_reply(cmd, wait_for_notify=False)
130
+
131
+ self._valid_reply = BMS._RT_DATA
132
+
133
+ async def _async_update(self) -> BMSsample:
134
+ """Update battery status information."""
135
+
136
+ self._data_event.clear() # Clear the event to ensure fresh data on each update
137
+ try:
138
+ # Wait for new data packet
139
+ await asyncio.wait_for(self._wait_event(), timeout=BMS.TIMEOUT)
140
+ except TimeoutError:
141
+ await self.disconnect()
142
+ raise
143
+
144
+ result: BMSsample = BMS._decode_data(
145
+ BMS._FIELDS, self._data, byteorder="little"
146
+ )
147
+ result["power"] *= -1 if result["current"] < 0 else 1
148
+ return result
@@ -0,0 +1,131 @@
1
+ """Module to support Redodo 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_sum
15
+
16
+
17
+ class BMS(BaseBMS):
18
+ """Redodo BMS implementation."""
19
+
20
+ _HEAD_LEN: Final[int] = 3
21
+ _MAX_CELLS: Final[int] = 16
22
+ _MAX_TEMP: Final[int] = 3
23
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
24
+ BMSdp("voltage", 12, 2, False, lambda x: x / 1000),
25
+ BMSdp("current", 48, 4, True, lambda x: x / 1000),
26
+ BMSdp("battery_level", 90, 2, False, lambda x: x),
27
+ BMSdp("cycle_charge", 62, 2, False, lambda x: x / 100),
28
+ BMSdp("cycles", 96, 4, False, lambda x: x),
29
+ BMSdp("problem_code", 76, 4, False, lambda x: x),
30
+ )
31
+
32
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
33
+ """Initialize BMS."""
34
+ super().__init__(ble_device, reconnect)
35
+
36
+ @staticmethod
37
+ def matcher_dict_list() -> list[MatcherPattern]:
38
+ """Provide BluetoothMatcher definition."""
39
+ return [
40
+ { # patterns required to exclude "BT-ROCC2440"
41
+ "local_name": pattern,
42
+ "service_uuid": BMS.uuid_services()[0],
43
+ "manufacturer_id": 0x585A,
44
+ "connectable": True,
45
+ }
46
+ for pattern in (
47
+ "R-12*",
48
+ "R-24*",
49
+ "RO-12*",
50
+ "RO-24*",
51
+ "P-12*",
52
+ "P-24*",
53
+ "PQ-12*",
54
+ "PQ-24*",
55
+ "L-12*", # vv *** LiTime *** vv
56
+ "L-24*",
57
+ "L-51*",
58
+ "LT-12???BG-A0[7-9]*", # LiTime based on ser#
59
+ "LT-51*",
60
+ )
61
+ ]
62
+
63
+ @staticmethod
64
+ def device_info() -> dict[str, str]:
65
+ """Return device information for the battery management system."""
66
+ return {"manufacturer": "Redodo", "model": "Bluetooth battery"}
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")]
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 "ffe2"
82
+
83
+ @staticmethod
84
+ def _calc_values() -> frozenset[BMSvalue]:
85
+ return frozenset(
86
+ {
87
+ "battery_charging",
88
+ "delta_voltage",
89
+ "cycle_capacity",
90
+ "power",
91
+ "runtime",
92
+ "temperature",
93
+ }
94
+ ) # calculate further values from BMS provided set ones
95
+
96
+ def _notification_handler(
97
+ self, _sender: BleakGATTCharacteristic, data: bytearray
98
+ ) -> None:
99
+ """Handle the RX characteristics notify event (new data arrives)."""
100
+ self._log.debug("RX BLE data: %s", data)
101
+
102
+ if len(data) < 3 or not data.startswith(b"\x00\x00"):
103
+ self._log.debug("incorrect SOF.")
104
+ return
105
+
106
+ if len(data) != data[2] + BMS._HEAD_LEN + 1: # add header length and CRC
107
+ self._log.debug("incorrect frame length (%i)", len(data))
108
+ return
109
+
110
+ if (crc := crc_sum(data[:-1])) != data[-1]:
111
+ self._log.debug("invalid checksum 0x%X != 0x%X", data[len(data) - 1], crc)
112
+ return
113
+
114
+ self._data = data
115
+ self._data_event.set()
116
+
117
+ async def _async_update(self) -> BMSsample:
118
+ """Update battery status information."""
119
+ await self._await_reply(b"\x00\x00\x04\x01\x13\x55\xaa\x17")
120
+
121
+ result: BMSsample = BMS._decode_data(
122
+ BMS._FIELDS, self._data, byteorder="little"
123
+ )
124
+ result["cell_voltages"] = BMS._cell_voltages(
125
+ self._data, cells=BMS._MAX_CELLS, start=16, byteorder="little"
126
+ )
127
+ result["temp_values"] = BMS._temp_values(
128
+ self._data, values=BMS._MAX_TEMP, start=52, byteorder="little"
129
+ )
130
+
131
+ return result
@@ -0,0 +1,152 @@
1
+ """Module to support Renogy BMS.
2
+
3
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
4
+ """
5
+
6
+ from typing import Final
7
+
8
+ from bleak.backends.characteristic import BleakGATTCharacteristic
9
+ from bleak.backends.device import BLEDevice
10
+ from bleak.uuids import normalize_uuid_str
11
+
12
+ from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
13
+ from aiobmsble.basebms import BaseBMS, crc_modbus
14
+
15
+
16
+ class BMS(BaseBMS):
17
+ """Renogy battery class implementation."""
18
+
19
+ HEAD: bytes = b"\x30\x03" # SOP, read fct (x03)
20
+ _CRC_POS: Final[int] = -2
21
+ _TEMP_POS: Final[int] = 37
22
+ _CELL_POS: Final[int] = 3
23
+ FIELDS: tuple[BMSdp, ...] = (
24
+ BMSdp("voltage", 5, 2, False, lambda x: x / 10),
25
+ BMSdp("current", 3, 2, True, lambda x: x / 100),
26
+ BMSdp("design_capacity", 11, 4, False, lambda x: x // 1000),
27
+ BMSdp("cycle_charge", 7, 4, False, lambda x: x / 1000),
28
+ BMSdp("cycles", 15, 2, False, lambda x: x),
29
+ )
30
+
31
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
32
+ """Initialize BMS."""
33
+ super().__init__(ble_device, reconnect)
34
+
35
+ @staticmethod
36
+ def matcher_dict_list() -> list[MatcherPattern]:
37
+ """Provide BluetoothMatcher definition."""
38
+ return [
39
+ {
40
+ "service_uuid": BMS.uuid_services()[0],
41
+ "manufacturer_id": 0x9860,
42
+ "connectable": True,
43
+ },
44
+ ]
45
+
46
+ @staticmethod
47
+ def device_info() -> dict[str, str]:
48
+ """Return device information for the battery management system."""
49
+ return {"manufacturer": "Renogy", "model": "Bluetooth battery"}
50
+
51
+ @staticmethod
52
+ def uuid_services() -> list[str]:
53
+ """Return list of 128-bit UUIDs of services required by BMS."""
54
+ return [normalize_uuid_str("ffd0"), normalize_uuid_str("fff0")]
55
+
56
+ @staticmethod
57
+ def uuid_rx() -> str:
58
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
59
+ return "fff1"
60
+
61
+ @staticmethod
62
+ def uuid_tx() -> str:
63
+ """Return 16-bit UUID of characteristic that provides write property."""
64
+ return "ffd1"
65
+
66
+ @staticmethod
67
+ def _calc_values() -> frozenset[BMSvalue]:
68
+ return frozenset(
69
+ {
70
+ "power",
71
+ "battery_charging",
72
+ "temperature",
73
+ "cycle_capacity",
74
+ "battery_level",
75
+ "runtime",
76
+ "delta_voltage",
77
+ }
78
+ ) # calculate further values from BMS provided set ones
79
+
80
+ def _notification_handler(
81
+ self, _sender: BleakGATTCharacteristic, data: bytearray
82
+ ) -> None:
83
+ """Handle the RX characteristics notify event (new data arrives)."""
84
+ self._log.debug("RX BLE data: %s", data)
85
+
86
+ if not data.startswith(BMS.HEAD) or len(data) < 3:
87
+ self._log.debug("incorrect SOF")
88
+ return
89
+
90
+ if data[2] + 5 != len(data):
91
+ self._log.debug("incorrect frame length: %i != %i", len(data), data[2] + 5)
92
+ return
93
+
94
+ if (crc := crc_modbus(data[: BMS._CRC_POS])) != int.from_bytes(
95
+ data[BMS._CRC_POS :], "little"
96
+ ):
97
+ self._log.debug(
98
+ "invalid checksum 0x%X != 0x%X",
99
+ crc,
100
+ int.from_bytes(data[BMS._CRC_POS :], "little"),
101
+ )
102
+ return
103
+
104
+ self._data = data.copy()
105
+ self._data_event.set()
106
+
107
+ @staticmethod
108
+ def _read_int16(data: bytearray, pos: int, signed: bool = False) -> int:
109
+ return int.from_bytes(data[pos : pos + 2], byteorder="big", signed=signed)
110
+
111
+ @staticmethod
112
+ def _cmd(addr: int, words: int) -> bytes:
113
+ """Assemble a Renogy BMS command (MODBUS)."""
114
+ frame: bytearray = (
115
+ bytearray(BMS.HEAD)
116
+ + int.to_bytes(addr, 2, byteorder="big")
117
+ + int.to_bytes(words, 2, byteorder="big")
118
+ )
119
+
120
+ frame.extend(int.to_bytes(crc_modbus(frame), 2, byteorder="little"))
121
+ return bytes(frame)
122
+
123
+ async def _async_update(self) -> BMSsample:
124
+ """Update battery status information."""
125
+
126
+ await self._await_reply(self._cmd(0x13B2, 0x7))
127
+ result: BMSsample = BMS._decode_data(type(self).FIELDS, self._data)
128
+
129
+ await self._await_reply(self._cmd(0x1388, 0x22))
130
+ result["cell_count"] = BMS._read_int16(self._data, BMS._CELL_POS)
131
+ result["cell_voltages"] = BMS._cell_voltages(
132
+ self._data,
133
+ cells=min(16, result.get("cell_count", 0)),
134
+ start=BMS._CELL_POS + 2,
135
+ byteorder="big",
136
+ divider=10,
137
+ )
138
+
139
+ result["temp_sensors"] = BMS._read_int16(self._data, BMS._TEMP_POS)
140
+ result["temp_values"] = BMS._temp_values(
141
+ self._data,
142
+ values=min(16, result.get("temp_sensors", 0)),
143
+ start=BMS._TEMP_POS + 2,
144
+ divider=10,
145
+ )
146
+
147
+ await self._await_reply(self._cmd(0x13EC, 0x7))
148
+ result["problem_code"] = int.from_bytes(self._data[3:-2], byteorder="big") & (
149
+ ~0xE
150
+ )
151
+
152
+ return result