├── .gitignore ├── LICENSE ├── README.md ├── changelog ├── config.sample ├── contributors.md ├── daemon3x.py ├── features-outdated ├── README.md ├── domoticz.py ├── ediplugs.py ├── influxdb.py ├── influxdb2.py ├── pvdata.py ├── pvdata_kostal_json.py ├── remotedebug.py ├── sample.py ├── sma_grafana.json ├── smamodbus.py ├── symcon.py ├── symcon_smaem_webhook.php └── symcon_smawr_webhook.php ├── features ├── README.md ├── mqtt.py └── simplefswriter.py ├── knownProblems.md ├── libs └── smartplug.py ├── requirements.txt ├── sma-daemon.py ├── sma-em-capture-package.py ├── sma-em-measurement.py ├── speedwiredecoder.py └── systemd-settings /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | 341 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SMA-EM 2 | 3 | a detailed german description could be found here 4 | https://www.unifox.at/software/sma-em-daemon/ 5 | 6 | translated by google 7 | https://translate.google.com/translate?sl=de&tl=en&u=https://www.unifox.at/software/sma-em-daemon/ 8 | 9 | 10 | ## SMA Energymeter / Homemanager measurement 11 | sma-em-measurement.py: Python3 loop display SMA Energymeter measurement values 12 | 13 | sma-daemon.py: Python3 daemon writing consume and supply values to /run/shm/em-[serial]-[value] 14 | 15 | ``` 16 | # HINT # 17 | Sma homemanager version 2.3.4R added 8 Byte of measurement data. 18 | This version trys to detect the measurement values on obis ids, so it should be save if new values were added or removed. 19 | ``` 20 | 21 | ## Requirements 22 | python3 23 | sys 24 | time 25 | configparser (SafeConfigParser) 26 | signal 27 | 28 | some features require additional python modules 29 | features/README.md should give an overview of maintained features. 30 | features-outdated/README.md: other features untested because I do not have the appropriate hardware / software could be found in features-outdated. 31 | 32 | 33 | ## Configuration 34 | create a config file in /etc/smaemd/config
35 | Use UTF-8 encoded configfile
36 | Example: 37 | ``` 38 | [SMA-EM] 39 | # serials of sma-ems the daemon should take notice 40 | # seperated by space 41 | serials=30028xxxxx 42 | # features could filter serials to, but wouldn't see serials if these serials was not defines in SMA-EM serials 43 | # list of features to load/run 44 | features=simplefswriter sample 45 | 46 | [DAEMON] 47 | pidfile=/run/smaemd.pid 48 | # listen on an interface with the given ip 49 | # use 0.0.0.0 for any interface 50 | ipbind=192.168.8.15 51 | # multicast ip and port of sma-datagrams 52 | # defaults 53 | mcastgrp=239.12.255.254 54 | mcastport=9522 55 | 56 | # each feature/plugin has its own section 57 | # called FEATURE-[featurename] 58 | # the feature section is required if a feature is listed in [SMA-EM]features 59 | 60 | [FEATURE-simplefswriter] 61 | # list serials simplefswriter notice 62 | serials=1900204522 63 | # measurement vars simplefswriter should write to filesystem (only from smas with serial in serials) 64 | values=pconsume psupply qsupply ssupply 65 | 66 | [FEATURE-sample] 67 | nothing=here 68 | 69 | ``` 70 | 71 | ## Routing 72 | maybe you have to add a route (example: on hosts with more than one interface)
73 | ``` 74 | sudo ip route add 224.0.0.0/4 dev interfacename 75 | ``` 76 | 77 | ## Install / Copy (tested on Raspbian 9.1) 78 | ``` 79 | sudo apt install git 80 | sudo apt install python3 cl-py-configparser 81 | sudo mkdir /opt/smaemd/ 82 | sudo mkdir /etc/smaemd/ 83 | sudo useradd -c "smaemd-user" -d /opt/smaemd -M -N -r -s /usr/sbin/nologin smaemd 84 | cd /opt/smaemd/ 85 | sudo git clone https://github.com/datenschuft/SMA-EM.git . 86 | sudo cp systemd-settings /etc/systemd/system/smaemd.service 87 | ``` 88 | 89 | Create a /etc/smaemd/config file 90 | ``` 91 | sudo cp /opt/smaemd/config.sample /etc/smaemd/config 92 | ``` 93 | Edit the /etc/smaemd/config file and customize it to suit your needs (e.g. set SMA energy meter serial number, IP address, enable features) 94 | ``` 95 | sudo nano /etc/smaemd/config 96 | ``` 97 | 98 | Update systemd 99 | ``` 100 | sudo systemctl daemon-reload 101 | sudo systemctl enable smaemd.service 102 | sudo systemctl start smaemd.service 103 | ``` 104 | feel lucky and read /run/shm/em-- 105 | 106 | 107 | 108 | ## Testing 109 | sma-em-capture-package - trys to capture a SMA-EM or SMA-homemanager Datagram and display hex and ascii package-info and all recogniced measurement values. 110 | Cloud be helpful on package/software changes. 111 | -------------------------------------------------------------------------------- /changelog: -------------------------------------------------------------------------------- 1 | SMA-EM-Daemon changelog-File 2 | 20240728001 3 | robustness check for dictionary content 4 | documentation switch from root to a new user (example smaemd in systemd-unit) 5 | focus on core competencies, remove features except mqtt and simplefswriter (other moved to features-outdated) 6 | 7 | 20210307001 8 | improve the feature init 9 | start_systemd; no doubleforking for Systemd 10 | 11 | 20200104001 12 | fixed issue 21 new datagramsize with homemanager version 2.3.4.R 13 | package split based on coding pull request 18 (david-m-m) 14 | 15 | 20190402001 16 | Cosmetics / variables naming #14 17 | replaced pregard with consume and surplus with supply 18 | 19 | 20180223001 20 | Merge branch 'Tommi2Day-master' https://github.com/datenschuft/SMA-EM/pull/13 21 | thanks to Tommi2Day 22 | - sma-daemon: allow read config from workdir, make status dir configurable, add config callback, exit if serials not set,small fixes 23 | - add feature "pvdata" for getting PV data from SMA Inverters via Modbus along SMA-EM/HM 24 | - add feature "mqtt" to send SMA EM and PV data to an MQTT broker 25 | - add feature "remotedebug" to allow remote debug from PyCharm 26 | - add feature "influxdb" and sample grafana dashboard based on this plugin 27 | - add feature "symcon" to supply SMA EM/HOM and PV data to "IP-Symcon" (https://www.symcon.de/en/ ) via WebHook and provide sample webhook scripts 28 | - add config.sample 29 | 30 | older versions were not tracked 31 | -------------------------------------------------------------------------------- /config.sample: -------------------------------------------------------------------------------- 1 | [SMA-EM] 2 | # serials of sma-ems the daemon should take notice 3 | # separated by space 4 | serials=30028xxxxx 5 | # features could filter serials to, but wouldn't see serials if these serials was not defines in SMA-EM serials 6 | # list of features to load/run 7 | #features=simplefswriter sample pvdata ediplugs mqtt remotedebug symcon influxdb 8 | features=simplefswriter 9 | 10 | [DAEMON] 11 | pidfile=/run/smaemd.pid 12 | # listen on an interface with the given ip 13 | # use 0.0.0.0 for any interface 14 | ipbind=0.0.0.0 15 | # multicast ip and port of sma-datagrams 16 | # defaults 17 | mcastgrp=239.12.255.254 18 | mcastport=9522 19 | statusdir= 20 | 21 | # each feature/plugin has its own section 22 | # called FEATURE-[featurename] 23 | # the feature section is required if a feature is listed in [SMA-EM]features 24 | 25 | [FEATURE-simplefswriter] 26 | # list serials simplefswriter notice 27 | serials=30028xxxxx 28 | # measurement vars simplefswriter should write to filesystem (only from smas with serial in serials) 29 | values=pconsume psupply qsupply ssupply 30 | statusdir= 31 | 32 | [FEATURE-sample] 33 | nothing=here 34 | 35 | [FEATURE-mqtt] 36 | # MQTT broker details 37 | #mqtthost=::1 38 | mqtthost=mqtt 39 | mqttport=1883 40 | #mqttuser= 41 | #mqttpass= 42 | 43 | #The following list contains all possible field names that you can use with 44 | #the features mqtt, symcon, influxdb 45 | # prefix: p=real power, q=reactive power, s=apparent power, i=current, u=voltage 46 | # postfix: unit=the unit of the item, e.g. W, VA, VAr, Hz, A, V, kWh, kVArh, kVAh ... 47 | # postfix: counter=energy value (kWh, kVArh, kVAh) 48 | # without postfix counter=>power value (W, VAr, VA) 49 | #mqttfields=pconsume, pconsumeunit, pconsumecounter, pconsumecounterunit, 50 | # psupply, psupplyunit, psupplycounter, psupplycounterunit, 51 | # qconsume, qconsumeunit, qconsumecounter, qconsumecounterunit, 52 | # qsupply, qsupplyunit, qsupplycounter, qsupplycounterunit, 53 | # sconsume, sconsumeunit, sconsumecounter, sconsumecounterunit, 54 | # ssupply, ssupplyunit, ssupplycounter, ssupplycounterunit, 55 | # cosphi, cosphiunit, 56 | # frequency, frequencyunit, 57 | # p1consume, p1consumeunit, p1consumecounter, p1consumecounterunit, 58 | # p1supply, p1supplyunit, p1supplycounter, p1supplycounterunit, 59 | # q1consume, q1consumeunit, q1consumecounter, q1consumecounterunit, 60 | # q1supply, q1supplyunit, q1supplycounter, q1supplycounterunit, 61 | # s1consume, s1consumeunit, s1consumecounter, s1consumecounterunit, 62 | # s1supply, s1supplyunit, s1supplycounter, s1supplycounterunit, 63 | # i1, i1unit, 64 | # u1, u1unit, 65 | # cosphi1, cosphi1unit, 66 | # p2consume, p2consumeunit, p2consumecounter, p2consumecounterunit, 67 | # p2supply, p2supplyunit, p2supplycounter, p2supplycounterunit, 68 | # q2consume, q2consumeunit, q2consumecounter, q2consumecounterunit, 69 | # q2supply, q2supplyunit, q2supplycounter, q2supplycounterunit, 70 | # s2consume, s2consumeunit, s2consumecounter, s2consumecounterunit, 71 | # s2supply, s2supplyunit, s2supplycounter, s2supplycounterunit, 72 | # i2, i2unit, 73 | # u2, u2unit, 74 | # cosphi2, cosphi2unit, 75 | # p3consume, p3consumeunit, p3consumecounter, p3consumecounterunit, 76 | # p3supply, p3supplyunit, p3supplycounter, p3supplycounterunit, 77 | # q3consume, q3consumeunit, q3consumecounter, q3consumecounterunit, 78 | # q3supply, q3supplyunit, q3supplycounter, q3supplycounterunit, 79 | # s3consume, s3consumeunit, s3consumecounter, s3consumecounterunit, 80 | # s3supply, s3supplyunit, s3supplycounter, s3supplycounterunit, 81 | # i3, i3unit, 82 | # u3, u3unit, 83 | # cosphi3, cosphi3unit, 84 | # speedwire-version 85 | mqttfields=pconsume,pconsumecounter,psupply,psupplycounter 86 | #topic will be exteded with serial 87 | mqtttopic=SMA-EM/status 88 | pvtopic=SMA-PV/status 89 | # publish all values as single topics (0 or 1) 90 | publish_single=1 91 | # How frequently to send updates over (defaults to 20 sec) 92 | min_update=5 93 | #debug output 94 | debug=0 95 | 96 | # ssl support 97 | # adopt mqttport above to your ssl enabled mqtt port, usually 8883 98 | # options: 99 | # activate without certs=use tls_insecure 100 | # activate with ca_file, but without client_certs 101 | ssl_activate=0 102 | # ca file to verify 103 | ssl_ca_file=ca.crt 104 | # client certs 105 | ssl_certfile= 106 | ssl_keyfile= 107 | #TLSv1.1 or TLSv1.2 (default 2) 108 | tls_protocol=2 109 | 110 | 111 | [FEATURE-remotedebug] 112 | # Debug settings 113 | debughost=mypc 114 | debugport=9100 115 | 116 | [FEATURE-symcon] 117 | # symcon 118 | host=ips 119 | port=3777 120 | timeout=5 121 | user=Symcon 122 | password=SMA-EMdata 123 | 124 | #A list of possible field names can be found above under FEATURE-mqtt 125 | fields=pconsume,psupply,p1consume,p2consume,p3consume,p1supply,p2supply,p3supply,psupplycounter,pconsumecounter 126 | emhook=/hook/smaem 127 | pvfields=AC Power,grid frequency,DC input voltage,daily yield,total yield,Power L1,Power L2,Power L3,Status 128 | pvhook=/hook/smawr 129 | 130 | # How frequently to send updates over (defaults to 20 sec) 131 | min_update=30 132 | 133 | debug=0 134 | 135 | [FEATURE-influxdb] 136 | # influx 137 | host=influxdb 138 | port=8086 139 | ssl= 140 | db=SMA 141 | 142 | timeout=5 143 | user= 144 | password= 145 | # How frequently to send updates over (defaults to 20 sec) 146 | min_update=30 147 | debug=0 148 | 149 | # emdata 150 | # A list of possible field names can be found above under FEATURE-mqtt 151 | measurement=SMAEM 152 | fields=pconsume,psupply,p1consume,p2consume,p3consume,p1supply,p2supply,p3supply 153 | 154 | # pvdata 155 | # Fields can be any modbus register queried under FEATURE-pvdata except serial, DeviceID, and Device Name, 156 | # as those are used as tags in any case. 157 | pvmeasurement=SMAWR 158 | pvfields=AC Power,grid frequency,DC input voltage,daily yield,total yield,Power L1,Power L2,Power L3 159 | 160 | # ediplugs 161 | edimeasurement=edimax 162 | 163 | [FEATURE-influxdb2] 164 | debug=0 165 | url=hostname.tld 166 | token=long_token 167 | org=org_name 168 | bucket=bucket_name 169 | 170 | # emdata 171 | # A list of possible field names can be found above under FEATURE-mqtt 172 | measurement=SMAEM 173 | fields=pconsume,psupply,p1consume,p2consume,p3consume,p1supply,p2supply,p3supply 174 | 175 | # pvdata 176 | # Fields can be any modbus register queried under FEATURE-pvdata except serial, DeviceID, and Device Name, 177 | # as those are used as tags in any case. 178 | pvmeasurement=SMAWR 179 | pvfields=AC Power,AC Voltage,grid frequency,DC Power,DC input voltage,daily yield,total yield 180 | # How frequently to send updates over (defaults to 20 sec) 181 | min_update=30 182 | 183 | [FEATURE-pvdata] 184 | #Reads data from SMA inverter via Modbus. 185 | #Enable the mqtt feature to publish the data to a mqtt broker (features=pvdata mqtt), 186 | #and/or stored the data to a influx database (features=pvdata influxdb), and/or symcom ... 187 | 188 | # How frequently to send updates over (defaults to 20 sec) 189 | min_update=5 190 | # debug output 191 | debug=0 192 | 193 | # inverter connection 194 | # ['host', 'port', 'modbus_id', 'manufacturer'] 195 | inverters = [ 196 | ['', '502', '3', 'SMA'], 197 | ['', '502', '3', 'SMA'] 198 | ] 199 | 200 | # For Modbus registers, see e.g. https://www.google.com/search?q=SMA_Modbus-TI-en-23.xlsx 201 | # ['Modbus register address', 'Type', 'Format', 'Name', 'Unit'] 202 | # If the mqtt feature is used, 'Name' is included in the MQTT JSON payload as tag name. 203 | registers = [ 204 | # Don't change names in this section as they are used by some features/*.py files 205 | # Alternatives for AC Power & daily yield in MQTT: 'SMA-EM/status/30028xxxxx/pvsum' & 'SMA-EM/status/30028xxxxx/pvdaily' 206 | # Also note that the daily yield register is broken for some inverters 207 | ['30057', 'U32', 'RAW', 'serial', ''], 208 | ['30201', 'U32', 'ENUM', 'Status',''], 209 | ['30051', 'U32', 'ENUM', 'DeviceClass',''], 210 | ['30053', 'U32', 'ENUM', 'DeviceID',''], 211 | ['40631', 'STR32', 'UTF8', 'Device Name', ''], 212 | ['30775', 'S32', 'FIX0', 'AC Power', 'W'], 213 | ['30517', 'U64', 'FIX3', 'daily yield', 'kWh'], 214 | #################################################### 215 | # ['30813', 'S32', 'FIX0', 'AC_Power_Apparent', 'VA'], 216 | ['30977', 'S32', 'FIX3', 'AC_Current', 'A'], 217 | # ['30783', 'S32', 'FIX2', 'AC_Voltage_L1', 'V'], 218 | # ['30785', 'S32', 'FIX2', 'AC_Voltage_L2', 'V'], 219 | # ['30787', 'S32', 'FIX2', 'AC_Voltage_L3', 'V'], 220 | # ['30777', 'S32', 'FIX0', 'AC_Power_L1', 'W'], 221 | # ['30779', 'S32', 'FIX0', 'AC_Power_L2', 'W'], 222 | # ['30781', 'S32', 'FIX0', 'AC_Power_L3', 'W'], 223 | ['30803', 'U32', 'FIX2', 'Grid_Frequency', 'Hz'], 224 | ['30773', 'S32', 'FIX0', 'DC_Input1_Power', 'W'], 225 | ['30771', 'S32', 'FIX2', 'DC_Input1_Voltage', 'V'], 226 | ['30769', 'S32', 'FIX3', 'DC_Input1_Current', 'A'], 227 | ['30961', 'S32', 'FIX0', 'DC_Input2_Power', 'W'], 228 | ['30959', 'S32', 'FIX2', 'DC_Input2_Voltage', 'V'], 229 | ['30957', 'S32', 'FIX3', 'DC_Input2_Current', 'A'], 230 | ['30953', 'S32', 'FIX1', 'Device_Temperature', u'\xb0C'], 231 | ['30513', 'U64', 'FIX3', 'Total_Yield', 'kWh'], 232 | ['30521', 'U64', 'FIX0', 'Operating_Time', 's'], 233 | ['30525', 'U64', 'FIX0', 'Feed-in_Time', 's'], 234 | ['30975', 'S32', 'FIX2', 'Intermediate_Circuit_Voltage', 'V'], 235 | ['30225', 'S32', 'FIX0', 'Isolation_Resistance', u'\u03a9'] 236 | ] 237 | 238 | registers_batt = [ 239 | # Don't change names in this section as they are used by some features/*.py files 240 | ['30057', 'U32', 'RAW', 'serial', ''], 241 | ['30201', 'U32', 'ENUM', 'Status',''], 242 | ['30051', 'U32', 'ENUM', 'DeviceClass',''], 243 | ['30053', 'U32', 'ENUM', 'DeviceID',''], 244 | ['40631', 'STR32', 'UTF8', 'Device Name', ''], 245 | ['30775', 'S32', 'FIX0', 'AC Power', 'W'], 246 | ['30517', 'U64', 'FIX3', 'daily yield', 'kWh'], 247 | #################################################### 248 | ['30953', 'S32', 'FIX1', 'Device_Temperature', u'\xb0C'], 249 | ['30849', 'S32', 'FIX1', 'BatteryTemp', u'\xb0C'], 250 | ['30843', 'S32', 'FIX3', 'BatteryAmp', 'A'], 251 | ['30851', 'U32', 'FIX2', 'BatteryVolt', 'V'], 252 | ['30845', 'U32', 'FIX0', 'BatteryCharge', u'\u0025'], 253 | ['30955', 'U32', 'ENUM', 'BatteryState', ''], 254 | ['31391', 'U32', 'ENUM', 'BatteryHealth', ''], 255 | ['30813', 'S32', 'FIX0', 'AC apparent power', 'VA'], 256 | ['30803', 'U32', 'FIX2', 'Grid_Frequency', 'Hz'], 257 | # ['30777', 'S32', 'FIX0', 'Power L1', 'W'], 258 | # ['30779', 'S32', 'FIX0', 'Power L2', 'W'], 259 | # ['30781', 'S32', 'FIX0', 'Power L3', 'W'], 260 | ['30513', 'U64', 'FIX3', 'Total_Yield', 'kWh'], 261 | ['30521', 'U64', 'FIX0', 'Operating_Time', 's'], 262 | ['30525', 'U64', 'FIX0', 'Feed-in_Time', 's'], 263 | ] 264 | 265 | [FEATURE-pvdata_kostal_json] 266 | # How frequently to send updates over (defaults to 20 sec) 267 | min_update=15 268 | #debug output 269 | debug=0 270 | 271 | #inverter connection 272 | inv_host = 273 | #['address', 'NONE', 'NONE' 'description', 'unit'] 274 | # to get the same structure of sma pvdata feature 275 | registers = [ 276 | ['33556736', 'NONE', 'NONE', 'DC Power', 'W'], 277 | ['33555202', 'NONE', 'NONE', 'DC string1 voltage', 'V'], 278 | ['33555201', 'NONE', 'NONE', 'DC string1 current', 'A'], 279 | ['33555203', 'NONE', 'NONE', 'DC string1 power', 'W'], 280 | ['67109120', 'NONE', 'NONE', 'AC Power', 'W'], 281 | ['67110400', 'NONE', 'NONE', 'AC frequency', 'Hz'], 282 | ['67110656', 'NONE', 'NONE', 'AC cosphi', u'\xb0C'], 283 | ['67110144', 'NONE', 'NONE', 'AC ptot limitation', ''], 284 | ['67109378', 'NONE', 'NONE', 'AC phase1 voltage', 'V'], 285 | ['67109377', 'NONE', 'NONE', 'AC phase1 current', 'A'], 286 | ['67109379', 'NONE', 'NONE', 'AC phase1 power', 'W'], 287 | ['251658754', 'NONE', 'NONE', 'yield today', 'Wh'], 288 | ['251658753', 'NONE', 'NONE', 'yield total', 'kWh'], 289 | ['251658496', 'NONE', 'NONE', 'operationtime', ''], 290 | ] 291 | 292 | [FEATURE-ediplugs] 293 | # How frequently to send updates over (defaults to 20 sec) 294 | min_update=15 295 | #debug output 296 | debug=0 297 | 298 | # Edimax SP-2101W V2 with Firmware 3.00c change their default password during initial setup. 299 | # Find the actual password using this hack: https://discourse.nodered.org/t/searching-for-help-to-read-status-of-edimax-smartplug/15789/6 300 | # ['', 'admin', ''] 301 | plugs = [ 302 | ['host1', 'admin', '1234'], 303 | ['host2', 'admin', '1234'] 304 | ] 305 | -------------------------------------------------------------------------------- /contributors.md: -------------------------------------------------------------------------------- 1 | SMA-EM-Daemon contributors 2 | ============================================ 3 | 4 | * **[Wenger Florian](https://github.com/datenschuft)** 5 | 6 | * Initiator 7 | * Author and maintainer 8 | 9 | * **[jhagberg](https://github.com/jhagberg)** 10 | 11 | * Encoding-hints 12 | 13 | * **[mzealey](https://github.com/mzealey)** 14 | 15 | * domoticz support 16 | 17 | * **[Tommi2Day](https://github.com/Tommi2Day)** 18 | 19 | * many modifications to enhance this tool to meke more configurable to allow developing und running on windows and add a new mqtt feature and a remote debug feature 20 | * "pvdata" for getting PV data from SMA Inverters via Modbus along SMA-EM/HM 21 | * "mqtt" to send SMA EM and PV data to an MQTT broker 22 | * "remotedebug" to allow remote debug from PyCharm 23 | * "influxdb" and sample grafana dashboard based on this plugin 24 | * "symcon" to supply SMA EM/HOM and PV data to "IP-Symcon" 25 | 26 | * **[david-m-m](https://github.com/david-m-m)** 27 | 28 | * enhance mqtt module to export topics for all metrics, works with [mqtt_exporter](https://github.com/bendikwa/mqtt_exporter) 29 | * rewrite SMA HM2.0 datagram parser 30 | * parse SMA EMETER datagrams 31 | 32 | 33 | * **[sellth](https://github.com/sellth)** 34 | 35 | * improved reporting of missing module dependencies 36 | 37 | * **[AnotherDaniel](https://github.com/AnotherDaniel)** 38 | 39 | * robustness check for dictionary content 40 | 41 | -------------------------------------------------------------------------------- /daemon3x.py: -------------------------------------------------------------------------------- 1 | """Generic linux daemon base class for python 3.x.""" 2 | 3 | """ 4 | Source: http://www.jejik.com/articles/2007/02/a_simple_unix_linux_daemon_in_python/ 5 | License Unknown 6 | * 2021-03-07 dervomsee improve the feature init 7 | """ 8 | import sys, os, time, atexit, signal 9 | 10 | class daemon3x: 11 | """A generic daemon class. 12 | 13 | Usage: subclass the daemon class and override the run() method.""" 14 | 15 | def __init__(self, pidfile): self.pidfile = pidfile 16 | 17 | def daemonize(self): 18 | """Deamonize class. UNIX double fork mechanism.""" 19 | 20 | try: 21 | pid = os.fork() 22 | if pid > 0: 23 | # exit first parent 24 | sys.exit(0) 25 | except OSError as err: 26 | sys.stderr.write('fork #1 failed: {0}\n'.format(err)) 27 | sys.exit(1) 28 | 29 | # decouple from parent environment 30 | os.chdir('/') 31 | os.setsid() 32 | os.umask(0) 33 | 34 | # do second fork 35 | try: 36 | pid = os.fork() 37 | if pid > 0: 38 | 39 | # exit from second parent 40 | sys.exit(0) 41 | except OSError as err: 42 | sys.stderr.write('fork #2 failed: {0}\n'.format(err)) 43 | sys.exit(1) 44 | 45 | # redirect standard file descriptors 46 | sys.stdout.flush() 47 | sys.stderr.flush() 48 | si = open(os.devnull, 'r') 49 | so = open(os.devnull, 'a+') 50 | se = open(os.devnull, 'a+') 51 | 52 | os.dup2(si.fileno(), sys.stdin.fileno()) 53 | os.dup2(so.fileno(), sys.stdout.fileno()) 54 | os.dup2(se.fileno(), sys.stderr.fileno()) 55 | 56 | # write pidfile 57 | atexit.register(self.delpid) 58 | 59 | pid = str(os.getpid()) 60 | try: 61 | with open(self.pidfile,'w+') as f: 62 | f.write(pid + '\n') 63 | except PermissionError: 64 | message = "no access on pidfile" 65 | sys.stderr.write(message.format(self.pidfile)) 66 | # my not work because of doubleforking 67 | sys.exit(1) 68 | 69 | def delpid(self): 70 | os.remove(self.pidfile) 71 | 72 | def start(self): 73 | """Start the daemon.""" 74 | 75 | # Check for a pidfile to see if the daemon already runs 76 | try: 77 | with open(self.pidfile,'r') as pf: 78 | pid = int(pf.read().strip()) 79 | except IOError: 80 | pid = None 81 | 82 | if pid: 83 | message = "pidfile {0} already exist. " + \ 84 | "Daemon already running?\n" 85 | sys.stderr.write(message.format(self.pidfile)) 86 | sys.exit(1) 87 | #check access to pid file, later checks my not generate readable output because of doubleforking 88 | pid = str(os.getpid()) 89 | try: 90 | with open(self.pidfile,'w+') as f: 91 | f.write('checkpidaccess\n') 92 | except PermissionError: 93 | message = "no access on pidfile" 94 | sys.stderr.write(message.format(self.pidfile)) 95 | sys.exit(1) 96 | 97 | # Start the daemon 98 | self.daemonize() 99 | self.config() 100 | self.run() 101 | 102 | def start_systemd(self): 103 | """Start the daemon.""" 104 | 105 | # Check for a pidfile to see if the daemon already runs 106 | try: 107 | with open(self.pidfile,'r') as pf: 108 | 109 | pid = int(pf.read().strip()) 110 | except IOError: 111 | pid = None 112 | 113 | if pid: 114 | message = "pidfile {0} already exist. " + \ 115 | "Daemon already running?\n" 116 | sys.stderr.write(message.format(self.pidfile)) 117 | sys.exit(1) 118 | 119 | #runc the main function without forking 120 | self.run() 121 | 122 | def stop(self): 123 | """Stop the daemon.""" 124 | 125 | # Get the pid from the pidfile 126 | try: 127 | with open(self.pidfile,'r') as pf: 128 | pid = int(pf.read().strip()) 129 | except IOError: 130 | pid = None 131 | 132 | if not pid: 133 | message = "pidfile {0} does not exist. " + \ 134 | "Daemon not running?\n" 135 | sys.stderr.write(message.format(self.pidfile)) 136 | return # not an error in a restart 137 | 138 | # Try killing the daemon process 139 | try: 140 | while 1: 141 | os.kill(pid, signal.SIGTERM) 142 | time.sleep(0.1) 143 | except OSError as err: 144 | e = str(err.args) 145 | if e.find("No such process") > 0: 146 | if os.path.exists(self.pidfile): 147 | os.remove(self.pidfile) 148 | else: 149 | print (str(err.args)) 150 | sys.exit(1) 151 | 152 | def restart(self): 153 | """Restart the daemon.""" 154 | self.stop() 155 | self.start() 156 | 157 | def restart_systemd(self): 158 | """Restart the daemon.""" 159 | self.stop() 160 | self.start_systemd() 161 | 162 | def run(self): 163 | """You should override this method when you subclass Daemon. 164 | 165 | It will be called after the process has been daemonized by 166 | start() or restart().""" 167 | 168 | def config(self): 169 | """overwritten in subclass""" 170 | 171 | -------------------------------------------------------------------------------- /features-outdated/README.md: -------------------------------------------------------------------------------- 1 | # SMA-EM daemon features (without maintenance) 2 | 3 | this page should give an overview of available features. 4 | I could not test all features, because I do not have the appropriate hardware / software. 5 | 6 | All the desired features must be activated in the configuration file 7 | ``` 8 | [SMA-EM] 9 | # list of features to load/run 10 | features=simplefswriter nextfeature 11 | ``` 12 | Each feature has it own configuration section in the configuration-file. 13 | 14 | [FEATURE-featurename] 15 | 16 | please have a look at the config.sample file or have a look at the features file (description) for supported configuration options. 17 | 18 | ``` 19 | [FEATURE-simplefswriter] 20 | # list serials simplefswriter notice 21 | serials=1900204522 22 | # measurement vars simplefswriter should write to filesystem (only from smas with serial in serials) 23 | values=pconsume psupply qsupply ssupply 24 | ``` 25 | 26 | Feature fist 27 | 28 | ## domoticz.py 29 | send SMA-measurement-values to domoticz. 30 | 31 | ## influxdb.py 32 | send SMA-measurement-values to an influxdb. 33 | 34 | ## mqtt.py 35 | send SMA-measurement-values to an mqtt broker. 36 | 37 | ## pvdata.py 38 | read sma inverter values via modbus. 39 | 40 | ## pvdata_kostal_json.py 41 | read kostal piko inverter values via http/json. 42 | 43 | ## remotedebug.py 44 | allow remote debug with PyCharm. 45 | 46 | ## sample.py 47 | a sample file; how to start writing a feature 48 | 49 | ## simplefswriter.py 50 | writes configureable measurement-values to the filesystem 51 | 52 | ## sma_grafana.json 53 | example grafana configuration to display SAM-measurement-values stored in influxdb 54 | 55 | ## smamodbus.py 56 | sma modbus library (required for pvdata.py) 57 | 58 | ## symcon.py 59 | send SMA-measurement-values to symcon 60 | 61 | ## symcon_smaem_webhook.php 62 | symcon webhook (required for symcon.py) 63 | 64 | ## symcon_smawr_webhook.php 65 | symcon webhook (required for symcon.py) 66 | 67 | ## influxdb2.py 68 | send SMA-measurement-values to an influxdb2. 69 | 70 | ## ediplugs.py 71 | get power consumption values of Edimax smartplugs 72 | -------------------------------------------------------------------------------- /features-outdated/domoticz.py: -------------------------------------------------------------------------------- 1 | """ 2 | Send SMA values over to domoticz. Configuration like: 3 | 4 | [FEATURE-domoticz] 5 | # Domoticz API endpoint 6 | api=http://127.0.1.1:8080/json.htm 7 | 8 | # How frequently to send updates over (defaults to 20 sec) 9 | min_update=30 10 | 11 | # List of items to send over. Each item should contain a string like :,:, ... 12 | pconsume=1234567869:73 13 | v1=1234567869:72 14 | """ 15 | 16 | import urllib.request 17 | import json 18 | import time 19 | 20 | last_update = 0 21 | def run(emparts,config): 22 | global last_update 23 | 24 | # Only update every X seconds 25 | if time.time() < last_update + int(config.get('min_update', 20)): 26 | #print("skipping") 27 | return 28 | 29 | last_update = time.time() 30 | 31 | serial = format(emparts['serial']) 32 | for key in config: 33 | if key in ['api', 'min_update']: 34 | continue 35 | 36 | # Dictionary of serial: domoticz device id 37 | dom_ids = dict(item.split(':') for item in config[key].split(',')) 38 | 39 | if serial not in dom_ids: 40 | continue 41 | 42 | url = "%s?type=command¶m=udevice&idx=%s&nvalue=0&svalue=" % (config['api'], dom_ids[serial]) 43 | if key in ['pconsume', 'p1consume', 'p2consume', 'p3consume']: 44 | url += "%0.2f;%0.2f" % (emparts[key], emparts[key + "counter"] * 1000) 45 | else: 46 | url += "%0.2f" % emparts[key] 47 | 48 | try: 49 | urllib.request.urlopen( url ) 50 | except Exception as e: # ignore if domoticz was down (URLError doesnt catch all io errors that may occur) 51 | print("Error from domoticz request") 52 | print(e) 53 | pass 54 | 55 | def stopping(emparts,config): 56 | pass 57 | -------------------------------------------------------------------------------- /features-outdated/ediplugs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Get power consumption values of Edimax smartplugs 3 | 4 | 2020-08-19 thsell 5 | 6 | [FEATURE-ediplugs] 7 | plugs = [ 8 | [ip, user, password] 9 | ] 10 | """ 11 | 12 | import time 13 | from libs.smartplug import SmartPlug 14 | 15 | edi_last_update = 0 16 | edi_debug = 0 17 | edi_data=[] 18 | 19 | def run(emparts,config): 20 | 21 | global edi_debug 22 | global edi_last_update 23 | global edi_data 24 | 25 | 26 | # Only update every X seconds 27 | if time.time() < edi_last_update + int(config.get('min_update', 20)): 28 | if (edi_debug > 1): 29 | print("edi: data skipping") 30 | return 31 | 32 | 33 | edi_last_update = time.time() 34 | 35 | edi_data = [] 36 | for inv in eval(config.get('plugs')): 37 | host, user, password = inv 38 | plug = SmartPlug(host, (user, password)) 39 | 40 | try: 41 | mdata = {'state': plug.state, 'pconsume': float(plug.power), 'aconsume': float(plug.current)} 42 | edi_data.append({**plug.info, **mdata}) 43 | except: 44 | print('Error connecting to Smartplug') 45 | 46 | # query 47 | if edi_data is None: 48 | if edi_debug > 0: 49 | print("Edi: no data" ) 50 | 51 | if edi_debug > 0: 52 | for i in edi_data: 53 | i['timestamp'] = time.time() 54 | print("Edi:" + format(i)) 55 | 56 | 57 | def stopping(emparts,config): 58 | pass 59 | 60 | def on_publish(client,userdata,result): 61 | pass 62 | 63 | def config(config): 64 | global edi_debug 65 | edi_debug=int(config.get('debug', 0)) 66 | print('ediplugs: feature enabled') 67 | -------------------------------------------------------------------------------- /features-outdated/influxdb.py: -------------------------------------------------------------------------------- 1 | """ 2 | Send SMA values influxdb 3 | 4 | 2018-12-28 Tommi2Day 5 | 2020-09-22 Tommi2Day fix empty pv data and no ssl option 6 | 2021-01-02 sellth added support for multiple inverters 7 | 8 | Configuration: 9 | pip3 install influxdb datetime 10 | 11 | [SMA-EM] 12 | # serials of sma-ems the daemon should take notice 13 | # seperated by space 14 | serials=30028xxx 15 | # features could filter serials to, but wouldn't see serials if these serials was not defines in SMA-EM serials 16 | # list of features to load/run 17 | features=influxdb 18 | 19 | [FEATURE-influxdb] 20 | # symcon 21 | host=influxdb 22 | port=8086 23 | db=SMA 24 | measurement=SMAEM 25 | timeout=5 26 | user= 27 | password= 28 | fields=pconsume,psupply,p1consume,p2consume,p3consume,p1supply,p2supply,p3supply 29 | 30 | # How frequently to send updates over (defaults to 20 sec) 31 | min_update=30 32 | 33 | debug=0 34 | pvmeasurement=SMAWR 35 | pvvields=AC Power,AC Voltage,grid frequency,DC Power,DC input voltage,daily yield,total yield 36 | 37 | 38 | """ 39 | 40 | import time 41 | import platform 42 | import datetime 43 | from influxdb import InfluxDBClient 44 | from influxdb.client import InfluxDBClientError 45 | 46 | influx_last_update = 0 47 | influx_debug = 0 48 | 49 | 50 | def run(emparts, config): 51 | global influx_last_update 52 | global influx_debug 53 | 54 | # Only update every X seconds 55 | if time.time() < influx_last_update + int(config.get('min_update', 20)): 56 | if (influx_debug > 1): 57 | print("InfluxDB: data skipping") 58 | return 59 | 60 | # db connect 61 | db = config.get('db', 'SMA') 62 | host = config.get('host', 'influxdb') 63 | port = int(config.get('port', 8086)) 64 | ssl = bool(config.get('ssl')) 65 | timeout = int(config.get('timeout', 5)) 66 | user = config.get('user', None) 67 | password = config.get('password', None) 68 | mesurement = config.get('measurement', 'SMAEM') 69 | fields = config.get('fields', 'pconsume,psupply') 70 | pvfields = config.get('pvfields') 71 | influx = None 72 | 73 | # connect to db, create one if needed 74 | try: 75 | if ssl == True: 76 | influx = InfluxDBClient(host=host, port=port, ssl=ssl, verify_ssl=ssl, username=user, password=password, 77 | timeout=timeout) 78 | if influx_debug > 0: 79 | print("Influxdb: use ssl") 80 | else: 81 | influx = InfluxDBClient(host=host, port=port, username=user, password=password, timeout=timeout) 82 | 83 | dbs = influx.get_list_database() 84 | if influx_debug > 1: 85 | print(dbs) 86 | if not {"name": db} in dbs: 87 | print(db + ' not in list, create') 88 | influx.create_database(db) 89 | 90 | influx.switch_database(db) 91 | if influx_debug > 1: 92 | print("Influxdb connected to '%s' @ '%s'(%s)" % (str(user), host, db)) 93 | 94 | except InfluxDBClientError as e: 95 | if influx_debug > 0: 96 | print("InfluxDB: Connect Error to '%s' @ '%s'(%s)" % (str(user), host, db)) 97 | print(format(e)) 98 | return 99 | except Exception as e: 100 | if influx_debug > 0: 101 | print("InfluxDB: Error while connecting to '%s' @ '%s'(%s)" % (str(user), host, db)) 102 | print(e) 103 | return 104 | 105 | myhostname = platform.node() 106 | influx_last_update = time.time() 107 | now = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') 108 | 109 | # last aupdate 110 | influx_last_update = time.time() 111 | serial = emparts['serial'] 112 | 113 | # data fields 114 | data = {} 115 | for f in fields.split(','): 116 | data[f] = emparts.get(f) 117 | if data[f] is None: data[f] = 0.0 118 | 119 | # data point 120 | influx_data = {} 121 | influx_data['measurement'] = mesurement 122 | influx_data['time'] = now 123 | influx_data['tags'] = {} 124 | influx_data['tags']["serial"] = serial 125 | pvpower = 0 126 | pdirectusage = 0 127 | pbattery = 0 128 | 129 | try: 130 | from features.pvdata import pv_data 131 | 132 | for inv in pv_data: 133 | # handle missing data during night hours 134 | if inv.get("AC Power") is None: 135 | pass 136 | elif inv.get("DeviceClass") == "Solar Inverter": 137 | pvpower += inv.get("AC Power") 138 | elif inv.get("DeviceClass") == "Battery Inverter": 139 | pbattery += inv.get("AC Power") 140 | 141 | pconsume = emparts.get('pconsume', 0) 142 | psupply = emparts.get('psupply', 0) 143 | pusage = pvpower + pconsume - psupply 144 | # total power consumption (grid + battery discharge) 145 | phouse = pvpower + pconsume - psupply + pbattery 146 | 147 | if pdirectusage is None: pdirectusage=0 148 | if pvpower > pusage: 149 | pdirectusage = pusage 150 | else: 151 | pdirectusage = pvpower 152 | 153 | data['pdirectusage'] = float(pdirectusage) 154 | data['pvpower'] = float(pvpower) 155 | data['pusage'] = float(pusage) 156 | data['pbattery'] = float(pbattery) 157 | data['phouse'] = float(phouse) 158 | except: 159 | # Kostal inverter? (pvdata_kostal_json) 160 | print("except - no sma - inverter") 161 | try: 162 | from features.pvdata_kostal_json import pv_data 163 | pvpower = pv_data.get("AC Power") 164 | if pvpower is None: pvpower = 0 165 | pconsume = emparts.get('pconsume', 0) 166 | psupply = emparts.get('psupply', 0) 167 | pusage = pvpower + pconsume - psupply 168 | if pdirectusage is None: pdirectusage=0 169 | if pvpower > pusage: 170 | pdirectusage = pusage 171 | else: 172 | pdirectusage = pvpower 173 | data['pdirectusage'] = pdirectusage 174 | data['pvpower'] = pvpower 175 | data['pusage'] = pusage 176 | except: 177 | pv_data = None 178 | print("no kostal inverter") 179 | pass 180 | 181 | influx_data['fields'] = data 182 | points = [influx_data] 183 | 184 | # send it 185 | try: 186 | influx.write_points(points, time_precision='s', protocol='json') 187 | except InfluxDBClientError as e: 188 | if influx_debug > 0: 189 | print('InfluxDBError: %s' % (format(e))) 190 | print("InfluxDB failed data:" + format(time.strftime("%H:%M:%S", time.localtime(influx_last_update))), 191 | format(points)) 192 | pass 193 | 194 | else: 195 | if influx_debug > 0: 196 | print("InfluxDB: em data published %s:%s" % ( 197 | format(time.strftime("%H:%M:%S", time.localtime(influx_last_update))), format(points))) 198 | 199 | pvmeasurement = config.get('pvmeasurement') 200 | if None in [pvfields, pv_data, pvmeasurement]: return 201 | 202 | influx_data = [] 203 | datapoint = { 204 | 'measurement': pvmeasurement, 205 | 'time': now, 206 | 'tags': {}, 207 | 'fields': {} 208 | } 209 | taglist = ['serial', 'DeviceID', 'Device Name'] 210 | tags = {} 211 | fields = {} 212 | 213 | if pv_data is not None: 214 | for inv in pv_data: 215 | # add tag columns and remove from data list 216 | for t in taglist: 217 | tags[t] = inv.get(t) 218 | 219 | for f in pvfields.split(','): 220 | fields[f] = inv.get(f) 221 | 222 | datapoint['tags'] = tags.copy() 223 | datapoint['fields'] = fields.copy() 224 | influx_data.append(datapoint.copy()) 225 | 226 | # send it 227 | try: 228 | influx.write_points(influx_data, time_precision='s', protocol='json') 229 | except InfluxDBClientError as e: 230 | if influx_debug > 0: 231 | print('InfluxDBError: %s' % (format(e))) 232 | print("InfluxDB failed pv data:" + format(time.strftime("%H:%M:%S", time.localtime(influx_last_update))), 233 | format(influx_data)) 234 | pass 235 | 236 | else: 237 | if influx_debug > 0: 238 | print("InfluxDB: pv data published %s:%s" % ( 239 | format(time.strftime("%H:%M:%S", time.localtime(influx_last_update))), format(influx_data))) 240 | 241 | 242 | # Edimax smartplug data ##### 243 | from features.ediplugs import edi_data 244 | edimeasurement=config.get('edimeasurement') 245 | 246 | if None in [edi_data,edimeasurement]: return 247 | 248 | influx_data = [] 249 | datapoint={ 250 | 'measurement': edimeasurement, 251 | 'time': now, 252 | 'tags': {}, 253 | 'fields': {} 254 | } 255 | taglist = ['vendor', 'model', 'mac', 'name'] 256 | fieldlist = ['state', 'pconsume', 'aconsume'] 257 | tags = {} 258 | fields = {} 259 | 260 | for inv in edi_data: 261 | for t in taglist: 262 | tags[t] = inv.get(t) 263 | 264 | for f in fieldlist: 265 | fields[f] = inv.get(f) 266 | 267 | datapoint['tags'] = tags.copy() 268 | datapoint['fields'] = fields.copy() 269 | influx_data.append(datapoint.copy()) 270 | 271 | points = influx_data 272 | 273 | #send it 274 | try: 275 | influx.write_points(points, time_precision='s', protocol='json') 276 | except InfluxDBClientError as e: 277 | if influx_debug > 0: 278 | print('InfluxDBError: %s' % (format(e))) 279 | print("InfluxDB failed edi data:" 280 | + format(time.strftime("%H:%M:%S", time.localtime(influx_last_update))) 281 | + format(points) 282 | ) 283 | pass 284 | 285 | else: 286 | if influx_debug > 0: 287 | print("InfluxDB: edi data published %s:%s" 288 | % (format(time.strftime("%H:%M:%S", time.localtime(influx_last_update))), 289 | format(points) 290 | ) 291 | ) 292 | 293 | 294 | def stopping(emparts, config): 295 | pass 296 | 297 | 298 | def config(config): 299 | global influx_debug 300 | influx_debug = int(config.get('debug', 0)) 301 | print('influxdb: feature enabled') 302 | -------------------------------------------------------------------------------- /features-outdated/influxdb2.py: -------------------------------------------------------------------------------- 1 | """ 2 | Send SMA values influxdb 2.0 3 | 4 | 2021-02-25 dervomsee 5 | 6 | Configuration: 7 | pip3 install influxdb-client 8 | 9 | [SMA-EM] 10 | # serials of sma-ems the daemon should take notice 11 | # seperated by space 12 | serials=30028xxx 13 | # features could filter serials to, but wouldn't see serials if these serials was not defines in SMA-EM serials 14 | # list of features to load/run 15 | features=influxdb2 16 | 17 | [FEATURE-influxdb2] 18 | debug=0 19 | url=hostname.tld 20 | token=long_token 21 | org=org_name 22 | bucket=bucket_name 23 | measurement=SMAEM 24 | fields=pconsume,psupply,p1consume,p2consume,p3consume,p1supply,p2supply,p3supply 25 | 26 | # How frequently to send updates over (defaults to 20 sec) 27 | min_update=30 28 | 29 | #pv fields 30 | pvmeasurement=SMAWR 31 | pvfields=AC Power,AC_Current,Grid_Frequency 32 | # How frequently to send updates over (defaults to 20 sec) 33 | 34 | """ 35 | 36 | import time 37 | from datetime import datetime 38 | from influxdb_client import InfluxDBClient, Point, WriteOptions, WritePrecision 39 | from influxdb_client.client.write_api import SYNCHRONOUS, WriteType 40 | 41 | influx2_client = InfluxDBClient(url="dummy", token="test", org="", debug=False) 42 | influx2_write_api = influx2_client.write_api(write_options=SYNCHRONOUS) 43 | influx2_last_update = 0 44 | influx2_debug = 0 45 | 46 | 47 | def run(emparts, config): 48 | global influx2_debug 49 | global influx2_last_update 50 | global influx2_client 51 | global influx2_write_api 52 | 53 | # Only update every X seconds 54 | if time.time() < influx2_last_update + int(config.get('min_update', 20)): 55 | if (influx2_debug > 1): 56 | print("InfluxDB: data skipping") 57 | return 58 | influx2_last_update = time.time() 59 | 60 | mesurement = config.get('measurement', 'SMAEM') 61 | fields = config.get('fields', 'pconsume,psupply') 62 | serial = emparts['serial'] 63 | 64 | # data fields 65 | data = {} 66 | for f in fields.split(','): 67 | data[f] = emparts.get(f) 68 | if data[f] is None: 69 | data[f] = 0.0 70 | 71 | # inverter data 72 | pvpower = 0 73 | pdirectusage = 0 74 | try: 75 | from features.pvdata import pv_data 76 | for inv in pv_data: 77 | # handle missing data during night hours 78 | if inv.get("AC Power") is None: 79 | pass 80 | elif inv.get("DeviceClass") == "Solar Inverter": 81 | pvpower += inv.get("AC Power", 0) 82 | pconsume = emparts.get('pconsume', 0) 83 | psupply = emparts.get('psupply', 0) 84 | pusage = pvpower + pconsume - psupply 85 | 86 | if pdirectusage is None: 87 | pdirectusage = 0 88 | if pvpower > pusage: 89 | pdirectusage = pusage 90 | else: 91 | pdirectusage = pvpower 92 | data['pdirectusage'] = float(pdirectusage) 93 | data['pvpower'] = float(pvpower) 94 | data['pusage'] = float(pusage) 95 | except: 96 | # Kostal inverter? (pvdata_kostal_json) 97 | if influx2_debug > 0: 98 | print("InfluxDB2: " + "except - no sma - inverter") 99 | try: 100 | from features.pvdata_kostal_json import pv_data 101 | pvpower = pv_data.get("AC Power") 102 | if pvpower is None: 103 | pvpower = 0 104 | pconsume = emparts.get('pconsume', 0) 105 | psupply = emparts.get('psupply', 0) 106 | pusage = pvpower + pconsume - psupply 107 | if pdirectusage is None: 108 | pdirectusage = 0 109 | if pvpower > pusage: 110 | pdirectusage = pusage 111 | else: 112 | pdirectusage = pvpower 113 | data['pdirectusage'] = float(pdirectusage) 114 | data['pvpower'] = float(pvpower) 115 | data['pusage'] = float(pusage) 116 | except: 117 | pv_data = None 118 | if influx2_debug > 0: 119 | print("InfluxDB2: " + "no kostal inverter") 120 | pass 121 | 122 | # data point 123 | influx_data = {} 124 | influx_data['measurement'] = mesurement 125 | influx_data['time'] = datetime.utcnow() 126 | influx_data['tags'] = {} 127 | influx_data['tags']["serial"] = serial 128 | influx_data['fields'] = data 129 | points = [influx_data] 130 | 131 | # write em data 132 | org = config.get('org', "my-org") 133 | bucket = config.get('bucket', "my-bucket") 134 | influx2_write_api.write(bucket, org, points, 135 | write_precision=WritePrecision.S) 136 | 137 | # prepare pv data 138 | pvfields = config.get('pvfields') 139 | pvmeasurement = config.get('pvmeasurement') 140 | if None in [pvfields, pv_data, pvmeasurement]: 141 | return 142 | 143 | # pv data are empty on first call 144 | if not pv_data: 145 | if influx2_debug > 0: 146 | print("pv_data empty") 147 | return 148 | 149 | points = [] 150 | influx_data = [] 151 | datapoint = { 152 | 'measurement': pvmeasurement, 153 | 'time': datetime.utcnow(), 154 | 'tags': {}, 155 | 'fields': {} 156 | } 157 | taglist = ['serial', 'DeviceID'] 158 | tags = {} 159 | fields = {} 160 | for inv in pv_data: 161 | # add tag columns and remove from data list 162 | for t in taglist: 163 | if inv.get(t) is None: 164 | pass 165 | else: 166 | tags[t] = inv.get(t) 167 | inv.pop(t) 168 | 169 | # only if we have values 170 | if pv_data is not None: 171 | for f in pvfields.split(','): 172 | if inv.get(f) is None: 173 | pass 174 | else: 175 | fields[f] = inv.get(f) 176 | 177 | datapoint['tags'] = tags.copy() 178 | datapoint['fields'] = fields.copy() 179 | influx_data.append(datapoint.copy()) 180 | 181 | # write pv data 182 | points = influx_data 183 | influx2_write_api.write(bucket, org, points, 184 | write_precision=WritePrecision.S) 185 | 186 | 187 | def stopping(emparts, config): 188 | global influx2_client 189 | influx2_client.close() 190 | global influx2_write_api 191 | influx2_write_api.close() 192 | pass 193 | 194 | 195 | def config(config): 196 | global influx2_debug 197 | global influx2_client 198 | global influx2_write_api 199 | influx2_debug = int(config.get('debug', 0)) 200 | org = config.get('org', "my-org") 201 | url = config.get('url', "http://localhost:8086") 202 | token = config.get('token', "my-token") 203 | 204 | # create connection 205 | influx2_client.close() 206 | if influx2_debug > 0: 207 | influx2_client = InfluxDBClient( 208 | url=url, token=token, org=org, debug=True) 209 | else: 210 | influx2_client = InfluxDBClient( 211 | url=url, token=token, org=org, debug=False) 212 | influx2_write_api.close() 213 | influx2_write_api = influx2_client.write_api(write_options=WriteOptions(batch_size=200, 214 | flush_interval=120_000, 215 | jitter_interval=2_000, 216 | retry_interval=5_000, 217 | max_retries=5, 218 | max_retry_delay=30_000, 219 | exponential_base=2)) 220 | print('influxdb2: feature enabled') 221 | -------------------------------------------------------------------------------- /features-outdated/pvdata.py: -------------------------------------------------------------------------------- 1 | """ 2 | Get inverter pv values via modbus 3 | 4 | 2018-12-28 Tommi2Day 5 | 2020-09-22 Tommi2Day fixes empty data exeptions 6 | 2021-01-02 sellth added support for multiple inverters 7 | 8 | Configuration: 9 | pip3 install pymodbus 10 | 11 | [FEATURE-pvdata] 12 | 13 | # How frequently to send updates over (defaults to 20 sec) 14 | min_update=20 15 | #debug output 16 | debug=0 17 | 18 | #inverter connection 19 | inv_host = 20 | inv_port = 502 21 | inv_modbus_id = 3 22 | inv_manufacturer = SMA 23 | #['address', 'type', 'format', 'description', 'unit', 'value'] 24 | registers = [ 25 | ['30057', 'U32', 'RAW', 'serial', ''], 26 | ['30201','U32','ENUM','Status',''], 27 | ['30051','U32','ENUM','DeviceClass',''], 28 | ['30053','U32','ENUM','DeviceID',''], 29 | ['40631', 'STR32', 'UTF8', 'Device Name', ''], 30 | ['30775', 'S32', 'FIX0', 'AC Power', 'W'], 31 | ['30813', 'S32', 'FIX0', 'AC apparent power', 'VA'], 32 | ['30977', 'S32', 'FIX3', 'AC current', 'A'], 33 | ['30783', 'S32', 'FIX2', 'AC voltage', 'V'], 34 | ['30803', 'U32', 'FIX2', 'grid frequency', 'Hz'], 35 | ['30773', 'S32', 'FIX0', 'DC power', 'W'], 36 | ['30771', 'S32', 'FIX2', 'DC input voltage', 'V'], 37 | ['30777', 'S32', 'FIX0', 'Power L1', 'W'], 38 | ['30779', 'S32', 'FIX0', 'Power L2', 'W'], 39 | ['30781', 'S32', 'FIX0', 'Power L3', 'W'], 40 | ['30953', 'S32', 'FIX1', u'device temperature', u'\xb0C'], 41 | ['30517', 'U64', 'FIX3', 'daily yield', 'kWh'], 42 | ['30513', 'U64', 'FIX3', 'total yield', 'kWh'], 43 | ['30521', 'U64', 'FIX0', 'operation time', 's'], 44 | ['30525', 'U64', 'FIX0', 'feed-in time', 's'], 45 | ['30975', 'S32', 'FIX2', 'intermediate voltage', 'V'], 46 | ['30225', 'S32', 'FIX0', 'Isolation resistance', u'\u03a9'], 47 | ['30581', 'U32', 'FIX0', u'energy from grid', 'Wh'], 48 | ['30583', 'U32', 'FIX0', u'energy to grid', 'Wh'], 49 | ['30865', 'S32', 'FIX0', 'Power from grid', 'W'], 50 | ['30867', 'S32', 'FIX0', 'Power to grid', 'W'] 51 | ] 52 | """ 53 | 54 | import time 55 | from features.smamodbus import get_device_class 56 | from features.smamodbus import get_pv_data 57 | 58 | pv_last_update = 0 59 | pv_debug = 0 60 | pv_data = [] 61 | 62 | 63 | def run(emparts, config): 64 | global pv_debug 65 | global pv_last_update 66 | global pv_data 67 | 68 | # Only update every X seconds 69 | if time.time() < pv_last_update + int(config.get('min_update', 20)): 70 | if (pv_debug > 1): 71 | print("pv: data skipping") 72 | return 73 | 74 | pv_last_update = time.time() 75 | registers = eval(config.get('registers')) 76 | 77 | pv_data = [] 78 | for inv in eval(config.get('inverters')): 79 | host, port, modbusid, manufacturer = inv 80 | 81 | device_class = get_device_class(host, int(port), int(modbusid)) 82 | if device_class == "Solar Inverter": 83 | relevant_registers = eval(config.get('registers')) 84 | mdata = get_pv_data(host, int(port), int(modbusid), relevant_registers) 85 | pv_data.append(mdata) 86 | elif device_class == "Battery Inverter": 87 | relevant_registers = eval(config.get('registers_batt')) 88 | mdata = get_pv_data(host, int(port), int(modbusid), relevant_registers) 89 | pv_data.append(mdata) 90 | else: 91 | if (pv_debug > 1): 92 | print("pv: unknown device class; skipping") 93 | pass 94 | 95 | # query 96 | if pv_data is None: 97 | if pv_debug > 0: 98 | print("PV: no data") 99 | return 100 | 101 | timestamp = time.time() 102 | for i in pv_data: 103 | i['timestamp'] = timestamp 104 | if pv_debug > 0: 105 | print("PV:" + format(i)) 106 | 107 | 108 | def stopping(emparts, config): 109 | pass 110 | 111 | 112 | def on_publish(client, userdata, result): 113 | pass 114 | 115 | 116 | def config(config): 117 | global pv_debug 118 | pv_debug = int(config.get('debug', 0)) 119 | print('pvdata: feature enabled') 120 | -------------------------------------------------------------------------------- /features-outdated/pvdata_kostal_json.py: -------------------------------------------------------------------------------- 1 | """ 2 | Get inverter pv values http / Json on Kostal interters 3 | 4 | 2020-05-24 Wenger Florian 5 | 6 | Configuration: 7 | pip3 install requests json 8 | 9 | [FEATURE-pvdata_kostal_json] 10 | 11 | # How frequently to send updates over (defaults to 20 sec) 12 | # my kostal inverter updates the values only every 3 seconds 13 | # 14 | # How frequently to send updates over (defaults to 20 sec) 15 | min_update=15 16 | #debug output 17 | debug=0 18 | 19 | #inverter connection 20 | inv_host = 21 | #['address', 'NONE', 'NONE' 'description', 'unit'] 22 | # to get the same structure of sma pvdata feature 23 | registers = [ 24 | ['33556736', 'NONE', 'NONE', 'DC Power', 'W'], 25 | ['33555202', 'NONE', 'NONE', 'DC string1 voltage', 'V'], 26 | ['33555201', 'NONE', 'NONE', 'DC string1 current', 'A'], 27 | ['33555203', 'NONE', 'NONE', 'DC string1 power', 'W'], 28 | ['67109120', 'NONE', 'NONE', 'AC Power', 'W'], 29 | ['67110400', 'NONE', 'NONE', 'AC frequency', 'Hz'], 30 | ['67110656', 'NONE', 'NONE', 'AC cosphi', '°'], 31 | ['67110144', 'NONE', 'NONE', 'AC ptot limitation', ''], 32 | ['67109378', 'NONE', 'NONE', 'AC phase1 voltage', 'V'], 33 | ['67109377', 'NONE', 'NONE', 'AC phase1 current', 'A'], 34 | ['67109379', 'NONE', 'NONE', 'AC phase1 power', 'W'], 35 | ['251658754', 'NONE', 'NONE', 'yield today', 'Wh'], 36 | ['251658753', 'NONE', 'NONE', 'yield total', 'kWh'], 37 | ['251658496', 'NONE', 'NONE', 'operationtime', ''], 38 | ] 39 | 40 | 41 | r = requests.get(url='http://192.168.1.21/api/dxs.json?dxsEntries=67109120&sessionId=1234567890') 42 | cont = json.loads(r.content) 43 | #print(cont) 44 | print(cont["dxsEntries"][0]["value"],"W Kostal") 45 | sys.exit() 46 | 47 | """ 48 | 49 | import requests, json, time 50 | pv_last_update = 0 51 | pv_debug = 0 52 | # pv_data 53 | pv_data={} 54 | 55 | def run(emparts,config): 56 | global pv_debug 57 | global pv_last_update 58 | #global pv_data_last 59 | global pv_data 60 | 61 | host = config.get('inv_host') 62 | registers = eval(config.get('registers')) 63 | 64 | # Only update every X seconds 65 | if time.time() < pv_last_update + int(config.get('min_update', 20)): 66 | if (pv_debug > 0): 67 | print("pv: data skipping") 68 | print("reuse last values") 69 | print("PV:" + format(pv_data)) 70 | return 71 | pv_last_update = time.time() 72 | url = "http://"+host+"/api/dxs.json?sessionId=1234567890" 73 | for register in registers: 74 | if (pv_debug > 0): 75 | print (register[0]) 76 | url=url+"&dxsEntries="+register[0] 77 | if (pv_debug > 1): 78 | print (url) 79 | r = requests.get(url) 80 | cont = json.loads(r.content) 81 | #print(cont) 82 | pv_data={} 83 | if cont['status']["code"] == 0: 84 | 85 | for pvjdata in cont["dxsEntries"]: 86 | #process the json values 87 | item=pvjdata["dxsId"] 88 | value=pvjdata["value"] 89 | for register in registers: 90 | if int(register[0])==int(item): 91 | if pv_debug > 0: 92 | print("---") 93 | print(str(item) + " " + register[3] + " " + str(value) + " " + register[4]) 94 | pv_data[register[3]]=float(value) 95 | #pv_data_last=pv_data 96 | else: 97 | print ("PVdata-Result json, but not OK") 98 | 99 | if pv_data is None: 100 | if pv_debug > 0: 101 | print("PV: no data" ) 102 | 103 | pv_data['timestamp'] = time.time() 104 | if pv_debug > 0: 105 | print("PV:" + format(pv_data)) 106 | 107 | 108 | def stopping(emparts,config): 109 | pass 110 | 111 | def config(config): 112 | global pv_debug 113 | pv_debug=int(config.get('debug', 0)) 114 | print('pvdata_kostal_json: feature enabled') 115 | -------------------------------------------------------------------------------- /features-outdated/remotedebug.py: -------------------------------------------------------------------------------- 1 | """ 2 | Allow remote debug with PyCharm 3 | 4 | 2020-09-20 Tommi2Day 5 | 6 | [FEATURE-debug] 7 | # Debug settings 8 | debughost=mypc 9 | debugport=9100 10 | 11 | """ 12 | import pydevd_pycharm 13 | 14 | 15 | def run(emparts, config): 16 | pass 17 | 18 | 19 | def stopping(emparts, config): 20 | pass 21 | 22 | 23 | def config(config): 24 | # prepare mqtt settings 25 | print('debug feature enabled') 26 | debughost = config.get('debughost', None) 27 | debugport = config.get('debugport', None) 28 | if None not in (debughost, debugport): 29 | try: 30 | print('activate debug for ' + debughost + ' Port ' + str(debugport)) 31 | pydevd_pycharm.settrace(debughost, port=int(debugport), stdoutToServer=True, stderrToServer=True) 32 | except Exception as e: 33 | print('...failed') 34 | print(e) 35 | pass 36 | -------------------------------------------------------------------------------- /features-outdated/sample.py: -------------------------------------------------------------------------------- 1 | """ 2 | * sample feature module / just an example 3 | * for sma-em daemon 4 | * 5 | """ 6 | def run(emparts,config): 7 | """ 8 | * sma-em daemon calls run for each measurement package 9 | * emparts: all measurements of one sma-em package 10 | * config: all config items from section FEATURE-[featurename] in /etc/smaemd/config 11 | * 12 | """ 13 | #print("running sample feature") 14 | #print ('config') 15 | #print(config) 16 | pass 17 | 18 | 19 | def stopping(emparts,config): 20 | """ 21 | * executed on daemon stop 22 | * do some cleanup / close filehandles if needed and so on... 23 | """ 24 | print("quitting") 25 | #close filehandles 26 | 27 | def config(config): 28 | """ 29 | * executed on daemon config init 30 | * do some configuration stuff... 31 | """ 32 | global sw_debug 33 | sw_debug = int(config.get('debug', 0)) 34 | print("sample: feature enabled") 35 | -------------------------------------------------------------------------------- /features-outdated/sma_grafana.json: -------------------------------------------------------------------------------- 1 | { 2 | "__inputs": [ 3 | { 4 | "name": "DS_INFLUXDB-SMA", 5 | "label": "InfluxDB-SMA", 6 | "description": "", 7 | "type": "datasource", 8 | "pluginId": "influxdb", 9 | "pluginName": "InfluxDB" 10 | } 11 | ], 12 | "__requires": [ 13 | { 14 | "type": "grafana", 15 | "id": "grafana", 16 | "name": "Grafana", 17 | "version": "5.2.1" 18 | }, 19 | { 20 | "type": "panel", 21 | "id": "graph", 22 | "name": "Graph", 23 | "version": "5.0.0" 24 | }, 25 | { 26 | "type": "datasource", 27 | "id": "influxdb", 28 | "name": "InfluxDB", 29 | "version": "5.0.0" 30 | } 31 | ], 32 | "annotations": { 33 | "list": [ 34 | { 35 | "builtIn": 1, 36 | "datasource": "-- Grafana --", 37 | "enable": true, 38 | "hide": true, 39 | "iconColor": "rgba(0, 211, 255, 1)", 40 | "name": "Annotations & Alerts", 41 | "type": "dashboard" 42 | } 43 | ] 44 | }, 45 | "editable": true, 46 | "gnetId": null, 47 | "graphTooltip": 0, 48 | "id": null, 49 | "links": [], 50 | "panels": [ 51 | { 52 | "aliasColors": { 53 | "SMAEM.Verbrauch": "#890f02" 54 | }, 55 | "bars": false, 56 | "dashLength": 10, 57 | "dashes": false, 58 | "datasource": "${DS_INFLUXDB-SMA}", 59 | "fill": 1, 60 | "gridPos": { 61 | "h": 9, 62 | "w": 12, 63 | "x": 0, 64 | "y": 0 65 | }, 66 | "id": 2, 67 | "legend": { 68 | "avg": false, 69 | "current": false, 70 | "max": false, 71 | "min": false, 72 | "show": true, 73 | "total": false, 74 | "values": false 75 | }, 76 | "lines": true, 77 | "linewidth": 1, 78 | "links": [], 79 | "nullPointMode": "null", 80 | "percentage": false, 81 | "pointradius": 5, 82 | "points": false, 83 | "renderer": "flot", 84 | "seriesOverrides": [], 85 | "spaceLength": 10, 86 | "stack": false, 87 | "steppedLine": false, 88 | "targets": [ 89 | { 90 | "groupBy": [ 91 | { 92 | "params": [ 93 | "1m" 94 | ], 95 | "type": "time" 96 | }, 97 | { 98 | "params": [ 99 | "null" 100 | ], 101 | "type": "fill" 102 | } 103 | ], 104 | "measurement": "SMAEM", 105 | "orderByTime": "ASC", 106 | "policy": "autogen", 107 | "refId": "A", 108 | "resultFormat": "time_series", 109 | "select": [ 110 | [ 111 | { 112 | "params": [ 113 | "pconsume" 114 | ], 115 | "type": "field" 116 | }, 117 | { 118 | "params": [], 119 | "type": "mean" 120 | }, 121 | { 122 | "params": [ 123 | "consume" 124 | ], 125 | "type": "alias" 126 | } 127 | ], 128 | [ 129 | { 130 | "params": [ 131 | "psupply" 132 | ], 133 | "type": "field" 134 | }, 135 | { 136 | "params": [], 137 | "type": "mean" 138 | }, 139 | { 140 | "params": [ 141 | "supply" 142 | ], 143 | "type": "alias" 144 | } 145 | ], 146 | [ 147 | { 148 | "params": [ 149 | "pusage" 150 | ], 151 | "type": "field" 152 | }, 153 | { 154 | "params": [], 155 | "type": "mean" 156 | }, 157 | { 158 | "params": [ 159 | "usage" 160 | ], 161 | "type": "alias" 162 | } 163 | ], 164 | [ 165 | { 166 | "params": [ 167 | "pvpower" 168 | ], 169 | "type": "field" 170 | }, 171 | { 172 | "params": [], 173 | "type": "mean" 174 | }, 175 | { 176 | "params": [ 177 | "PV" 178 | ], 179 | "type": "alias" 180 | } 181 | ] 182 | ], 183 | "tags": [ 184 | { 185 | "key": "serial", 186 | "operator": "=", 187 | "value": "3002849936" 188 | } 189 | ] 190 | } 191 | ], 192 | "thresholds": [], 193 | "timeFrom": null, 194 | "timeShift": null, 195 | "title": "SMA-EM Data", 196 | "tooltip": { 197 | "shared": true, 198 | "sort": 0, 199 | "value_type": "individual" 200 | }, 201 | "type": "graph", 202 | "xaxis": { 203 | "buckets": null, 204 | "mode": "time", 205 | "name": null, 206 | "show": true, 207 | "values": [] 208 | }, 209 | "yaxes": [ 210 | { 211 | "format": "watt", 212 | "label": "Watt", 213 | "logBase": 1, 214 | "max": null, 215 | "min": null, 216 | "show": true 217 | }, 218 | { 219 | "format": "short", 220 | "label": "", 221 | "logBase": 1, 222 | "max": null, 223 | "min": null, 224 | "show": true 225 | } 226 | ], 227 | "yaxis": { 228 | "align": false, 229 | "alignLevel": null 230 | } 231 | } 232 | ], 233 | "schemaVersion": 16, 234 | "style": "dark", 235 | "tags": [], 236 | "templating": { 237 | "list": [] 238 | }, 239 | "time": { 240 | "from": "now-2d", 241 | "to": "now" 242 | }, 243 | "timepicker": { 244 | "refresh_intervals": [ 245 | "5s", 246 | "10s", 247 | "30s", 248 | "1m", 249 | "5m", 250 | "15m", 251 | "30m", 252 | "1h", 253 | "2h", 254 | "1d" 255 | ], 256 | "time_options": [ 257 | "5m", 258 | "15m", 259 | "1h", 260 | "6h", 261 | "12h", 262 | "24h", 263 | "2d", 264 | "7d", 265 | "30d" 266 | ] 267 | }, 268 | "timezone": "", 269 | "title": "SMA EM/HM", 270 | "uid": "L1e-KEymz", 271 | "version": 3 272 | } 273 | -------------------------------------------------------------------------------- /features-outdated/smamodbus.py: -------------------------------------------------------------------------------- 1 | ''' 2 | smaem modbus library 3 | 4 | 2018-12-28 Tommi2Day 5 | 6 | requires pymodbus 7 | 8 | huge parts taken from 9 | - https://github.com/transistorgrab/PyModMon 10 | - https://github.com/CodeKing/de.codeking.symcon.sma 11 | 12 | config sample: 13 | config={ 14 | 'inv_host' : "sma", 15 | 'inv_port' : 502, 16 | 'inv_modbus_id' : 3, 17 | 'registers': [ 18 | ['address', 'type', 'format', 'description', 'unit', 'value'], 19 | ['30057', 'U32', 'RAW', 'serial', ''], 20 | ['30201','U32','ENUM',Status',''], 21 | ['30775', 'S32', 'FIX0', 'AC Power', 'W'] 22 | ] 23 | } 24 | ''' 25 | 26 | from pymodbus.payload import BinaryPayloadDecoder 27 | from pymodbus.constants import Endian 28 | import datetime 29 | from pymodbus.client.sync import ModbusTcpClient as ModbusClient 30 | import traceback 31 | 32 | # defines 33 | MIN_SIGNED = -2147483648 34 | MAX_UNSIGNED = 4294967295 35 | modbusdatatype = { ## allowed data types, sent from target 36 | 'S32': 2, 37 | 'U32': 2, 38 | 'U64': 4, 39 | 'STR32': 16, 40 | 'S16': 1, 41 | 'U16': 1 42 | } 43 | pvenums = { 44 | 'Status': { 45 | 35: 'Error', 46 | 303: 'Off', 47 | 307: 'OK', 48 | 455: 'Warning' 49 | }, 50 | 'DeviceClass': { 51 | 460: 'Solar Inverter', 52 | 8000: 'All Devices', 53 | 8001: 'Solar Inverter', 54 | 8002: 'Wind Turbine Inverter', 55 | 8007: 'Battery Inverter', 56 | 8033: 'Consumer', 57 | 8064: 'Sensor System in General', 58 | 8065: 'Electricity meter', 59 | 8128: 'Communication device' 60 | }, 61 | 'DeviceID': { 62 | 9000: 'SWR 700', 63 | 9001: 'SWR 850', 64 | 9002: 'SWR 850E', 65 | 9003: 'SWR 1100', 66 | 9004: 'SWR 1100E', 67 | 9005: 'SWR 1100LV', 68 | 9006: 'SWR 1500', 69 | 9007: 'SWR 1600', 70 | 9008: 'SWR 1700E', 71 | 9009: 'SWR 1800U', 72 | 9010: 'SWR 2000', 73 | 9011: 'SWR 2400', 74 | 9012: 'SWR 2500', 75 | 9013: 'SWR 2500U', 76 | 9014: 'SWR 3000', 77 | 9015: 'SB 700', 78 | 9016: 'SB 700U', 79 | 9017: 'SB 1100', 80 | 9018: 'SB 1100U', 81 | 9019: 'SB 1100LV', 82 | 9020: 'SB 1700', 83 | 9021: 'SB 1900TLJ', 84 | 9022: 'SB 2100TL', 85 | 9023: 'SB 2500', 86 | 9024: 'SB 2800', 87 | 9025: 'SB 2800i', 88 | 9026: 'SB 3000', 89 | 9027: 'SB 3000US', 90 | 9028: 'SB 3300', 91 | 9029: 'SB 3300U', 92 | 9030: 'SB 3300TL', 93 | 9031: 'SB 3300TL HC', 94 | 9032: 'SB 3800', 95 | 9033: 'SB 3800U', 96 | 9034: 'SB 4000US', 97 | 9035: 'SB 4200TL', 98 | 9036: 'SB 4200TL HC', 99 | 9037: 'SB 5000TL', 100 | 9038: 'SB 5000TLW', 101 | 9039: 'SB 5000TL HC', 102 | 9040: 'Convert 2700', 103 | 9041: 'SMC 4600A', 104 | 9042: 'SMC 5000', 105 | 9043: 'SMC 5000A', 106 | 9044: 'SB 5000US', 107 | 9045: 'SMC 6000', 108 | 9046: 'SMC 6000A', 109 | 9047: 'SB 6000US', 110 | 9048: 'SMC 6000UL', 111 | 9049: 'SMC 6000TL', 112 | 9050: 'SMC 6500A', 113 | 9051: 'SMC 7000A', 114 | 9052: 'SMC 7000HV', 115 | 9053: 'SB 7000US', 116 | 9054: 'SMC 7000TL', 117 | 9055: 'SMC 8000TL', 118 | 9056: 'SMC 9000TL-10', 119 | 9057: 'SMC 10000TL-10', 120 | 9058: 'SMC 11000TL-10', 121 | 9059: 'SB 3000 K', 122 | 9060: 'Unknown device', 123 | 9061: 'SensorBox', 124 | 9062: 'SMC 11000TLRP', 125 | 9063: 'SMC 10000TLRP', 126 | 9064: 'SMC 9000TLRP', 127 | 9065: 'SMC 7000HVRP', 128 | 9066: 'SB 1200', 129 | 9067: 'STP 10000TL-10', 130 | 9068: 'STP 12000TL-10', 131 | 9069: 'STP 15000TL-10', 132 | 9070: 'STP 17000TL-10', 133 | 9071: 'SB 2000HF-30', 134 | 9072: 'SB 2500HF-30', 135 | 9073: 'SB 3000HF-30', 136 | 9074: 'SB 3000TL-21', 137 | 9075: 'SB 4000TL-21', 138 | 9076: 'SB 5000TL-21', 139 | 9077: 'SB 2000HFUS-30', 140 | 9078: 'SB 2500HFUS-30', 141 | 9079: 'SB 3000HFUS-30', 142 | 9080: 'SB 8000TLUS', 143 | 9081: 'SB 9000TLUS', 144 | 9082: 'SB 10000TLUS', 145 | 9083: 'SB 8000US', 146 | 9084: 'WB 3600TL-20', 147 | 9085: 'WB 5000TL-20', 148 | 9086: 'SB 3800US-10', 149 | 9087: 'Sunny Beam BT11', 150 | 9088: 'Sunny Central 500CP', 151 | 9089: 'Sunny Central 630CP', 152 | 9090: 'Sunny Central 800CP', 153 | 9091: 'Sunny Central 250U', 154 | 9092: 'Sunny Central 500U', 155 | 9093: 'Sunny Central 500HEUS', 156 | 9094: 'Sunny Central 760CP', 157 | 9095: 'Sunny Central 720CP', 158 | 9096: 'Sunny Central 910CP', 159 | 9097: 'SMU8', 160 | 9098: 'STP 5000TL-20', 161 | 9099: 'STP 6000TL-20', 162 | 9100: 'STP 7000TL-20', 163 | 9101: 'STP 8000TL-10', 164 | 9102: 'STP 9000TL-20', 165 | 9103: 'STP 8000TL-20', 166 | 9104: 'SB 3000TL-JP-21', 167 | 9105: 'SB 3500TL-JP-21', 168 | 9106: 'SB 4000TL-JP-21', 169 | 9107: 'SB 4500TL-JP-21', 170 | 9108: 'SCSMC', 171 | 9109: 'SB 1600TL-10', 172 | 9110: 'SSM US', 173 | 9111: 'SMA radio-controlled socket', 174 | 9112: 'WB 2000HF-30', 175 | 9113: 'WB 2500HF-30', 176 | 9114: 'WB 3000HF-30', 177 | 9115: 'WB 2000HFUS-30', 178 | 9116: 'WB 2500HFUS-30', 179 | 9117: 'WB 3000HFUS-30', 180 | 9118: 'VIEW-10', 181 | 9119: 'Sunny Home Manager', 182 | 9120: 'SMID', 183 | 9121: 'Sunny Central 800HE-20', 184 | 9122: 'Sunny Central 630HE-20', 185 | 9123: 'Sunny Central 500HE-20', 186 | 9124: 'Sunny Central 720HE-20', 187 | 9125: 'Sunny Central 760HE-20', 188 | 9126: 'SMC 6000A-11', 189 | 9127: 'SMC 5000A-11', 190 | 9128: 'SMC 4600A-11', 191 | 9129: 'SB 3800-11', 192 | 9130: 'SB 3300-11', 193 | 9131: 'STP 20000TL-10', 194 | 9132: 'SMA CT Meter', 195 | 9133: 'SB 2000HFUS-32', 196 | 9134: 'SB 2500HFUS-32', 197 | 9135: 'SB 3000HFUS-32', 198 | 9136: 'WB 2000HFUS-32', 199 | 9137: 'WB 2500HFUS-32', 200 | 9138: 'WB 3000HFUS-32', 201 | 9139: 'STP 20000TLHE-10', 202 | 9140: 'STP 15000TLHE-10', 203 | 9141: 'SB 3000US-12', 204 | 9142: 'SB 3800US-12', 205 | 9143: 'SB 4000US-12', 206 | 9144: 'SB 5000US-12', 207 | 9145: 'SB 6000US-12', 208 | 9146: 'SB 7000US-12', 209 | 9147: 'SB 8000US-12', 210 | 9148: 'SB 8000TLUS-12', 211 | 9149: 'SB 9000TLUS-12', 212 | 9150: 'SB 10000TLUS-12', 213 | 9151: 'SB 11000TLUS-12', 214 | 9152: 'SB 7000TLUS-12', 215 | 9153: 'SB 6000TLUS-12', 216 | 9154: 'SB 1300TL-10', 217 | 9155: 'Sunny Backup 2200', 218 | 9156: 'Sunny Backup 5000', 219 | 9157: 'Sunny Island 2012', 220 | 9158: 'Sunny Island 2224', 221 | 9159: 'Sunny Island 5048', 222 | 9160: 'SB 3600TL-20', 223 | 9161: 'SB 3000TL-JP-22', 224 | 9162: 'SB 3500TL-JP-22', 225 | 9163: 'SB 4000TL-JP-22', 226 | 9164: 'SB 4500TL-JP-22', 227 | 9165: 'SB 3600TL-21', 228 | 9167: 'Cluster Controller', 229 | 9168: 'SC630HE-11', 230 | 9169: 'SC500HE-11', 231 | 9170: 'SC400HE-11', 232 | 9171: 'WB 3000TL-21', 233 | 9172: 'WB 3600TL-21', 234 | 9173: 'WB 4000TL-21', 235 | 9174: 'WB 5000TL-21', 236 | 9175: 'SC 250', 237 | 9176: 'SMA Meteo Station', 238 | 9177: 'SB 240-10', 239 | 9178: 'SB 240-US-10', 240 | 9179: 'Multigate-10', 241 | 9180: 'Multigate-US-10', 242 | 9181: 'STP 20000TLEE-10', 243 | 9182: 'STP 15000TLEE-10', 244 | 9183: 'SB 2000TLST-21', 245 | 9184: 'SB 2500TLST-21', 246 | 9185: 'SB 3000TLST-21', 247 | 9186: 'WB 2000TLST-21', 248 | 9187: 'WB 2500TLST-21', 249 | 9188: 'WB 3000TLST-21', 250 | 9189: 'WTP 5000TL-20', 251 | 9190: 'WTP 6000TL-20', 252 | 9191: 'WTP 7000TL-20', 253 | 9192: 'WTP 8000TL-20', 254 | 9193: 'WTP 9000TL-20', 255 | 9194: 'STP 12000TL-US-10', 256 | 9195: 'STP 15000TL-US-10', 257 | 9196: 'STP 20000TL-US-10', 258 | 9197: 'STP 24000TL-US-10', 259 | 9198: 'SB 3000TLUS-22', 260 | 9199: 'SB 3800TLUS-22', 261 | 9200: 'SB 4000TLUS-22', 262 | 9201: 'SB 5000TLUS-22', 263 | 9202: 'WB 3000TLUS-22', 264 | 9203: 'WB 3800TLUS-22', 265 | 9204: 'WB 4000TLUS-22', 266 | 9205: 'WB 5000TLUS-22', 267 | 9206: 'SC 500CP-JP', 268 | 9207: 'SC 850CP', 269 | 9208: 'SC 900CP', 270 | 9209: 'SC 850 CP-US', 271 | 9210: 'SC 900 CP-US', 272 | 9211: 'SC 619CP', 273 | 9212: 'SMA Meteo Station', 274 | 9213: 'SC 800 CP-US', 275 | 9214: 'SC 630 CP-US', 276 | 9215: 'SC 500 CP-US', 277 | 9216: 'SC 720 CP-US', 278 | 9217: 'SC 750 CP-US', 279 | 9218: 'SB 240 Dev', 280 | 9219: 'SB 240-US BTF', 281 | 9220: 'Grid Gate-20', 282 | 9221: 'SC 500 CP-US/600V', 283 | 9222: 'STP 10000TLEE-JP-10', 284 | 9223: 'Sunny Island 6.0H', 285 | 9224: 'Sunny Island 8.0H', 286 | 9225: 'SB 5000SE-10', 287 | 9226: 'SB 3600SE-10', 288 | 9227: 'SC 800CP-JP', 289 | 9228: 'SC 630CP-JP', 290 | 9229: 'WebBox-30', 291 | 9230: 'Power Reducer Box', 292 | 9231: 'Sunny Sensor Counter', 293 | 9232: 'Sunny Boy Control', 294 | 9233: 'Sunny Boy Control Plus', 295 | 9234: 'Sunny Boy Control Light', 296 | 9235: 'Sunny Central 100 Outdoor', 297 | 9236: 'Sunny Central 1000MV', 298 | 9237: 'Sunny Central 100 LV', 299 | 9238: 'Sunny Central 1120MV', 300 | 9239: 'Sunny Central 125 LV', 301 | 9240: 'Sunny Central 150', 302 | 9241: 'Sunny Central 200', 303 | 9242: 'Sunny Central 200 HE', 304 | 9243: 'Sunny Central 250 HE', 305 | 9244: 'Sunny Central 350', 306 | 9245: 'Sunny Central 350 HE', 307 | 9246: 'Sunny Central 400 HE', 308 | 9247: 'Sunny Central 400MV', 309 | 9248: 'Sunny Central 500 HE', 310 | 9249: 'Sunny Central 500MV', 311 | 9250: 'Sunny Central 560 HE', 312 | 9251: 'Sunny Central 630 HE', 313 | 9252: 'Sunny Central 700MV', 314 | 9253: 'Sunny Central', 315 | 9254: 'Sunny Island 3324', 316 | 9255: 'Sunny Island 4.0M', 317 | 9256: 'Sunny Island 4248', 318 | 9257: 'Sunny Island 4248U', 319 | 9258: 'Sunny Island 4500', 320 | 9259: 'Sunny Island 4548U', 321 | 9260: 'Sunny Island 5.4M', 322 | 9261: 'Sunny Island 5048U', 323 | 9262: 'Sunny Island 6048U', 324 | 9263: 'Sunny Mini Central 7000HV-11', 325 | 9264: 'Sunny Solar Tracker', 326 | 9265: 'Sunny Beam', 327 | 9266: 'Sunny Boy SWR 700/150', 328 | 9267: 'Sunny Boy SWR 700/200', 329 | 9268: 'Sunny Boy SWR 700/250', 330 | 9269: 'Sunny WebBox für SC', 331 | 9270: 'Sunny WebBox', 332 | 9271: 'STP 20000TLEE-JP-11', 333 | 9272: 'STP 10000TLEE-JP-11', 334 | 9273: 'SB 6000TL-21', 335 | 9274: 'SB 6000TL-US-22', 336 | 9275: 'SB 7000TL-US-22', 337 | 9276: 'SB 7600TL-US-22', 338 | 9277: 'SB 8000TL-US-22', 339 | 9278: 'SI 3.0M', 340 | 9279: 'SI 4.4M', 341 | 9281: 'STP 10000TL-20', 342 | 9282: 'STP 11000TL-20', 343 | 9283: 'STP 12000TL-20', 344 | 9284: 'STP 20000TL-30', 345 | 9285: 'STP 25000TL-30', 346 | 9286: 'SCS-500', 347 | 9287: 'SCS-630', 348 | 9288: 'SCS-720', 349 | 9289: 'SCS-760', 350 | 9290: 'SCS-800', 351 | 9291: 'SCS-850', 352 | 9292: 'SCS-900', 353 | 9293: 'SB 7700TL-US-22', 354 | 9294: 'SB20.0-3SP-40', 355 | 9295: 'SB30.0-3SP-40', 356 | 9296: 'SC 1000 CP', 357 | 9297: 'Zeversolar 1000', 358 | 9298: 'SC 2200-10', 359 | 9299: 'SC 2200-US-10', 360 | 9300: 'SC 2475-EV-10', 361 | 9301: 'SB1.5-1VL-40', 362 | 9302: 'SB2.5-1VL-40', 363 | 9303: 'SB2.0-1VL-40', 364 | 9304: 'SB5.0-1SP-US-40', 365 | 9305: 'SB6.0-1SP-US-40', 366 | 9306: 'SB8.0-1SP-US-40', 367 | 9307: 'Energy Meter', 368 | 9308: 'ZoneMonitoring', 369 | 9309: 'STP 27kTL-US-10', 370 | 9310: 'STP 30kTL-US-10', 371 | 9311: 'STP 25kTL-JP-30', 372 | 9312: 'SSM30', 373 | 9313: 'SB50.0-3SP-40', 374 | 9314: 'PlugwiseCircle', 375 | 9315: 'PlugwiseSting', 376 | 9316: 'SCS-1000', 377 | 9317: 'SB 5400TL-JP-22', 378 | 9319: 'SB3.0-1AV-40', 379 | 9320: 'SB3.6-1AV-40', 380 | 9321: 'SB4.0-1AV-40', 381 | 9322: 'SB5.0-1AV-40', 382 | 9324: 'SBS1.5-1VL-10', 383 | 9325: 'SBS2.0-1VL-10', 384 | 9326: 'SBS2.5-1VL-10', 385 | 9344: 'STP4.0-3AV-40', 386 | 9345: 'STP5.0-3AV-40', 387 | 9346: 'STP6.0-3AV-40', 388 | 9356: 'SBS3.7-10', 389 | 9358: 'SBS5.0-10', 390 | 9359: 'SBS6.0-10', 391 | 9360: 'SBS3.8-US-10', 392 | 9361: 'SBS5.0-US-10', 393 | 9362: 'SBS6.0-US-10', 394 | 9366: 'STP3.0-3AV-40', 395 | 9401: 'SB3.0-1AV-41', 396 | 9402: 'SB3.6-1AV-41', 397 | 9403: 'SB4.0-1AV-41', 398 | 9404: 'SB5.0-1AV-41', 399 | 9405: 'SB6.0-1AV-41' 400 | }, 401 | 'BatteryState': { 402 | 303: 'Off', 403 | 2291: 'Standby', 404 | 2292: 'Charging', 405 | 2293: 'Discharging', 406 | 16777213: 'NA' 407 | }, 408 | 'BatteryHealth': { 409 | 35: 'Fault', 410 | 303: 'Off', 411 | 307: 'OK', 412 | 455: 'Warning', 413 | 16777213: 'NA' 414 | } 415 | } 416 | 417 | 418 | def get_device_class(host, port, modbusid): 419 | client = ModbusClient(host=host, port=port) 420 | 421 | # connects even within if clause 422 | if client.connect() == False: 423 | print('Modbus Connection Error: Could not connect to', host) 424 | return None 425 | 426 | try: 427 | received = client.read_input_registers(address=30051, count=2, unit=3) 428 | except: 429 | thisdate = str(datetime.datetime.now()).partition('.')[0] 430 | thiserrormessage = thisdate + ': Connection not possible. Check settings or connection.' 431 | print(thiserrormessage) 432 | return None 433 | 434 | message = BinaryPayloadDecoder.fromRegisters(received.registers, byteorder=Endian.Big, wordorder=Endian.Big) 435 | interpreted = message.decode_32bit_uint() 436 | dclass = pvenums["DeviceClass"].get(interpreted) 437 | 438 | client.close() 439 | return dclass 440 | 441 | 442 | def get_pv_data(host, port, modbusid, registers): 443 | client = ModbusClient(host=host, port=port) 444 | 445 | # connects even within if clause 446 | if client.connect() == False: 447 | print('Modbus Connection Error: Could not connect to', host) 448 | return None 449 | 450 | data = {} ## empty data store for current values 451 | 452 | for myreg in registers: 453 | ## if the connection is somehow not possible (e.g. target not responding) 454 | # show a error message instead of excepting and stopping 455 | try: 456 | addr = int(myreg[0]) 457 | dt = myreg[1] 458 | received = client.read_input_registers(address=addr, count=modbusdatatype[dt], unit=int(modbusid)) 459 | except Exception as e: 460 | thisdate = str(datetime.datetime.now()).partition('.')[0] 461 | thiserrormessage = thisdate + 'Modbus: Connection not possible. Check settings or connection.' 462 | print(thiserrormessage) 463 | print(traceback.format_exc()) 464 | return None ## prevent further execution of this function 465 | 466 | name = myreg[3] 467 | message = BinaryPayloadDecoder.fromRegisters(received.registers, byteorder=Endian.Big, wordorder=Endian.Big) 468 | ## provide the correct result depending on the defined datatype 469 | if myreg[1] == 'S32': 470 | interpreted = message.decode_32bit_int() 471 | elif myreg[1] == 'U32': 472 | interpreted = message.decode_32bit_uint() 473 | elif myreg[1] == 'U64': 474 | interpreted = message.decode_64bit_uint() 475 | elif myreg[1] == 'STR32': 476 | interpreted = message.decode_string(32) 477 | elif myreg[1] == 'S16': 478 | interpreted = message.decode_16bit_int() 479 | elif myreg[1] == 'U16': 480 | interpreted = message.decode_16bit_uint() 481 | else: ## if no data type is defined do raw interpretation of the delivered data 482 | interpreted = message.decode_16bit_uint() 483 | 484 | ## check for "None" data before doing anything else 485 | if ((interpreted == MIN_SIGNED) or (interpreted == MAX_UNSIGNED)): 486 | value = None 487 | else: 488 | ## put the data with correct formatting into the data table 489 | if myreg[2] == 'FIX3': 490 | value = float(interpreted) / 1000 491 | elif myreg[2] == 'FIX2': 492 | value = float(interpreted) / 100 493 | elif myreg[2] == 'FIX1': 494 | value = float(interpreted) / 10 495 | elif myreg[2] == 'UTF8': 496 | value = str(interpreted,'UTF-8',errors='ignore').rstrip("\x00") 497 | elif myreg[2] == 'ENUM': 498 | e = pvenums.get(name, {}) 499 | value = e.get(interpreted, str(interpreted)) 500 | else: 501 | value = interpreted 502 | data[name] = value 503 | 504 | client.close() 505 | return data 506 | -------------------------------------------------------------------------------- /features-outdated/symcon.py: -------------------------------------------------------------------------------- 1 | """ 2 | Send SMA values to symcon (www.symcon.de) via web hook 3 | need Symcon 4.0+ 4 | 5 | 2018-12-23 Tommi2Day 6 | 2020-09-22 Tommi2Day fix empty pv data 7 | 2021-01-07 sellth added support for multiple inverters 8 | 9 | Configuration: 10 | [FEATURE-symcon] 11 | # symcon 12 | host=ips 13 | port=3777 14 | emhook=/hook/smaem 15 | pvhook=/hook/smawr 16 | timeout=5 17 | user= 18 | password= 19 | fields=pconsume,psupply,p1consume,p2consume,p3consume,p1supply,p2supply,p3supply 20 | pvfields=AC Power,AC Voltage,grid frequency,DC Power,DC input voltage,daily yield,total yield,Power L1,Power L2,Power L3 21 | 22 | # How frequently to send updates over (defaults to 20 sec) 23 | min_update=30 24 | 25 | debug=0 26 | 27 | """ 28 | 29 | import urllib.request, urllib.error 30 | import json 31 | import time 32 | import platform 33 | 34 | symcon_last_update = 0 35 | symcon_debug = 0 36 | 37 | 38 | def run(emparts, config): 39 | global symcon_last_update 40 | global symcon_debug 41 | 42 | # Only update every X seconds 43 | if time.time() < symcon_last_update + int(config.get('min_update', 20)): 44 | if (symcon_debug > 1): 45 | print("Symcon: data skipping") 46 | return 47 | 48 | # prepare hook settings 49 | host = config.get('host', 'ips') 50 | port = config.get('port', 3777) 51 | timeout = config.get('timeout', 5) 52 | emhook = config.get('emhook', '/hook/smaem') 53 | user = config.get('user', None) 54 | password = config.get('password', None) 55 | fields = config.get('fields', 'pconsume,psupply') 56 | 57 | # mqtt client settings 58 | myhostname = platform.node() 59 | symcon_last_update = time.time() 60 | 61 | url = 'http://' + host + ':' + str(port) + emhook 62 | 63 | serial = emparts['serial'] 64 | data = {} 65 | for f in fields.split(','): 66 | data[f] = emparts.get(f, 0) 67 | 68 | data['timestamp'] = symcon_last_update 69 | data['sender'] = myhostname 70 | data['serial'] = str(serial) 71 | payload = json.dumps(data) 72 | 73 | # prepare request 74 | req = urllib.request.Request(url) 75 | req.add_header('Content-Type', 'application/json; charset=utf-8') 76 | dataasbytes = payload.encode('utf-8') # needs to be bytes 77 | req.add_header('Content-Length', str(len(dataasbytes))) 78 | # print(dataasbytes) 79 | req.add_header("User-Agent", "SMEM") 80 | 81 | # prepare auth 82 | if None not in [user, password]: 83 | passman = urllib.request.HTTPPasswordMgrWithDefaultRealm() 84 | passman.add_password(None, url, user, password) 85 | authhandler = urllib.request.HTTPBasicAuthHandler(passman) 86 | opener = urllib.request.build_opener(authhandler) 87 | urllib.request.install_opener(opener) 88 | if symcon_debug > 2: 89 | print('Symcon EM: use Basic auth') 90 | 91 | # send it 92 | try: 93 | response = urllib.request.urlopen(req, data=dataasbytes, timeout=int(timeout)) 94 | 95 | except urllib.error.HTTPError as e: 96 | if symcon_debug > 0: 97 | print('Symcon EM: HTTPError: {%s} to %s' % (format(e.code), url)) 98 | pass 99 | except urllib.error.URLError as e: 100 | if symcon_debug > 0: 101 | print('Symcon EM: URLError: {%s} to %s ' % (format(e.reason), url)) 102 | pass 103 | except Exception as e: 104 | if symcon_debug > 0: 105 | print("Symcon EM: Error from symcon request") 106 | print(e) 107 | pass 108 | else: 109 | if symcon_debug > 0: 110 | print("Symcon EM: data published %s:%s to %s" % ( 111 | format(time.strftime("%H:%M:%S", time.localtime(symcon_last_update))), payload, url)) 112 | 113 | # send pv data 114 | data = {} 115 | pvhook = config.get('pvhook') 116 | pvfields = config.get('pvfields', 'AC Power,daily yield') 117 | if None in [pvhook, pvfields]: return 118 | pvurl = 'http://' + host + ':' + str(port) + pvhook 119 | 120 | try: 121 | from features.pvdata import pv_data 122 | except: 123 | if symcon_debug > 0: 124 | print("Symcon EM: Error importing PV Data") 125 | return 126 | if pv_data == None: 127 | if symcon_debug > 0: 128 | print("Symcon EM: No PV Data") 129 | 130 | return 131 | 132 | # prepare auth 133 | if None not in [user, password]: 134 | passman = urllib.request.HTTPPasswordMgrWithDefaultRealm() 135 | passman.add_password(None, pvurl, user, password) 136 | authhandler = urllib.request.HTTPBasicAuthHandler(passman) 137 | opener = urllib.request.build_opener(authhandler) 138 | urllib.request.install_opener(opener) 139 | if symcon_debug > 2: 140 | print('Symcon PV: use Basic auth') 141 | 142 | # prepare request 143 | pvreq = urllib.request.Request(pvurl) 144 | pvreq.add_header('Content-Type', 'application/json; charset=utf-8') 145 | pvreq.add_header("User-Agent", "SMWR") 146 | 147 | for inv in pv_data: 148 | serial = inv.get("serial") 149 | if serial is not None: 150 | for f in pvfields.split(','): 151 | data[f] = inv.get(f, 0) 152 | 153 | data['timestamp'] = symcon_last_update 154 | data['sender'] = myhostname 155 | data['serial'] = str(serial) 156 | pvpayload = json.dumps(data) 157 | pvdataasbytes = pvpayload.encode('utf-8') # needs to be bytes 158 | pvreq.add_header('Content-Length', str(len(pvdataasbytes))) 159 | # print(dataasbytes) 160 | 161 | # send it 162 | try: 163 | response = urllib.request.urlopen(pvreq, data=pvdataasbytes, timeout=int(timeout)) 164 | except urllib.error.HTTPError as e: 165 | if symcon_debug > 0: 166 | print('Symcon PV : HTTPError: {%s} to %s ' % (format(e.reason), pvurl)) 167 | pass 168 | except urllib.error.URLError as e: 169 | if symcon_debug > 0: 170 | print('Symcon PV: URLError: {%s} to %s ' % (format(e.reason), pvurl)) 171 | pass 172 | except Exception as e: 173 | if symcon_debug > 0: 174 | print("Symcon PV: Error from symcon request") 175 | print(e) 176 | pass 177 | else: 178 | if symcon_debug > 0: 179 | print("Symcon PV: data published %s:%s to %s" % ( 180 | format(time.strftime("%H:%M:%S", time.localtime(symcon_last_update))), pvpayload, pvurl)) 181 | 182 | 183 | def stopping(emparts, config): 184 | pass 185 | 186 | 187 | def config(config): 188 | global symcon_debug 189 | symcon_debug = int(config.get('debug', 0)) 190 | print('symcon: feature enabled') 191 | -------------------------------------------------------------------------------- /features-outdated/symcon_smaem_webhook.php: -------------------------------------------------------------------------------- 1 | array('type'=>3,'profile'=>''), 70 | 'pconsume'=>array('type'=>2,'profile'=>'~Watt.14490'), 71 | 'p1consume'=>array('type'=>2,'profile'=>'~Watt.14490'), 72 | 'p2consume'=>array('type'=>2,'profile'=>'~Watt.14490'), 73 | 'p3consume'=>array('type'=>2,'profile'=>'~Watt.14490'), 74 | 'psupply'=>array('type'=>2,'profile'=>'~Watt.14490'), 75 | 'psupply'=>array('type'=>2,'profile'=>'~Watt.14490'), 76 | 'p1supply'=>array('type'=>2,'profile'=>'~Watt.14490'), 77 | 'p2supply'=>array('type'=>2,'profile'=>'~Watt.14490'), 78 | 'p3supply'=>array('type'=>2,'profile'=>'~Watt.14490'), 79 | 'pconsumecounter'=>array('type'=>2,'profile'=>'~Electricity'), 80 | 'psupplycounter'=>array('type'=>2,'profile'=>'~Electricity'), 81 | 'timestamp'=>array('type'=>1,'profile'=>'~UnixTimestamp') 82 | ); 83 | //auth only if called by webhook 84 | //IPS_LogMessage('SMA-EM WebHook Server',print_r($_SERVER,true)); 85 | if (($_IPS['SENDER']=='WebHook') && $hook_user && $hook_password){ 86 | if(!isset($_SERVER['PHP_AUTH_USER'])) 87 | $_SERVER['PHP_AUTH_USER'] = ""; 88 | if(!isset($_SERVER['PHP_AUTH_PW'])) 89 | $_SERVER['PHP_AUTH_PW'] = ""; 90 | 91 | if(($_SERVER['PHP_AUTH_USER'] != $hook_user) || ($_SERVER['PHP_AUTH_PW'] != $hook_password)) { 92 | header('WWW-Authenticate: Basic Realm="SMA-EM WebHook"'); 93 | header('HTTP/1.0 401 Unauthorized'); 94 | echo "Authorization required"; 95 | return; 96 | } 97 | 98 | $raw=file_get_contents("php://input"); 99 | }else { 100 | $raw=$test; 101 | } 102 | 103 | //sanity 104 | $data=@json_decode($raw); 105 | if (!is_object($data)) { 106 | IPS_LogMessage('SMA-EM WebHook','json_decode error'.print_r($raw,true)); 107 | return; 108 | } 109 | 110 | if (!isset($data->{'serial'})) { 111 | IPS_LogMessage('SMA-EM WebHook','missing serial field'.print_r($data,true)); 112 | return; 113 | } 114 | $serial=$data->{'serial'}; 115 | $varids=get_ips_vars($serial,$vartypes,$cat,$prefix); 116 | if (is_null($varids)) { 117 | IPS_LogMessage($cat, "cannot get ids for device $serial"); 118 | print($cat. " cannot get ids for device $serial"); 119 | //no vars available, maybe autocreate disabled 120 | return; 121 | } 122 | $fields=array_keys($vartypes); 123 | foreach ($fields as $f) { 124 | if (isset($data->{"$f"})) { 125 | $ident=fix_ident($f); 126 | SetValue($varids["$ident"]['id'],$data->{"$f"}); 127 | } 128 | 129 | } 130 | return; 131 | 132 | //----- end main --------------------------------------- 133 | 134 | /** 135 | * function fix_ident 136 | * remove unwanted chars from name for ips_setIdent 137 | * @param string $name 138 | * @returns string 139 | */ 140 | function fix_ident($name) { 141 | $chars=array(" ","_","-","%"); 142 | $ident=str_replace($chars,"",$name); 143 | return $ident; 144 | } 145 | /** 146 | * IPS Variablen handler 147 | * creates variables as needed 148 | * returns assoc. Array with IPS Variable ID and Value 149 | * @param string $serial Device serial 150 | * @param array $vartypes Array with Variable Names, Types and Profiles 151 | * @param string $cat Master Categorie Name 152 | * @param string $prefix default name, will be extended with $addr 153 | */ 154 | function get_ips_vars($serial,$vartypes,$cat,$prefix) { 155 | 156 | $varids=null; 157 | $master=@IPS_GetCategoryIDByName($cat,0); 158 | //no master cat, create new 159 | if (!$master) { 160 | $master=IPS_CreateCategory(); 161 | IPS_SetName($master,$cat); 162 | IPS_SetParent($master,0); 163 | if ($master>0) { 164 | IPS_LogMessage($cat, "Master category created, ID=$master\n"); 165 | }else{ 166 | IPS_LogMessage($cat, "Can't create Master Category\n"); 167 | return null; 168 | } 169 | } 170 | 171 | $id=0; 172 | 173 | if ($master>0) { 174 | //get chilren devices 175 | $devices=IPS_GetChildrenIDs($master); 176 | foreach($devices as $dev) { 177 | $name=IPS_GetName($dev); 178 | 179 | $vars=IPS_GetChildrenIDs($dev); 180 | foreach($vars as $vid) { 181 | $obj=IPS_GetObject($vid); 182 | $vname=$obj['ObjectIdent']; 183 | $typ=$obj['ObjectType']; 184 | if ($typ==2) { //Variable 185 | //if ID, here is the address 186 | if ($vname="serial") { 187 | $i=GetValue($vid); 188 | //go out if matches, $id returns the sensor categorie id 189 | if ($i===$serial) { 190 | $id=$dev; 191 | break; 192 | } 193 | } 194 | } 195 | } 196 | if ($id>0) break; 197 | } 198 | if ($id==0) { 199 | //Sensor with address $addr not found in IPS 200 | if ($GLOBALS['autocreate']==false) { 201 | //autocreate disable, ignore new device 202 | return null; 203 | } 204 | //create new sensor 205 | $id=ips_createCategory(); 206 | ips_setName($id,$prefix.' '.$serial); 207 | $ident=fix_ident($prefix.$serial); 208 | ips_setIdent($id,$ident); 209 | ips_setParent($id,$master); 210 | //creates all needed variables for the new sensor 211 | foreach (array_keys($vartypes) as $name) { 212 | $ident=fix_ident($name); 213 | $typ=$vartypes["$name"]['type']; 214 | $profile=$vartypes["$name"]['profile']; 215 | $vid=IPS_CreateVariable($typ); 216 | ips_setname($vid,"$name"); 217 | ips_setident($vid,"$ident"); 218 | ips_setParent($vid,$id); 219 | IPS_SetVariableCustomProfile($vid,$profile); 220 | //preload variables 221 | SetValue($vid,0); 222 | $varids["$ident"]['id']=$vid; 223 | $varids["$ident"]['val']=0; 224 | //Store address in $ID for next time 225 | if ($name=='serial') { 226 | SetValue($vid,$serial); 227 | $varids["$ident"]['val']=$serial; 228 | } 229 | } 230 | }else{ 231 | //found matching cat, collect ids and vals for this sensor 232 | $vars=IPS_GetChildrenIDs($id); 233 | foreach($vars as $vid) { 234 | $obj=IPS_GetObject($vid); 235 | $ident=$obj['ObjectIdent']; 236 | $typ=$obj['ObjectType']; 237 | if ($typ==2) { //Variable 238 | $val=GetValue($vid); 239 | $varids["$ident"]['id']=$vid; 240 | $varids["$ident"]['val']=$val; 241 | } 242 | 243 | } 244 | 245 | } 246 | //returns IDs and Values of this Sensor, Name is Key 247 | return $varids; 248 | } 249 | } 250 | 251 | /** 252 | * list existing device categories 253 | * will be used for deletion 254 | * @param $catname master category 255 | * @return array of devices=>id 256 | */ 257 | function list_cats($catname) { 258 | $master=@IPS_GetCategoryIDByName($catname,0); 259 | $ret=null; 260 | if ($master>0) { 261 | //get chilren sensors 262 | $devices=IPS_GetChildrenIDs($master); 263 | foreach($devices as $ids) { 264 | $name=IPS_GetName($ids); 265 | $ret{$name}=$ids; 266 | } 267 | } 268 | return $ret; 269 | } 270 | -------------------------------------------------------------------------------- /features-outdated/symcon_smawr_webhook.php: -------------------------------------------------------------------------------- 1 | array('type'=>3,'profile'=>''), 70 | 'Status'=>array('type'=>3,'profile'=>''), 71 | 'AC Power'=>array('type'=>2,'profile'=>'~Watt.14490'), 72 | 'DC input voltage'=>array('type'=>2,'profile'=>'~Volt'), 73 | 'Power L1'=>array('type'=>2,'profile'=>'~Watt.14490'), 74 | 'Power L2'=>array('type'=>2,'profile'=>'~Watt.14490'), 75 | 'Power L3'=>array('type'=>2,'profile'=>'~Watt.14490'), 76 | 'daily yield'=>array('type'=>2,'profile'=>'~Electricity'), 77 | 'total yield'=>array('type'=>2,'profile'=>'~Electricity'), 78 | 'grid frequency'=>array('type'=>2,'profile'=>'~Hertz.50'), 79 | 'timestamp'=>array('type'=>1,'profile'=>'~UnixTimestamp') 80 | ); 81 | 82 | //auth only if called by webhook 83 | //IPS_LogMessage('SMA-WR WebHook Server',print_r($_SERVER,true)); 84 | if (($_IPS['SENDER']=='WebHook') && $hook_user && $hook_password){ 85 | if(!isset($_SERVER['PHP_AUTH_USER'])) 86 | $_SERVER['PHP_AUTH_USER'] = ""; 87 | if(!isset($_SERVER['PHP_AUTH_PW'])) 88 | $_SERVER['PHP_AUTH_PW'] = ""; 89 | 90 | if(($_SERVER['PHP_AUTH_USER'] != $hook_user) || ($_SERVER['PHP_AUTH_PW'] != $hook_password)) { 91 | header('WWW-Authenticate: Basic Realm="SMA-WR WebHook"'); 92 | header('HTTP/1.0 401 Unauthorized'); 93 | echo "Authorization required"; 94 | return; 95 | } 96 | 97 | $raw=file_get_contents("php://input"); 98 | }else { 99 | $raw=$test; 100 | } 101 | 102 | //sanity 103 | 104 | $data=@json_decode($raw); 105 | if (!is_object($data)) { 106 | IPS_LogMessage('SMA-WR WebHook','json_decode error'.print_r($raw,true)); 107 | return; 108 | } 109 | 110 | if (!isset($data->{'serial'})) { 111 | IPS_LogMessage('SMA-WR WebHook','missing serial field'.print_r($data,true)); 112 | return; 113 | } 114 | 115 | $serial=$data->{'serial'}; 116 | $varids=get_ips_vars($serial,$vartypes,$cat,$prefix); 117 | if (is_null($varids)) { 118 | IPS_LogMessage($cat, "cannot get ids for device $serial"); 119 | print($cat. " cannot get ids for device $serial"); 120 | //no vars available, maybe autocreate disabled 121 | return; 122 | } 123 | $fields=array_keys($vartypes); 124 | foreach ($fields as $f) { 125 | if (isset($data->{"$f"})) { 126 | $ident=fix_ident($f); 127 | SetValue($varids["$ident"]['id'],$data->{"$f"}); 128 | } 129 | } 130 | return; 131 | //----- end main --------------------------------------- 132 | 133 | /** 134 | * function fix_ident 135 | * remove unwanted chars from name for ips_setIdent 136 | * @param string $name 137 | * @returns string 138 | */ 139 | function fix_ident($name) { 140 | $chars=array(" ","_","-","%"); 141 | $ident=str_replace($chars,"",$name); 142 | return $ident; 143 | } 144 | /** 145 | * IPS Variablen handler 146 | * creates variables as needed 147 | * returns assoc. Array with IPS Variable ID and Value 148 | * @param string $serial Device serial 149 | * @param array $vartypes Array with Variable Names, Types and Profiles 150 | * @param string $cat Master Categorie Name 151 | * @param string $prefix default name, will be extended with $addr 152 | */ 153 | function get_ips_vars($serial,$vartypes,$cat,$prefix) { 154 | 155 | $varids=null; 156 | $master=@IPS_GetCategoryIDByName($cat,0); 157 | //no master cat, create new 158 | if (!$master) { 159 | $master=IPS_CreateCategory(); 160 | IPS_SetName($master,$cat); 161 | IPS_SetParent($master,0); 162 | if ($master>0) { 163 | IPS_LogMessage($cat, "Master category created, ID=$master\n"); 164 | }else{ 165 | IPS_LogMessage($cat, "Can't create Master Category\n"); 166 | return null; 167 | } 168 | } 169 | 170 | $id=0; 171 | 172 | if ($master>0) { 173 | //get chilren devices 174 | $devices=IPS_GetChildrenIDs($master); 175 | foreach($devices as $dev) { 176 | $name=IPS_GetName($dev); 177 | 178 | $vars=IPS_GetChildrenIDs($dev); 179 | foreach($vars as $vid) { 180 | $obj=IPS_GetObject($vid); 181 | $vname=$obj['ObjectIdent']; 182 | $typ=$obj['ObjectType']; 183 | if ($typ==2) { //Variable 184 | //if ID, here is the address 185 | if ($vname="serial") { 186 | $i=GetValue($vid); 187 | //go out if matches, $id returns the sensor categorie id 188 | if ($i===$serial) { 189 | $id=$dev; 190 | break; 191 | } 192 | } 193 | } 194 | } 195 | if ($id>0) break; 196 | } 197 | if ($id==0) { 198 | //Sensor with address $addr not found in IPS 199 | if ($GLOBALS['autocreate']==false) { 200 | //autocreate disable, ignore new device 201 | return null; 202 | } 203 | //create new sensor 204 | $id=ips_createCategory(); 205 | ips_setName($id,$prefix.' '.$serial); 206 | $ident=fix_ident($prefix.$serial); 207 | ips_setIdent($id,$ident); 208 | ips_setParent($id,$master); 209 | //creates all needed variables for the new sensor 210 | foreach (array_keys($vartypes) as $name) { 211 | $ident=fix_ident($name); 212 | $typ=$vartypes["$name"]['type']; 213 | $profile=$vartypes["$name"]['profile']; 214 | $vid=IPS_CreateVariable($typ); 215 | ips_setname($vid,"$name"); 216 | ips_setident($vid,"$ident"); 217 | ips_setParent($vid,$id); 218 | IPS_SetVariableCustomProfile($vid,$profile); 219 | //preload variables 220 | SetValue($vid,0); 221 | $varids["$ident"]['id']=$vid; 222 | $varids["$ident"]['val']=0; 223 | //Store address in $ID for next time 224 | if ($name=='serial') { 225 | SetValue($vid,$serial); 226 | $varids["$ident"]['val']=$serial; 227 | } 228 | } 229 | }else{ 230 | //found matching cat, collect ids and vals for this sensor 231 | $vars=IPS_GetChildrenIDs($id); 232 | foreach($vars as $vid) { 233 | $obj=IPS_GetObject($vid); 234 | $ident=$obj['ObjectIdent']; 235 | $typ=$obj['ObjectType']; 236 | if ($typ==2) { //Variable 237 | $val=GetValue($vid); 238 | $varids["$ident"]['id']=$vid; 239 | $varids["$ident"]['val']=$val; 240 | } 241 | 242 | } 243 | 244 | } 245 | //returns IDs and Values of this Sensor, Name is Key 246 | return $varids; 247 | } 248 | } 249 | 250 | /** 251 | * list existing device categories 252 | * will be used for deletion 253 | * @param $catname master category 254 | * @return array of devices=>id 255 | */ 256 | function list_cats($catname) { 257 | $master=@IPS_GetCategoryIDByName($catname,0); 258 | $ret=null; 259 | if ($master>0) { 260 | //get chilren sensors 261 | $devices=IPS_GetChildrenIDs($master); 262 | foreach($devices as $ids) { 263 | $name=IPS_GetName($ids); 264 | $ret{$name}=$ids; 265 | } 266 | } 267 | return $ret; 268 | } 269 | -------------------------------------------------------------------------------- /features/README.md: -------------------------------------------------------------------------------- 1 | # SMA-EM daemon features 2 | 3 | this page should give an overview of maintained features. 4 | other features untested because I do not have the appropriate hardware / software could be found in features-outdated. 5 | 6 | All the desired features must be activated in the configuration file 7 | ``` 8 | [SMA-EM] 9 | # list of features to load/run 10 | features=simplefswriter nextfeature 11 | ``` 12 | Each feature has it own configuration section in the configuration-file. 13 | 14 | [FEATURE-featurename] 15 | 16 | please have a look at the config.sample file or have a look at the features file (description) for supported configuration options. 17 | 18 | ``` 19 | [FEATURE-simplefswriter] 20 | # list serials simplefswriter notice 21 | serials=1900204522 22 | # measurement vars simplefswriter should write to filesystem (only from smas with serial in serials) 23 | values=pconsume psupply qsupply ssupply 24 | ``` 25 | 26 | Feature fist 27 | 28 | ## mqtt.py 29 | send SMA-measurement-values to an mqtt broker. 30 | 31 | ## simplefswriter.py 32 | writes configureable measurement-values to the filesystem 33 | 34 | -------------------------------------------------------------------------------- /features/mqtt.py: -------------------------------------------------------------------------------- 1 | """ 2 | Send SMA values to mqtt broker. 3 | 4 | 2018-12-23 Tommi2Day 5 | 2019-03-02 david-m-m 6 | 2020-09-22 Tommi2Day ssl support 7 | 2021-01-07 sellth added support for multiple inverters 8 | 9 | Configuration: 10 | 11 | [FEATURE-mqtt] 12 | # MQTT broker details 13 | mqtthost=mqtt 14 | mqttport=1883 15 | #mqttuser= 16 | #mqttpass= 17 | mqttfields=pconsume,psupply,p1consume,p2consume,p3consume,p1supply,p2supply,p3supply 18 | #topic will be exted3ed with serial 19 | mqtttopic=SMA-EM/status 20 | pvtopic=SMA-PV/status 21 | # publish all values as single topics (0 or 1) 22 | publish_single=1 23 | # How frequently to send updates over (defaults to 20 sec) 24 | min_update=30 25 | #debug output 26 | debug=0 27 | 28 | # ssl support 29 | # adopt mqttport above to your ssl enabled mqtt port, usually 8883 30 | # options: 31 | # activate without certs=use tls_insecure 32 | # activate with ca_file, but without client_certs 33 | ssl_activate=0 34 | # ca file to verify 35 | ssl_ca_file=ca.crt 36 | # client certs 37 | ssl_certfile= 38 | ssl_keyfile= 39 | #TLSv1.1 or TLSv1.2 (default 2) 40 | tls_protocol=2 41 | 42 | """ 43 | 44 | import paho.mqtt.client as mqtt 45 | import platform 46 | import json 47 | import time 48 | import ssl 49 | import traceback 50 | 51 | mqtt_last_update = 0 52 | mqtt_debug = 0 53 | 54 | 55 | def run(emparts, config): 56 | global mqtt_last_update 57 | global mqtt_debug 58 | 59 | # Only update every X seconds 60 | if time.time() < mqtt_last_update + int(config.get('min_update', 20)): 61 | if (mqtt_debug > 1): 62 | print("mqtt: data skipping") 63 | return 64 | 65 | # prepare mqtt settings 66 | mqtthost = config.get('mqtthost', 'mqtt') 67 | mqttport = config.get('mqttport', 1883) 68 | mqttuser = config.get('mqttuser', None) 69 | mqttpass = config.get('mqttpass', None) 70 | mqtttopic = config.get('mqtttopic', "SMA-EM/status") 71 | mqttfields = config.get('mqttfields', 'pconsume,psupply') 72 | publish_single = int(config.get('publish_single', 0)) 73 | 74 | ssl_activate = config.get('ssl_activate', False) 75 | ssl_ca_file = config.get('ssl_ca_file', None) 76 | ssl_certfile = config.get('ssl_certfile', None) 77 | ssl_keyfile = config.get('ssl_keyfile', None) 78 | tls_protocol = config.get('tls_protocol', "2") 79 | if tls_protocol == "1": 80 | tls = ssl.PROTOCOL_TLSv1_1 81 | elif tls_protocol == "2": 82 | tls = ssl.PROTOCOL_TLSv1_2 83 | else: 84 | tls = ssl.PROTOCOL_TLSv1_2 85 | if mqtt_debug > 0: 86 | print("tls_protocol %s unsupported, use (TLSv1.)2" % tls_protocol) 87 | 88 | # mqtt client settings 89 | myhostname = platform.node() 90 | mqtt_clientID = 'SMA-EM@' + myhostname 91 | client = mqtt.Client(mqtt_clientID) 92 | if None not in [mqttuser, mqttpass]: 93 | client.username_pw_set(username=mqttuser, password=mqttpass) 94 | 95 | if ssl_activate == "1": 96 | # and ssl_ca_file: 97 | if ssl_certfile and ssl_keyfile and ssl_ca_file: 98 | # use client cert 99 | client.tls_set(ssl_ca_file, certfile=ssl_certfile, keyfile=ssl_keyfile, tls_version=tls) 100 | if mqtt_debug > 0: 101 | print("mqtt: ssl ca and client verify enabled") 102 | elif ssl_ca_file: 103 | # no client cert 104 | client.tls_set(ssl_ca_file, tls_version=tls) 105 | if mqtt_debug > 0: 106 | print("mqtt: ssl ca verify enabled") 107 | else: 108 | # disable certificat verify as there is no certificate 109 | client.tls_set(tls_version=tls) 110 | client.tls_insecure_set(True) 111 | if mqtt_debug > 0: 112 | print("mqtt: ssl verify disabled") 113 | else: 114 | if mqtt_debug > 0: 115 | print("mqtt: ssl disabled") 116 | 117 | # last aupdate 118 | # last aupdate 119 | mqtt_last_update = time.time() 120 | 121 | serial = emparts['serial'] 122 | data = {} 123 | for f in mqttfields.split(','): 124 | data[f] = emparts.get(f, 0) 125 | 126 | # add pv data 127 | pvpower = 0 128 | daily = 0 129 | try: 130 | from features.pvdata import pv_data 131 | 132 | for inv in pv_data: 133 | # handle missing data during night hours 134 | if inv.get("AC Power") is None: 135 | pass 136 | elif inv.get("DeviceClass") == "Solar Inverter": 137 | pvpower += inv.get("AC Power", 0) 138 | # NOTE: daily yield is broken for some inverters 139 | daily += inv.get("daily yield", 0) 140 | 141 | pconsume = emparts.get('pconsume', 0) 142 | psupply = emparts.get('psupply', 0) 143 | pusage = pvpower + pconsume - psupply 144 | data['pvsum'] = pvpower 145 | data['pusage'] = pusage 146 | data['pvdaily'] = daily 147 | except: 148 | pv_data = None 149 | pass 150 | 151 | data['timestamp'] = mqtt_last_update 152 | payload = json.dumps(data) 153 | topic = mqtttopic + '/' + str(serial) 154 | try: 155 | # mqtt connect 156 | client.connect(str(mqtthost), int(mqttport)) 157 | client.loop_start() 158 | client.publish(topic, payload) 159 | if mqtt_debug > 0: 160 | print("mqtt: sma-em topic %s data published %s:%s" % (topic, 161 | format(time.strftime("%H:%M:%S", time.localtime( 162 | mqtt_last_update))), payload)) 163 | # publish each value as separate topic 164 | if publish_single == 1: 165 | for item in data.keys(): 166 | itemtopic = topic + '/' + item 167 | if mqtt_debug > 0: 168 | print("mqtt: publishing %s:%s" % (itemtopic, data[item])) 169 | client.publish(itemtopic, str(data[item])) 170 | 171 | # pvoption 172 | mqttpvtopic = config.get('pvtopic', None) 173 | if None not in [pv_data, mqttpvtopic]: 174 | if pv_data is not None: 175 | for inv in pv_data: 176 | pvserial = inv.get("serial") 177 | pvtopic = mqttpvtopic + '/' + str(pvserial) 178 | payload = json.dumps(inv) 179 | # sendf pv topic 180 | client.publish(pvtopic, payload) 181 | if mqtt_debug > 0: 182 | print("mqtt: sma-pv topic %s data published %s:%s" % ( 183 | pvtopic, 184 | format(time.strftime("%H:%M:%S", 185 | time.localtime( 186 | mqtt_last_update))), 187 | payload)) 188 | client.loop_stop() 189 | client.disconnect() 190 | 191 | except Exception as e: 192 | print("mqtt: Error publishing") 193 | print(traceback.format_exc()) 194 | pass 195 | 196 | 197 | def stopping(emparts, config): 198 | pass 199 | 200 | 201 | def config(config): 202 | global mqtt_debug 203 | mqtt_debug = int(config.get('debug', 0)) 204 | print('mqtt: feature enabled') 205 | -------------------------------------------------------------------------------- /features/simplefswriter.py: -------------------------------------------------------------------------------- 1 | """ 2 | * Feature-Module for SMA-EM daemon 3 | * Simple measurement to file writer 4 | * by Wenger Florian 2018-01-30 5 | * 6 | * 7 | * this software is released under GNU General Public License, version 2. 8 | * This program is free software; 9 | * you can redistribute it and/or modify it under the terms of the GNU General Public License 10 | * as published by the Free Software Foundation; version 2 of the License. 11 | * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; 12 | * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 13 | * See the GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License along with this program; 16 | * if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 17 | * 18 | */ 19 | """ 20 | 21 | import os,time 22 | sw_debug=0 23 | 24 | def run(emparts,config): 25 | global sw_debug 26 | values=config['values'].split(' ') 27 | serials=config['serials'].split(' ') 28 | statusdir = config.get('statusdir','') 29 | #prefere shm 30 | if (statusdir==''): 31 | statusdir="/run/shm/" 32 | #fallback to local dir 33 | if not os.path.isdir(statusdir): 34 | statusdir='' 35 | for serial in serials: 36 | if serial==format(emparts['serial']): 37 | ts=(format(time.strftime("%H:%M:%S", time.localtime()))) 38 | for value in values: 39 | if value in emparts.keys(): 40 | if sw_debug >0: 41 | print ('simplewriter: '+ts+" - "+format(value)+': '+('%.4f' % emparts[value])) 42 | file = open(statusdir+"em-"+format(serial)+"-"+format(value), "w") 43 | file.write('%.4f' % emparts[value]) 44 | file.close() 45 | elif sw_debug > 0: 46 | print ('simplefswriter: could not find value for '+format(value)) 47 | 48 | def stopping(emparts,config): 49 | print("quitting") 50 | #close files 51 | def config(config): 52 | global sw_debug 53 | sw_debug = int(config.get('debug', 0)) 54 | print("simplefswriter: feature enabled") 55 | -------------------------------------------------------------------------------- /knownProblems.md: -------------------------------------------------------------------------------- 1 | ## Known Problems 2 | Systemd starts daemon to early 3 | 4 | systemd[1]: Started SMA Energymeter measurement daemon. 5 | sma-daemon.py[574]: Traceback (most recent call last): 6 | sma-daemon.py[574]: File "/opt/smaem/sma-daemon.py", line 24, in 7 | sma-daemon.py[574]: import smaem 8 | sma-daemon.py[574]: File "/opt/smaem/smaem.py", line 49, in 9 | sma-daemon.py[574]: sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) 10 | sma-daemon.py[574]: OSError: [Errno 19] No such device 11 | systemd[1]: smaemd.service: main process exited, code=exited, status=1/FAILURE 12 | 13 | should create a .socket file with ListenNetlink= MCastGroup 14 | need to import more systemd - stuff to brain. 15 | -------------------------------------------------------------------------------- /libs/smartplug.py: -------------------------------------------------------------------------------- 1 | ## 2 | # The MIT License (MIT) 3 | # 4 | # Copyright (c) 2018 Stefan Wendler 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | ## 24 | 25 | 26 | import requests as req 27 | import optparse as par 28 | import logging as log 29 | 30 | from xml.dom.minidom import getDOMImplementation 31 | from xml.dom.minidom import parseString 32 | from requests.auth import HTTPDigestAuth 33 | 34 | __author__ = 'Stefan Wendler, sw@kaltpost.de' 35 | 36 | 37 | class SmartPlug(object): 38 | """ 39 | Simple class to access a "EDIMAX Smart Plug Switch SP1101W/SP2101W" 40 | 41 | Usage example when used as library: 42 | 43 | p = SmartPlug("172.16.100.75", ('admin', '1234')) 44 | 45 | # get device info 46 | print(p.info) 47 | 48 | # change state of plug 49 | p.state = "OFF" 50 | p.state = "ON" 51 | 52 | # query and print current state of plug 53 | print(p.state) 54 | 55 | # get power consumption (only SP2101W) 56 | print(p.power) 57 | 58 | # get current consumption (only SP2101W) 59 | print(p.current) 60 | 61 | # read and print complete week schedule from plug 62 | print(p.schedule.__str__()) 63 | 64 | # write schedule for on day to plug (Saturday, 11:15 - 11:45) 65 | p.schedule = {'state': u'ON', 'sched': [[[11, 15], [11, 45]]], 'day': 6} 66 | 67 | # write schedule for the whole week 68 | p.schedule = [ 69 | {'state': u'ON', 'sched': [[[0, 3], [0, 4]]], 'day': 0}, 70 | {'state': u'ON', 'sched': [[[0, 10], [0, 20]], [[10, 16], [11, 55]], 71 | [[15, 19], [15, 32]], [[21, 0], [23, 8]], [[23, 17], [23, 59]]], 'day': 1}, 72 | {'state': u'OFF', 'sched': [[[19, 59], [21, 1]]], 'day': 2}, 73 | {'state': u'OFF', 'sched': [[[20, 59], [21, 12]]], 'day': 3}, 74 | {'state': u'OFF', 'sched': [], 'day': 4}, 75 | {'state': u'OFF', 'sched': [[[0, 0], [0, 30]], [[11, 14], [14, 31]]], 'day': 5}, 76 | {'state': u'ON', 'sched': [[[1, 42], [2, 41]]], 'day': 6}] 77 | 78 | 79 | Usage example when used as command line utility: 80 | 81 | Get device info: 82 | 83 | python smartplug.py -H 172.16.100.75 -l admin -p 1234 -i 84 | 85 | turn plug on: 86 | 87 | python smartplug.py -H 172.16.100.75 -l admin -p 1234 -s ON 88 | 89 | turn plug off: 90 | 91 | python smartplug.py -H 172.16.100.75 -l admin -p 1234 -s OFF 92 | 93 | get plug state: 94 | 95 | python smartplug.py -H 172.16.100.75 -l admin -p 1234 -g 96 | 97 | get power consumption (only SP2101W) 98 | 99 | python smartplug.py -H 172.16.100.75 -l admin -p 1234 -w 100 | 101 | get current consumption (only SP2101W) 102 | 103 | python smartplug.py -H 172.16.100.75 -l admin -p 1234 -a 104 | 105 | get schedule of the whole week: 106 | 107 | python smartplug.py -H 172.16.100.75 -l admin -p 1234 -G 108 | 109 | get schedule of the whole week as python array: 110 | 111 | python smartplug.py -H 172.16.100.75 -l admin -p 1234 -P 112 | 113 | set schedule for one day: 114 | 115 | python smartplug.py -H 172.16.100.75 -l admin -p 1234 -S 116 | "{'state': u'ON', 'sched': [[[11, 0], [11, 45]]], 'day': 6}" 117 | 118 | set schedule for the whole week: 119 | 120 | python smartplug.py -H 172.16.100.75 -l admin -p 1234 -S "[ 121 | {'state': u'ON', 'sched': [[[1, 0], [1, 1]]], 'day': 0}, 122 | {'state': u'ON', 'sched': [[[2, 0], [2, 2]]], 'day': 1}, 123 | {'state': u'ON', 'sched': [[[3, 0], [3, 3]]], 'day': 2}, 124 | {'state': u'ON', 'sched': [[[4, 0], [4, 4]]], 'day': 3}, 125 | {'state': u'ON', 'sched': [[[5, 0], [5, 5]]], 'day': 4}, 126 | {'state': u'ON', 'sched': [[[6, 0], [6, 6]]], 'day': 5}, 127 | {'state': u'ON', 'sched': [[[7, 0], [7, 7]]], 'day': 6}, 128 | ]" 129 | """ 130 | 131 | def __init__(self, host, auth): 132 | """ 133 | Create a new SmartPlug instance identified by the given URL. 134 | 135 | :rtype: object 136 | :param host: The IP/hostname of the SmartPlug. E.g. '172.16.100.75' 137 | :param auth: User and password to authenticate with the plug. E.g. ('admin', '1234') 138 | """ 139 | object.__init__(self) 140 | 141 | self.url = "http://%s:10000/smartplug.cgi" % host 142 | self.auth = auth 143 | self.domi = getDOMImplementation() 144 | 145 | # Make a request to detect if Authentication type is Digest 146 | res = req.head(self.url) 147 | if res.headers['WWW-Authenticate'][0:6] == 'Digest': 148 | self.auth = HTTPDigestAuth(auth[0], auth[1]) 149 | 150 | self.log = log.getLogger("SmartPlug") 151 | 152 | def _xml_cmd_setget_state(self, cmdId, cmdStr): 153 | """ 154 | Create XML representation of a state command. 155 | 156 | :type self: object 157 | :type cmdId: str 158 | :type cmdStr: str 159 | :rtype: str 160 | :param cmdId: Use 'get' to request plug state, use 'setup' change plug state. 161 | :param cmdStr: Empty string for 'get', 'ON' or 'OFF' for 'setup' 162 | :return: XML representation of command 163 | """ 164 | 165 | assert (cmdId == "setup" and cmdStr in ["ON", "OFF"]) or (cmdId == "get" and cmdStr == "") 166 | 167 | doc = self.domi.createDocument(None, "SMARTPLUG", None) 168 | doc.documentElement.setAttribute("id", "edimax") 169 | 170 | cmd = doc.createElement("CMD") 171 | cmd.setAttribute("id", cmdId) 172 | state = doc.createElement("Device.System.Power.State") 173 | cmd.appendChild(state) 174 | state.appendChild(doc.createTextNode(cmdStr)) 175 | 176 | doc.documentElement.appendChild(cmd) 177 | 178 | xml = doc.toxml() 179 | self.log.debug("Request: %s" % xml) 180 | 181 | return xml 182 | 183 | def _xml_cmd_get_pc(self, what): 184 | """ 185 | Get power or current consumption (only SP2101W). 186 | 187 | :type self: object 188 | :type what: str 189 | :rtype: str 190 | :param what: What to retrieve: "NowPower" or "NowCurrent 191 | :return: XML representation of command 192 | """ 193 | 194 | assert what in ["NowPower", "NowCurrent"] 195 | 196 | doc = self.domi.createDocument(None, "SMARTPLUG", None) 197 | doc.documentElement.setAttribute("id", "edimax") 198 | 199 | cmd = doc.createElement("CMD") 200 | cmd.setAttribute("id", "get") 201 | pwr = doc.createElement("NOW_POWER") 202 | cmd.appendChild(pwr) 203 | state = doc.createElement("Device.System.Power.%s" % what) 204 | pwr.appendChild(state) 205 | 206 | doc.documentElement.appendChild(cmd) 207 | 208 | xml = doc.toxml() 209 | self.log.debug("Request: %s" % xml) 210 | 211 | return xml 212 | 213 | def _xml_cmd_get_info(self): 214 | """ 215 | Create XML representation of a command to query some information 216 | 217 | :type self: object 218 | :rtype: str 219 | :return: XML representation of command 220 | """ 221 | 222 | doc = self.domi.createDocument(None, "SMARTPLUG", None) 223 | doc.documentElement.setAttribute("id", "edimax") 224 | 225 | cmd = doc.createElement("CMD") 226 | cmd.setAttribute("id", "get") 227 | si = doc.createElement("SYSTEM_INFO") 228 | cmd.appendChild(si) 229 | doc.documentElement.appendChild(cmd) 230 | 231 | xml = doc.toxml() 232 | self.log.debug("Request: %s" % xml) 233 | 234 | return xml 235 | 236 | def _xml_cmd_get_sched(self): 237 | """ 238 | Create XML representation of a command to query schedule of whole week from plug. 239 | 240 | :type self: object 241 | :rtype: str 242 | :return: XML representation of command 243 | """ 244 | 245 | doc = self.domi.createDocument(None, "SMARTPLUG", None) 246 | doc.documentElement.setAttribute("id", "edimax") 247 | 248 | cmd = doc.createElement("CMD") 249 | cmd.setAttribute("id", "get") 250 | sched = doc.createElement("SCHEDULE") 251 | cmd.appendChild(sched) 252 | doc.createElement("Device.System.Power.State") 253 | 254 | doc.documentElement.appendChild(cmd) 255 | 256 | xml = doc.toxml() 257 | self.log.debug("Request: %s" % xml) 258 | 259 | return xml 260 | 261 | def _xml_cmd_set_sched(self, sched_days): 262 | """ 263 | Create XML representation of a command to set scheduling for one day or whole week. 264 | 265 | :type self: object 266 | :type sched_days: list 267 | :rtype: str 268 | :param sched_day: Single day or whole week 269 | :return: XML representation of command 270 | """ 271 | 272 | doc = self.domi.createDocument(None, "SMARTPLUG", None) 273 | doc.documentElement.setAttribute("id", "edimax") 274 | 275 | cmd = doc.createElement("CMD") 276 | cmd.setAttribute("id", "setup") 277 | sched = doc.createElement("SCHEDULE") 278 | cmd.appendChild(sched) 279 | 280 | if isinstance(sched_days, list): 281 | # more then one day 282 | 283 | for one_sched_day in sched_days: 284 | 285 | dev_sched = doc.createElement("Device.System.Power.Schedule.%d" % one_sched_day["day"]) 286 | dev_sched.appendChild(doc.createTextNode(self._render_schedule(one_sched_day["sched"]))) 287 | dev_sched.attributes["value"] = one_sched_day["state"] 288 | 289 | sched.appendChild(dev_sched) 290 | 291 | else: 292 | # one day 293 | dev_sched = doc.createElement("Device.System.Power.Schedule.%d" % sched_days["day"]) 294 | dev_sched.appendChild(doc.createTextNode(self._render_schedule(sched_days["sched"]))) 295 | dev_sched.attributes["value"] = sched_days["state"] 296 | 297 | sched.appendChild(dev_sched) 298 | 299 | doc.documentElement.appendChild(cmd) 300 | 301 | xml = doc.toxml() 302 | self.log.debug("Request: %s" % xml) 303 | 304 | return xml 305 | 306 | def _post_xml(self, xml): 307 | """ 308 | Post XML command as multipart file to SmartPlug, parse XML response. 309 | 310 | :type self: object 311 | :type xml: str 312 | :rtype: str 313 | :param xml: XML representation of command (as generated by _xml_cmd) 314 | :return: 'OK' on success, 'FAILED' otherwise 315 | """ 316 | 317 | files = {'file': xml} 318 | 319 | res = req.post(self.url, auth=self.auth, files=files) 320 | 321 | self.log.debug("Status code: %d" % res.status_code) 322 | self.log.debug("Response: %s" % res.text) 323 | 324 | if res.status_code == req.codes.ok: 325 | dom = parseString(res.text) 326 | 327 | try: 328 | val = dom.getElementsByTagName("CMD")[0].firstChild.nodeValue 329 | 330 | if val is None: 331 | val = dom.getElementsByTagName("CMD")[0].getElementsByTagName("Device.System.Power.State")[0].\ 332 | firstChild.nodeValue 333 | 334 | return val 335 | 336 | except Exception as e: 337 | 338 | print(e.__str__()) 339 | 340 | return None 341 | 342 | def _post_xml_dom(self, xml): 343 | """ 344 | Post XML command as multipart file to SmartPlug, return response as raw dom. 345 | 346 | :type self: object 347 | :type xml: str 348 | :rtype: object 349 | :param xml: XML representation of command (as generated by _xml_cmd) 350 | :return: dom representation of XML response 351 | """ 352 | 353 | files = {'file': xml} 354 | 355 | res = req.post(self.url, auth=self.auth, files=files) 356 | 357 | self.log.debug("Status code: %d" % res.status_code) 358 | self.log.debug("Response: %s" % res.text) 359 | 360 | if res.status_code == req.codes.ok: 361 | return parseString(res.text) 362 | 363 | return None 364 | 365 | @property 366 | def info(self): 367 | """ 368 | Get device info (vendor, model, version, mac and system name (if available)). 369 | 370 | :type self: object 371 | :rtype: dictonary 372 | :return: dictonary with the following keys: vendor, model, version, mac, name 373 | """ 374 | 375 | dom = self._post_xml_dom(self._xml_cmd_get_info()) 376 | 377 | vendor = dom.getElementsByTagName("Run.Cus")[0].firstChild.nodeValue 378 | model = dom.getElementsByTagName("Run.Model")[0].firstChild.nodeValue 379 | version = dom.getElementsByTagName("Run.FW.Version")[0].firstChild.nodeValue 380 | mac = dom.getElementsByTagName("Run.LAN.Client.MAC.Address")[0].firstChild.nodeValue 381 | 382 | inf = {"vendor":vendor, "model":model, "version":version, "mac":mac} 383 | 384 | # not all plugs/fw versions seem to return the system name ... 385 | try: 386 | inf["name"] = dom.getElementsByTagName("Device.System.Name")[0].firstChild.nodeValue 387 | except IndexError: 388 | pass 389 | 390 | return inf 391 | 392 | @property 393 | def state(self): 394 | """ 395 | Get the current state of the SmartPlug. 396 | 397 | :type self: object 398 | :rtype: str 399 | :return: 'ON' or 'OFF' 400 | """ 401 | 402 | res = self._post_xml(self._xml_cmd_setget_state("get", "")) 403 | 404 | if res != "ON" and res != "OFF": 405 | raise Exception("Failed to communicate with SmartPlug") 406 | 407 | return res 408 | 409 | @state.setter 410 | def state(self, value): 411 | """ 412 | Set the state of the SmartPlug 413 | 414 | :type self: object 415 | :type value: str 416 | :param value: 'ON', 'on', 'OFF' or 'off' 417 | """ 418 | 419 | if value == "ON" or value == "on": 420 | res = self._post_xml(self._xml_cmd_setget_state("setup", "ON")) 421 | else: 422 | res = self._post_xml(self._xml_cmd_setget_state("setup", "OFF")) 423 | 424 | if res != "OK": 425 | raise Exception("Failed to communicate with SmartPlug") 426 | 427 | @property 428 | def power(self): 429 | """ 430 | Get the power consumption of the SmartPlug (only SP2101W). 431 | 432 | :type self: object 433 | :rtype: tuple (str, float) 434 | :return: power consumption in W 435 | """ 436 | 437 | dom = self._post_xml_dom(self._xml_cmd_get_pc("NowPower")) 438 | 439 | try: 440 | power = dom.getElementsByTagName("Device.System.Power.NowPower")[0].firstChild.nodeValue 441 | except: 442 | raise Exception("Failed to communicate with SmartPlug") 443 | 444 | return power 445 | 446 | @property 447 | def current(self): 448 | """ 449 | Get the current consumption of the SmartPlug (only SP2101W). 450 | 451 | :type self: object 452 | :rtype: tuple (str, float) 453 | :return: current consumption in A 454 | """ 455 | 456 | dom = self._post_xml_dom(self._xml_cmd_get_pc("NowCurrent")) 457 | 458 | try: 459 | current = dom.getElementsByTagName("Device.System.Power.NowCurrent")[0].firstChild.nodeValue 460 | except: 461 | raise Exception("Failed to communicate with SmartPlug") 462 | 463 | return current 464 | 465 | def _parse_schedule(self, sched): 466 | """ 467 | Parse the plugs internal scheduling format string to python array 468 | 469 | :type self: object 470 | :type sched: str 471 | :rtype: list 472 | :param sched: scheduling string (of one day) as returned by plug 473 | :return: Python array with scheduling: [[[start_hh:start_mm],[end_hh:end_mm]], ... ] 474 | """ 475 | 476 | sched_unpacked = [0] * 60 * 24 477 | hours = [] 478 | 479 | idx_sched = 0 480 | 481 | # first, unpack the packed schedule from the plug 482 | for packed in sched: 483 | 484 | int_packed = int(packed, 16) 485 | 486 | sched_unpacked[idx_sched+0] = (int_packed >> 3) & 1 487 | sched_unpacked[idx_sched+1] = (int_packed >> 2) & 1 488 | sched_unpacked[idx_sched+2] = (int_packed >> 1) & 1 489 | sched_unpacked[idx_sched+3] = (int_packed >> 0) & 1 490 | 491 | idx_sched += 4 492 | 493 | idx_hours = 0 494 | 495 | hour = 0 496 | min = 0 497 | 498 | found_range = False 499 | 500 | # second build time array from unpacked schedule 501 | for m in sched_unpacked: 502 | 503 | if m == 1 and not found_range: 504 | found_range = True 505 | hours.append([[hour, min], [23, 59]]) 506 | 507 | elif m == 0 and found_range: 508 | found_range = False 509 | hours[idx_hours][1][0] = hour 510 | hours[idx_hours][1][1] = min 511 | idx_hours += 1 512 | 513 | min += 1 514 | 515 | if min > 59: 516 | min = 0 517 | hour += 1 518 | 519 | return hours 520 | 521 | def _render_schedule(self, hours): 522 | """ 523 | Render Python scheduling array back to plugs internal format 524 | 525 | :type self: object 526 | :type hours: list 527 | :rtype: str 528 | :param hours: Python array with scheduling hours: [[[start_hh:start_mm],[end_hh:end_mm]], ... ] 529 | :return: scheduling string (of one day) as needed by plug 530 | """ 531 | 532 | sched = [0] * 60 * 24 533 | sched_str = '' 534 | 535 | # first, set every minute we found a schedule to 1 in the sched array 536 | for times in hours: 537 | 538 | idx_start = times[0][0] * 60 + times[0][1] 539 | idx_end = times[1][0] * 60 + times[1][1] 540 | 541 | if idx_end < idx_start: 542 | idx_end = 60 * 24 543 | 544 | for i in range(idx_start, idx_end): 545 | sched[i] = 1 546 | 547 | # second, pack the minute array from above into the plug format and make a string out of it 548 | for i in range(0, 60 * 24, 4): 549 | packed = (sched[i] << 3) + (sched[i+1] << 2) + (sched[i+2] << 1) + (sched[i+3] << 0) 550 | sched_str += "%X" % packed 551 | 552 | return sched_str 553 | 554 | @property 555 | def schedule(self): 556 | """ 557 | Get scheduling for all days of week from plug as python list. 558 | Note: it looks like the plug only is able to return a whole week. 559 | 560 | :type self: object 561 | :rtype: list 562 | :return: List with scheduling for each day of week: 563 | 564 | [ 565 | {'state': u'ON|OFF', 'sched': [[[hh, mm], [hh, mm]], ...], 'day': 0..6}, 566 | ... 567 | ] 568 | """ 569 | 570 | sched = [] 571 | 572 | dom = self._post_xml_dom(self._xml_cmd_get_sched()) 573 | 574 | if dom is None: 575 | return sched 576 | 577 | try: 578 | 579 | dom_sched = dom.getElementsByTagName("CMD")[0].getElementsByTagName("SCHEDULE")[0] 580 | 581 | for i in range(0, 7): 582 | 583 | sched.append( 584 | {"day": i, 585 | "state": dom_sched.getElementsByTagName("Device.System.Power.Schedule.%d" % i)[0].attributes[ 586 | "value"]. 587 | firstChild.nodeValue, 588 | "sched": self._parse_schedule( 589 | dom_sched.getElementsByTagName("Device.System.Power.Schedule.%d" % i)[0]. 590 | firstChild.nodeValue)}) 591 | 592 | except Exception as e: 593 | 594 | print(e.__str__()) 595 | 596 | return sched 597 | 598 | @schedule.setter 599 | def schedule(self, sched): 600 | """ 601 | Set scheduling for ony day of week or for whole week on the plug. 602 | Note: it seams not to be possible to schedule anything else then one day or a whole week. 603 | 604 | :type self: object 605 | :type sched: list 606 | :rtype: str 607 | :param sched: Array with scheduling hours for ons day: 608 | 609 | {'day': 0..6, 'state': 'ON' | 'OFF', [[start_hh:start_mm],[end_hh:end_mm]], ... ]} 610 | 611 | Or whole week: 612 | 613 | [{'day': 0..6, 'state': 'ON' | 'OFF', [[start_hh:start_mm],[end_hh:end_mm]], ... ]}, ...] 614 | 615 | :return: 'OK' (or exception on error) 616 | """ 617 | 618 | res = self._post_xml(self._xml_cmd_set_sched(sched)) 619 | 620 | if res != "OK": 621 | raise Exception("Failed to communicate with SmartPlug") 622 | 623 | return res 624 | 625 | if __name__ == "__main__": 626 | 627 | usage = "%prog [options]" 628 | 629 | parser = par.OptionParser(usage) 630 | 631 | parser.add_option("-v", "--verbose", action="store_true", help="Print debug information") 632 | 633 | parser.add_option("-H", "--host", default="172.16.100.75", help="Base URL of the SmartPlug") 634 | parser.add_option("-l", "--login", default="admin", help="Login user to authenticate with SmartPlug") 635 | parser.add_option("-p", "--password", default="1234", help="Password to authenticate with SmartPlug") 636 | 637 | parser.add_option("-i", "--info", action="store_true", help="Get plug information") 638 | parser.add_option("-g", "--get", action="store_true", help="Get state of plug") 639 | parser.add_option("-s", "--set", help="Set state of plug: ON or OFF") 640 | 641 | parser.add_option("-w", "--power", action="store_true", help="Get plug power consumption (only SP2101W)") 642 | parser.add_option("-a", "--current", action="store_true", help="Get plug current consumption (only SP2101W)") 643 | 644 | parser.add_option("-G", "--getsched", action="store_true", help="Get schedule from Plug") 645 | parser.add_option("-P", "--getschedpy", action="store_true", help="Get schedule from Plug as Python list") 646 | parser.add_option("-S", "--setsched", help="Set schedule of Plug") 647 | 648 | (options, args) = parser.parse_args() 649 | 650 | # this turns on debugging 651 | level = log.ERROR 652 | 653 | if options.verbose: 654 | level = log.DEBUG 655 | 656 | log.basicConfig(level=level, format='%(asctime)s - %(levelname) 8s [%(module) 15s] - %(message)s') 657 | 658 | p = SmartPlug(options.host, (options.login, options.password)) 659 | 660 | if options.info: 661 | 662 | print("Plug info:") 663 | for i in sorted(p.info.items()): 664 | print("- %s: %s" % i) 665 | 666 | if options.get: 667 | 668 | print(p.state) 669 | 670 | elif options.set: 671 | 672 | p.state = options.set 673 | 674 | if options.power: 675 | 676 | print("%s W" % p.power) 677 | 678 | if options.current: 679 | 680 | print("%s A" % p.current) 681 | 682 | elif options.getsched: 683 | 684 | days = {0: "Sunday", 1: "Monday", 2: "Tuesday", 3: "Wednesday", 685 | 4: "Thursday", 5: "Friday", 6: "Saturday"} 686 | 687 | for day in p.schedule: 688 | 689 | if len(day["sched"]) > 0: 690 | print("Schedules for: %s (%s)" % (days[day["day"]], day["state"])) 691 | 692 | for sched in day["sched"]: 693 | print(" * %02d:%02d - %02d:%02d" % (sched[0][0], sched[0][1], sched[1][0], sched[1][1])) 694 | 695 | elif options.getschedpy: 696 | 697 | print(p.schedule.__str__()) 698 | 699 | elif options.setsched: 700 | 701 | try: 702 | 703 | sched = eval(options.setsched) 704 | p.schedule = sched 705 | 706 | except Exception as e: 707 | 708 | print("Wrong input format: %s" % e.__str__()) 709 | exit(-1) 710 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | paho-mqtt~=1.5.0 2 | influxdb~=5.3.0 3 | influxdb-client~=1.14.0 4 | pymodbus~=2.4.0 5 | requests~=2.24.0 -------------------------------------------------------------------------------- /sma-daemon.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding=utf-8 3 | """ 4 | * 5 | * by Wenger Florian 2018-01-30 6 | * wenger@unifox.at 7 | * 8 | * this software is released under GNU General Public License, version 2. 9 | * This program is free software; 10 | * you can redistribute it and/or modify it under the terms of the GNU General Public License 11 | * as published by the Free Software Foundation; version 2 of the License. 12 | * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; 13 | * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 14 | * See the GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License along with this program; 17 | * if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 | * 19 | * 2020-01-04 datenschuft changes to tun with speedwiredecoder 20 | * 2020-09-21 Tommi2Day add traceback for exception analysis 21 | * 2021-03-07 datenschuft add config to run section (required beause of seperating moduleconfig to extra section by dervomsee 22 | */ 23 | """ 24 | import sys, time,os 25 | from daemon3x import daemon3x 26 | from configparser import ConfigParser 27 | #import smaem 28 | import socket 29 | import struct 30 | from speedwiredecoder import * 31 | import traceback 32 | import importlib 33 | 34 | #read configuration 35 | parser = ConfigParser() 36 | #alternate config locations 37 | parser.read(['/etc/smaemd/config','config']) 38 | try: 39 | smaemserials=parser.get('SMA-EM', 'serials') 40 | except: 41 | print('Cannot find base config entry SMA-EM serials') 42 | sys.exit(1) 43 | 44 | serials=smaemserials.split(' ') 45 | #smavalues=parser.get('SMA-EM', 'values') 46 | #values=smavalues.split(' ') 47 | pidfile=parser.get('DAEMON', 'pidfile') 48 | ipbind=parser.get('DAEMON', 'ipbind') 49 | MCAST_GRP = parser.get('DAEMON', 'mcastgrp') 50 | MCAST_PORT = int(parser.get('DAEMON', 'mcastport')) 51 | features=parser.get('SMA-EM', 'features') 52 | features=features.split(' ') 53 | statusdir='' 54 | try: 55 | statusdir=parser.get('DAEMON','statusdir') 56 | except: 57 | statusdir="/run/shm/" 58 | 59 | if os.path.isdir(statusdir): 60 | statusfile=statusdir+"em-status" 61 | else: 62 | statusfile = "em-status" 63 | 64 | #feature list 65 | featurelist = {} 66 | featurecounter=0 67 | 68 | #set defaults 69 | if MCAST_GRP == "": 70 | MCAST_GRP = '239.12.255.254' 71 | if MCAST_PORT == 0: 72 | MCAST_PORT = 9522 73 | 74 | class MyDaemon(daemon3x): 75 | def config(self): 76 | global featurelist 77 | global featurecounter 78 | global features 79 | # Check features and load 80 | for feature in features: 81 | print ('import ' + feature + '.py') 82 | featureitem = {'name': feature} 83 | try: 84 | featureitem['feature'] = importlib.import_module('features.' + feature) 85 | except ModuleNotFoundError as e: 86 | print('Dependency problem: ' + str(e)) 87 | sys.exit() 88 | except (ImportError, FileNotFoundError, TypeError): 89 | print('feature '+feature+ ' not found') 90 | sys.exit() 91 | try: 92 | featureitem['config']=dict(parser.items('FEATURE-'+feature)) 93 | #print (featureitem['config']) 94 | except: 95 | print('feature '+feature+ ' not configured') 96 | sys.exit() 97 | try: 98 | # run config action, if any 99 | featureitem['feature'].config(featureitem['config']) 100 | except: 101 | pass 102 | featurelist[featurecounter]=featureitem 103 | featurecounter += 1 104 | def run(self): 105 | # prepare listen to socket-Multicast 106 | print("config") 107 | self.config() 108 | socketconnected = False 109 | while not socketconnected: 110 | #try: 111 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 112 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 113 | sock.bind(('', MCAST_PORT)) 114 | try: 115 | mreq = struct.pack("4s4s", socket.inet_aton(MCAST_GRP), socket.inet_aton(ipbind)) 116 | sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) 117 | file = open(statusfile, "w") 118 | file.write('multicastgroup connected') 119 | file.close() 120 | socketconnected = True 121 | except BaseException: 122 | print('could not connect to mulicast group... rest a bit and retry') 123 | file = open(statusfile, "w") 124 | file.write('could not connect to mulicast group... rest a bit and retry') 125 | file.close() 126 | time.sleep(5) 127 | emparts = {} 128 | while True: 129 | #getting sma values 130 | try: 131 | #emparts=smaem.readem(sock) 132 | emparts=decode_speedwire(sock.recv(608)) 133 | for serial in serials: 134 | # process only known sma serials 135 | if 'serial' in emparts: 136 | if serial==format(emparts['serial']): 137 | # running all enabled features 138 | for featurenr in featurelist: 139 | #print('>>> starting '+featurelist[featurenr]['name']) 140 | featurelist[featurenr]['feature'].run(emparts,featurelist[featurenr]['config']) 141 | except Exception as e: 142 | print("Daemon: Exception occured") 143 | print(traceback.format_exc()) 144 | pass 145 | #Daemon - Coding 146 | if __name__ == "__main__": 147 | daemon = MyDaemon(pidfile) 148 | if len(sys.argv) == 2: 149 | if 'start' == sys.argv[1]: 150 | daemon.start() 151 | elif 'start_systemd' == sys.argv[1]: 152 | daemon.start_systemd() 153 | elif 'stop' == sys.argv[1]: 154 | for featurenr in featurelist: 155 | print('>>> stopping '+featurelist[featurenr]['name']) 156 | featurelist[featurenr]['feature'].stopping({},featurelist[featurenr]['config']) 157 | daemon.stop() 158 | elif 'restart' == sys.argv[1]: 159 | daemon.restart() 160 | elif 'restart_systemd' == sys.argv[1]: 161 | daemon.restart_systemd() 162 | elif 'run' == sys.argv[1]: 163 | daemon.run() 164 | else: 165 | print ("Unknown command") 166 | sys.exit(2) 167 | sys.exit(0) 168 | else: 169 | print ("usage: %s start|start_systemd|stop|restart|restart_systemd|run" % sys.argv[0]) 170 | print (pidfile) 171 | sys.exit(2) 172 | -------------------------------------------------------------------------------- /sma-em-capture-package.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding=utf-8 3 | """ 4 | * 5 | * by Wenger Florian 2020-01-04 6 | * wenger@unifox.at 7 | * 8 | * 9 | * this software is released under GNU General Public License, version 2. 10 | * This program is free software; 11 | * you can redistribute it and/or modify it under the terms of the GNU General Public License 12 | * as published by the Free Software Foundation; version 2 of the License. 13 | * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; 14 | * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 15 | * See the GNU General Public License for more details. 16 | * 17 | * You should have received a copy of the GNU General Public License along with this program; 18 | * if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 19 | * 20 | * 2018-12-22 Tommi2Day small enhancements 21 | * 2019-08-13 datenschuft run without config 22 | * 23 | */ 24 | """ 25 | 26 | import signal 27 | import sys 28 | import socket 29 | import struct 30 | import binascii 31 | from configparser import ConfigParser 32 | from speedwiredecoder import * 33 | 34 | # clean exit 35 | def abortprogram(signal,frame): 36 | # Housekeeping -> nothing to cleanup 37 | print('STRG + C = end program') 38 | sys.exit(0) 39 | 40 | # abort-signal 41 | signal.signal(signal.SIGINT, abortprogram) 42 | 43 | 44 | #read configuration 45 | parser = ConfigParser() 46 | #default values 47 | smaserials = "" 48 | ipbind = '0.0.0.0' 49 | MCAST_GRP = '239.12.255.254' 50 | MCAST_PORT = 9522 51 | parser.read(['/etc/smaemd/config','config']) 52 | try: 53 | smaemserials=parser.get('SMA-EM', 'serials') 54 | ipbind=parser.get('DAEMON', 'ipbind') 55 | MCAST_GRP = parser.get('DAEMON', 'mcastgrp') 56 | MCAST_PORT = int(parser.get('DAEMON', 'mcastport')) 57 | except: 58 | print('Cannot find config /etc/smaemd/config... using defaults') 59 | 60 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 61 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 62 | sock.bind(('', MCAST_PORT)) 63 | try: 64 | mreq = struct.pack("4s4s", socket.inet_aton(MCAST_GRP), socket.inet_aton(ipbind)) 65 | sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) 66 | except BaseException: 67 | print('could not connect to mulicast group or bind to given interface') 68 | sys.exit(1) 69 | 70 | # processing received messages 71 | smainfo=sock.recv(1024) 72 | 73 | #test-datagrem sma-em-1.2.4.R 74 | #smainfo=b'SMA\x00\x00\x04\x02\xa0\x00\x00\x00\x01\x02D\x00\x10`i\x01\x0eqB\xd1\xeb_\xc9\r\xd0\x00\x01\x04\x00\x00\x00\x87\x13\x00\x01\x08\x00\x00\x00\x00\x16R/\x00h\x00\x02\x04\x00\x00\x00\x00\x00\x00\x02\x08\x00\x00\x00\x00\x08\x9d\x14\xb8`\x00\x03\x04\x00\x00\x00\x00\x00\x00\x03\x08\x00\x00\x00\x00\x00\xc1%\xc1H\x00\x04\x04\x00\x00\x00\x11Q\x00\x04\x08\x00\x00\x00\x00\no\xce|\xe0\x00\t\x04\x00\x00\x00\x88.\x00\t\x08\x00\x00\x00\x00\x19\xdfo\x07\x18\x00\n\x04\x00\x00\x00\x00\x00\x00\n\x08\x00\x00\x00\x00\tJ\xc5x\xc8\x00\r\x04\x00\x00\x00\x03\xe0\x00\x15\x04\x00\x00\x00}P\x00\x15\x08\x00\x00\x00\x00\n.\x07o`\x00\x16\x04\x00\x00\x00\x00\x00\x00\x16\x08\x00\x00\x00\x00\n\xcb\xab\xf4 \x00\x17\x04\x00\x00\x00\x00\x00\x00\x17\x08\x00\x00\x00\x00\x00bF\x05p\x00\x18\x04\x00\x00\x00\r\xe4\x00\x18\x08\x00\x00\x00\x00\x04\xf3E\'\xc8\x00\x1d\x04\x00\x00\x00~\x14\x00\x1d\x08\x00\x00\x00\x00\x0c\x0f\xb7\xbd`\x00\x1e\x04\x00\x00\x00\x00\x00\x00\x1e\x08\x00\x00\x00\x00\x0b"p\x95\x90\x00\x1f\x04\x00\x00\x008G\x00 \x04\x00\x00\x03l\xd4\x00!\x04\x00\x00\x00\x03\xe2\x00)\x04\x00\x00\x00\x07,\x00)\x08\x00\x00\x00\x00\t\xdfT;\xf0\x00*\x04\x00\x00\x00\x00\x00\x00*\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00+\x04\x00\x00\x00\x00\x00\x00+\x08\x00\x00\x00\x00\x00\x03\x12\xb1H\x00,\x04\x00\x00\x00\x03\x89\x00,\x08\x00\x00\x00\x00\x05)\x8a\x93h\x001\x04\x00\x00\x00\x07\xff\x001\x08\x00\x00\x00\x00\x0c4\xa7\x9b@\x002\x04\x00\x00\x00\x00\x00\x002\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x003\x04\x00\x00\x00\x03\xba\x004\x04\x00\x00\x03\x85\t\x005\x04\x00\x00\x00\x03\x81\x00=\x04\x00\x00\x00\x02\x97\x00=\x08\x00\x00\x00\x00\x04sj\xe2h\x00>\x04\x00\x00\x00\x00\x00\x00>\x08\x00\x00\x00\x00\x00\x00\x00o\x18\x00?\x04\x00\x00\x00\x00\x1c\x00?\x08\x00\x00\x00\x00\x00\xe2\xa0\xb1\xe8\x00@\x04\x00\x00\x00\x00\x00\x00@\x08\x00\x00\x00\x00\x00\xd9\xd2`\x98\x00E\x04\x00\x00\x00\x02\x97\x00E\x08\x00\x00\x00\x00\x05K\xf5vp\x00F\x04\x00\x00\x00\x00\x00\x00F\x08\x00\x00\x00\x00\x00\x00\x00o\x18\x00G\x04\x00\x00\x00\x01\xe6\x00H\x04\x00\x00\x03\x84.\x00I\x04\x00\x00\x00\x03\xe7\x90\x00\x00\x00\x01\x02\x04R\x00\x00\x00\x00' 75 | 76 | #test-datagram sma-homemanager-2.3.4.R 77 | #smainfo=b'SMA\x00\x00\x04\x02\xa0\x00\x00\x00\x01\x02L\x00\x10`i\x01t\xb2\xfb\xdb\na3\xfe\xa4\x00\x01\x04\x00\x00\x00\x00\x00\x00\x01\x08\x00\x00\x00\x00\x01\xd6[.\xf8\x00\x02\x04\x00\x00\x00\xbe\x80\x00\x02\x08\x00\x00\x00\x00\x07\x81\x86E`\x00\x03\x04\x00\x00\x00\x17x\x00\x03\x08\x00\x00\x00\x00\x0144\xf6\x90\x00\x04\x04\x00\x00\x00\x00\x00\x00\x04\x08\x00\x00\x00\x00\x00\xd5#\xa7P\x00\t\x04\x00\x00\x00\x00\x00\x00\t\x08\x00\x00\x00\x00\x02\x0fv\xbb\xf8\x00\n\x04\x00\x00\x00\xbf\xf0\x00\n\x08\x00\x00\x00\x00\x07\xac\xcc\x0c\xa0\x00\r\x04\x00\x00\x00\x03\xe0\x00\x0e\x04\x00\x00\x00\xc3<\x00\x15\x04\x00\x00\x00\x00\x00\x00\x15\x08\x00\x00\x00\x00\x00jb\xee\x80\x00\x16\x04\x00\x00\x00B9\x00\x16\x08\x00\x00\x00\x00\x02\xb4\xc4\xfa \x00\x17\x04\x00\x00\x00\x07\x16\x00\x17\x08\x00\x00\x00\x00\x00d>U\x08\x00\x18\x04\x00\x00\x00\x00\x00\x00\x18\x08\x00\x00\x00\x00\x00EC\xec0\x00\x1d\x04\x00\x00\x00\x00\x00\x00\x1d\x08\x00\x00\x00\x00\x00\x87z=H\x00\x1e\x04\x00\x00\x00B\x99\x00\x1e\x08\x00\x00\x00\x00\x02\xc4G\xe0 \x00\x1f\x04\x00\x00\x00\x1d\x15\x00 \x04\x00\x00\x03\x80\xbb\x00!\x04\x00\x00\x00\x03\xe2\x00)\x04\x00\x00\x00\x00\x00\x00)\x08\x00\x00\x00\x00\x00\xcc\xc2b\xe0\x00*\x04\x00\x00\x00=j\x00*\x08\x00\x00\x00\x00\x02n\xbf)\x88\x00+\x04\x00\x00\x00\tt\x00+\x08\x00\x00\x00\x00\x00u\x08\xddh\x00,\x04\x00\x00\x00\x00\x00\x00,\x08\x00\x00\x00\x00\x00P\x11\xe9x\x001\x04\x00\x00\x00\x00\x00\x001\x08\x00\x00\x00\x00\x00\xe7\xdb\xc0\xa8\x002\x04\x00\x00\x00>#\x002\x08\x00\x00\x00\x00\x02~E=\xc0\x003\x04\x00\x00\x00\x1a\xc2\x004\x04\x00\x00\x03\x8e\xb2\x005\x04\x00\x00\x00\x03\xdc\x00=\x04\x00\x00\x00\x00\x00\x00=\x08\x00\x00\x00\x00\x00\xc6T\xc4 \x00>\x04\x00\x00\x00>\xdd\x00>\x08\x00\x00\x00\x00\x02\x85!\t\xa8\x00?\x04\x00\x00\x00\x06\xed\x00?\x08\x00\x00\x00\x00\x00x~\xa8\xd8\x00@\x04\x00\x00\x00\x00\x00\x00@\x08\x00\x00\x00\x00\x00]^\xb90\x00E\x04\x00\x00\x00\x00\x00\x00E\x08\x00\x00\x00\x00\x00\xe1K,\x88\x00F\x04\x00\x00\x00?>\x00F\x08\x00\x00\x00\x00\x02\x97>i(\x00G\x04\x00\x00\x00\x1bi\x00H\x04\x00\x00\x03\x88i\x00I\x04\x00\x00\x00\x03\xe2\x90\x00\x00\x00\x02\x03\x04R\x00\x00\x00\x00' 78 | 79 | smainfoasci=binascii.b2a_hex(smainfo) 80 | 81 | 82 | emparts=decode_speedwire(smainfo) 83 | 84 | 85 | print ('----raw-output---') 86 | print (smainfo) 87 | print ('----asci-output---') 88 | print (smainfoasci) 89 | 90 | print ('----all-found-values---') 91 | for val in emparts: 92 | print ('{}: value:{}'.format(val,emparts[val])) 93 | -------------------------------------------------------------------------------- /sma-em-measurement.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding=utf-8 3 | """ 4 | * 5 | * by Wenger Florian 2015-09-02 6 | * wenger@unifox.at 7 | * 8 | * endless loop (until ctrl+c) displays measurement from SMA Energymeter 9 | * 10 | * 11 | * this software is released under GNU General Public License, version 2. 12 | * This program is free software; 13 | * you can redistribute it and/or modify it under the terms of the GNU General Public License 14 | * as published by the Free Software Foundation; version 2 of the License. 15 | * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; 16 | * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 17 | * See the GNU General Public License for more details. 18 | * 19 | * You should have received a copy of the GNU General Public License along with this program; 20 | * if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 21 | * 22 | * 2018-12-22 Tommi2Day small enhancements 23 | * 2019-08-13 datenschuft run without config 24 | * 2020-01-04 datenschuft changes to tun with speedwiredecoder 25 | * 26 | */ 27 | """ 28 | 29 | import signal 30 | import sys 31 | #import smaem 32 | import socket 33 | import struct 34 | from configparser import ConfigParser 35 | from speedwiredecoder import * 36 | 37 | # clean exit 38 | def abortprogram(signal,frame): 39 | # Housekeeping -> nothing to cleanup 40 | print('STRG + C = end program') 41 | sys.exit(0) 42 | 43 | # abort-signal 44 | signal.signal(signal.SIGINT, abortprogram) 45 | 46 | 47 | #read configuration 48 | parser = ConfigParser() 49 | #default values 50 | smaserials = "" 51 | ipbind = '0.0.0.0' 52 | MCAST_GRP = '239.12.255.254' 53 | MCAST_PORT = 9522 54 | parser.read(['/etc/smaemd/config','config']) 55 | try: 56 | smaemserials=parser.get('SMA-EM', 'serials') 57 | ipbind=parser.get('DAEMON', 'ipbind') 58 | MCAST_GRP = parser.get('DAEMON', 'mcastgrp') 59 | MCAST_PORT = int(parser.get('DAEMON', 'mcastport')) 60 | except: 61 | print('Cannot find config /etc/smaemd/config... using defaults') 62 | 63 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 64 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 65 | sock.bind(('', MCAST_PORT)) 66 | try: 67 | mreq = struct.pack("4s4s", socket.inet_aton(MCAST_GRP), socket.inet_aton(ipbind)) 68 | sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) 69 | except BaseException: 70 | print('could not connect to mulicast group or bind to given interface') 71 | sys.exit(1) 72 | # processing received messages 73 | while True: 74 | emparts = {} 75 | emparts=decode_speedwire(sock.recv(608)) 76 | # Output... 77 | # don't know what P,Q and S means: 78 | # http://en.wikipedia.org/wiki/AC_power or http://de.wikipedia.org/wiki/Scheinleistung 79 | # thd = Total_Harmonic_Distortion http://de.wikipedia.org/wiki/Total_Harmonic_Distortion 80 | # cos phi is always positive, no matter what quadrant 81 | print ('\n') 82 | print ('SMA-EM Serial:{}'.format(emparts['serial'])) 83 | print ('----sum----') 84 | print ('P: consume:{}W {}kWh supply:{}W {}kWh'.format(emparts['pconsume'],emparts['pconsumecounter'],emparts['psupply'],emparts['psupplycounter'])) 85 | print ('S: consume:{}VA {}kVAh supply:{}VA {}VAh'.format(emparts['sconsume'],emparts['sconsumecounter'],emparts['ssupply'],emparts['ssupplycounter'])) 86 | print ('Q: cap {}var {}kvarh ind {}var {}kvarh'.format(emparts['qconsume'],emparts['qconsumecounter'],emparts['qsupply'],emparts['qsupplycounter'])) 87 | print ('cos phi:{}°'.format(emparts['cosphi'])) 88 | if emparts['speedwire-version']=="2.3.4.R|020304": 89 | print ('frequency:{}Hz'.format(emparts['frequency'])) 90 | print ('----L1----') 91 | print ('P: consume:{}W {}kWh supply:{}W {}kWh'.format(emparts['p1consume'],emparts['p1consumecounter'],emparts['p1supply'],emparts['p1supplycounter'])) 92 | print ('S: consume:{}VA {}kVAh supply:{}VA {}kVAh'.format(emparts['s1consume'],emparts['s1consumecounter'],emparts['s1supply'],emparts['s1supplycounter'])) 93 | print ('Q: cap {}var {}kvarh ind {}var {}kvarh'.format(emparts['q1consume'],emparts['q1consumecounter'],emparts['q1supply'],emparts['q1supplycounter'])) 94 | print ('U: {}V I:{}A cos phi:{}°'.format(emparts['u1'],emparts['i1'],emparts['cosphi1'])) 95 | print ('----L2----') 96 | print ('P: consume:{}W {}kWh supply:{}W {}kWh'.format(emparts['p2consume'],emparts['p2consumecounter'],emparts['p2supply'],emparts['p2supplycounter'])) 97 | print ('S: consume:{}VA {}kVAh supply:{}VA {}kVAh'.format(emparts['s2consume'],emparts['s2consumecounter'],emparts['s2supply'],emparts['s2supplycounter'])) 98 | print ('Q: cap {}var {}kvarh ind {}var {}kvarh'.format(emparts['q2consume'],emparts['q2consumecounter'],emparts['q2supply'],emparts['q2supplycounter'])) 99 | print ('U: {}V I:{}A cos phi:{}°'.format(emparts['u2'],emparts['i2'],emparts['cosphi2'])) 100 | print ('----L3----') 101 | print ('P: consume:{}W {}kWh supply:{}W {}kWh'.format(emparts['p3consume'],emparts['p3consumecounter'],emparts['p3supply'],emparts['p3supplycounter'])) 102 | print ('S: consume:{}VA {}kVAh supply:{}VA {}kVAh'.format(emparts['s3consume'],emparts['s3consumecounter'],emparts['s3supply'],emparts['s3supplycounter'])) 103 | print ('Q: cap {}var {}kvarh ind {}var {}kvarh'.format(emparts['q3consume'],emparts['q3consumecounter'],emparts['q3supply'],emparts['q3supplycounter'])) 104 | print ('U: {}V I:{}A cos phi:{}°'.format(emparts['u3'],emparts['i3'],emparts['cosphi3'])) 105 | print ('Version: {}'.format(emparts['speedwire-version'])) 106 | -------------------------------------------------------------------------------- /speedwiredecoder.py: -------------------------------------------------------------------------------- 1 | """ 2 | * 3 | * by david-m-m 2019-Mar-17 4 | * by datenschuft 2020-Jan-04 5 | * 6 | * this software is released under GNU General Public License, version 2. 7 | * This program is free software; 8 | * you can redistribute it and/or modify it under the terms of the GNU General Public License 9 | * as published by the Free Software Foundation; version 2 of the License. 10 | * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; 11 | * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 12 | * See the GNU General Public License for more details. 13 | * 14 | * You should have received a copy of the GNU General Public License along with this program; 15 | * if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | * 17 | */ 18 | """ 19 | 20 | import binascii 21 | 22 | # unit definitions with scaling 23 | sma_units={ 24 | "W": 10, 25 | "VA": 10, 26 | "VAr": 10, 27 | "kWh": 3600000, 28 | "kVAh": 3600000, 29 | "kVArh": 3600000, 30 | "A": 1000, 31 | "V": 1000, 32 | "°": 1000, 33 | "Hz": 1000, 34 | } 35 | 36 | # map of all defined SMA channels 37 | # format: :(emparts_name>,,) 38 | sma_channels={ 39 | # totals 40 | 1:('pconsume','W','kWh'), 41 | 2:('psupply','W','kWh'), 42 | 3:('qconsume','VAr','kVArh'), 43 | 4:('qsupply','VAr','kVArh'), 44 | 9:('sconsume','VA','kVAh'), 45 | 10:('ssupply','VA','kVAh'), 46 | 13:('cosphi','°'), 47 | 14:('frequency','Hz'), 48 | # phase 1 49 | 21:('p1consume','W','kWh'), 50 | 22:('p1supply','W','kWh'), 51 | 23:('q1consume','VAr','kVArh'), 52 | 24:('q1supply','VAr','kVArh'), 53 | 29:('s1consume','VA','kVAh'), 54 | 30:('s1supply','VA','kVAh'), 55 | 31:('i1','A'), 56 | 32:('u1','V'), 57 | 33:('cosphi1','°'), 58 | # phase 2 59 | 41:('p2consume','W','kWh'), 60 | 42:('p2supply','W','kWh'), 61 | 43:('q2consume','VAr','kVArh'), 62 | 44:('q2supply','VAr','kVArh'), 63 | 49:('s2consume','VA','kVAh'), 64 | 50:('s2supply','VA','kVAh'), 65 | 51:('i2','A'), 66 | 52:('u2','V'), 67 | 53:('cosphi2','°'), 68 | # phase 3 69 | 61:('p3consume','W','kWh'), 70 | 62:('p3supply','W','kWh'), 71 | 63:('q3consume','VAr','kVArh'), 72 | 64:('q3supply','VAr','kVArh'), 73 | 69:('s3consume','VA','kVAh'), 74 | 70:('s3supply','VA','kVAh'), 75 | 71:('i3','A'), 76 | 72:('u3','V'), 77 | 73:('cosphi3','°'), 78 | # common 79 | 36864:('speedwire-version',''), 80 | } 81 | 82 | def decode_OBIS(obis): 83 | measurement=int.from_bytes(obis[0:2], byteorder='big' ) 84 | raw_type=int.from_bytes(obis[2:3], byteorder='big') 85 | if raw_type==4: 86 | datatype='actual' 87 | elif raw_type==8: 88 | datatype='counter' 89 | elif raw_type==0 and measurement==36864: 90 | datatype='version' 91 | else: 92 | datatype='unknown' 93 | print('unknown datatype: measurement {} datatype {} raw_type {}'.format(measurement,datatype,raw_type)) 94 | return (measurement,datatype) 95 | 96 | def decode_speedwire(datagram): 97 | emparts={} 98 | # process data only of SMA header is present 99 | if datagram[0:3]==b'SMA': 100 | # datagram length 101 | datalength=int.from_bytes(datagram[12:14],byteorder='big')+16 102 | #print('data lenght: {}'.format(datalength)) 103 | if datalength != 54: 104 | # serial number 105 | emID=int.from_bytes(datagram[20:24],byteorder='big') 106 | #print('seral: {}'.format(emID)) 107 | emparts['serial']=emID 108 | # timestamp 109 | timestamp=int.from_bytes(datagram[24:28],byteorder='big') 110 | #print('timestamp: {}'.format(timestamp)) 111 | # decode OBIS data blocks 112 | # start with header 113 | position=28 114 | while position