├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── pysolaredge ├── __init__.py ├── crypto.py ├── decoder.py ├── devices │ ├── __init__.py │ ├── battery.py │ ├── devicebase.py │ ├── event.py │ ├── inverter1ph.py │ ├── inverter3ph.py │ ├── meter.py │ ├── oldoptimizer.py │ └── optimizer.py ├── exceptions.py ├── peewee.py └── utils.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | rsync -avP --delete pysolaredge /usr/local/lib/python3.5/dist-packages 3 | 4 | build: 5 | rm -f dist/* 6 | python3 setup.py sdist bdist_wheel 7 | 8 | upload-test: 9 | twine upload -r testpypi dist/* 10 | 11 | upload: 12 | twine upload dist/* 13 | 14 | .PHONY: install build upload-test upload 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python-solaredge 2 | 3 | ## 2024 update 4 | 5 | The following note was largely shamelessly stolen from https://github.com/jbuehl/solaredge/, 6 | because it is equally true for this library. 7 | 8 | When jbuehl's project was started, even before this Python library was created from it, 9 | SolarEdge inverters communicated with their monitoringserver using a proprietary 10 | data format that was sent in the clear. It was possible to reverse engineer most 11 | of the messages in order to obtain the optimizer level data whichwas not otherwise 12 | available. The same data was also available on the RS232 and RS485interfaces which 13 | were provided on their inverters. A few years later, SolarEdgeimplemented a 14 | homegrown encryption algorithm for the communications to their monitoring 15 | server, which prevented access for a while until an effort by a number of contributors 16 | to this project was successful in figuring out the algorithm which got it working again. 17 | 18 | In the years since then, SolarEdge has introduced new products which no longer support 19 | the RS232 interface, and they have dropped their encryption algorithm and are now using 20 | the far more secure and mainstream SSL/TLS encryption method which is essentially not 21 | hackable. This means that if you have a newer inverter or if the firmware in your older 22 | inverter is up to date, the only way to access performance data is going to be via the 23 | RS485 interface. 24 | 25 | This project is now archived, and no development or support of any kind will take place here. 26 | 27 | ## Introduction 28 | 29 | Python-solaredge (*pysolaredge* for short) is a library for decrypting and 30 | decoding messages from a SolarEdge photo-voltaic installation (solar panels, 31 | optimizers and inverters, mainly). Such an installation normally reports its 32 | statistics to a server operated by SolarEdge. This libray allows you to decode 33 | the data yourself, and use it how you see fit. 34 | 35 | This is not an entirely original work. In essence, it is a rewrite of [the 36 | SolarEdge monitoring scripts by Joe Buehl](https://github.com/jbuehl/solaredge/). 37 | There are multiple reasons that J. Buehl's project didn't work for me, and 38 | I wanted to fully understand SolarEdge's protocol. I wanted to have a library, as 39 | minimalistic as possible, to decrypt and decode the messages from my SolarEdge 40 | installation, so I wrote this, using Joe Buehls code as a study guide. 41 | 42 | I am very grateful to Joe Buehl and all the contributors to his project for the 43 | work they have done, in particular the reverse engineering of the SolarEdge 44 | protocol and the encryption. Truly remarkable work, that I could never have 45 | done myself. 46 | 47 | This library is written in Python3 and tested on Python 3.5 on Debian. 48 | 49 | **Status**: alpha. It works, but it's not complete and it needs testing. 50 | 51 | It uses a few modules from the standard library: *logging*, *binascii*, *struct* 52 | and *time*. It has one external dependency: 53 | [pycrypto](https://pypi.org/project/pycrypto/) (*python3-crypto* in Debian). 54 | 55 | If you want to use the included module `pysolaredge.peewee` for storing decoded 56 | data in MySQL or PostgreSQL, you also need 57 | [peewee](https://pypi.org/project/peewee/). 58 | 59 | ## The SolarEdge protocol 60 | 61 | A simple SolarEdge installation consists of 3 main types of components: 62 | 63 | * solar panels 64 | * optimizers 65 | * an inverter 66 | 67 | The inverter is the central part of the installation. It inverts the DC coming 68 | from the optimizers to AC suitable for the electricity grid. The optimizers 69 | report statistics about their performance to the inverter. The inverter 70 | aggregates those stats and normally sends them to the SolarEdge monitoring 71 | portal over an internet connection. 72 | 73 | The inverter uses a proprietary binary protocol to send its data to the server. 74 | The protocol consists of various types of *messages*, each with a unique code 75 | or *function*. When a new installation has been communicating with the 76 | SolarEdge server for a few days, an encryption key is negotiated, and from that 77 | moment on, all communication with the server will be encrypted. 78 | 79 | All messages have a header and a payload. The header describes the function of 80 | the message, its source, destination and length. The payload is 81 | function-specific. 82 | 83 | The most important message functions that this library handles are: 84 | 85 | * 0x003d - encrypted message 86 | * 0x0500 - device telemetry 87 | * 0x0503 - temporary key exchange 88 | 89 | In the case of an encryped message, the payload is just a whole new message, 90 | which, after decryption, has exactly the same format. In other words, device 91 | telemetry is contained in a 0x0500 message, which is encrypted and then 92 | *wrapped* in a 0x003d message. 93 | 94 | Telemetry messages (function 0x0500) contain data from different devices 95 | (optimizers, inverters, batteries) and sometimes events and other data. The 96 | payload of a 0x0500 message is a collection of concatenated sets of device 97 | data, each set identified by a *device ID*. The library parses these messages 98 | and returns the telemetry data in dictionaries, one dictionary per device type. 99 | 100 | The messages with function code 0x0503 contain an encrypted temporary key, that 101 | is used to encrypt all subsequent messages, until the key is changed with the 102 | next 0x0503 message. Encryption is done with AES128, which uses a 16-byte key. 103 | The temporary key is a random string of 16 bytes, encrypted with the private 104 | key. This way, the private key (which never changes) doesn't have to be used to 105 | encrypt individual messages, and a form of forward secrecy is obtained, meaning 106 | that even with the private key, you cannot decrypt any past messages if you 107 | didn't capture the temporary key exchange. 108 | 109 | ## Using this library 110 | 111 | To be able to decode and store the data from a SolarEdge installation that was 112 | sent over the network, you need a few things: 113 | 114 | * the encrypted data 115 | * the private key that was negotiated in the beginning 116 | * a temporary key that is rotated regularly (every few hours to days), in the 117 | form of a so-called '0x0503 message' 118 | 119 | If the data you have is *not* encrypted, you do not need any key material, of 120 | course. Decoding messages works just the same, in that case. 121 | 122 | How to get all of those things, I will cover later. But if you have them, using 123 | this library is as simple as this: 124 | 125 | ``` 126 | import pysolaredge 127 | decoder = pysolaredge.Decoder(privkey = '', last_503_msg = '') 128 | result = decoder.decode('') 129 | ``` 130 | 131 | The *result* will be a dictionary, whose contents depend on the type of message 132 | you were trying to decode, or an exception if something went wrong. The same 133 | decoder object can be used to decode as many messages as you like. If a 0x0503 134 | message is encountered, the decryption is automatically re-initialized with the 135 | new temporary key. 136 | 137 | Take care though: in the case of a 0x0503 message, the decoded payload will not 138 | contain meaningful information, but the entire message will have to be stored 139 | somewhere (in a file, a database or something similar) because it will be 140 | needed every time the decoder is initialized. The library does NOT handle the 141 | storage of 0x0503 messages, that is up to the application using this library. 142 | 143 | The Decoder class has only 3 methods meant to be used publicly: 144 | 145 | * `set_privkey(privkey)` - set the private key if not done when instantiating 146 | * `set_last_503_msg(msg)` - set the last 0x0503 message, if not done when 147 | instantiating 148 | * `decode(msg)` - decode a message 149 | 150 | Please note that the *last_503_msg* in the context of this package is supposed 151 | to be the *entire* message, starting with the SolarEdge magic sequence (`12 34 152 | 56 79`), up to and including the checksum, because the message will be pulled 153 | through the decoder like any other message. 154 | 155 | ## Getting the private key and the data 156 | 157 | [Joe Buehl](https://github.com/jbuehl/solaredge/) has written extensively about 158 | how to get the data from a SolarEdge inverter, so I am not going to copy that 159 | here. By far the easiest and non-intrusive way to get the data, is to passively 160 | sniff it from the network. For that to be possible, the device that you run 161 | your software on, should receive the network packets from the inverter. In my 162 | own home, I run a Linux box as a router for the local network, and the 163 | SolarEdge inverter is on a dedicated VLAN. This makes it easy to sniff the data 164 | with *tcpdump* or *Wireshark*. If this kind of setup is not possible for you, I 165 | can think of a few other ways to passively sniff the data: 166 | 167 | * Use a network hub (not a switch) to connect the inverter and your server to 168 | the rest of the network. A hub will send all traffic to all ports, so you can 169 | easily sniff it. Network hubs are becoming hard to get, though. 170 | * Use port mirroring on a switch to copy all traffic from the inverter to 171 | another port on the switch, so you can sniff it there. It takes a managed 172 | switch to be able to do this, though. 173 | * Use a device like a Raspberry Pi with two ethernet interfaces in a bridge. 174 | Connect it *in serial* between the inverter and the router. All traffic will 175 | pass through the bridge and you can sniff it there. 176 | 177 | The private key that is used for the encryption will be sent from the inverter 178 | to the SolarEdge server in a couple of messages with function code 0x0090. 179 | These are normally only sent once, so if you do not want to resort to a serial 180 | connection to extract the key from the inverter, it is important to start 181 | storing the network communication from the inverter right from the start. 182 | 183 | Please note that the code for extracting the private key with this library has 184 | not been fully implemented yet, so I recommend keeping a dump of all traffic, 185 | at least until you have successfully obtained your private key. 186 | 187 | ## Storing data in a database with Peewee 188 | 189 | [Peewee](http://docs.peewee-orm.com/en/latest/) is a simple and small ORM, that 190 | makes it quite easy to access relational databases. It uses model classes to 191 | map database tables to Python objects. 192 | 193 | Pysolaredge includes a module that takes care of the hard part: mapping the 194 | decoded data from the decoder module to columns in a database table. Using it 195 | is quite simple. First, install the Peewee ORM and the database driver of your 196 | choice (MySQL, PostgreSQL and SQLite are supported): 197 | 198 | ``` 199 | pip install peewee mysqlclient 200 | ``` 201 | 202 | Then, import the necessary stuff in your program, initialize the database 203 | and store the data: 204 | 205 | ``` 206 | from peewee import * 207 | from pysolaredge.peewee import db_proxy, Inverter, Optimizer 208 | 209 | # Set db, db_user, db_pass and db_host appropriately 210 | dbc = MySQLDatabase(db, user=db_user, password=db_pass, host=db_host) 211 | db_proxy.initialize(dbc) 212 | dbc.connect() 213 | dbc.create_tables([Inverter, Optimizer]) 214 | 215 | # See decoder usage above! 216 | result = decoder.decode('') 217 | 218 | if 'decoded' in result: 219 | if 'inverters' in result['decoded']: 220 | for dev_id,inverter in result['decoded']['inverters'].items(): 221 | Inverter.create(**inverter) 222 | if 'optimizers' in result['decoded']: 223 | for dev_id,optimizer in result['decoded']['optimizers'].items(): 224 | Optimizer.create(**optimizer) 225 | ``` 226 | 227 | The added value of the `pysolaredge.peewee` module is, that it provides 228 | a database structure that is a one-on-one mapping to the data structure 229 | that is returned by the decoder. 230 | 231 | ## Integrated solution: Pyp 232 | 233 | When I first started working on my SolarEdge scripts, I quickly realised, that 234 | I needed to be able to read from multiple inputs: sniff the network, read from 235 | a file, perhaps even make the inverter talk to my script directly. And even 236 | more importantly, I had no idea where I wanted the data to go: CSV or JSON files, 237 | MySQL database, InfluxDB or Graphite? Soon, I decided my script needed to be 238 | pluggable: input, processing and output should all be done by separate modules 239 | that weren't tightly integrated. 240 | 241 | That's when Pyp (pronounce: "pipe") was born. 242 | 243 | Pyp is a simple data pipeline. Think of it as an extremely simple version of 244 | Apache Flink or AWS Kinesis. 245 | 246 | Pyp comes with a 'solaredge' decoder plugin, that uses Pysolaredge for the 247 | decrypting and decoding. It also stores 0x0503 messages, and reads them back on 248 | startup, so the decryptor can be initialized correctly and pick up decrypting 249 | and decoding where it left off. 250 | 251 | Pyp also comes with a few handy input plugins, that make the collection of data 252 | from your SolarEdge inverter quite easy, and there are some output plugins too. 253 | The Pysolaredge library was separated from Pyp, because Pyp turned out to be 254 | quite a generic tool, that can be used for all kinds of data processing, not 255 | just SolarEdge data. 256 | 257 | Pyp will be up on Github shorty. 258 | 259 | -------------------------------------------------------------------------------- /pysolaredge/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from .decoder import Decoder 3 | from .exceptions import SeError,CryptoNotReadyError 4 | 5 | logger = logging.getLogger(__package__) 6 | logger.addHandler(logging.NullHandler()) 7 | 8 | name = 'pysolaredge' 9 | -------------------------------------------------------------------------------- /pysolaredge/crypto.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from Crypto.Cipher import AES 3 | import binascii 4 | 5 | class Crypto(object): 6 | 7 | cipher = None 8 | 9 | def __init__(self, privkey, payload): 10 | self.privkey = privkey 11 | self.payload = payload 12 | self.logger = logging.getLogger(__name__) 13 | self.logger.info('Initialized %s' % __name__) 14 | self.create_cipher() 15 | 16 | def create_cipher(self): 17 | # encrypt the first part of the 503 message with the private key 18 | p0 = AES.new(self.privkey).encrypt(self.payload[0:16]) 19 | p1 = self.payload[16:32] 20 | # XOR each byte of the first part with the corresponding byte in the second part 21 | tmpkey = bytes((p0[i] ^ p1[i] for i in range(16))) 22 | self.cipher = AES.new(tmpkey) 23 | 24 | def decrypt(self, payload): 25 | # This method will return decrypted data or raise an exception 26 | self.logger.info('Going to decrypt') 27 | payload = bytearray(payload) 28 | payload_rand = payload[0:16] 29 | payload_rand_enc = self.cipher.encrypt(bytes(payload_rand)) 30 | 31 | i=0 32 | j=16 33 | 34 | # 1. For bytes 16 to end of payload, XOR the value with an ENCRYPTED byte from bytes 0-15 35 | # 2. Cycle the encrypted bytes 0-15, but at the end of every cycle, increase the seed by one and re-encrypt: 36 | 37 | while j < len(payload): 38 | payload[j] ^= payload_rand_enc[i] 39 | i += 1 40 | j += 1 41 | if i == 16: 42 | i = 0 43 | 44 | # https://stackoverflow.com/questions/50389707/adding-1-to-a-16-byte-number-in-python 45 | new_value = int.from_bytes(payload_rand, 'big') + 1 46 | try: 47 | payload_rand = bytearray(new_value.to_bytes(len(payload_rand), 'big')) 48 | except OverflowError: 49 | payload_rand = bytearray(len(payload_rand)) 50 | 51 | payload_rand_enc = self.cipher.encrypt(bytes(payload_rand)) 52 | 53 | # Not sure what we need this for... 54 | # 2 bytes, little endian. 55 | #seqno = int.from_bytes(payload[16:18], 'little') 56 | 57 | data = payload[22:] 58 | xor = payload[18:22] 59 | 60 | decrypted = bytearray() 61 | for i in range(len(data)): 62 | decrypted.append( data[i] ^ xor[i&3] ) 63 | 64 | self.logger.debug('Decrypted data: %s' % binascii.hexlify(decrypted).decode('ascii')) 65 | return decrypted 66 | -------------------------------------------------------------------------------- /pysolaredge/decoder.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import binascii 3 | import time 4 | import logging 5 | from . import crypto, utils 6 | from .exceptions import SeError, CryptoNotReadyError 7 | from . import devices 8 | 9 | class Decoder(object): 10 | 11 | privkey = None 12 | last_503_msg = None 13 | magic = b'\x12\x34\x56\x79' 14 | magic_len = len(magic) 15 | header_len = 16 16 | checksum_len = 2 17 | crypto = None 18 | 19 | def __init__(self, privkey=None, last_503_msg=None): 20 | self.logger = logging.getLogger(__name__) 21 | self.set_privkey(privkey) 22 | self.set_last_503_msg(last_503_msg) 23 | 24 | def set_privkey(self, privkey): 25 | if len(privkey) == 32: 26 | privkey = binascii.unhexlify(privkey) 27 | if len(privkey) != 16: 28 | raise CryptoNotReadyError('Invalid private key: key should be 128 bits / 16 bytes') 29 | self.privkey = privkey 30 | self.init_crypto() 31 | 32 | def set_last_503_msg(self, last_503_msg): 33 | self.last_503_msg = last_503_msg 34 | self.init_crypto() 35 | 36 | def init_crypto(self): 37 | if self.crypto is None: 38 | if self.privkey is not None and self.last_503_msg is not None: 39 | self.decode(self.last_503_msg) 40 | 41 | def decode(self, data): 42 | self.logger.info('Starting decode') 43 | self.reset_data(data) 44 | self.validate_data() 45 | decoded = None 46 | 47 | if self.function == 0x003d: 48 | if self.crypto is None: 49 | raise CryptoNotReadyError('Cannot decrypt: crypto not ready') 50 | 51 | # Encrypted message. Decrypt and recurse. 52 | self.reset_data(self.crypto.decrypt(self.payload)) 53 | return self.decode(self.data) 54 | 55 | if self.function == 0x0500: 56 | # Telemetry message. Parse and return data. 57 | decoded = self.handle_500() 58 | 59 | if self.function == 0x0503: 60 | # Temporary key update. Parse and return data. 61 | decoded = self.handle_503() 62 | 63 | if self.function == 0x0080: 64 | self.logger.info('ACK message, seq: %d' % self.msg_seq) 65 | decoded = {} 66 | 67 | if self.function == 0x0090: 68 | self.logger.debug('Private key component: %s' % self.printable) 69 | decoded = {} 70 | 71 | if decoded is not None: 72 | return { 73 | 'seq': self.msg_seq, 74 | 'function': self.function, 75 | 'src': self.from_addr, 76 | 'dst': self.to_addr, 77 | 'decoded': decoded, 78 | } 79 | else: 80 | self.logger.warning('Unknown function 0x%04x' % self.function) 81 | 82 | def reset_data(self, data=None): 83 | self.data = data 84 | self.printable = binascii.hexlify(data).decode('ascii') 85 | self.function = None 86 | self.payload = None 87 | 88 | def validate_data(self): 89 | # This method will update relevant attributes or raise an exception 90 | 91 | length0 = len(self.magic) + self.header_len 92 | length1 = length0 + self.checksum_len 93 | 94 | # A message should have the magic string, a header and a checksum at minimum 95 | if len(self.data) < length1: 96 | raise SeError('Message too short, should be %d bytes minimum: %s' % (length1,self.printable)) 97 | 98 | # Parse the header (20 bytes total) 99 | (magic, data_len, data_len_inv, msg_seq, from_addr, to_addr, function) = struct.unpack("<4sHHHLLH", self.data[0:length0]) 100 | 101 | # Check if the message has the magic word 102 | if magic != self.magic: 103 | raise SeError('Illegal message: magic word not found') 104 | 105 | length2 = length1 + data_len 106 | 107 | # Check message length 108 | if len(self.data) == length2: 109 | self.logger.info('Message length as expected: %d' % length2) 110 | else: 111 | self.logger.warning('Expected message length to be %d bytes, but got %d' % (length2, len(self.data))) 112 | 113 | self.function = function 114 | self.payload = self.data[length0:length0 + data_len] 115 | self.msg_seq = msg_seq 116 | self.from_addr = from_addr 117 | self.to_addr = to_addr 118 | 119 | # Validate the checksum. If the message is truncated, the checksum 120 | # position will be out of range and a struct.error will be raised. 121 | try: 122 | checksum = struct.unpack("HLLH", msg_seq, from_addr, to_addr, function) + self.payload) 126 | 127 | if checksum == calcsum: 128 | self.logger.info('Checksum passed: 0x%04x' % checksum) 129 | else: 130 | self.logger.error('Checksum error: expected 0x%04x, message has 0x%04x' % (calcsum, checksum)) 131 | 132 | self.logger.info('Validated message: seq=0x%04x (%d), from=0x%08x, to=0x%08x, function=0x%04x' % 133 | (msg_seq, msg_seq, from_addr, to_addr, function)) 134 | 135 | def handle_500(self): 136 | self.logger.info('Handling 0x0500 message with %d bytes payload. Msg_seq = %d' % 137 | (len(self.payload), self.msg_seq)) 138 | if len(self.payload) > 0: 139 | pdata = self.parse_0x0500_payload() 140 | return pdata 141 | else: 142 | self.logger.info('No 0x0500 payload found, nothing to do') 143 | return {} 144 | 145 | def handle_503(self): 146 | # This method will reinitialize the decryptor if the private key is known 147 | # 503 payload should be 34 bytes 148 | self.logger.info('Handling 0x0503 message') 149 | 150 | if self.privkey is None: 151 | raise SeError('Cannot handle 0x0503 message: missing private key') 152 | 153 | self.crypto = crypto.Crypto(self.privkey,self.payload) 154 | 155 | # We must return *something* 156 | return { 'result': 'success' } 157 | 158 | def parse_0x0500_payload(self): 159 | data = self.payload 160 | inverters = {} 161 | inverters3ph = {} 162 | optimizers = {} 163 | meters = {} 164 | events = {} 165 | batteries = {} 166 | 167 | for dev_type, dev_id, chunk_len, chunk in self.get_0x0500_chunk(): 168 | 169 | dev_type_str = self.get_dev_type_str(dev_type) 170 | self.logger.info('Data chunk found: type=0x%04x (%s data), dev_id=%s, len=%d' % 171 | (dev_type, dev_type_str, dev_id, chunk_len)) 172 | 173 | if not self.check_data_len(chunk, chunk_len): 174 | continue 175 | 176 | if dev_type == 0x0000: 177 | dev = devices.Oldoptimizer(dev_id, chunk) 178 | optimizers[dev_id] = dev.parse() 179 | elif dev_type == 0x0080: 180 | dev = devices.Optimizer(dev_id, chunk) 181 | optimizers[dev_id] = dev.parse() 182 | elif dev_type == 0x0010: 183 | dev = devices.Inverter1ph(dev_id, chunk) 184 | inverters[dev_id] = dev.parse() 185 | elif dev_type == 0x0011: 186 | dev = devices.Inverter3ph(dev_id, chunk) 187 | inverters3ph[dev_id] = dev.parse() 188 | elif dev_type == 0x0300: 189 | dev = devices.Event(dev_id, chunk) 190 | events[dev_id] = dev.parse() 191 | elif dev_type == 0x0022: 192 | dev = devices.Meter(dev_id, chunk) 193 | meters[dev_id] = dev.parse() 194 | elif dev_type == 0x0030: 195 | dev = devices.Battery(dev_id, chunk) 196 | batteries[dev_id] = dev.parse() 197 | else: 198 | self.logger.warning('Cannot parse data type 0x%04x' % dev_type) 199 | 200 | result = {} 201 | result["inverters"] = inverters 202 | result["inverters3ph"] = inverters3ph 203 | result["optimizers"] = optimizers 204 | result["events"] = events 205 | result["meters"] = meters 206 | result["batteries"] = batteries 207 | return result 208 | 209 | # Read the payload of a 0x0500 message and yield a chunk per data item 210 | def get_0x0500_chunk(self): 211 | data = self.payload 212 | header_len = 8 213 | ptr = 0 214 | 215 | while ptr < len(data): 216 | if len(data) < ptr + header_len: 217 | self.logger.warning('Only %d bytes of data left, not a valid chunk' % len(data) - ptr) 218 | break 219 | # Read the header 220 | dev_type, dev_id, chunk_len = struct.unpack("> 8) 256 | return crc 257 | 258 | crc_table = [ 259 | 0x0000, 0xc0c1, 0xc181, 0x0140, 0xc301, 0x03c0, 0x0280, 0xc241, 0xc601, 260 | 0x06c0, 0x0780, 0xc741, 0x0500, 0xc5c1, 0xc481, 0x0440, 0xcc01, 0x0cc0, 261 | 0x0d80, 0xcd41, 0x0f00, 0xcfc1, 0xce81, 0x0e40, 0x0a00, 0xcac1, 0xcb81, 262 | 0x0b40, 0xc901, 0x09c0, 0x0880, 0xc841, 0xd801, 0x18c0, 0x1980, 0xd941, 263 | 0x1b00, 0xdbc1, 0xda81, 0x1a40, 0x1e00, 0xdec1, 0xdf81, 0x1f40, 0xdd01, 264 | 0x1dc0, 0x1c80, 0xdc41, 0x1400, 0xd4c1, 0xd581, 0x1540, 0xd701, 0x17c0, 265 | 0x1680, 0xd641, 0xd201, 0x12c0, 0x1380, 0xd341, 0x1100, 0xd1c1, 0xd081, 266 | 0x1040, 0xf001, 0x30c0, 0x3180, 0xf141, 0x3300, 0xf3c1, 0xf281, 0x3240, 267 | 0x3600, 0xf6c1, 0xf781, 0x3740, 0xf501, 0x35c0, 0x3480, 0xf441, 0x3c00, 268 | 0xfcc1, 0xfd81, 0x3d40, 0xff01, 0x3fc0, 0x3e80, 0xfe41, 0xfa01, 0x3ac0, 269 | 0x3b80, 0xfb41, 0x3900, 0xf9c1, 0xf881, 0x3840, 0x2800, 0xe8c1, 0xe981, 270 | 0x2940, 0xeb01, 0x2bc0, 0x2a80, 0xea41, 0xee01, 0x2ec0, 0x2f80, 0xef41, 271 | 0x2d00, 0xedc1, 0xec81, 0x2c40, 0xe401, 0x24c0, 0x2580, 0xe541, 0x2700, 272 | 0xe7c1, 0xe681, 0x2640, 0x2200, 0xe2c1, 0xe381, 0x2340, 0xe101, 0x21c0, 273 | 0x2080, 0xe041, 0xa001, 0x60c0, 0x6180, 0xa141, 0x6300, 0xa3c1, 0xa281, 274 | 0x6240, 0x6600, 0xa6c1, 0xa781, 0x6740, 0xa501, 0x65c0, 0x6480, 0xa441, 275 | 0x6c00, 0xacc1, 0xad81, 0x6d40, 0xaf01, 0x6fc0, 0x6e80, 0xae41, 0xaa01, 276 | 0x6ac0, 0x6b80, 0xab41, 0x6900, 0xa9c1, 0xa881, 0x6840, 0x7800, 0xb8c1, 277 | 0xb981, 0x7940, 0xbb01, 0x7bc0, 0x7a80, 0xba41, 0xbe01, 0x7ec0, 0x7f80, 278 | 0xbf41, 0x7d00, 0xbdc1, 0xbc81, 0x7c40, 0xb401, 0x74c0, 0x7580, 0xb541, 279 | 0x7700, 0xb7c1, 0xb681, 0x7640, 0x7200, 0xb2c1, 0xb381, 0x7340, 0xb101, 280 | 0x71c0, 0x7080, 0xb041, 0x5000, 0x90c1, 0x9181, 0x5140, 0x9301, 0x53c0, 281 | 0x5280, 0x9241, 0x9601, 0x56c0, 0x5780, 0x9741, 0x5500, 0x95c1, 0x9481, 282 | 0x5440, 0x9c01, 0x5cc0, 0x5d80, 0x9d41, 0x5f00, 0x9fc1, 0x9e81, 0x5e40, 283 | 0x5a00, 0x9ac1, 0x9b81, 0x5b40, 0x9901, 0x59c0, 0x5880, 0x9841, 0x8801, 284 | 0x48c0, 0x4980, 0x8941, 0x4b00, 0x8bc1, 0x8a81, 0x4a40, 0x4e00, 0x8ec1, 285 | 0x8f81, 0x4f40, 0x8d01, 0x4dc0, 0x4c80, 0x8c41, 0x4400, 0x84c1, 0x8581, 286 | 0x4540, 0x8701, 0x47c0, 0x4680, 0x8641, 0x8201, 0x42c0, 0x4380, 0x8341, 287 | 0x4100, 0x81c1, 0x8081, 0x4040 288 | ] 289 | -------------------------------------------------------------------------------- /pysolaredge/devices/__init__.py: -------------------------------------------------------------------------------- 1 | from .battery import Battery 2 | from .event import Event 3 | from .inverter1ph import Inverter1ph 4 | from .inverter3ph import Inverter3ph 5 | from .meter import Meter 6 | from .oldoptimizer import Oldoptimizer 7 | from .optimizer import Optimizer 8 | -------------------------------------------------------------------------------- /pysolaredge/devices/battery.py: -------------------------------------------------------------------------------- 1 | from .devicebase import Device 2 | 3 | class Battery(Device): 4 | 5 | # Untested 6 | 7 | labels = [ 8 | 'date', 'time', 'timestamp', 'battery_id', 'v_dc', 'i_dc', 9 | 'capacity_nom', 'capacity_actual', 'charge', 'e_in_total', 10 | 'e_out_total', 'temperature', 'charging_status', 'interval', 'e_in_intv', 11 | 'e_out_intv' 12 | ] 13 | fmt = 'L12sfffffLfLf4s4sfHffLLL' # 20 items 14 | item_idx = [ 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 9, 13, 14, 17, 18, 19 ] 15 | -------------------------------------------------------------------------------- /pysolaredge/devices/devicebase.py: -------------------------------------------------------------------------------- 1 | from .. import utils 2 | import struct 3 | import logging 4 | 5 | class Device(object): 6 | 7 | def __init__(self, dev_id, data): 8 | self.logger = logging.getLogger(self.__module__) 9 | self.logger.info('Device %s instantiated' % self.__module__) 10 | self.dev_id = dev_id 11 | self.data = data 12 | 13 | # Parse() returns a list of data items, which should be the same length 14 | # as the list of labels. Can be overridden in subclasses if the standard 15 | # fmt <-> labels paradigm does not apply. 16 | def parse(self): 17 | length = struct.calcsize(self.fmt) 18 | parsed = [ 19 | struct.unpack(self.fmt, self.data[:length])[i] for i in self.item_idx 20 | ] 21 | 22 | # If this parser has a post_process method, call it. 23 | # It acts as filter, i.e. it should return the modified data. 24 | # The post_process() method can access self.data if it wants to. 25 | try: 26 | parsed = self.post_process(parsed) 27 | except AttributeError: 28 | pass 29 | except Exception: 30 | raise 31 | return self.device_data(parsed) 32 | 33 | def device_data(self, parsed): 34 | result = {} 35 | result["dev_id"] = self.dev_id 36 | for i in range(len(self.labels)): 37 | if self.labels[i].lower() == 'date': 38 | result[self.labels[i]] = utils.format_datestamp(parsed[i]) 39 | elif self.labels[i].lower() == 'time': 40 | result[self.labels[i]] = utils.format_timestamp(parsed[i]) 41 | else: 42 | result[self.labels[i]] = parsed[i] 43 | self.logger.debug('Device data parse result: %s' % str(result)) 44 | return result 45 | -------------------------------------------------------------------------------- /pysolaredge/devices/event.py: -------------------------------------------------------------------------------- 1 | from .devicebase import Device 2 | 3 | # https://github.com/jbuehl/solaredge/issues/47 4 | 5 | class Event(Device): 6 | 7 | labels = [ 8 | 'date', 'time', 'timestamp', 'type', 'param1', 9 | 'param2', 'param3', 'param4', 'param5' 10 | ] 11 | fmt = '> 2 & 0x3ff) / 8 35 | 36 | # 00000011 11000001 00010011 00010101 37 | # ---- -------- 38 | v0 = struct.unpack('> 4 & 0x3ff) / 160 40 | 41 | e_day = 0.25 * (data[11] << 8 | data[10]) 42 | # TODO: 43 | #e_day = struct.unpack('