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,151 @@
1
+ """Module to support ECO-WORTHY 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, crc_modbus
12
+
13
+
14
+ class BMS(BaseBMS):
15
+ """ECO-WORTHY BMS implementation."""
16
+
17
+ _HEAD: Final[tuple] = (b"\xa1", b"\xa2")
18
+ _CELL_POS: Final[int] = 14
19
+ _TEMP_POS: Final[int] = 80
20
+ _FIELDS_V1: Final[tuple[BMSdp, ...]] = (
21
+ BMSdp("battery_level", 16, 2, False, lambda x: x, 0xA1),
22
+ BMSdp("voltage", 20, 2, False, lambda x: x / 100, 0xA1),
23
+ BMSdp("current", 22, 2, True, lambda x: x / 100, 0xA1),
24
+ BMSdp("problem_code", 51, 2, False, lambda x: x, 0xA1),
25
+ BMSdp("design_capacity", 26, 2, False, lambda x: x // 100, 0xA1),
26
+ BMSdp("cell_count", _CELL_POS, 2, False, lambda x: x, 0xA2),
27
+ BMSdp("temp_sensors", _TEMP_POS, 2, False, lambda x: x, 0xA2),
28
+ # ("cycles", 0xA1, 8, 2, False, lambda x: x),
29
+ )
30
+ _FIELDS_V2: Final[tuple[BMSdp, ...]] = tuple(
31
+ BMSdp(
32
+ *field[:-2],
33
+ (lambda x: x / 10) if field.key == "current" else field.fct,
34
+ field.idx,
35
+ )
36
+ for field in _FIELDS_V1
37
+ )
38
+
39
+ _CMDS: Final[set[int]] = set({field.idx for field in _FIELDS_V1})
40
+
41
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
42
+ """Initialize BMS."""
43
+ super().__init__(ble_device, reconnect)
44
+ self._mac_head: Final[tuple] = tuple(
45
+ int(self._ble_device.address.replace(":", ""), 16).to_bytes(6) + head
46
+ for head in BMS._HEAD
47
+ )
48
+ self._data_final: dict[int, bytearray] = {}
49
+
50
+ @staticmethod
51
+ def matcher_dict_list() -> list[MatcherPattern]:
52
+ """Provide BluetoothMatcher definition."""
53
+ return [MatcherPattern(local_name="ECO-WORTHY 02_*", connectable=True)] + [
54
+ MatcherPattern(
55
+ local_name=pattern,
56
+ service_uuid=BMS.uuid_services()[0],
57
+ connectable=True,
58
+ )
59
+ for pattern in ("DCHOUSE*", "ECO-WORTHY*")
60
+ ]
61
+
62
+ @staticmethod
63
+ def device_info() -> dict[str, str]:
64
+ """Return device information for the battery management system."""
65
+ return {"manufacturer": "ECO-WORTHY", "model": "BW02"}
66
+
67
+ @staticmethod
68
+ def uuid_services() -> list[str]:
69
+ """Return list of 128-bit UUIDs of services required by BMS."""
70
+ return [normalize_uuid_str("fff0")]
71
+
72
+ @staticmethod
73
+ def uuid_rx() -> str:
74
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
75
+ return "fff1"
76
+
77
+ @staticmethod
78
+ def uuid_tx() -> str:
79
+ """Return 16-bit UUID of characteristic that provides write property."""
80
+ raise NotImplementedError
81
+
82
+ @staticmethod
83
+ def _calc_values() -> frozenset[BMSvalue]:
84
+ return frozenset(
85
+ {
86
+ "battery_charging",
87
+ "cycle_charge",
88
+ "cycle_capacity",
89
+ "delta_voltage",
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 not data.startswith(BMS._HEAD + self._mac_head):
103
+ self._log.debug("invalid frame type: '%s'", data[0:1].hex())
104
+ return
105
+
106
+ if (crc := crc_modbus(data[:-2])) != int.from_bytes(data[-2:], "little"):
107
+ self._log.debug(
108
+ "invalid checksum 0x%X != 0x%X",
109
+ int.from_bytes(data[-2:], "little"),
110
+ crc,
111
+ )
112
+ self._data = bytearray()
113
+ return
114
+
115
+ # copy final data without message type and adapt to protocol type
116
+ shift: Final[bool] = data.startswith(self._mac_head)
117
+ self._data_final[data[6 if shift else 0]] = (
118
+ bytearray(2 if shift else 0) + data.copy()
119
+ )
120
+ if BMS._CMDS.issubset(self._data_final.keys()):
121
+ self._data_event.set()
122
+
123
+ async def _async_update(self) -> BMSsample:
124
+ """Update battery status information."""
125
+
126
+ self._data_final.clear()
127
+ self._data_event.clear() # clear event to ensure new data is acquired
128
+ await asyncio.wait_for(self._wait_event(), timeout=BMS.TIMEOUT)
129
+
130
+ result: BMSsample = BMS._decode_data(
131
+ (
132
+ BMS._FIELDS_V1
133
+ if self._data_final[0xA1].startswith(BMS._HEAD)
134
+ else BMS._FIELDS_V2
135
+ ),
136
+ self._data_final,
137
+ )
138
+
139
+ result["cell_voltages"] = BMS._cell_voltages(
140
+ self._data_final[0xA2],
141
+ cells=result.get("cell_count", 0),
142
+ start=BMS._CELL_POS + 2,
143
+ )
144
+ result["temp_values"] = BMS._temp_values(
145
+ self._data_final[0xA2],
146
+ values=result.get("temp_sensors", 0),
147
+ start=BMS._TEMP_POS + 2,
148
+ divider=10,
149
+ )
150
+
151
+ return result
@@ -0,0 +1,177 @@
1
+ """Module to support Ective BMS."""
2
+
3
+ import asyncio
4
+ from string import hexdigits
5
+ from typing import 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 BMSdp, BMSsample, BMSvalue, MatcherPattern
12
+ from aiobmsble.basebms import BaseBMS
13
+
14
+
15
+ class BMS(BaseBMS):
16
+ """Ective BMS implementation."""
17
+
18
+ _HEAD_RSP: Final[tuple[bytes, ...]] = (b"\x5e", b"\x83") # header for responses
19
+ _MAX_CELLS: Final[int] = 16
20
+ _INFO_LEN: Final[int] = 113
21
+ _CRC_LEN: Final[int] = 4
22
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
23
+ BMSdp("voltage", 1, 8, False, lambda x: x / 1000),
24
+ BMSdp("current", 9, 8, True, lambda x: x / 1000),
25
+ BMSdp("battery_level", 29, 4, False, lambda x: x),
26
+ BMSdp("cycle_charge", 17, 8, False, lambda x: x / 1000),
27
+ BMSdp("cycles", 25, 4, False, lambda x: x),
28
+ BMSdp("temperature", 33, 4, False, lambda x: round(x * 0.1 - 273.15, 1)),
29
+ BMSdp("problem_code", 37, 2, 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
+ self._data_final: bytearray = bytearray()
36
+
37
+ @staticmethod
38
+ def matcher_dict_list() -> list[MatcherPattern]:
39
+ """Provide BluetoothMatcher definition."""
40
+ return [
41
+ {
42
+ "service_uuid": BMS.uuid_services()[0],
43
+ "connectable": True,
44
+ "manufacturer_id": m_id,
45
+ }
46
+ for m_id in (0, 0xFFFF)
47
+ ]
48
+
49
+ @staticmethod
50
+ def device_info() -> dict[str, str]:
51
+ """Return device information for the battery management system."""
52
+ return {"manufacturer": "Ective", "model": "Smart BMS"}
53
+
54
+ @staticmethod
55
+ def uuid_services() -> list[str]:
56
+ """Return list of 128-bit UUIDs of services required by BMS."""
57
+ return [normalize_uuid_str("ffe0")]
58
+
59
+ @staticmethod
60
+ def uuid_rx() -> str:
61
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
62
+ return "ffe4"
63
+
64
+ @staticmethod
65
+ def uuid_tx() -> str:
66
+ """Return 16-bit UUID of characteristic that provides write property."""
67
+ raise NotImplementedError
68
+
69
+ @staticmethod
70
+ def _calc_values() -> frozenset[BMSvalue]:
71
+ return frozenset(
72
+ {
73
+ "battery_charging",
74
+ "cycle_capacity",
75
+ "cycle_charge",
76
+ "delta_voltage",
77
+ "power",
78
+ "runtime",
79
+ }
80
+ ) # calculate further values from BMS provided set ones
81
+
82
+ def _notification_handler(
83
+ self, _sender: BleakGATTCharacteristic, data: bytearray
84
+ ) -> None:
85
+ """Handle the RX characteristics notify event (new data arrives)."""
86
+
87
+ if (
88
+ start := next(
89
+ (i for i, b in enumerate(data) if bytes([b]) in BMS._HEAD_RSP), -1
90
+ )
91
+ ) != -1: # check for beginning of frame
92
+ data = data[start:]
93
+ self._data.clear()
94
+
95
+ self._data += data
96
+ self._log.debug(
97
+ "RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
98
+ )
99
+
100
+ if len(self._data) < BMS._INFO_LEN:
101
+ return
102
+
103
+ self._data = self._data[: BMS._INFO_LEN] # cut off exceeding data
104
+
105
+ if not (
106
+ self._data.startswith(BMS._HEAD_RSP)
107
+ and set(self._data.decode(errors="replace")[1:]).issubset(hexdigits)
108
+ ):
109
+ self._log.debug("incorrect frame coding: %s", self._data)
110
+ self._data.clear()
111
+ return
112
+
113
+ if (crc := BMS._crc(self._data[1 : -BMS._CRC_LEN])) != int(
114
+ self._data[-BMS._CRC_LEN :], 16
115
+ ):
116
+ self._log.debug(
117
+ "invalid checksum 0x%X != 0x%X",
118
+ int(self._data[-BMS._CRC_LEN :], 16),
119
+ crc,
120
+ )
121
+ self._data.clear()
122
+ return
123
+
124
+ self._data_final = self._data.copy()
125
+ self._data_event.set()
126
+
127
+ @staticmethod
128
+ def _crc(data: bytearray) -> int:
129
+ return sum(int(data[idx : idx + 2], 16) for idx in range(0, len(data), 2))
130
+
131
+ @staticmethod
132
+ def _cell_voltages(
133
+ data: bytearray,
134
+ *,
135
+ cells: int,
136
+ start: int,
137
+ size: int = 2,
138
+ byteorder: Literal["little", "big"] = "big",
139
+ divider: int = 1000,
140
+ ) -> list[float]:
141
+ """Parse cell voltages from status message."""
142
+ return [
143
+ (value / divider)
144
+ for idx in range(cells)
145
+ if (
146
+ value := BMS._conv_int(
147
+ data[start + idx * size : start + (idx + 1) * size]
148
+ )
149
+ )
150
+ ]
151
+
152
+ @staticmethod
153
+ def _conv_int(data: bytearray, sign: bool = False) -> int:
154
+ return int.from_bytes(
155
+ bytes.fromhex(data.decode("ascii", errors="strict")),
156
+ byteorder="little",
157
+ signed=sign,
158
+ )
159
+
160
+ @staticmethod
161
+ def _conv_data(data: bytearray) -> BMSsample:
162
+ result: BMSsample = {}
163
+ for field in BMS._FIELDS:
164
+ result[field.key] = field.fct(
165
+ BMS._conv_int(data[field.pos : field.pos + field.size], field.signed)
166
+ )
167
+ return result
168
+
169
+ async def _async_update(self) -> BMSsample:
170
+ """Update battery status information."""
171
+
172
+ await asyncio.wait_for(self._wait_event(), timeout=BMS.TIMEOUT)
173
+ return self._conv_data(self._data_final) | {
174
+ "cell_voltages": BMS._cell_voltages(
175
+ self._data_final, cells=BMS._MAX_CELLS, start=45, size=4
176
+ )
177
+ }
@@ -0,0 +1,233 @@
1
+ """Module to support E&J Technology BMS."""
2
+
3
+ from enum import IntEnum
4
+ from string import hexdigits
5
+ from typing import Final, Literal
6
+
7
+ from bleak.backends.characteristic import BleakGATTCharacteristic
8
+ from bleak.backends.device import BLEDevice
9
+
10
+ from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
11
+ from aiobmsble.basebms import BaseBMS
12
+
13
+
14
+ class Cmd(IntEnum):
15
+ """BMS operation codes."""
16
+
17
+ RT = 0x2
18
+ CAP = 0x10
19
+
20
+
21
+ class BMS(BaseBMS):
22
+ """E&J Technology BMS implementation."""
23
+
24
+ _BT_MODULE_MSG: Final[bytes] = bytes([0x41, 0x54, 0x0D, 0x0A]) # BLE module message
25
+ _IGNORE_CRC: Final[str] = "libattU"
26
+ _HEAD: Final[bytes] = b"\x3a"
27
+ _TAIL: Final[bytes] = b"\x7e"
28
+ _MAX_CELLS: Final[int] = 16
29
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
30
+ BMSdp(
31
+ "current", 89, 8, False, lambda x: ((x >> 16) - (x & 0xFFFF)) / 100, Cmd.RT
32
+ ),
33
+ BMSdp("battery_level", 123, 2, False, lambda x: x, Cmd.RT),
34
+ BMSdp("cycle_charge", 15, 4, False, lambda x: x / 10, Cmd.CAP),
35
+ BMSdp(
36
+ "temperature", 97, 2, False, lambda x: x - 40, Cmd.RT
37
+ ), # only 1st sensor relevant
38
+ BMSdp("cycles", 115, 4, False, lambda x: x, Cmd.RT),
39
+ BMSdp(
40
+ "problem_code", 105, 4, False, lambda x: x & 0x0FFC, Cmd.RT
41
+ ), # mask status bits
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
+
49
+ @staticmethod
50
+ def matcher_dict_list() -> list[MatcherPattern]:
51
+ """Provide BluetoothMatcher definition."""
52
+ return (
53
+ [ # Lithtech Energy (2x), Volthium
54
+ MatcherPattern(local_name=pattern, connectable=True)
55
+ for pattern in ("L-12V???AH-*", "LT-12V-*", "V-12V???Ah-*")
56
+ ]
57
+ + [ # Fliteboard, Electronix battery
58
+ {
59
+ "local_name": "libatt*",
60
+ "manufacturer_id": 21320,
61
+ "connectable": True,
62
+ },
63
+ {"local_name": "SV12V*", "manufacturer_id": 33384, "connectable": True},
64
+ {"local_name": "LT-24*", "manufacturer_id": 22618, "connectable": True},
65
+ ]
66
+ + [ # LiTime
67
+ MatcherPattern( # LiTime based on ser#
68
+ local_name="LT-12???BG-A0[0-6]*",
69
+ manufacturer_id=m_id,
70
+ connectable=True,
71
+ )
72
+ for m_id in (33384, 22618)
73
+ ]
74
+ )
75
+
76
+ @staticmethod
77
+ def device_info() -> dict[str, str]:
78
+ """Return device information for the battery management system."""
79
+ return {"manufacturer": "E&J Technology", "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 ["6e400001-b5a3-f393-e0a9-e50e24dcca9e"]
85
+
86
+ @staticmethod
87
+ def uuid_rx() -> str:
88
+ """Return 128-bit UUID of characteristic that provides notification/read property."""
89
+ return "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
90
+
91
+ @staticmethod
92
+ def uuid_tx() -> str:
93
+ """Return 128-bit UUID of characteristic that provides write property."""
94
+ return "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
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
+ "voltage",
106
+ }
107
+ ) # calculate further values from BMS provided set ones
108
+
109
+ def _notification_handler(
110
+ self, _sender: BleakGATTCharacteristic, data: bytearray
111
+ ) -> None:
112
+ """Handle the RX characteristics notify event (new data arrives)."""
113
+
114
+ if data.startswith(BMS._BT_MODULE_MSG):
115
+ self._log.debug("filtering AT cmd")
116
+ if not (data := data.removeprefix(BMS._BT_MODULE_MSG)):
117
+ return
118
+
119
+ if data.startswith(BMS._HEAD): # check for beginning of frame
120
+ self._data.clear()
121
+
122
+ self._data += data
123
+
124
+ self._log.debug(
125
+ "RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
126
+ )
127
+
128
+ exp_frame_len: Final[int] = (
129
+ int(self._data[7:11], 16)
130
+ if len(self._data) > 10
131
+ and all(chr(c) in hexdigits for c in self._data[7:11])
132
+ else 0xFFFF
133
+ )
134
+
135
+ if not self._data.startswith(BMS._HEAD) or (
136
+ not self._data.endswith(BMS._TAIL) and len(self._data) < exp_frame_len
137
+ ):
138
+ return
139
+
140
+ if not self._data.endswith(BMS._TAIL):
141
+ self._log.debug("incorrect EOF: %s", data)
142
+ self._data.clear()
143
+ return
144
+
145
+ if not all(chr(c) in hexdigits for c in self._data[1:-1]):
146
+ self._log.debug("incorrect frame encoding.")
147
+ self._data.clear()
148
+ return
149
+
150
+ if len(self._data) != exp_frame_len:
151
+ self._log.debug(
152
+ "incorrect frame length %i != %i",
153
+ len(self._data),
154
+ exp_frame_len,
155
+ )
156
+ self._data.clear()
157
+ return
158
+
159
+ if not self.name.startswith(BMS._IGNORE_CRC) and (
160
+ crc := BMS._crc(self._data[1:-3])
161
+ ) != int(self._data[-3:-1], 16):
162
+ # libattU firmware uses no CRC, so we ignore it
163
+ self._log.debug(
164
+ "invalid checksum 0x%X != 0x%X", int(self._data[-3:-1], 16), crc
165
+ )
166
+ self._data.clear()
167
+ return
168
+
169
+ self._log.debug(
170
+ "address: 0x%X, command 0x%X, version: 0x%X, length: 0x%X",
171
+ int(self._data[1:3], 16),
172
+ int(self._data[3:5], 16) & 0x7F,
173
+ int(self._data[5:7], 16),
174
+ len(self._data),
175
+ )
176
+ self._data_final = self._data.copy()
177
+ self._data_event.set()
178
+
179
+ @staticmethod
180
+ def _crc(data: bytearray) -> int:
181
+ return (sum(data) ^ 0xFF) & 0xFF
182
+
183
+ @staticmethod
184
+ def _cell_voltages(
185
+ data: bytearray,
186
+ *,
187
+ cells: int,
188
+ start: int,
189
+ size: int = 2,
190
+ byteorder: Literal["little", "big"] = "big",
191
+ divider: int = 1000,
192
+ ) -> list[float]:
193
+ """Return cell voltages from status message."""
194
+ return [
195
+ (value / divider)
196
+ for idx in range(cells)
197
+ if (value := int(data[start + size * idx : start + size * (idx + 1)], 16))
198
+ ]
199
+
200
+ @staticmethod
201
+ def _conv_data(data: dict[int, bytearray]) -> BMSsample:
202
+ result: BMSsample = {}
203
+ for field in BMS._FIELDS:
204
+ result[field.key] = field.fct(
205
+ int(data[field.idx][field.pos : field.pos + field.size], 16)
206
+ )
207
+ return result
208
+
209
+ async def _async_update(self) -> BMSsample:
210
+ """Update battery status information."""
211
+ raw_data: dict[int, bytearray] = {}
212
+
213
+ # query real-time information and capacity
214
+ for cmd in (b":000250000E03~", b":001031000E05~"):
215
+ await self._await_reply(cmd)
216
+ rsp: int = int(self._data_final[3:5], 16) & 0x7F
217
+ raw_data[rsp] = self._data_final
218
+ if rsp == Cmd.RT and len(self._data_final) == 0x8C:
219
+ # handle metrisun version
220
+ self._log.debug("single frame protocol detected")
221
+ raw_data[Cmd.CAP] = bytearray(15) + self._data_final[125:]
222
+ break
223
+
224
+ if len(raw_data) != len(list(Cmd)) or not all(
225
+ len(value) > 0 for value in raw_data.values()
226
+ ):
227
+ return {}
228
+
229
+ return self._conv_data(raw_data) | {
230
+ "cell_voltages": BMS._cell_voltages(
231
+ raw_data[Cmd.RT], cells=BMS._MAX_CELLS, start=25, size=4
232
+ )
233
+ }
@@ -0,0 +1,139 @@
1
+ """Module to support Felicity BMS."""
2
+
3
+ from collections.abc import Callable
4
+ from json import JSONDecodeError, loads
5
+ from typing import Any, Final
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
+ """Felicity BMS implementation."""
17
+
18
+ _HEAD: Final[bytes] = b"{"
19
+ _TAIL: Final[bytes] = b"}"
20
+ _CMD_PRE: Final[bytes] = b"wifilocalMonitor:" # CMD prefix
21
+ _CMD_BI: Final[bytes] = b"get dev basice infor"
22
+ _CMD_DT: Final[bytes] = b"get Date"
23
+ _CMD_RT: Final[bytes] = b"get dev real infor"
24
+ _FIELDS: Final[list[tuple[BMSvalue, str, Callable[[list], Any]]]] = [
25
+ ("voltage", "Batt", lambda x: x[0][0] / 1000),
26
+ ("current", "Batt", lambda x: x[1][0] / 10),
27
+ (
28
+ "cycle_charge",
29
+ "BatsocList",
30
+ lambda x: (int(x[0][0]) * int(x[0][2])) / 1e7,
31
+ ),
32
+ ("battery_level", "BatsocList", lambda x: x[0][0] / 100),
33
+ ]
34
+
35
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
36
+ """Initialize BMS."""
37
+ super().__init__(ble_device, reconnect)
38
+ self._data_final: dict = {}
39
+
40
+ @staticmethod
41
+ def matcher_dict_list() -> list[MatcherPattern]:
42
+ """Provide BluetoothMatcher definition."""
43
+ return [
44
+ {"local_name": pattern, "connectable": True} for pattern in ("F07*", "F10*")
45
+ ]
46
+
47
+ @staticmethod
48
+ def device_info() -> dict[str, str]:
49
+ """Return device information for the battery management system."""
50
+ return {"manufacturer": "Felicity Solar", "model": "LiFePo4 battery"}
51
+
52
+ @staticmethod
53
+ def uuid_services() -> list[str]:
54
+ """Return list of 128-bit UUIDs of services required by BMS."""
55
+ return [normalize_uuid_str("6e6f736a-4643-4d44-8fa9-0fafd005e455")]
56
+
57
+ @staticmethod
58
+ def uuid_rx() -> str:
59
+ """Return 128-bit UUID of characteristic that provides notification/read property."""
60
+ return "49535458-8341-43f4-a9d4-ec0e34729bb3"
61
+
62
+ @staticmethod
63
+ def uuid_tx() -> str:
64
+ """Return 128-bit UUID of characteristic that provides write property."""
65
+ return "49535258-184d-4bd9-bc61-20c647249616"
66
+
67
+ @staticmethod
68
+ def _calc_values() -> frozenset[BMSvalue]:
69
+ return frozenset(
70
+ {
71
+ "battery_charging",
72
+ "cycle_capacity",
73
+ "delta_voltage",
74
+ "power",
75
+ "runtime",
76
+ "temperature",
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
+
85
+ if data.startswith(BMS._HEAD):
86
+ self._data = bytearray()
87
+
88
+ self._data += data
89
+ self._log.debug(
90
+ "RX BLE data (%s): %s", "start" if data == self._data else "cnt.", data
91
+ )
92
+
93
+ if not data.endswith(BMS._TAIL):
94
+ return
95
+
96
+ try:
97
+ self._data_final = loads(self._data)
98
+ except (JSONDecodeError, UnicodeDecodeError):
99
+ self._log.debug("JSON decode error: %s", self._data)
100
+ return
101
+
102
+ if (ver := self._data_final.get("CommVer", 0)) != 1:
103
+ self._log.debug("Unknown protocol version (%i)", ver)
104
+ return
105
+
106
+ self._data_event.set()
107
+
108
+ @staticmethod
109
+ def _conv_data(data: dict) -> BMSsample:
110
+ result: BMSsample = {}
111
+ for key, itm, func in BMS._FIELDS:
112
+ result[key] = func(data.get(itm, []))
113
+ return result
114
+
115
+ @staticmethod
116
+ def _conv_cells(data: dict) -> list[float]:
117
+ return [(value / 1000) for value in data.get("BatcelList", [])[0]]
118
+
119
+ @staticmethod
120
+ def _conv_temp(data: dict) -> list[float]:
121
+ return [
122
+ (value / 10) for value in data.get("BtemList", [])[0] if value != 0x7FFF
123
+ ]
124
+
125
+ async def _async_update(self) -> BMSsample:
126
+ """Update battery status information."""
127
+
128
+ await self._await_reply(BMS._CMD_PRE + BMS._CMD_RT)
129
+
130
+ return (
131
+ BMS._conv_data(self._data_final)
132
+ | {"temp_values": BMS._conv_temp(self._data_final)}
133
+ | {"cell_voltages": BMS._conv_cells(self._data_final)}
134
+ | {
135
+ "problem_code": int(
136
+ self._data_final.get("Bwarn", 0) + self._data_final.get("Bfault", 0)
137
+ )
138
+ }
139
+ )