├── pylontech ├── __init__.py └── pylontech.py ├── RS485-protocol-pylon-low-voltage-V3.3-20180821.pdf ├── setup.py ├── .github └── workflows │ ├── pypi-release.yml │ └── python-app.yml ├── LICENSE ├── README.md └── tests └── test_basic.py /pylontech/__init__.py: -------------------------------------------------------------------------------- 1 | from .pylontech import Pylontech 2 | -------------------------------------------------------------------------------- /RS485-protocol-pylon-low-voltage-V3.3-20180821.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Frankkkkk/python-pylontech/HEAD/RS485-protocol-pylon-low-voltage-V3.3-20180821.pdf -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | name="python-pylontech", 6 | version="0.3.3", 7 | author="Frank Villaro-Dixon", 8 | author_email="frank@villaro-dixon.eu", 9 | description=("Interfaces with Pylontech Batteries using RS485 protocol"), 10 | license="MIT", 11 | keywords="pylontech pylon rs485 lithium battery US2000 US2000C US3000", 12 | url="http://github.com/Frankkkkk/python-pylontech", 13 | packages=['pylontech'], 14 | long_description=open("README.md", "r").read(), 15 | long_description_content_type="text/markdown", 16 | install_requires=['pyserial', 'construct'], 17 | classifiers=[ 18 | "Development Status :: 3 - Alpha", 19 | "Topic :: Utilities", 20 | "License :: OSI Approved :: MIT License", 21 | ], 22 | ) 23 | -------------------------------------------------------------------------------- /.github/workflows/pypi-release.yml: -------------------------------------------------------------------------------- 1 | name: PyPi Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | # based on https://github.com/pypa/gh-action-pypi-publish 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-python@v2 16 | with: 17 | python-version: 3.7 18 | 19 | - name: Install dependencies 20 | run: >- 21 | python -m pip install --user --upgrade setuptools wheel 22 | - name: Build 23 | run: >- 24 | python setup.py sdist bdist_wheel 25 | - name: Publish distribution 📦 to PyPI 26 | if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' 27 | uses: pypa/gh-action-pypi-publish@master 28 | with: 29 | user: __token__ 30 | password: ${{ secrets.pypi_password }} 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Frank Villaro-Dixon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python 3.10 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: "3.10" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install flake8 pytest serial construct 30 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 31 | - name: Lint with flake8 32 | run: | 33 | # stop the build if there are Python syntax errors or undefined names 34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 35 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 36 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 37 | - name: Test with pytest 38 | run: | 39 | pytest 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-pylontech 2 | Python lib to talk to pylontech lithium batteries (US2000, US3000, ...) using RS485 3 | 4 | ## What is this lib ? 5 | This lib is meant to talk to Pylontech batteries using RS485. Sadly the protocol over RS485 is not some fancy thing like MODBUS but their own crappy protocol. 6 | 7 | ## How to use this lib ? 8 | First of all, you need a USB to RS485 converter. They are many available online for some bucks. 9 | 10 | Then, you simply need to import the lib and start asking values: 11 | ```python 12 | 13 | >>> import pylontech 14 | >>> p = pylontech.Pylontech() 15 | >>> print(p.get_values()) 16 | Container: 17 | NumberOfModules = 3 18 | Module = ListContainer: 19 | Container: 20 | NumberOfCells = 15 21 | CellVoltages = ListContainer: 22 | 3.306 23 | 3.307 24 | 3.305 25 | 3.305 26 | 3.306 27 | 3.305 28 | 3.304 29 | 3.305 30 | 3.306 31 | 3.306 32 | 3.307 33 | 3.307 34 | 3.308 35 | 3.307 36 | 3.306 37 | NumberOfTemperatures = 5 38 | AverageBMSTemperature = 29.81 39 | GroupedCellsTemperatures = ListContainer: 40 | 29.61 41 | 29.61 42 | 29.61 43 | 29.61 44 | Current = -3.5 45 | Voltage = 49.59 46 | Power = -173.565 47 | RemainingCapacity = 39.5 48 | TotalCapacity = 50.0 49 | CycleNumber = 5 50 | -->8-- SNIP -->8-- 51 | TotalPower = -525.8022 52 | StateOfCharge = 0.79 53 | 54 | >>> print(p.get_system_parameters()) 55 | Container: 56 | CellHighVoltageLimit = 3.7 57 | CellLowVoltageLimit = 3.05 58 | CellUnderVoltageLimit = 2.9 59 | ChargeHighTemperatureLimit = 33.41 60 | ChargeLowTemperatureLimit = 26.21 61 | ChargeCurrentLimit = 10.2 62 | ModuleHighVoltageLimit = 54.0 63 | ModuleLowVoltageLimit = 46.0 64 | ModuleUnderVoltageLimit = 44.5 65 | DischargeHighTemperatureLimit = 33.41 66 | DischargeLowTemperatureLimit = 26.21 67 | DischargeCurrentLimit = -10.0 68 | ``` 69 | 70 | ## Dependencies 71 | `python-pylontech` needs python 3.5 or greater (but please, use at least 3.7 or more if possible to be future-proof). 72 | 73 | This lib depends on `pyserial` and the awesome `construct` lib. 74 | 75 | # Hardware wiring 76 | The pylontech modules talk using the RS485 line protocol. 77 | ## Pylontech side 78 | The first DIP switch on the pylontech indicates the line speed. It must be off (`0`, down position) so that the speed is set to 115200 Bd. 79 | 80 | The RS485 port is exposed on the pins 7 & 8 on the RJ45 connector names `RS485`. 81 | 82 | ## Client side 83 | 84 | ### USB to RS485 85 | Any RS485 to USB converter should would. You just have to wire the two pins above to the `A` and `B` ports (swap them around if it doesn't work). of your converter. 86 | 87 | I personally use cheap chinese "RS485 to USB" converters worth a couple of bucks each. 88 | 89 | ### TCP/IP 90 | If you are using a Ethernet to RS485 bridge, connect as for USB and run the following command (Linux only) for creating a virtual serial port. Then run the python script with adapted serial parameter in the constructor. 91 | 92 | `socat pty,link=$HOME/bat2,waitslave tcp::` 93 | 94 | This is tested with an USR-N540 95 | 96 | # Known bugs 97 | ## Mixing between US2000 and US3000 98 | If you are using US2000 and US3000 batteries, then the main battery must be a US2000. Please see bug https://github.com/Frankkkkk/python-pylontech/issues/2#issuecomment-915966564 for more information 99 | 100 | 101 | # FAQ 102 | 103 | ## Using Pylontech LV Hub with multible battery banks 104 | 105 | If the LV hub is used the address of the RS485 devices is depending on the battery bank. To read values the specific device address is needed. To scan for devices on a bank you can use the `scan_for_batteries` function. The max range is 0 to 255. -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import List 3 | from pytest import approx 4 | 5 | sys.path.extend("..") 6 | 7 | import pylontech 8 | from pylontech.pylontech import ToVolt, ToAmp, ToCelsius, DivideBy1000 9 | import construct 10 | 11 | 12 | class MockSerial(object): 13 | def __init__(self, responses: List[bytes]): 14 | self.responses = responses 15 | 16 | def readline(self) -> bytes: 17 | assert len(self.responses) > 0 18 | reply = self.responses[0] 19 | self.responses = self.responses[1:] 20 | return reply 21 | 22 | def write(self, data: bytes): 23 | print(f"write: {data}") 24 | 25 | 26 | class Pylontech(pylontech.Pylontech): 27 | def __init__(self, responses): 28 | self.s = MockSerial(responses) 29 | 30 | 31 | def test_us2000_3modules_info_parsing_1(): 32 | 33 | p = Pylontech( 34 | [ 35 | b"~20024600914211030F0CE70CE80CE60CE70CE80CE80CE80CE60CE50CE60CE80CE70CEA0CE50CE6050B910B870B870B870B87FFE6C18982DC02C350001F0F0CE20CE60CE60CE10CE50CE70CE60CE30CE20CE50CE30CE90CE70CE90CE9050B910B870B870B870B87FFE7C17082DC02C350001F0F0CE20CE50CE50CE20CE30CE30CE40CE50CE60CE60CE30CE40CE40CE60CE6050B910B7D0B7D0B7D0B7DFFE5C16082DC02C350001FB476\r" 36 | ] 37 | ) 38 | 39 | d = p.get_values() 40 | 41 | assert d.NumberOfModules == 3 42 | m = d.Module[0] 43 | assert m.NumberOfCells == 15 44 | assert m.CellVoltages == approx( 45 | [ 46 | 3.303, 47 | 3.304, 48 | 3.302, 49 | 3.303, 50 | 3.304, 51 | 3.304, 52 | 3.304, 53 | 3.302, 54 | 3.301, 55 | 3.302, 56 | 3.304, 57 | 3.303, 58 | 3.306, 59 | 3.301, 60 | 3.302, 61 | ] 62 | ) 63 | assert m.NumberOfTemperatures == 5 64 | assert m.GroupedCellsTemperatures == approx([22.0, 22.0, 22.0, 22.0]) 65 | assert m.Current == approx(-2.6) 66 | assert m.Voltage == approx(49.545) 67 | assert m.Power == m.Current * m.Voltage 68 | assert m.CycleNumber == 31 69 | assert m.AverageBMSTemperature == approx(23.0) 70 | assert m.RemainingCapacity == approx(33.5) 71 | assert m.TotalCapacity == approx(50) 72 | 73 | m = d.Module[1] 74 | assert m.NumberOfCells == 15 75 | assert m.CellVoltages == approx( 76 | [ 77 | 3.298, 78 | 3.302, 79 | 3.302, 80 | 3.297, 81 | 3.301, 82 | 3.303, 83 | 3.302, 84 | 3.299, 85 | 3.298, 86 | 3.301, 87 | 3.299, 88 | 3.305, 89 | 3.303, 90 | 3.305, 91 | 3.305, 92 | ] 93 | ) 94 | assert m.NumberOfTemperatures == 5 95 | assert m.GroupedCellsTemperatures == approx([22.0, 22.0, 22.0, 22.0]) 96 | assert m.Current == approx(-2.5) 97 | assert m.Voltage == approx(49.52) 98 | assert m.Power == m.Current * m.Voltage 99 | assert m.CycleNumber == 31 100 | assert m.AverageBMSTemperature == approx(23.0) 101 | assert m.RemainingCapacity == approx(33.5) 102 | assert m.TotalCapacity == approx(50) 103 | 104 | m = d.Module[2] 105 | assert m.NumberOfCells == 15 106 | assert m.CellVoltages == approx( 107 | [ 108 | 3.298, 109 | 3.301, 110 | 3.301, 111 | 3.298, 112 | 3.299, 113 | 3.299, 114 | 3.3, 115 | 3.301, 116 | 3.302, 117 | 3.302, 118 | 3.299, 119 | 3.3, 120 | 3.3, 121 | 3.302, 122 | 3.302, 123 | ] 124 | ) 125 | assert m.NumberOfTemperatures == 5 126 | assert m.GroupedCellsTemperatures == approx([21.0, 21.0, 21.0, 21.0]) 127 | assert m.Current == approx(-2.7) 128 | assert m.Voltage == approx(49.504) 129 | assert m.Power == m.Current * m.Voltage 130 | assert m.CycleNumber == 31 131 | assert m.AverageBMSTemperature == approx(23.0) 132 | assert m.RemainingCapacity == approx(33.5) 133 | assert m.TotalCapacity == approx(50) 134 | 135 | assert d.TotalPower == approx(-386.2778) 136 | assert d.StateOfCharge == approx(0.67) 137 | 138 | 139 | def test_us3000_4modules_info_parsing_1(): 140 | 141 | p = Pylontech( 142 | [ 143 | b"~2002460061DC11040F0CFD0CFC0CFC0CFB0CFC0CFB0CFD0CFC0CFC0CFB0CFA0CFD0CFB0CFE0CFA050BE10BCD0BCD0BCD0BCD0000C2C1FFFF04FFFF002F00EFEC0121100F0CEB0CEB0CEB0CEA0CEA0CEC0CEB0CEB0CE90CE80CE60CE90CE90CEA0CE8050BE10BCD0BCD0BCD0BCDFFBCC1B2FFFF04FFFF002800F2D00121100F0CE80CE90CEA0CEA0CEA0CE90CEA0CEA0CEB0CEC0CEB0CEB0CEB0CEA0CEA050BE10BC30BC30BC30BC3FFB7C1B8FFFF04FFFF007100E7400121100F0CE90CEC0CEB0CEA0CEA0CEB0CE90CE80CEA0CEA0CEA0CEB0CEC0CEA0CEA050BD70BC30BC30BC30BB9FFBBC1B9FFFF04FFFF006B00ED080121108D63\r" 144 | ] 145 | ) 146 | 147 | d = p.get_values() 148 | print(d) 149 | 150 | assert d.NumberOfModules == 4 151 | m = d.Module[0] 152 | assert m.NumberOfCells == 15 153 | assert m.CellVoltages == approx( 154 | [ 155 | 3.325, 156 | 3.324, 157 | 3.324, 158 | 3.323, 159 | 3.324, 160 | 3.323, 161 | 3.325, 162 | 3.324, 163 | 3.324, 164 | 3.323, 165 | 3.322, 166 | 3.325, 167 | 3.323, 168 | 3.326, 169 | 3.322, 170 | ] 171 | ) 172 | assert m.NumberOfTemperatures == 5 173 | assert m.GroupedCellsTemperatures == approx([29.0, 29.0, 29.0, 29.0]) 174 | assert m.Current == approx(0) # really?? 175 | assert m.Voltage == approx(49.857) 176 | assert m.Power == m.Current * m.Voltage 177 | assert m.CycleNumber == 47 178 | assert m.AverageBMSTemperature == approx(31.0) 179 | assert m.RemainingCapacity == approx(61.42) 180 | assert m.TotalCapacity == approx(74) 181 | 182 | m = d.Module[1] 183 | assert m.NumberOfCells == 15 184 | assert m.CellVoltages == approx( 185 | [ 186 | 3.307, 187 | 3.307, 188 | 3.307, 189 | 3.306, 190 | 3.306, 191 | 3.308, 192 | 3.307, 193 | 3.307, 194 | 3.305, 195 | 3.304, 196 | 3.302, 197 | 3.305, 198 | 3.305, 199 | 3.306, 200 | 3.304, 201 | ] 202 | ) 203 | assert m.NumberOfTemperatures == 5 204 | assert m.GroupedCellsTemperatures == approx([29.0, 29.0, 29.0, 29.0]) 205 | assert m.Current == approx(-6.8) 206 | assert m.Voltage == approx(49.586) 207 | assert m.Power == m.Current * m.Voltage 208 | assert m.CycleNumber == 40 209 | assert m.AverageBMSTemperature == approx(31.0) 210 | assert m.RemainingCapacity == approx(62.16) 211 | assert m.TotalCapacity == approx(74) 212 | 213 | m = d.Module[2] 214 | assert m.NumberOfCells == 15 215 | assert m.CellVoltages == approx( 216 | [ 217 | 3.304, 218 | 3.305, 219 | 3.306, 220 | 3.306, 221 | 3.306, 222 | 3.305, 223 | 3.306, 224 | 3.306, 225 | 3.307, 226 | 3.308, 227 | 3.307, 228 | 3.307, 229 | 3.307, 230 | 3.306, 231 | 3.306, 232 | ] 233 | ) 234 | assert m.NumberOfTemperatures == 5 235 | assert m.GroupedCellsTemperatures == approx([28.0, 28.0, 28.0, 28.0]) 236 | assert m.Current == approx(-7.3) 237 | assert m.Voltage == approx(49.592) 238 | assert m.Power == m.Current * m.Voltage 239 | assert m.CycleNumber == 113 240 | assert m.AverageBMSTemperature == approx(31.0) 241 | assert m.RemainingCapacity == approx(59.2) 242 | assert m.TotalCapacity == approx(74) 243 | 244 | m = d.Module[3] 245 | assert m.NumberOfCells == 15 246 | assert m.CellVoltages == approx( 247 | [ 248 | 3.305, 249 | 3.308, 250 | 3.307, 251 | 3.306, 252 | 3.306, 253 | 3.307, 254 | 3.305, 255 | 3.304, 256 | 3.306, 257 | 3.306, 258 | 3.306, 259 | 3.307, 260 | 3.308, 261 | 3.306, 262 | 3.306, 263 | ] 264 | ) 265 | assert m.NumberOfTemperatures == 5 266 | assert m.GroupedCellsTemperatures == approx([28.0, 28.0, 28.0, 27.0]) 267 | assert m.Current == approx(-6.9) 268 | assert m.Voltage == approx(49.593) 269 | assert m.Power == m.Current * m.Voltage 270 | assert m.CycleNumber == 107 271 | assert m.AverageBMSTemperature == approx(30.0) 272 | assert m.RemainingCapacity == approx(60.68) 273 | assert m.TotalCapacity == approx(74) 274 | 275 | assert d.TotalPower == approx(-1041.3981) 276 | assert d.StateOfCharge == approx(0.8225) 277 | 278 | 279 | def test_mixed_us3000_us2000_status_info_parsing_1(): 280 | 281 | p = Pylontech( 282 | [ 283 | b"~2002460010F011020F0CCD0CCE0CCC0CCE0CCB0CCC0CCD0CCC0CCD0CCB0CCC0CCD0CCD0CCE0CCC050BE10BCD0BCD0BD70BCDFFC3BFFDFFFF04FFFF0234007F300121100F0CCA0CCA0CCB0CCC0CCA0CCC0CCB0CCB0CCB0CCB0CCB0CCA0CCC0CCC0CCB050BEB0BCD0BCD0BCD0BC3FFD1BFE5FFFF04FFFF0292005FB400C350C4A7\r" 284 | ] 285 | ) 286 | 287 | d = p.get_values() 288 | print(d) 289 | 290 | assert d.NumberOfModules == 2 291 | m = d.Module[0] # US3000 292 | assert m.NumberOfCells == 15 293 | assert m.CellVoltages == approx( 294 | [ 295 | 3.277, 296 | 3.278, 297 | 3.276, 298 | 3.278, 299 | 3.275, 300 | 3.276, 301 | 3.277, 302 | 3.276, 303 | 3.277, 304 | 3.275, 305 | 3.276, 306 | 3.277, 307 | 3.277, 308 | 3.278, 309 | 3.276, 310 | ] 311 | ) 312 | assert m.NumberOfTemperatures == 5 313 | assert m.GroupedCellsTemperatures == approx([29.0, 29.0, 30.0, 29.0]) 314 | assert m.Current == approx(-6.1) 315 | assert m.Voltage == approx(49.149) 316 | assert m.Power == m.Current * m.Voltage 317 | assert m.CycleNumber == 564 318 | assert m.AverageBMSTemperature == approx(31.0) 319 | assert m.RemainingCapacity == approx(32.56) 320 | assert m.TotalCapacity == approx(74) 321 | 322 | m = d.Module[1] # US2000 323 | assert m.NumberOfCells == 15 324 | assert m.CellVoltages == approx( 325 | [ 326 | 3.274, 327 | 3.274, 328 | 3.275, 329 | 3.276, 330 | 3.274, 331 | 3.276, 332 | 3.275, 333 | 3.275, 334 | 3.275, 335 | 3.275, 336 | 3.275, 337 | 3.274, 338 | 3.276, 339 | 3.276, 340 | 3.275, 341 | ] 342 | ) 343 | assert m.NumberOfTemperatures == 5 344 | assert m.GroupedCellsTemperatures == approx([29.0, 29.0, 29.0, 28.0]) 345 | assert m.Current == approx(-4.7) 346 | assert m.Voltage == approx(49.125) 347 | assert m.Power == m.Current * m.Voltage 348 | assert m.CycleNumber == 658 349 | assert m.AverageBMSTemperature == approx(32.0) 350 | assert m.RemainingCapacity == approx(24.5) 351 | assert m.TotalCapacity == approx(50) 352 | 353 | assert d.TotalPower == approx(-530.6964) 354 | assert d.StateOfCharge == approx(0.460161) 355 | 356 | 357 | def test_up2500_1module_status_info_parsing_1(): 358 | p = Pylontech( 359 | [ 360 | b"~20024600D05E1002080D020D020D020D030D000D010D010D03050B7D0B690B690B690B73FFFA680EFFFF04FFFF00000174E401B198E906\r" 361 | ] 362 | ) 363 | 364 | d = p.get_values_single(2) 365 | assert d.NumberOfModule == 2 366 | assert d.NumberOfCells == 8 367 | assert d.CellVoltages == approx( 368 | [3.33, 3.33, 3.33, 3.331, 3.328, 3.329, 3.329, 3.331] 369 | ) 370 | assert d.NumberOfTemperatures == 5 371 | assert d.GroupedCellsTemperatures == approx([19.0, 19.0, 19.0, 20.0]) 372 | assert d.Current == approx(-0.6) 373 | assert d.Voltage == approx(26.638) 374 | assert d.Power == d.Current * d.Voltage 375 | assert d.CycleNumber == 0 376 | assert d.AverageBMSTemperature == approx(21.0) 377 | assert d.RemainingCapacity == approx(95.460) 378 | assert d.TotalCapacity == approx(111) 379 | assert d.TotalPower == d.Power 380 | assert d.StateOfCharge == approx(0.86) 381 | 382 | 383 | def test_up2500_management_info(): 384 | p = Pylontech([b"~20024600B014026EF05AA0022BFDD5C0F915\r"]) 385 | 386 | d = p.get_management_info(2) 387 | 388 | assert d.ChargeVoltageLimit == 28.4 389 | assert d.DischargeVoltageLimit == 23.2 390 | assert d.ChargeCurrentLimit == 55.5 391 | assert d.DischargeCurrentLimit == -55.5 392 | assert d.status.ChargeEnable 393 | assert d.status.DischargeEnable 394 | assert not d.status.ChargeImmediately2 395 | assert not d.status.ChargeImmediately1 396 | assert not d.status.FullChargeRequest 397 | assert not d.status.ShouldCharge 398 | -------------------------------------------------------------------------------- /pylontech/pylontech.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | import logging 3 | import serial 4 | import construct 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | class HexToByte(construct.Adapter): 9 | def _decode(self, obj, context, path) -> bytes: 10 | hexstr = ''.join([chr(x) for x in obj]) 11 | return bytes.fromhex(hexstr) 12 | 13 | 14 | class JoinBytes(construct.Adapter): 15 | def _decode(self, obj, context, path) -> bytes: 16 | return ''.join([chr(x) for x in obj]).encode() 17 | 18 | 19 | class DivideBy1000(construct.Adapter): 20 | def _decode(self, obj, context, path) -> float: 21 | return obj / 1000 22 | 23 | 24 | class DivideBy100(construct.Adapter): 25 | def _decode(self, obj, context, path) -> float: 26 | return obj / 100 27 | 28 | class DivideBy10(construct.Adapter): 29 | def _decode(self, obj, context, path) -> float: 30 | return obj / 10 31 | 32 | class ToVolt(construct.Adapter): 33 | def _decode(self, obj, context, path) -> float: 34 | return obj / 1000 35 | 36 | class ToAmp(construct.Adapter): 37 | def _decode(self, obj, context, path) -> float: 38 | return obj / 10 39 | 40 | class ToCelsius(construct.Adapter): 41 | def _decode(self, obj, context, path) -> float: 42 | return (obj - 2731) / 10.0 # in Kelvin*10 43 | 44 | 45 | 46 | class Pylontech: 47 | manufacturer_info_fmt = construct.Struct( 48 | "DeviceName" / JoinBytes(construct.Array(10, construct.Byte)), 49 | "SoftwareVersion" / construct.Array(2, construct.Byte), 50 | "ManufacturerName" / JoinBytes(construct.GreedyRange(construct.Byte)), 51 | ) 52 | 53 | system_parameters_fmt = construct.Struct( 54 | "CellHighVoltageLimit" / ToVolt(construct.Int16ub), 55 | "CellLowVoltageLimit" / ToVolt(construct.Int16ub), 56 | "CellUnderVoltageLimit" / ToVolt(construct.Int16sb), 57 | "ChargeHighTemperatureLimit" / ToCelsius(construct.Int16sb), 58 | "ChargeLowTemperatureLimit" / ToCelsius(construct.Int16sb), 59 | "ChargeCurrentLimit" / DivideBy10(construct.Int16sb), 60 | "ModuleHighVoltageLimit" / ToVolt(construct.Int16ub), 61 | "ModuleLowVoltageLimit" / ToVolt(construct.Int16ub), 62 | "ModuleUnderVoltageLimit" / ToVolt(construct.Int16ub), 63 | "DischargeHighTemperatureLimit" / ToCelsius(construct.Int16sb), 64 | "DischargeLowTemperatureLimit" / ToCelsius(construct.Int16sb), 65 | "DischargeCurrentLimit" / DivideBy10(construct.Int16sb), 66 | ) 67 | 68 | management_info_fmt = construct.Struct( 69 | "ChargeVoltageLimit" / DivideBy1000(construct.Int16ub), 70 | "DischargeVoltageLimit" / DivideBy1000(construct.Int16ub), 71 | "ChargeCurrentLimit" / ToAmp(construct.Int16sb), 72 | "DischargeCurrentLimit" / ToAmp(construct.Int16sb), 73 | "status" 74 | / construct.BitStruct( 75 | "ChargeEnable" / construct.Flag, 76 | "DischargeEnable" / construct.Flag, 77 | "ChargeImmediately2" / construct.Flag, 78 | "ChargeImmediately1" / construct.Flag, 79 | "FullChargeRequest" / construct.Flag, 80 | "ShouldCharge" 81 | / construct.Computed( 82 | lambda this: this.ChargeImmediately2 83 | | this.ChargeImmediately1 84 | | this.FullChargeRequest 85 | ), 86 | "_padding" / construct.BitsInteger(3), 87 | ), 88 | ) 89 | 90 | module_serial_number_fmt = construct.Struct( 91 | "CommandValue" / construct.Byte, 92 | "ModuleSerialNumber" / JoinBytes(construct.Array(16, construct.Byte)), 93 | ) 94 | 95 | get_values_fmt = construct.Struct( 96 | "NumberOfModules" / construct.Byte, 97 | "Module" / construct.Array(construct.this.NumberOfModules, construct.Struct( 98 | "NumberOfCells" / construct.Int8ub, 99 | "CellVoltages" / construct.Array(construct.this.NumberOfCells, ToVolt(construct.Int16sb)), 100 | "NumberOfTemperatures" / construct.Int8ub, 101 | "AverageBMSTemperature" / ToCelsius(construct.Int16sb), 102 | "GroupedCellsTemperatures" / construct.Array(construct.this.NumberOfTemperatures - 1, ToCelsius(construct.Int16sb)), 103 | "Current" / ToAmp(construct.Int16sb), 104 | "Voltage" / ToVolt(construct.Int16ub), 105 | "Power" / construct.Computed(construct.this.Current * construct.this.Voltage), 106 | "_RemainingCapacity1" / DivideBy1000(construct.Int16ub), 107 | "_UserDefinedItems" / construct.Int8ub, 108 | "_TotalCapacity1" / DivideBy1000(construct.Int16ub), 109 | "CycleNumber" / construct.Int16ub, 110 | "_OptionalFields" / construct.If(construct.this._UserDefinedItems > 2, 111 | construct.Struct("RemainingCapacity2" / DivideBy1000(construct.Int24ub), 112 | "TotalCapacity2" / DivideBy1000(construct.Int24ub))), 113 | "RemainingCapacity" / construct.Computed(lambda this: this._OptionalFields.RemainingCapacity2 if this._UserDefinedItems > 2 else this._RemainingCapacity1), 114 | "TotalCapacity" / construct.Computed(lambda this: this._OptionalFields.TotalCapacity2 if this._UserDefinedItems > 2 else this._TotalCapacity1), 115 | )), 116 | "TotalPower" / construct.Computed(lambda this: sum([x.Power for x in this.Module])), 117 | "StateOfCharge" / construct.Computed(lambda this: sum([x.RemainingCapacity for x in this.Module]) / sum([x.TotalCapacity for x in this.Module])), 118 | 119 | ) 120 | get_values_single_fmt = construct.Struct( 121 | "NumberOfModule" / construct.Byte, 122 | "NumberOfCells" / construct.Int8ub, 123 | "CellVoltages" / construct.Array(construct.this.NumberOfCells, ToVolt(construct.Int16sb)), 124 | "NumberOfTemperatures" / construct.Int8ub, 125 | "AverageBMSTemperature" / ToCelsius(construct.Int16sb), 126 | "GroupedCellsTemperatures" / construct.Array(construct.this.NumberOfTemperatures - 1, ToCelsius(construct.Int16sb)), 127 | "Current" / ToAmp(construct.Int16sb), 128 | "Voltage" / ToVolt(construct.Int16ub), 129 | "Power" / construct.Computed(construct.this.Current * construct.this.Voltage), 130 | "_RemainingCapacity1" / DivideBy1000(construct.Int16ub), 131 | "_UserDefinedItems" / construct.Int8ub, 132 | "_TotalCapacity1" / DivideBy1000(construct.Int16ub), 133 | "CycleNumber" / construct.Int16ub, 134 | "_OptionalFields" / construct.If(construct.this._UserDefinedItems > 2, 135 | construct.Struct("RemainingCapacity2" / DivideBy1000(construct.Int24ub), 136 | "TotalCapacity2" / DivideBy1000(construct.Int24ub))), 137 | "RemainingCapacity" / construct.Computed(lambda this: this._OptionalFields.RemainingCapacity2 if this._UserDefinedItems > 2 else this._RemainingCapacity1), 138 | "TotalCapacity" / construct.Computed(lambda this: this._OptionalFields.TotalCapacity2 if this._UserDefinedItems > 2 else this._TotalCapacity1), 139 | "TotalPower" / construct.Computed(construct.this.Power), 140 | "StateOfCharge" / construct.Computed(construct.this.RemainingCapacity / construct.this.TotalCapacity), 141 | ) 142 | 143 | def __init__(self, serial_port='/dev/ttyUSB0', baudrate=115200): 144 | self.s = serial.Serial(serial_port, baudrate, bytesize=8, parity=serial.PARITY_NONE, stopbits=1, timeout=2, exclusive=True) 145 | 146 | 147 | @staticmethod 148 | def get_frame_checksum(frame: bytes): 149 | assert isinstance(frame, bytes) 150 | 151 | sum = 0 152 | for byte in frame: 153 | sum += byte 154 | sum = ~sum 155 | sum %= 0x10000 156 | sum += 1 157 | return sum 158 | 159 | @staticmethod 160 | def get_info_length(info: bytes) -> int: 161 | lenid = len(info) 162 | if lenid == 0: 163 | return 0 164 | 165 | lenid_sum = (lenid & 0xf) + ((lenid >> 4) & 0xf) + ((lenid >> 8) & 0xf) 166 | lenid_modulo = lenid_sum % 16 167 | lenid_invert_plus_one = 0b1111 - lenid_modulo + 1 168 | 169 | return (lenid_invert_plus_one << 12) + lenid 170 | 171 | 172 | def send_cmd(self, address: int, cmd, info: bytes = b''): 173 | raw_frame = self._encode_cmd(address, cmd, info) 174 | self.s.write(raw_frame) 175 | 176 | 177 | def _encode_cmd(self, address: int, cid2: int, info: bytes = b''): 178 | cid1 = 0x46 179 | 180 | info_length = Pylontech.get_info_length(info) 181 | 182 | frame = "{:02X}{:02X}{:02X}{:02X}{:04X}".format(0x20, address, cid1, cid2, info_length).encode() 183 | frame += info 184 | 185 | frame_chksum = Pylontech.get_frame_checksum(frame) 186 | whole_frame = (b"~" + frame + "{:04X}".format(frame_chksum).encode() + b"\r") 187 | return whole_frame 188 | 189 | 190 | def _decode_hw_frame(self, raw_frame: bytes) -> bytes: 191 | # XXX construct 192 | frame_data = raw_frame[1:len(raw_frame) - 5] 193 | frame_chksum = raw_frame[len(raw_frame) - 5:-1] 194 | 195 | got_frame_checksum = Pylontech.get_frame_checksum(frame_data) 196 | assert got_frame_checksum == int(frame_chksum, 16) 197 | 198 | return frame_data 199 | 200 | def _decode_frame(self, frame): 201 | format = construct.Struct( 202 | "ver" / HexToByte(construct.Array(2, construct.Byte)), 203 | "adr" / HexToByte(construct.Array(2, construct.Byte)), 204 | "cid1" / HexToByte(construct.Array(2, construct.Byte)), 205 | "cid2" / HexToByte(construct.Array(2, construct.Byte)), 206 | "infolength" / HexToByte(construct.Array(4, construct.Byte)), 207 | "info" / HexToByte(construct.GreedyRange(construct.Byte)), 208 | ) 209 | 210 | return format.parse(frame) 211 | 212 | 213 | def read_frame(self): 214 | raw_frame = self.s.readline() 215 | f = self._decode_hw_frame(raw_frame=raw_frame) 216 | parsed = self._decode_frame(f) 217 | return parsed 218 | 219 | 220 | def scan_for_batteries(self, start=0, end=255) -> Dict[int, str]: 221 | """ Returns a map of the batteries id to their serial number """ 222 | batteries = {} 223 | for adr in range(start, end, 1): 224 | bdevid = "{:02X}".format(adr).encode() 225 | self.send_cmd(adr, 0x93, bdevid) # Probe for serial number 226 | raw_frame = self.s.readline() 227 | 228 | if raw_frame: 229 | sn = self.get_module_serial_number(adr) 230 | sn_str = sn["ModuleSerialNumber"].decode() 231 | 232 | batteries[adr] = sn_str 233 | logger.debug("Found battery at address " + str(adr) + " with serial " + sn_str) 234 | else: 235 | logger.debug("No battery found at address " + str(adr)) 236 | 237 | return batteries 238 | 239 | 240 | def get_protocol_version(self): 241 | self.send_cmd(0, 0x4f) 242 | return self.read_frame() 243 | 244 | 245 | def get_manufacturer_info(self): 246 | self.send_cmd(0, 0x51) 247 | f = self.read_frame() 248 | return self.manufacturer_info_fmt.parse(f.info) 249 | 250 | 251 | def get_system_parameters(self, dev_id=None): 252 | if dev_id: 253 | bdevid = "{:02X}".format(dev_id).encode() 254 | self.send_cmd(dev_id, 0x47, bdevid) 255 | else: 256 | self.send_cmd(2, 0x47) 257 | 258 | f = self.read_frame() 259 | return self.system_parameters_fmt.parse(f.info[1:]) 260 | 261 | def get_management_info(self, dev_id): 262 | bdevid = "{:02X}".format(dev_id).encode() 263 | self.send_cmd(dev_id, 0x92, bdevid) 264 | f = self.read_frame() 265 | 266 | print(f.info) 267 | print(len(f.info)) 268 | ff = self.management_info_fmt.parse(f.info[1:]) 269 | print(ff) 270 | return ff 271 | 272 | def get_module_serial_number(self, dev_id=None): 273 | if dev_id: 274 | bdevid = "{:02X}".format(dev_id).encode() 275 | self.send_cmd(dev_id, 0x93, bdevid) 276 | else: 277 | self.send_cmd(2, 0x93) 278 | 279 | f = self.read_frame() 280 | # infoflag = f.info[0] 281 | return self.module_serial_number_fmt.parse(f.info[0:]) 282 | 283 | def get_values(self): 284 | self.send_cmd(2, 0x42, b'FF') 285 | f = self.read_frame() 286 | 287 | # infoflag = f.info[0] 288 | d = self.get_values_fmt.parse(f.info[1:]) 289 | return d 290 | 291 | def get_values_single(self, dev_id): 292 | bdevid = "{:02X}".format(dev_id).encode() 293 | self.send_cmd(dev_id, 0x42, bdevid) 294 | f = self.read_frame() 295 | # infoflag = f.info[0] 296 | d = self.get_values_single_fmt.parse(f.info[1:]) 297 | return d 298 | 299 | 300 | if __name__ == '__main__': 301 | p = Pylontech() 302 | # print(p.get_protocol_version()) 303 | # print(p.get_manufacturer_info()) 304 | # print(p.get_system_parameters()) 305 | # print(p.get_management_info()) 306 | # print(p.get_module_serial_number()) 307 | # print(p.get_values()) 308 | print(p.get_values_single(2)) 309 | --------------------------------------------------------------------------------