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,205 @@
1
+ """Module to support Seplos v2 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_xmodem
11
+
12
+
13
+ class BMS(BaseBMS):
14
+ """Seplos v2 BMS implementation."""
15
+
16
+ _HEAD: Final[bytes] = b"\x7e"
17
+ _TAIL: Final[bytes] = b"\x0d"
18
+ _CMD_VER: Final[int] = 0x10 # TX protocol version
19
+ _RSP_VER: Final[int] = 0x14 # RX protocol version
20
+ _MIN_LEN: Final[int] = 10
21
+ _MAX_SUBS: Final[int] = 0xF
22
+ _CELL_POS: Final[int] = 9
23
+ _PRB_MAX: Final[int] = 8 # max number of alarm event bytes
24
+ _PRB_MASK: Final[int] = ~0x82FFFF # ignore byte 7-8 + byte 6 (bit 7,2)
25
+ _PFIELDS: Final[tuple[BMSdp, ...]] = ( # Seplos V2: single machine data
26
+ BMSdp("voltage", 2, 2, False, lambda x: x / 100),
27
+ BMSdp("current", 0, 2, True, lambda x: x / 100), # /10 for 0x62
28
+ BMSdp("cycle_charge", 4, 2, False, lambda x: x / 100), # /10 for 0x62
29
+ BMSdp("cycles", 13, 2, False, lambda x: x),
30
+ BMSdp("battery_level", 9, 2, False, lambda x: x / 10),
31
+ )
32
+ _GSMD_LEN: Final[int] = _CELL_POS + max((dp.pos + dp.size) for dp in _PFIELDS) + 3
33
+ _CMDS: Final[list[tuple[int, bytes]]] = [(0x51, b""), (0x61, b"\x00"), (0x62, b"")]
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[int, bytearray] = {}
39
+ self._exp_len: int = BMS._MIN_LEN
40
+ self._exp_reply: set[int] = set()
41
+
42
+ @staticmethod
43
+ def matcher_dict_list() -> list[MatcherPattern]:
44
+ """Provide BluetoothMatcher definition."""
45
+ return [
46
+ {
47
+ "local_name": pattern,
48
+ "service_uuid": BMS.uuid_services()[0],
49
+ "connectable": True,
50
+ }
51
+ for pattern in ("BP0?", "BP1?", "BP2?")
52
+ ]
53
+
54
+ @staticmethod
55
+ def device_info() -> dict[str, str]:
56
+ """Return device information for the battery management system."""
57
+ return {"manufacturer": "Seplos", "model": "Smart BMS V2"}
58
+
59
+ @staticmethod
60
+ def uuid_services() -> list[str]:
61
+ """Return list of 128-bit UUIDs of services required by BMS."""
62
+ return [normalize_uuid_str("ff00")]
63
+
64
+ @staticmethod
65
+ def uuid_rx() -> str:
66
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
67
+ return "ff01"
68
+
69
+ @staticmethod
70
+ def uuid_tx() -> str:
71
+ """Return 16-bit UUID of characteristic that provides write property."""
72
+ return "ff02"
73
+
74
+ @staticmethod
75
+ def _calc_values() -> frozenset[BMSvalue]:
76
+ return frozenset(
77
+ {
78
+ "battery_charging",
79
+ "cycle_capacity",
80
+ "delta_voltage",
81
+ "power",
82
+ "runtime",
83
+ "temperature",
84
+ }
85
+ ) # calculate further values from BMS provided set ones
86
+
87
+ def _notification_handler(
88
+ self, _sender: BleakGATTCharacteristic, data: bytearray
89
+ ) -> None:
90
+ """Handle the RX characteristics notify event (new data arrives)."""
91
+ if (
92
+ len(data) > BMS._MIN_LEN
93
+ and data.startswith(BMS._HEAD)
94
+ and len(self._data) >= self._exp_len
95
+ ):
96
+ self._exp_len = BMS._MIN_LEN + int.from_bytes(data[5:7])
97
+ self._data = bytearray()
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
+ # verify that data is long enough
105
+ if len(self._data) < self._exp_len:
106
+ return
107
+
108
+ if not self._data.endswith(BMS._TAIL):
109
+ self._log.debug("incorrect frame end: %s", self._data)
110
+ return
111
+
112
+ if self._data[1] != BMS._RSP_VER:
113
+ self._log.debug("unknown frame version: V%.1f", self._data[1] / 10)
114
+ return
115
+
116
+ if self._data[4]:
117
+ self._log.debug("BMS reported error code: 0x%X", self._data[4])
118
+ return
119
+
120
+ if (crc := crc_xmodem(self._data[1:-3])) != int.from_bytes(self._data[-3:-1]):
121
+ self._log.debug(
122
+ "invalid checksum 0x%X != 0x%X",
123
+ crc,
124
+ int.from_bytes(self._data[-3:-1]),
125
+ )
126
+ return
127
+
128
+ self._log.debug(
129
+ "address: 0x%X, function: 0x%X, return: 0x%X",
130
+ self._data[2],
131
+ self._data[3],
132
+ self._data[4],
133
+ )
134
+
135
+ self._data_final[self._data[3]] = self._data
136
+ try:
137
+ self._exp_reply.remove(self._data[3])
138
+ self._data_event.set()
139
+ except KeyError:
140
+ self._log.debug("unexpected reply: 0x%X", self._data[3])
141
+
142
+ async def _init_connection(
143
+ self, char_notify: BleakGATTCharacteristic | int | str | None = None
144
+ ) -> None:
145
+ """Initialize protocol state."""
146
+ await super()._init_connection()
147
+ self._exp_len = BMS._MIN_LEN
148
+
149
+ @staticmethod
150
+ def _cmd(cmd: int, address: int = 0, data: bytearray = bytearray()) -> bytes:
151
+ """Assemble a Seplos V2 BMS command."""
152
+ assert cmd in (0x47, 0x51, 0x61, 0x62, 0x04) # allow only read commands
153
+ frame = bytearray([*BMS._HEAD, BMS._CMD_VER, address, 0x46, cmd])
154
+ frame += len(data).to_bytes(2, "big", signed=False) + data
155
+ frame += int.to_bytes(crc_xmodem(frame[1:]), 2, byteorder="big") + BMS._TAIL
156
+ return bytes(frame)
157
+
158
+ async def _async_update(self) -> BMSsample:
159
+ """Update battery status information."""
160
+
161
+ for cmd, data in BMS._CMDS:
162
+ self._exp_reply.add(cmd)
163
+ await self._await_reply(BMS._cmd(cmd, data=bytearray(data)))
164
+
165
+ result: BMSsample = {}
166
+ result["cell_count"] = self._data_final[0x61][BMS._CELL_POS]
167
+ result["temp_sensors"] = self._data_final[0x61][
168
+ BMS._CELL_POS + result["cell_count"] * 2 + 1
169
+ ]
170
+ ct_blk_len: Final[int] = (result["cell_count"] + result["temp_sensors"]) * 2 + 2
171
+
172
+ if (BMS._GSMD_LEN + ct_blk_len) > len(self._data_final[0x61]):
173
+ raise ValueError("message too short to decode data")
174
+
175
+ result |= BMS._decode_data(
176
+ BMS._PFIELDS, self._data_final[0x61], offset=BMS._CELL_POS + ct_blk_len
177
+ )
178
+
179
+ # get extention pack count from parallel data (main pack)
180
+ result["pack_count"] = self._data_final[0x51][42]
181
+
182
+ # get alarms from parallel data (main pack)
183
+ alarm_evt: Final[int] = min(self._data_final[0x62][46], BMS._PRB_MAX)
184
+ result["problem_code"] = (
185
+ int.from_bytes(self._data_final[0x62][47 : 47 + alarm_evt], byteorder="big")
186
+ & BMS._PRB_MASK
187
+ )
188
+
189
+ result["cell_voltages"] = BMS._cell_voltages(
190
+ self._data_final[0x61],
191
+ cells=self._data_final[0x61][BMS._CELL_POS],
192
+ start=10,
193
+ )
194
+ result["temp_values"] = BMS._temp_values(
195
+ self._data_final[0x61],
196
+ values=result["temp_sensors"],
197
+ start=BMS._CELL_POS + result.get("cell_count", 0) * 2 + 2,
198
+ signed=False,
199
+ offset=2731,
200
+ divider=10,
201
+ )
202
+
203
+ self._data_final.clear()
204
+
205
+ return result
@@ -0,0 +1,199 @@
1
+ """Module to support TDT 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
+ """TDT BMS implementation."""
15
+
16
+ _UUID_CFG: Final[str] = "fffa"
17
+ _HEAD: Final[int] = 0x7E
18
+ _CMD_HEADS: list[int] = [0x7E, 0x1E] # alternative command head
19
+ _TAIL: Final[int] = 0x0D
20
+ _CMD_VER: Final[int] = 0x00
21
+ _RSP_VER: Final[frozenset[int]] = frozenset({0x00, 0x04})
22
+ _CELL_POS: Final[int] = 0x8
23
+ _INFO_LEN: Final[int] = 10 # minimal frame length
24
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
25
+ BMSdp("voltage", 2, 2, False, lambda x: x / 100, 0x8C),
26
+ BMSdp(
27
+ "current",
28
+ 0,
29
+ 2,
30
+ False,
31
+ lambda x: (x & 0x3FFF) / 10 * (-1 if x >> 15 else 1),
32
+ 0x8C,
33
+ ),
34
+ BMSdp("cycle_charge", 4, 2, False, lambda x: x / 10, 0x8C),
35
+ BMSdp("battery_level", 13, 1, False, lambda x: x, 0x8C),
36
+ BMSdp("cycles", 8, 2, False, lambda x: x, 0x8C),
37
+ ) # problem code is not included in the list, but extra
38
+ _CMDS: Final[list[int]] = [*list({field.idx for field in _FIELDS}), 0x8D]
39
+
40
+ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
41
+ """Initialize BMS."""
42
+ super().__init__(ble_device, reconnect)
43
+ self._data_final: dict[int, bytearray] = {}
44
+ self._cmd_heads: list[int] = BMS._CMD_HEADS
45
+ self._exp_len: int = 0
46
+
47
+ @staticmethod
48
+ def matcher_dict_list() -> list[MatcherPattern]:
49
+ """Provide BluetoothMatcher definition."""
50
+ return [{"manufacturer_id": 54976, "connectable": True}]
51
+
52
+ @staticmethod
53
+ def device_info() -> dict[str, str]:
54
+ """Return device information for the battery management system."""
55
+ return {"manufacturer": "TDT", "model": "Smart BMS"}
56
+
57
+ @staticmethod
58
+ def uuid_services() -> list[str]:
59
+ """Return list of 128-bit UUIDs of services required by BMS."""
60
+ return [normalize_uuid_str("fff0")]
61
+
62
+ @staticmethod
63
+ def uuid_rx() -> str:
64
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
65
+ return "fff1"
66
+
67
+ @staticmethod
68
+ def uuid_tx() -> str:
69
+ """Return 16-bit UUID of characteristic that provides write property."""
70
+ return "fff2"
71
+
72
+ @staticmethod
73
+ def _calc_values() -> frozenset[BMSvalue]:
74
+ return frozenset(
75
+ {
76
+ "battery_charging",
77
+ "cycle_capacity",
78
+ "delta_voltage",
79
+ "power",
80
+ "runtime",
81
+ "temperature",
82
+ }
83
+ ) # calculate further values from BMS provided set ones
84
+
85
+ async def _init_connection(
86
+ self, char_notify: BleakGATTCharacteristic | int | str | None = None
87
+ ) -> None:
88
+ await self._await_reply(
89
+ data=b"HiLink", char=BMS._UUID_CFG, wait_for_notify=False
90
+ )
91
+ if (
92
+ ret := int.from_bytes(await self._client.read_gatt_char(BMS._UUID_CFG))
93
+ ) != 0x1:
94
+ self._log.debug("error unlocking BMS: %X", ret)
95
+
96
+ await super()._init_connection()
97
+
98
+ def _notification_handler(
99
+ self, _sender: BleakGATTCharacteristic, data: bytearray
100
+ ) -> None:
101
+ """Handle the RX characteristics notify event (new data arrives)."""
102
+ self._log.debug("RX BLE data: %s", data)
103
+
104
+ if (
105
+ len(data) > BMS._INFO_LEN
106
+ and data[0] == BMS._HEAD
107
+ and len(self._data) >= self._exp_len
108
+ ):
109
+ self._exp_len = BMS._INFO_LEN + int.from_bytes(data[6:8])
110
+ self._data = bytearray()
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
+ # verify that data is long enough
118
+ if len(self._data) < max(BMS._INFO_LEN, self._exp_len):
119
+ return
120
+
121
+ if self._data[-1] != BMS._TAIL:
122
+ self._log.debug("frame end incorrect: %s", self._data)
123
+ return
124
+
125
+ if self._data[1] not in BMS._RSP_VER:
126
+ self._log.debug("unknown frame version: V%.1f", self._data[1] / 10)
127
+ return
128
+
129
+ if self._data[4]:
130
+ self._log.debug("BMS reported error code: 0x%X", self._data[4])
131
+ return
132
+
133
+ if (crc := crc_modbus(self._data[:-3])) != int.from_bytes(
134
+ self._data[-3:-1], "big"
135
+ ):
136
+ self._log.debug(
137
+ "invalid checksum 0x%X != 0x%X",
138
+ int.from_bytes(self._data[-3:-1], "big"),
139
+ crc,
140
+ )
141
+ return
142
+ self._data_final[self._data[5]] = self._data
143
+ self._data_event.set()
144
+
145
+ @staticmethod
146
+ def _cmd(cmd: int, data: bytearray = bytearray(), cmd_head: int = _HEAD) -> bytes:
147
+ """Assemble a TDT BMS command."""
148
+ assert cmd in (0x8C, 0x8D, 0x92) # allow only read commands
149
+
150
+ frame = bytearray([cmd_head, BMS._CMD_VER, 0x1, 0x3, 0x0, cmd])
151
+ frame += len(data).to_bytes(2, "big", signed=False) + data
152
+ frame += crc_modbus(frame).to_bytes(2, "big") + bytes([BMS._TAIL])
153
+
154
+ return bytes(frame)
155
+
156
+ async def _async_update(self) -> BMSsample:
157
+ """Update battery status information."""
158
+
159
+ for head in self._cmd_heads:
160
+ try:
161
+ for cmd in BMS._CMDS:
162
+ await self._await_reply(BMS._cmd(cmd, cmd_head=head))
163
+ self._cmd_heads = [head] # set to single head for further commands
164
+ break
165
+ except TimeoutError:
166
+ ... # try next command head
167
+ else:
168
+ raise TimeoutError
169
+
170
+ result: BMSsample = {"cell_count": self._data_final[0x8C][BMS._CELL_POS]}
171
+ result["temp_sensors"] = self._data_final[0x8C][
172
+ BMS._CELL_POS + result["cell_count"] * 2 + 1
173
+ ]
174
+
175
+ result["cell_voltages"] = BMS._cell_voltages(
176
+ self._data_final[0x8C],
177
+ cells=result.get("cell_count", 0),
178
+ start=BMS._CELL_POS + 1,
179
+ )
180
+ result["temp_values"] = BMS._temp_values(
181
+ self._data_final[0x8C],
182
+ values=result["temp_sensors"],
183
+ start=BMS._CELL_POS + result.get("cell_count", 0) * 2 + 2,
184
+ signed=False,
185
+ offset=2731,
186
+ divider=10,
187
+ )
188
+ idx: Final[int] = result.get("cell_count", 0) + result.get("temp_sensors", 0)
189
+
190
+ result |= BMS._decode_data(
191
+ BMS._FIELDS, self._data_final, offset=BMS._CELL_POS + idx * 2 + 2
192
+ )
193
+ result["problem_code"] = int.from_bytes(
194
+ self._data_final[0x8D][BMS._CELL_POS + idx + 6 : BMS._CELL_POS + idx + 8]
195
+ )
196
+
197
+ self._data_final.clear()
198
+
199
+ return result
@@ -0,0 +1,138 @@
1
+ """Module to support TianPwr 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
+ """TianPwr BMS implementation."""
15
+
16
+ _HEAD: Final[bytes] = b"\x55"
17
+ _TAIL: Final[bytes] = b"\xaa"
18
+ _RDCMD: Final[bytes] = b"\x04"
19
+ _MAX_CELLS: Final[int] = 16
20
+ _MAX_TEMP: Final[int] = 6
21
+ _MIN_LEN: Final[int] = 4
22
+ _DEF_LEN: Final[int] = 20
23
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
24
+ BMSdp("battery_level", 3, 2, False, lambda x: x, 0x83),
25
+ BMSdp("voltage", 5, 2, False, lambda x: x / 100, 0x83),
26
+ BMSdp("current", 13, 2, True, lambda x: x / 100, 0x83),
27
+ BMSdp("problem_code", 11, 8, False, lambda x: x, 0x84),
28
+ BMSdp("cell_count", 3, 1, False, lambda x: x, 0x84),
29
+ BMSdp("temp_sensors", 4, 1, False, lambda x: x, 0x84),
30
+ BMSdp("design_capacity", 5, 2, False, lambda x: x // 100, 0x84),
31
+ BMSdp("cycle_charge", 7, 2, False, lambda x: x / 100, 0x84),
32
+ BMSdp("cycles", 9, 2, False, lambda x: x, 0x84),
33
+ )
34
+ _CMDS: Final[set[int]] = set({field.idx for field in _FIELDS}) | set({0x87})
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: dict[int, bytearray] = {}
40
+
41
+ @staticmethod
42
+ def matcher_dict_list() -> list[MatcherPattern]:
43
+ """Provide BluetoothMatcher definition."""
44
+ return [{"local_name": "TP_*", "connectable": True}]
45
+
46
+ @staticmethod
47
+ def device_info() -> dict[str, str]:
48
+ """Return device information for the battery management system."""
49
+ return {"manufacturer": "TianPwr", "model": "SmartBMS"}
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("ff00")]
55
+
56
+ @staticmethod
57
+ def uuid_rx() -> str:
58
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
59
+ return "ff01"
60
+
61
+ @staticmethod
62
+ def uuid_tx() -> str:
63
+ """Return 16-bit UUID of characteristic that provides write property."""
64
+ return "ff02"
65
+
66
+ @staticmethod
67
+ def _calc_values() -> frozenset[BMSvalue]:
68
+ return frozenset(
69
+ {
70
+ "battery_charging",
71
+ "cycle_capacity",
72
+ "delta_voltage",
73
+ "power",
74
+ "temperature",
75
+ }
76
+ ) # calculate further values from BMS provided set ones
77
+
78
+ def _notification_handler(
79
+ self, _sender: BleakGATTCharacteristic, data: bytearray
80
+ ) -> None:
81
+ """Handle the RX characteristics notify event (new data arrives)."""
82
+ self._log.debug("RX BLE data: %s", data)
83
+
84
+ # verify that data is long enough
85
+ if len(data) != BMS._DEF_LEN:
86
+ self._log.debug("incorrect frame length")
87
+ return
88
+
89
+ if not data.startswith(BMS._HEAD):
90
+ self._log.debug("incorrect SOF.")
91
+ return
92
+
93
+ if not data.endswith(BMS._TAIL):
94
+ self._log.debug("incorrect EOF.")
95
+ return
96
+
97
+ self._data_final[data[2]] = data.copy()
98
+ self._data_event.set()
99
+
100
+ @staticmethod
101
+ def _cmd(addr: int) -> bytes:
102
+ """Assemble a TianPwr BMS command."""
103
+ return BMS._HEAD + BMS._RDCMD + addr.to_bytes(1) + BMS._TAIL
104
+
105
+ async def _async_update(self) -> BMSsample:
106
+ """Update battery status information."""
107
+
108
+ self._data_final.clear()
109
+ for cmd in BMS._CMDS:
110
+ await self._await_reply(BMS._cmd(cmd))
111
+
112
+ result: BMSsample = BMS._decode_data(BMS._FIELDS, self._data_final)
113
+
114
+ for cmd in range(
115
+ 0x88, 0x89 + min(result.get("cell_count", 0), BMS._MAX_CELLS) // 8
116
+ ):
117
+ await self._await_reply(BMS._cmd(cmd))
118
+ result["cell_voltages"] = result.setdefault(
119
+ "cell_voltages", []
120
+ ) + BMS._cell_voltages(
121
+ self._data_final.get(cmd, bytearray()), cells=8, start=3
122
+ )
123
+
124
+ if {0x83, 0x87}.issubset(self._data_final):
125
+ result["temp_values"] = [
126
+ int.from_bytes(
127
+ self._data_final[0x83][idx : idx + 2], byteorder="big", signed=True
128
+ )
129
+ / 10
130
+ for idx in (7, 11) # take ambient and mosfet temperature
131
+ ] + BMS._temp_values(
132
+ self._data_final.get(0x87, bytearray()),
133
+ values=min(BMS._MAX_TEMP, result.get("temp_sensors", 0)),
134
+ start=3,
135
+ divider=10,
136
+ )
137
+
138
+ return result
aiobmsble/utils.py CHANGED
@@ -1,22 +1,26 @@
1
1
  """Utilitiy/Support functions for aiobmsble."""
2
2
 
3
3
  from fnmatch import translate
4
+ from functools import lru_cache
5
+ import importlib
6
+ import pkgutil
4
7
  import re
8
+ from types import ModuleType
5
9
 
6
10
  from bleak.backends.scanner import AdvertisementData
7
11
 
8
- from aiobmsble import AdvertisementPattern
12
+ from aiobmsble import MatcherPattern
9
13
  from aiobmsble.basebms import BaseBMS
10
14
 
11
15
 
12
- def advertisement_matches(
13
- matcher: AdvertisementPattern,
16
+ def _advertisement_matches(
17
+ matcher: MatcherPattern,
14
18
  adv_data: AdvertisementData,
15
19
  ) -> bool:
16
20
  """Determine whether the given advertisement data matches the specified pattern.
17
21
 
18
22
  Args:
19
- matcher (AdvertisementPattern): A dictionary containing the matching criteria.
23
+ matcher (MatcherPattern): A dictionary containing the matching criteria.
20
24
  Possible keys include:
21
25
  - "service_uuid" (str): A specific service 128-bit UUID to match.
22
26
  - "service_data_uuid" (str): A specific service data UUID to match.
@@ -56,18 +60,104 @@ def advertisement_matches(
56
60
  )
57
61
 
58
62
 
59
- def bms_supported(bms: BaseBMS, adv_data: AdvertisementData) -> bool:
63
+ @lru_cache
64
+ def load_bms_plugins() -> set[ModuleType]:
65
+ """Discover and load all available Battery Management System (BMS) plugin modules.
66
+
67
+ This function scans the 'aiobmsble/bms' directory for all Python modules,
68
+ dynamically imports each discovered module, and returns a set containing
69
+ the imported module objects required to end with "_bms".
70
+
71
+ Returns:
72
+ set[ModuleType]: A set of imported BMS plugin modules.
73
+
74
+ Raises:
75
+ ImportError: If a module cannot be imported.
76
+ OSError: If the plugin directory cannot be accessed.
77
+
78
+ """
79
+ return {
80
+ importlib.import_module(f"aiobmsble.bms.{module_name}")
81
+ for _, module_name, _ in pkgutil.iter_modules(["aiobmsble/bms"])
82
+ if module_name.endswith("_bms")
83
+ }
84
+
85
+
86
+ def bms_cls(name: str) -> type[BaseBMS] | None:
87
+ """Return the BMS class that is defined by the name argument.
88
+
89
+ Args:
90
+ name (str): The name of the BMS type
91
+
92
+ Returns:
93
+ type[BaseBMS] | None: If the BMS class defined by name is found, None otherwise.
94
+
95
+ """
96
+ try:
97
+ bms_module: ModuleType = importlib.import_module(f"aiobmsble.bms.{name}_bms")
98
+ except ModuleNotFoundError:
99
+ return None
100
+ return bms_module.BMS
101
+
102
+
103
+ def bms_matching(
104
+ adv_data: AdvertisementData, mac_addr: str | None = None
105
+ ) -> list[type[BaseBMS]]:
106
+ """Return the BMS classes that match the given advertisement data.
107
+
108
+ Currently the function returns at most one match, but this behaviour might change
109
+ in the future to multiple entries, if BMSs cannot be distinguished uniquely using
110
+ their Bluetooth advertisement / OUI (Organizationally Unique Identifier)
111
+
112
+ Args:
113
+ adv_data (AdvertisementData): The advertisement data to match against available BMS plugins.
114
+ mac_addr (str | None): Optional MAC address to check OUI against
115
+
116
+ Returns:
117
+ list[type[BaseBMS]]: A list of matching BMS class(es) if found, an empty list otherwhise.
118
+
119
+ """
120
+ for bms_module in load_bms_plugins():
121
+ if bms_supported(bms_module.BMS, adv_data, mac_addr):
122
+ return [bms_module.BMS]
123
+ return []
124
+
125
+
126
+ def bms_identify(
127
+ adv_data: AdvertisementData, mac_addr: str | None = None
128
+ ) -> type[BaseBMS] | None:
129
+ """Return the BMS classes that best matches the given advertisement data.
130
+
131
+ Args:
132
+ adv_data (AdvertisementData): The advertisement data to match against available BMS plugins.
133
+ mac_addr (str | None): Optional MAC address to check OUI against
134
+
135
+ Returns:
136
+ type[BaseBMS] | None: The identified BMS class if a match is found, None otherwhise
137
+
138
+ """
139
+
140
+ matching_bms: list[type[BaseBMS]] = bms_matching(adv_data, mac_addr)
141
+ return matching_bms[0] if matching_bms else None
142
+
143
+
144
+ def bms_supported(
145
+ bms: BaseBMS, adv_data: AdvertisementData, mac_addr: str | None = None
146
+ ) -> bool:
60
147
  """Determine if the given BMS is supported based on advertisement data.
61
148
 
62
149
  Args:
63
150
  bms (BaseBMS): The BMS class to check.
64
151
  adv_data (AdvertisementData): The advertisement data to match against.
152
+ mac_addr (str | None): Optional MAC address to check OUI against
65
153
 
66
154
  Returns:
67
155
  bool: True if the BMS is supported, False otherwise.
68
156
 
69
157
  """
158
+ if mac_addr:
159
+ raise NotImplementedError # pragma: no cover
70
160
  for matcher in bms.matcher_dict_list():
71
- if advertisement_matches(matcher, adv_data):
161
+ if _advertisement_matches(matcher, adv_data):
72
162
  return True
73
163
  return False