aiobmsble 0.2.3__tar.gz → 0.4__tar.gz

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 (80) hide show
  1. aiobmsble-0.4/PKG-INFO +163 -0
  2. aiobmsble-0.4/README.md +131 -0
  3. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/__init__.py +2 -0
  4. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/__main__.py +4 -2
  5. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/basebms.py +40 -15
  6. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/abc_bms.py +2 -2
  7. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/ant_bms.py +4 -3
  8. aiobmsble-0.4/aiobmsble/bms/ant_leg_bms.py +177 -0
  9. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/braunpwr_bms.py +2 -2
  10. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/cbtpwr_bms.py +2 -2
  11. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/cbtpwr_vb_bms.py +2 -2
  12. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/daly_bms.py +2 -2
  13. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/dpwrcore_bms.py +2 -2
  14. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/dummy_bms.py +3 -3
  15. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/ecoworthy_bms.py +2 -2
  16. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/ective_bms.py +2 -2
  17. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/ej_bms.py +9 -3
  18. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/felicity_bms.py +2 -2
  19. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/jbd_bms.py +2 -2
  20. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/jikong_bms.py +2 -2
  21. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/neey_bms.py +2 -2
  22. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/ogt_bms.py +2 -2
  23. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/pro_bms.py +2 -2
  24. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/redodo_bms.py +5 -2
  25. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/renogy_bms.py +2 -2
  26. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/renogy_pro_bms.py +2 -2
  27. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/roypow_bms.py +2 -2
  28. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/seplos_bms.py +3 -3
  29. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/seplos_v2_bms.py +2 -2
  30. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/tdt_bms.py +2 -2
  31. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/tianpwr_bms.py +2 -2
  32. aiobmsble-0.4/aiobmsble/test_data/__init__.py +95 -0
  33. aiobmsble-0.4/aiobmsble/test_data/abc_bms.json +34 -0
  34. aiobmsble-0.4/aiobmsble/test_data/ant_bms.json +18 -0
  35. aiobmsble-0.4/aiobmsble/test_data/ant_leg_bms.json +19 -0
  36. aiobmsble-0.4/aiobmsble/test_data/braunpwr_bms.json +34 -0
  37. aiobmsble-0.4/aiobmsble/test_data/cbtpwr_bms.json +90 -0
  38. aiobmsble-0.4/aiobmsble/test_data/cbtpwr_vb_bms.json +20 -0
  39. aiobmsble-0.4/aiobmsble/test_data/daly_bms.json +100 -0
  40. aiobmsble-0.4/aiobmsble/test_data/dpwrcore_bms.json +18 -0
  41. aiobmsble-0.4/aiobmsble/test_data/ecoworthy_bms.json +100 -0
  42. aiobmsble-0.4/aiobmsble/test_data/ective_bms.json +104 -0
  43. aiobmsble-0.4/aiobmsble/test_data/ej_bms.json +107 -0
  44. aiobmsble-0.4/aiobmsble/test_data/felicity_bms.json +24 -0
  45. aiobmsble-0.4/aiobmsble/test_data/ignore.json +48 -0
  46. aiobmsble-0.4/aiobmsble/test_data/jbd_bms.json +440 -0
  47. aiobmsble-0.4/aiobmsble/test_data/jikong_bms.json +54 -0
  48. aiobmsble-0.4/aiobmsble/test_data/neey_bms.json +65 -0
  49. aiobmsble-0.4/aiobmsble/test_data/ogt_bms.json +16 -0
  50. aiobmsble-0.4/aiobmsble/test_data/pro_bms.json +15 -0
  51. aiobmsble-0.4/aiobmsble/test_data/redodo_bms.json +151 -0
  52. aiobmsble-0.4/aiobmsble/test_data/renogy_bms.json +19 -0
  53. aiobmsble-0.4/aiobmsble/test_data/renogy_pro_bms.json +16 -0
  54. aiobmsble-0.4/aiobmsble/test_data/roypow_bms.json +54 -0
  55. aiobmsble-0.4/aiobmsble/test_data/seplos_bms.json +96 -0
  56. aiobmsble-0.4/aiobmsble/test_data/seplos_v2_bms.json +41 -0
  57. aiobmsble-0.4/aiobmsble/test_data/tdt_bms.json +14 -0
  58. aiobmsble-0.4/aiobmsble/test_data/tianpwr_bms.json +13 -0
  59. aiobmsble-0.4/aiobmsble.egg-info/PKG-INFO +163 -0
  60. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble.egg-info/SOURCES.txt +29 -0
  61. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble.egg-info/requires.txt +2 -6
  62. {aiobmsble-0.2.3 → aiobmsble-0.4}/pyproject.toml +8 -12
  63. {aiobmsble-0.2.3 → aiobmsble-0.4}/tests/test_basebms.py +53 -4
  64. {aiobmsble-0.2.3 → aiobmsble-0.4}/tests/test_fuzzing.py +1 -1
  65. {aiobmsble-0.2.3 → aiobmsble-0.4}/tests/test_main.py +1 -1
  66. {aiobmsble-0.2.3 → aiobmsble-0.4}/tests/test_plugins.py +5 -7
  67. aiobmsble-0.4/tests/test_test_data.py +42 -0
  68. {aiobmsble-0.2.3 → aiobmsble-0.4}/tests/test_utils.py +7 -7
  69. aiobmsble-0.2.3/PKG-INFO +0 -122
  70. aiobmsble-0.2.3/README.md +0 -86
  71. aiobmsble-0.2.3/aiobmsble.egg-info/PKG-INFO +0 -122
  72. {aiobmsble-0.2.3 → aiobmsble-0.4}/LICENSE +0 -0
  73. {aiobmsble-0.2.3 → aiobmsble-0.4}/MANIFEST.in +0 -0
  74. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/bms/__init__.py +0 -0
  75. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble/utils.py +0 -0
  76. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble.egg-info/dependency_links.txt +0 -0
  77. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble.egg-info/entry_points.txt +0 -0
  78. {aiobmsble-0.2.3 → aiobmsble-0.4}/aiobmsble.egg-info/top_level.txt +0 -0
  79. {aiobmsble-0.2.3 → aiobmsble-0.4}/setup.cfg +0 -0
  80. {aiobmsble-0.2.3 → aiobmsble-0.4}/tests/test_examples.py +0 -0
aiobmsble-0.4/PKG-INFO ADDED
@@ -0,0 +1,163 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiobmsble
3
+ Version: 0.4
4
+ Summary: Asynchronous Python library to query battery management systems via Bluetooth Low Energy.
5
+ Author: Patrick Loschmidt
6
+ Maintainer: Patrick Loschmidt
7
+ License-Expression: Apache-2.0
8
+ Project-URL: Homepage, https://github.com/patman15/aiobmsble/
9
+ Project-URL: Documentation, https://github.com/patman15/aiobmsble/README
10
+ Project-URL: Source Code, https://github.com/patman15/aiobmsble/
11
+ Project-URL: Bug Reports, https://github.com/patman15/aiobmsble/issues
12
+ Keywords: BMS,BLE,battery,bluetooth
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: bleak>=1.0.1
22
+ Requires-Dist: bleak-retry-connector>=4.0.2
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest; extra == "dev"
25
+ Requires-Dist: pytest-asyncio; extra == "dev"
26
+ Requires-Dist: pytest-cov; extra == "dev"
27
+ Requires-Dist: pytest-xdist; extra == "dev"
28
+ Requires-Dist: hypothesis; extra == "dev"
29
+ Requires-Dist: mypy; extra == "dev"
30
+ Requires-Dist: ruff<0.14.0,>=0.12.1; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ [![GitHub Release][releases-shield]](https://pypi.org/p/aiobmsble/)
34
+ [![License][license-shield]](LICENSE)
35
+
36
+ # Aiobmsble
37
+ Requires Python 3 and uses [asyncio](https://pypi.org/project/asyncio/) and [bleak](https://pypi.org/project/bleak/)
38
+
39
+ ## Asynchronous Library to Query Battery Management Systems via Bluetooth LE
40
+ This library is intended to query data from battery management systems that use Bluetooth LE. This library can be used stand-alone in any Python environment (with necessary dependencies installed). It is developed to support [BMS_BLE-HA integration](https://github.com/patman15/BMS_BLE-HA/) that was written to make BMS data available to Home Assistant, but can be hopefully usefull for other use-cases as well.
41
+
42
+ * [Features](#features)
43
+ * [Usage](#usage)
44
+ * [Installation](#installation)
45
+ * [Troubleshooting](#troubleshooting)
46
+
47
+ ## Features
48
+ - Support for autodetecting compatible BLE BMSs
49
+ - Automatic detection of compatible BLE write mode
50
+ - Asynchronous operation using [asyncio](https://pypi.org/project/asyncio/)
51
+ - Any number of batteries in parallel
52
+ - 100% test coverage plus fuzz tests for BLE data
53
+
54
+ ### Supported Devices
55
+ The [list of supported devices](https://github.com/patman15/BMS_BLE-HA/blob/feature-aiobmsble/README.md#supported-devices) is maintained in the repository of the related [Home Assistant integration](https://github.com/patman15/BMS_BLE-HA).
56
+
57
+ ## Usage
58
+ In order to identify all devices that are reachable and supported by the library, simply run
59
+ ```bash
60
+ aiobmsble
61
+ ```
62
+ from the command line after [installation](#installation).
63
+
64
+ ### From your Python code
65
+ In case you need a reference to include the code into your library, please see [\_\_main\_\_.py](/aiobmsble/__main__.py).
66
+
67
+ ### From a Script
68
+ This example can also be found as an [example](/examples/minimal.py) in the respective [folder](/main/examples).
69
+ ```python
70
+ """Example of using the aiobmsble library to find a BLE device by name and print its sensor data.
71
+
72
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
73
+ License: Apache-2.0, http://www.apache.org/licenses/
74
+ """
75
+
76
+ import asyncio
77
+ import logging
78
+ from typing import Final
79
+
80
+ from bleak import BleakScanner
81
+ from bleak.backends.device import BLEDevice
82
+ from bleak.exc import BleakError
83
+
84
+ from aiobmsble import BMSsample
85
+ from aiobmsble.bms.dummy_bms import BMS # TODO: use the right BMS class for your device
86
+
87
+ NAME: Final[str] = "BT Device Name" # TODO: replace with the name of your BLE device
88
+
89
+ # Configure logging
90
+ logging.basicConfig(level=logging.INFO)
91
+ logger: logging.Logger = logging.getLogger(__name__)
92
+
93
+
94
+ async def main(dev_name) -> None:
95
+ """Find a BLE device by name and update its sensor data."""
96
+
97
+ device: BLEDevice | None = await BleakScanner.find_device_by_name(dev_name)
98
+ if device is None:
99
+ logger.error("Device '%s' not found.", dev_name)
100
+ return
101
+
102
+ logger.info("Found device: %s (%s)", device.name, device.address)
103
+ try:
104
+ async with BMS(ble_device=device) as bms:
105
+ logger.info("Updating BMS data...")
106
+ data: BMSsample = await bms.async_update()
107
+ logger.info("BMS data: %s", repr(data).replace(", ", ",\n\t"))
108
+ except BleakError as ex:
109
+ logger.error("Failed to update BMS: %s", type(ex).__name__)
110
+
111
+
112
+ if __name__ == "__main__":
113
+ asyncio.run(main(NAME)) # pragma: no cover
114
+ ```
115
+
116
+ ### Testing
117
+ For integrations tests (using pytest) the library provides advertisement data that can be used to verify detection of BMSs. To use it, include the following line into your `conftest.py`:
118
+ ```python
119
+ pytest_plugins: list[str] = ["aiobmsble.test_data"]
120
+ ```
121
+
122
+ For your tests you can then use
123
+ ```python
124
+ def test_advertisements(bms_advertisements) -> None:
125
+ """Run some tests with the advertisements"""
126
+ for advertisement, bms_type, _comments in bms_advertisements:
127
+ ...
128
+ ```
129
+
130
+ ## Installation
131
+ Install python and pip if you have not already, then run:
132
+ ```bash
133
+ pip3 install pip --upgrade
134
+ pip3 install wheel
135
+ ```
136
+
137
+ ### For Production:
138
+
139
+ ```bash
140
+ pip3 install aiobmsble
141
+ ```
142
+ This will install the latest library release and all of it's python dependencies.
143
+
144
+ ### For Development:
145
+ ```bash
146
+ git clone https://github.com/patman15/aiobmsble.git
147
+ cd aiobmsble
148
+ pip3 install -e .[dev]
149
+ ```
150
+ This gives you the latest library code from the main branch.
151
+
152
+ ## Troubleshooting
153
+ In case you have problems with the library, please enable debug logging. You can also run `aiobmsble -v` from the command line in order to query all known BMS that are reachable.
154
+
155
+ ### In case you have troubles you'd like to have help with
156
+
157
+ - please record a debug log using `aiobmsble -v -l debug.log`,
158
+ - [open an issue](https://github.com/patman15/aiobmsble/issues/new?assignees=&labels=question&projects=&template=support.yml) with a good description of what your question/issue is and attach the log, or
159
+ - [open a bug](https://github.com/patman15/aiobmsble/issues/new?assignees=&labels=Bug&projects=&template=bug.yml) if you think the behaviour you see is misbehaviour of the library, including a good description of what happened, your expectations,
160
+ - and put the `debug.log` **as attachement** to the issue.
161
+
162
+ [license-shield]: https://img.shields.io/github/license/patman15/aiobmsble?style=for-the-badge&cacheSeconds=86400
163
+ [releases-shield]: https://img.shields.io/pypi/v/aiobmsble?style=for-the-badge
@@ -0,0 +1,131 @@
1
+ [![GitHub Release][releases-shield]](https://pypi.org/p/aiobmsble/)
2
+ [![License][license-shield]](LICENSE)
3
+
4
+ # Aiobmsble
5
+ Requires Python 3 and uses [asyncio](https://pypi.org/project/asyncio/) and [bleak](https://pypi.org/project/bleak/)
6
+
7
+ ## Asynchronous Library to Query Battery Management Systems via Bluetooth LE
8
+ This library is intended to query data from battery management systems that use Bluetooth LE. This library can be used stand-alone in any Python environment (with necessary dependencies installed). It is developed to support [BMS_BLE-HA integration](https://github.com/patman15/BMS_BLE-HA/) that was written to make BMS data available to Home Assistant, but can be hopefully usefull for other use-cases as well.
9
+
10
+ * [Features](#features)
11
+ * [Usage](#usage)
12
+ * [Installation](#installation)
13
+ * [Troubleshooting](#troubleshooting)
14
+
15
+ ## Features
16
+ - Support for autodetecting compatible BLE BMSs
17
+ - Automatic detection of compatible BLE write mode
18
+ - Asynchronous operation using [asyncio](https://pypi.org/project/asyncio/)
19
+ - Any number of batteries in parallel
20
+ - 100% test coverage plus fuzz tests for BLE data
21
+
22
+ ### Supported Devices
23
+ The [list of supported devices](https://github.com/patman15/BMS_BLE-HA/blob/feature-aiobmsble/README.md#supported-devices) is maintained in the repository of the related [Home Assistant integration](https://github.com/patman15/BMS_BLE-HA).
24
+
25
+ ## Usage
26
+ In order to identify all devices that are reachable and supported by the library, simply run
27
+ ```bash
28
+ aiobmsble
29
+ ```
30
+ from the command line after [installation](#installation).
31
+
32
+ ### From your Python code
33
+ In case you need a reference to include the code into your library, please see [\_\_main\_\_.py](/aiobmsble/__main__.py).
34
+
35
+ ### From a Script
36
+ This example can also be found as an [example](/examples/minimal.py) in the respective [folder](/main/examples).
37
+ ```python
38
+ """Example of using the aiobmsble library to find a BLE device by name and print its sensor data.
39
+
40
+ Project: aiobmsble, https://pypi.org/p/aiobmsble/
41
+ License: Apache-2.0, http://www.apache.org/licenses/
42
+ """
43
+
44
+ import asyncio
45
+ import logging
46
+ from typing import Final
47
+
48
+ from bleak import BleakScanner
49
+ from bleak.backends.device import BLEDevice
50
+ from bleak.exc import BleakError
51
+
52
+ from aiobmsble import BMSsample
53
+ from aiobmsble.bms.dummy_bms import BMS # TODO: use the right BMS class for your device
54
+
55
+ NAME: Final[str] = "BT Device Name" # TODO: replace with the name of your BLE device
56
+
57
+ # Configure logging
58
+ logging.basicConfig(level=logging.INFO)
59
+ logger: logging.Logger = logging.getLogger(__name__)
60
+
61
+
62
+ async def main(dev_name) -> None:
63
+ """Find a BLE device by name and update its sensor data."""
64
+
65
+ device: BLEDevice | None = await BleakScanner.find_device_by_name(dev_name)
66
+ if device is None:
67
+ logger.error("Device '%s' not found.", dev_name)
68
+ return
69
+
70
+ logger.info("Found device: %s (%s)", device.name, device.address)
71
+ try:
72
+ async with BMS(ble_device=device) as bms:
73
+ logger.info("Updating BMS data...")
74
+ data: BMSsample = await bms.async_update()
75
+ logger.info("BMS data: %s", repr(data).replace(", ", ",\n\t"))
76
+ except BleakError as ex:
77
+ logger.error("Failed to update BMS: %s", type(ex).__name__)
78
+
79
+
80
+ if __name__ == "__main__":
81
+ asyncio.run(main(NAME)) # pragma: no cover
82
+ ```
83
+
84
+ ### Testing
85
+ For integrations tests (using pytest) the library provides advertisement data that can be used to verify detection of BMSs. To use it, include the following line into your `conftest.py`:
86
+ ```python
87
+ pytest_plugins: list[str] = ["aiobmsble.test_data"]
88
+ ```
89
+
90
+ For your tests you can then use
91
+ ```python
92
+ def test_advertisements(bms_advertisements) -> None:
93
+ """Run some tests with the advertisements"""
94
+ for advertisement, bms_type, _comments in bms_advertisements:
95
+ ...
96
+ ```
97
+
98
+ ## Installation
99
+ Install python and pip if you have not already, then run:
100
+ ```bash
101
+ pip3 install pip --upgrade
102
+ pip3 install wheel
103
+ ```
104
+
105
+ ### For Production:
106
+
107
+ ```bash
108
+ pip3 install aiobmsble
109
+ ```
110
+ This will install the latest library release and all of it's python dependencies.
111
+
112
+ ### For Development:
113
+ ```bash
114
+ git clone https://github.com/patman15/aiobmsble.git
115
+ cd aiobmsble
116
+ pip3 install -e .[dev]
117
+ ```
118
+ This gives you the latest library code from the main branch.
119
+
120
+ ## Troubleshooting
121
+ In case you have problems with the library, please enable debug logging. You can also run `aiobmsble -v` from the command line in order to query all known BMS that are reachable.
122
+
123
+ ### In case you have troubles you'd like to have help with
124
+
125
+ - please record a debug log using `aiobmsble -v -l debug.log`,
126
+ - [open an issue](https://github.com/patman15/aiobmsble/issues/new?assignees=&labels=question&projects=&template=support.yml) with a good description of what your question/issue is and attach the log, or
127
+ - [open a bug](https://github.com/patman15/aiobmsble/issues/new?assignees=&labels=Bug&projects=&template=bug.yml) if you think the behaviour you see is misbehaviour of the library, including a good description of what happened, your expectations,
128
+ - and put the `debug.log` **as attachement** to the issue.
129
+
130
+ [license-shield]: https://img.shields.io/github/license/patman15/aiobmsble?style=for-the-badge&cacheSeconds=86400
131
+ [releases-shield]: https://img.shields.io/pypi/v/aiobmsble?style=for-the-badge
@@ -19,6 +19,7 @@ type BMSvalue = Literal[
19
19
  "cycles",
20
20
  "cycle_capacity",
21
21
  "cycle_charge",
22
+ "total_charge",
22
23
  "delta_voltage",
23
24
  "problem",
24
25
  "runtime",
@@ -69,6 +70,7 @@ class BMSsample(TypedDict, total=False):
69
70
  cell_count: int # [#]
70
71
  cell_voltages: list[float] # [V]
71
72
  cycle_charge: int | float # [Ah]
73
+ total_charge: int # [Ah], overall discharged
72
74
  design_capacity: int # [Ah]
73
75
  pack_count: int # [#]
74
76
  temp_sensors: int # [#]
@@ -31,7 +31,7 @@ async def scan_devices() -> dict[str, tuple[BLEDevice, AdvertisementData]]:
31
31
  scan_result: dict[str, tuple[BLEDevice, AdvertisementData]] = (
32
32
  await BleakScanner.discover(return_adv=True)
33
33
  )
34
- logger.info(scan_result)
34
+ logger.debug(scan_result)
35
35
  logger.info("%i BT devices in range.", len(scan_result))
36
36
  return scan_result
37
37
 
@@ -51,7 +51,7 @@ async def detect_bms() -> None:
51
51
 
52
52
  if bms_cls := bms_identify(advertisement):
53
53
  logger.info("Found matching BMS type: %s", bms_cls.device_id())
54
- bms: BaseBMS = bms_cls(ble_device=ble_dev, reconnect=True)
54
+ bms: BaseBMS = bms_cls(ble_device=ble_dev)
55
55
 
56
56
  try:
57
57
  logger.info("Updating BMS data...")
@@ -59,6 +59,8 @@ async def detect_bms() -> None:
59
59
  logger.info("BMS data: %s", repr(data).replace(", '", ",\n\t'"))
60
60
  except (BleakError, TimeoutError) as exc:
61
61
  logger.error("Failed to update BMS: %s", type(exc).__name__)
62
+ finally:
63
+ await bms.disconnect()
62
64
 
63
65
  logger.info("done.")
64
66
 
@@ -9,7 +9,8 @@ import asyncio
9
9
  from collections.abc import Callable, MutableMapping
10
10
  import logging
11
11
  from statistics import fmean
12
- from typing import Any, Final, Literal
12
+ from types import TracebackType
13
+ from typing import Any, Final, Literal, Self
13
14
 
14
15
  from bleak import BleakClient
15
16
  from bleak.backends.characteristic import BleakGATTCharacteristic
@@ -44,26 +45,28 @@ class BaseBMS(ABC):
44
45
  def __init__(
45
46
  self,
46
47
  ble_device: BLEDevice,
47
- reconnect: bool = False,
48
+ keep_alive: bool = True,
48
49
  logger_name: str = "",
49
50
  ) -> None:
50
51
  """Intialize the BMS.
51
52
 
52
- notification_handler: the callback function used for notifications from 'uuid_rx()'
53
+ `_notification_handler`: the callback function used for notifications from `uuid_rx()`
53
54
  characteristic. Not defined as abstract in this base class, as it can be both,
54
55
  a normal or async function
55
56
 
56
57
  Args:
57
- logger_name (str): name of the logger for the BMS instance (usually file name)
58
58
  ble_device (BLEDevice): the Bleak device to connect to
59
- reconnect (bool): if true, the connection will be closed after each update
59
+ keep_alive (bool): if true, the connection will be kept active after each update.
60
+ Make sure to call `disconnect()` when done using the BMS class or better use
61
+ `async with` context manager (requires `keep_alive=True`).
62
+ logger_name (str): name of the logger for the BMS instance, default: module name
60
63
 
61
64
  """
62
65
  assert (
63
66
  getattr(self, "_notification_handler", None) is not None
64
67
  ), "BMS class must define _notification_handler method"
65
68
  self._ble_device: Final[BLEDevice] = ble_device
66
- self._reconnect: Final[bool] = reconnect
69
+ self._keep_alive: Final[bool] = keep_alive
67
70
  self.name: Final[str] = self._ble_device.name or "undefined"
68
71
  self._inv_wr_mode: bool | None = None # invert write mode (WNR <-> W)
69
72
  logger_name = logger_name or self.__class__.__module__
@@ -83,6 +86,22 @@ class BaseBMS(ABC):
83
86
  self._data: bytearray = bytearray()
84
87
  self._data_event: Final[asyncio.Event] = asyncio.Event()
85
88
 
89
+ async def __aenter__(self) -> Self:
90
+ """Asynchronous context manager to implement `async with` functionality."""
91
+ if not self._keep_alive:
92
+ raise ValueError("usage of context manager requires `keep_alive=True`.")
93
+ await self._connect()
94
+ return self
95
+
96
+ async def __aexit__(
97
+ self,
98
+ typ: type[BaseException] | None,
99
+ exc: BaseException | None,
100
+ tb: TracebackType | None,
101
+ ) -> None:
102
+ """Asynchronous context manager exit functionality."""
103
+ await self.disconnect()
104
+
86
105
  @classmethod
87
106
  def get_bms_module(cls) -> str:
88
107
  """Return BMS module name, e.g. aiobmsble.bms.dummy_bms."""
@@ -177,6 +196,10 @@ class BaseBMS(ABC):
177
196
  {"voltage", "cycle_charge"},
178
197
  lambda: round(data.get("voltage", 0) * data.get("cycle_charge", 0), 3),
179
198
  ),
199
+ "cycles": (
200
+ {"design_capacity", "total_charge"},
201
+ lambda: data.get("total_charge", 0) // data.get("design_capacity", 0),
202
+ ),
180
203
  "power": (
181
204
  {"voltage", "current"},
182
205
  lambda: round(data.get("voltage", 0) * current, 3),
@@ -356,8 +379,8 @@ class BaseBMS(ABC):
356
379
  if reset:
357
380
  self._inv_wr_mode = None # reset write mode
358
381
  await self._client.disconnect()
359
- except BleakError:
360
- self._log.warning("disconnect failed!")
382
+ except (BleakError, TimeoutError) as exc:
383
+ self._log.error("disconnect failed! (%s)", type(exc).__name__)
361
384
 
362
385
  async def _wait_event(self) -> None:
363
386
  """Wait for data event and clear it."""
@@ -384,7 +407,7 @@ class BaseBMS(ABC):
384
407
  data: BMSsample = await self._async_update()
385
408
  self._add_missing_values(data, self._calc_values())
386
409
 
387
- if self._reconnect:
410
+ if not self._keep_alive:
388
411
  # disconnect after data update to force reconnect next time (slow!)
389
412
  await self.disconnect()
390
413
 
@@ -478,16 +501,18 @@ class BaseBMS(ABC):
478
501
 
479
502
  """
480
503
  return [
481
- value / divider if divider != 1 else value
504
+ (value - offset) / divider
482
505
  for idx in range(values)
483
506
  if (len(data) >= start + (idx + 1) * size)
484
507
  and (
485
- value := int.from_bytes(
486
- data[start + idx * size : start + (idx + 1) * size],
487
- byteorder=byteorder,
488
- signed=signed,
508
+ (
509
+ value := int.from_bytes(
510
+ data[start + idx * size : start + (idx + 1) * size],
511
+ byteorder=byteorder,
512
+ signed=signed,
513
+ )
489
514
  )
490
- - offset
515
+ or (offset == 0)
491
516
  )
492
517
  ]
493
518
 
@@ -47,9 +47,9 @@ class BMS(BaseBMS):
47
47
  )
48
48
  _RESPS: Final[set[int]] = {field.idx for field in _FIELDS} | {0xF4} # cell voltages
49
49
 
50
- def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
50
+ def __init__(self, ble_device: BLEDevice, keep_alive: bool = True) -> None:
51
51
  """Initialize BMS."""
52
- super().__init__(ble_device, reconnect)
52
+ super().__init__(ble_device, keep_alive)
53
53
  self._data_final: dict[int, bytearray] = {}
54
54
  self._exp_reply: set[int] = set()
55
55
 
@@ -41,13 +41,14 @@ class BMS(BaseBMS):
41
41
  | ((x & 0xF) if (x & 0xF) not in (0x1, 0x4, 0xB, 0xC, 0xF) else 0),
42
42
  ),
43
43
  BMSdp("cycle_charge", 54, 4, False, lambda x: x / 1e6),
44
+ BMSdp("total_charge", 58, 4, False, lambda x: x // 1000),
44
45
  BMSdp("delta_voltage", 82, 2, False, lambda x: x / 1000),
45
46
  BMSdp("power", 62, 4, True, lambda x: x / 1),
46
47
  )
47
48
 
48
- def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
49
+ def __init__(self, ble_device: BLEDevice, keep_alive: bool = True) -> None:
49
50
  """Initialize BMS."""
50
- super().__init__(ble_device, reconnect)
51
+ super().__init__(ble_device, keep_alive)
51
52
  self._data_final: bytearray = bytearray()
52
53
  self._valid_reply: int = BMS._CMD_STAT | 0x10 # valid reply mask
53
54
  self._exp_len: int = BMS._MIN_LEN
@@ -87,7 +88,7 @@ class BMS(BaseBMS):
87
88
  @staticmethod
88
89
  def _calc_values() -> frozenset[BMSvalue]:
89
90
  return frozenset(
90
- {"cycle_capacity", "temperature"}
91
+ {"cycle_capacity", "cycles", "temperature"}
91
92
  ) # calculate further values from BMS provided set ones
92
93
 
93
94
  async def _init_connection(
@@ -0,0 +1,177 @@
1
+ """Module to support ANT BMS."""
2
+
3
+ import contextlib
4
+ from enum import IntEnum
5
+ from typing import Final, override
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, crc_sum
13
+
14
+
15
+ class BMS(BaseBMS):
16
+ """ANT BMS (legacy) implementation."""
17
+
18
+ class CMD(IntEnum):
19
+ """Command codes for ANT BMS."""
20
+
21
+ GET = 0xDB
22
+ SET = 0xA5
23
+
24
+ class ADR(IntEnum):
25
+ """Address codes for ANT BMS."""
26
+
27
+ STATUS = 0x00
28
+
29
+ _RX_HEADER: Final[bytes] = b"\xaa\x55\xaa"
30
+ _RX_HEADER_RSP_STAT: Final[bytes] = b"\xaa\x55\xaa\xff"
31
+
32
+ _RSP_STAT: Final[int] = 0xFF
33
+ _RSP_STAT_LEN: Final[int] = 140
34
+
35
+ _FIELDS: Final[tuple[BMSdp, ...]] = (
36
+ BMSdp("voltage", 4, 2, False, lambda x: x / 10),
37
+ BMSdp("current", 70, 4, True, lambda x: x / -10),
38
+ BMSdp("battery_level", 74, 1, False),
39
+ BMSdp("design_capacity", 75, 4, False, lambda x: x // 1e6),
40
+ BMSdp("cycle_charge", 79, 4, False, lambda x: x / 1e6),
41
+ BMSdp("total_charge", 83, 4, False, lambda x: x // 1000),
42
+ BMSdp("runtime", 87, 4, False),
43
+ BMSdp("cell_count", 123, 1, False),
44
+ )
45
+
46
+ def __init__(self, ble_device: BLEDevice, keep_alive: bool = True) -> None:
47
+ """Initialize BMS."""
48
+ super().__init__(ble_device, keep_alive)
49
+ self._data_final: bytearray
50
+
51
+ @staticmethod
52
+ @override
53
+ def matcher_dict_list() -> list[MatcherPattern]:
54
+ """Provide BluetoothMatcher definition."""
55
+ return [
56
+ {
57
+ "local_name": "ANT-BLE*",
58
+ "service_uuid": BMS.uuid_services()[0],
59
+ "manufacturer_id": 1623,
60
+ "connectable": True,
61
+ }
62
+ ]
63
+
64
+ @staticmethod
65
+ @override
66
+ def device_info() -> dict[str, str]:
67
+ """Return device information for the battery management system."""
68
+ return {"manufacturer": "ANT", "model": "Smart BMS"}
69
+
70
+ @staticmethod
71
+ @override
72
+ def uuid_services() -> list[str]:
73
+ """Return list of 128-bit UUIDs of services required by BMS."""
74
+ return [normalize_uuid_str("ffe0")] # change service UUID here!
75
+
76
+ @staticmethod
77
+ @override
78
+ def uuid_rx() -> str:
79
+ """Return 16-bit UUID of characteristic that provides notification/read property."""
80
+ return "ffe1"
81
+
82
+ @staticmethod
83
+ @override
84
+ def uuid_tx() -> str:
85
+ """Return 16-bit UUID of characteristic that provides write property."""
86
+ return "ffe1"
87
+
88
+ @staticmethod
89
+ @override
90
+ def _calc_values() -> frozenset[BMSvalue]:
91
+ return frozenset(
92
+ (
93
+ "battery_charging",
94
+ "cycle_capacity",
95
+ "cycles",
96
+ "delta_voltage",
97
+ "power",
98
+ "temperature",
99
+ )
100
+ ) # calculate further values from BMS provided set ones
101
+
102
+ def _notification_handler(
103
+ self, _sender: BleakGATTCharacteristic, data: bytearray
104
+ ) -> None:
105
+ """Handle the RX characteristics notify event (new data arrives)."""
106
+
107
+ self._log.debug("RX BLE data: %s", data)
108
+
109
+ if data.startswith(BMS._RX_HEADER_RSP_STAT):
110
+ self._data = bytearray()
111
+ elif not self._data:
112
+ self._log.debug("invalid start of frame")
113
+ return
114
+
115
+ self._data += data
116
+
117
+ _data_len: Final[int] = len(self._data)
118
+ if _data_len < BMS._RSP_STAT_LEN:
119
+ return
120
+
121
+ if _data_len > BMS._RSP_STAT_LEN:
122
+ self._log.debug("invalid length %d > %d", _data_len, BMS._RSP_STAT_LEN)
123
+ self._data.clear()
124
+ return
125
+
126
+ if (local_crc := crc_sum(self._data[4:-2], 2)) != (
127
+ remote_crc := int.from_bytes(self._data[-2:], byteorder="big", signed=False)
128
+ ):
129
+ self._log.debug("invalid checksum 0x%X != 0x%X", local_crc, remote_crc)
130
+ self._data.clear()
131
+ return
132
+
133
+ self._data_final = self._data.copy()
134
+ self._data.clear()
135
+ self._data_event.set()
136
+
137
+ @staticmethod
138
+ def _cmd(cmd: CMD, adr: ADR, value: int = 0x0000) -> bytes:
139
+ """Assemble a ANT BMS command."""
140
+ _frame = bytearray((cmd, cmd, adr))
141
+ _frame += value.to_bytes(2, "big")
142
+ _frame += crc_sum(_frame[2:], 1).to_bytes(1, "big")
143
+ return bytes(_frame)
144
+
145
+ @override
146
+ async def _async_update(self) -> BMSsample:
147
+ """Update battery status information."""
148
+ await self._await_reply(BMS._cmd(BMS.CMD.GET, BMS.ADR.STATUS))
149
+
150
+ _data: bytearray = self._data_final
151
+ result: BMSsample = BMS._decode_data(
152
+ BMS._FIELDS, _data, byteorder="big", offset=0
153
+ )
154
+
155
+ result["cell_voltages"] = BMS._cell_voltages(
156
+ _data,
157
+ cells=result["cell_count"],
158
+ start=6,
159
+ size=2,
160
+ byteorder="big",
161
+ divider=1000,
162
+ )
163
+
164
+ if not result["design_capacity"]:
165
+ # Workaround for some BMS always reporting 0 for design_capacity
166
+ result.pop("design_capacity")
167
+ with contextlib.suppress(ZeroDivisionError):
168
+ result["design_capacity"] = int(
169
+ round((result["cycle_charge"] / result["battery_level"]) * 100, -1)
170
+ ) # leads to `cycles` not available when level == 0
171
+
172
+ # ANT-BMS carries 6 slots for temp sensors but only 4 looks like being connected by default
173
+ result["temp_values"] = BMS._temp_values(
174
+ _data, values=4, start=91, size=2, byteorder="big", signed=True
175
+ )
176
+
177
+ return result