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