├── .gitignore ├── LICENSE ├── README.md ├── doc ├── Hub-4-10-manual.pdf ├── flexnetdc_user_guide.pdf ├── fx_mobile_install.pdf ├── fx_mobile_operator.pdf ├── fxr_operator.pdf ├── invertercharger_fx2012et.pdf ├── mate_mate2_installation_usermanual.pdf ├── mate_serial_communicationsguide.pdf ├── protocol │ ├── DCRegisters.md │ ├── FXRegisters.md │ ├── MATEMaster.md │ ├── MXRegisters.md │ ├── Protocol.md │ ├── StatusPages.md │ └── wireshark-startup-sequence.png └── pymate.png ├── examples ├── srv1 │ ├── README.md │ ├── __init__.py │ ├── collector.py │ ├── mate-collector │ ├── requirements.txt │ └── settings.py └── srv2 │ ├── __init__.py │ ├── bachnet.fcgi │ ├── environment.py │ ├── models.py │ ├── receiver.py │ ├── requirements.txt │ ├── run-fcgi.py │ └── settings.py ├── plot.py ├── pymate ├── __init__.py ├── cstruct.py ├── matecom.py ├── matenet │ ├── __init__.py │ ├── flexnetdc.py │ ├── fx.py │ ├── matedevice.py │ ├── matenet.py │ ├── matenet_pjon.py │ ├── matenet_ser.py │ ├── mx.py │ └── tester.py ├── packet_capture │ ├── Capture Hub FX CC DC.pcapng │ ├── README.md │ ├── __init__.py │ ├── mate_dissector.lua │ └── wireshark_tap.py ├── util.py └── value.py ├── readout.py ├── requirements.txt ├── scan.py ├── settings.py ├── setup.cfg ├── setup.py ├── testflexnet.py ├── testfx.py └── x.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Add your custom ignores here: 3 | .idea/ 4 | 5 | 6 | # Created by https://www.gitignore.io/api/python,macos,windows,visualstudiocode,pycharm 7 | # Edit at https://www.gitignore.io/?templates=python,macos,windows,visualstudiocode,pycharm 8 | 9 | ### macOS ### 10 | # General 11 | .DS_Store 12 | .AppleDouble 13 | .LSOverride 14 | 15 | # Icon must end with two \r 16 | Icon 17 | 18 | # Thumbnails 19 | ._* 20 | 21 | # Files that might appear in the root of a volume 22 | .DocumentRevisions-V100 23 | .fseventsd 24 | .Spotlight-V100 25 | .TemporaryItems 26 | .Trashes 27 | .VolumeIcon.icns 28 | .com.apple.timemachine.donotpresent 29 | 30 | # Directories potentially created on remote AFP share 31 | .AppleDB 32 | .AppleDesktop 33 | Network Trash Folder 34 | Temporary Items 35 | .apdisk 36 | 37 | ### PyCharm ### 38 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 39 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 40 | 41 | # User-specific stuff 42 | .idea/**/workspace.xml 43 | .idea/**/tasks.xml 44 | .idea/**/usage.statistics.xml 45 | .idea/**/dictionaries 46 | .idea/**/shelf 47 | 48 | # Generated files 49 | .idea/**/contentModel.xml 50 | 51 | # Sensitive or high-churn files 52 | .idea/**/dataSources/ 53 | .idea/**/dataSources.ids 54 | .idea/**/dataSources.local.xml 55 | .idea/**/sqlDataSources.xml 56 | .idea/**/dynamic.xml 57 | .idea/**/uiDesigner.xml 58 | .idea/**/dbnavigator.xml 59 | 60 | # Gradle 61 | .idea/**/gradle.xml 62 | .idea/**/libraries 63 | 64 | # Gradle and Maven with auto-import 65 | # When using Gradle or Maven with auto-import, you should exclude module files, 66 | # since they will be recreated, and may cause churn. Uncomment if using 67 | # auto-import. 68 | # .idea/modules.xml 69 | # .idea/*.iml 70 | # .idea/modules 71 | 72 | # CMake 73 | cmake-build-*/ 74 | 75 | # Mongo Explorer plugin 76 | .idea/**/mongoSettings.xml 77 | 78 | # File-based project format 79 | *.iws 80 | 81 | # IntelliJ 82 | out/ 83 | 84 | # mpeltonen/sbt-idea plugin 85 | .idea_modules/ 86 | 87 | # JIRA plugin 88 | atlassian-ide-plugin.xml 89 | 90 | # Cursive Clojure plugin 91 | .idea/replstate.xml 92 | 93 | # Crashlytics plugin (for Android Studio and IntelliJ) 94 | com_crashlytics_export_strings.xml 95 | crashlytics.properties 96 | crashlytics-build.properties 97 | fabric.properties 98 | 99 | # Editor-based Rest Client 100 | .idea/httpRequests 101 | 102 | # Android studio 3.1+ serialized cache file 103 | .idea/caches/build_file_checksums.ser 104 | 105 | # JetBrains templates 106 | **___jb_tmp___ 107 | 108 | ### PyCharm Patch ### 109 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 110 | 111 | # *.iml 112 | # modules.xml 113 | # .idea/misc.xml 114 | # *.ipr 115 | 116 | # Sonarlint plugin 117 | .idea/sonarlint 118 | 119 | ### Python ### 120 | # Byte-compiled / optimized / DLL files 121 | __pycache__/ 122 | *.py[cod] 123 | *$py.class 124 | 125 | # C extensions 126 | *.so 127 | 128 | # Distribution / packaging 129 | .Python 130 | build/ 131 | develop-eggs/ 132 | dist/ 133 | downloads/ 134 | eggs/ 135 | .eggs/ 136 | lib/ 137 | lib64/ 138 | parts/ 139 | sdist/ 140 | var/ 141 | wheels/ 142 | pip-wheel-metadata/ 143 | share/python-wheels/ 144 | *.egg-info/ 145 | .installed.cfg 146 | *.egg 147 | MANIFEST 148 | 149 | # PyInstaller 150 | # Usually these files are written by a python script from a template 151 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 152 | *.manifest 153 | *.spec 154 | 155 | # Installer logs 156 | pip-log.txt 157 | pip-delete-this-directory.txt 158 | 159 | # Unit test / coverage reports 160 | htmlcov/ 161 | .tox/ 162 | .nox/ 163 | .coverage 164 | .coverage.* 165 | .cache 166 | nosetests.xml 167 | coverage.xml 168 | *.cover 169 | .hypothesis/ 170 | .pytest_cache/ 171 | 172 | # Translations 173 | *.mo 174 | *.pot 175 | 176 | # Django stuff: 177 | *.log 178 | local_settings.py 179 | db.sqlite3 180 | 181 | # Flask stuff: 182 | instance/ 183 | .webassets-cache 184 | 185 | # Scrapy stuff: 186 | .scrapy 187 | 188 | # Sphinx documentation 189 | docs/_build/ 190 | 191 | # PyBuilder 192 | target/ 193 | 194 | # Jupyter Notebook 195 | .ipynb_checkpoints 196 | 197 | # IPython 198 | profile_default/ 199 | ipython_config.py 200 | 201 | # pyenv 202 | .python-version 203 | 204 | # pipenv 205 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 206 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 207 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 208 | # install all needed dependencies. 209 | #Pipfile.lock 210 | 211 | # celery beat schedule file 212 | celerybeat-schedule 213 | 214 | # SageMath parsed files 215 | *.sage.py 216 | 217 | # Environments 218 | .env 219 | .venv 220 | env/ 221 | venv/ 222 | ENV/ 223 | env.bak/ 224 | venv.bak/ 225 | 226 | # Spyder project settings 227 | .spyderproject 228 | .spyproject 229 | 230 | # Rope project settings 231 | .ropeproject 232 | 233 | # mkdocs documentation 234 | /site 235 | 236 | # mypy 237 | .mypy_cache/ 238 | .dmypy.json 239 | dmypy.json 240 | 241 | # Pyre type checker 242 | .pyre/ 243 | 244 | ### VisualStudioCode ### 245 | .vscode/* 246 | !.vscode/settings.json 247 | !.vscode/tasks.json 248 | !.vscode/launch.json 249 | !.vscode/extensions.json 250 | 251 | ### VisualStudioCode Patch ### 252 | # Ignore all local history of files 253 | .history 254 | 255 | ### Windows ### 256 | # Windows thumbnail cache files 257 | Thumbs.db 258 | ehthumbs.db 259 | ehthumbs_vista.db 260 | 261 | # Dump file 262 | *.stackdump 263 | 264 | # Folder config file 265 | [Dd]esktop.ini 266 | 267 | # Recycle Bin used on file shares 268 | $RECYCLE.BIN/ 269 | 270 | # Windows Installer files 271 | *.cab 272 | *.msi 273 | *.msix 274 | *.msm 275 | *.msp 276 | 277 | # Windows shortcuts 278 | *.lnk 279 | 280 | # End of https://www.gitignore.io/api/python,macos,windows,visualstudiocode,pycharm 281 | -------------------------------------------------------------------------------- /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 | # pyMATE 2 | 3 | ![pyMATE](doc/pymate.png "pyMATE") 4 | 5 | pyMATE is a python library that can be used to emulate an Outback MATE unit, and talk to any supported 6 | Outback Power Inc. device such as an MX charge controller, an FX inverter, a FlexNET DC monitor, or a hub with 7 | multiple devices attached to it. 8 | 9 | You will need a simple adapter circuit and a TTL serial port. For more details, see [jared.geek.nz/pymate](http://jared.geek.nz/pymate) 10 | 11 | To see the library in action, check out my post on connecting it with Grafana! [jared.geek.nz/grafana-outback-solar](http://jared.geek.nz/grafana-outback-solar) 12 | 13 | ### Related Projects 14 | 15 | - [outback_mate_rs232](https://github.com/Ryanf55/outback_mate_rs232) - For use with the Mate's RS232 port 16 | - [uMATE](https://github.com/jorticus/uMATE) - Companion Arduino library, featuring more reliable communication and better perf 17 | 18 | ## MX/CC Charge Controller Interface 19 | 20 | To set up communication with an MX charge controller: 21 | 22 | ```python 23 | mate_bus = MateNET('COM1') # Windows 24 | mate_bus = MateNET('/dev/ttyUSB0') # Linux 25 | 26 | mate_mx = MateMXDevice(mate_bus, port=0) # 0: No hub. 1-9: Hub port 27 | mate_mx.scan() # This will raise an exception if the device isn't found 28 | ``` 29 | 30 | Or to automatically a hub for an attached MX: 31 | ```python 32 | bus = MateNET('COM1', supports_spacemark=False) 33 | mate = MateMXDevice(bus, port=bus.find_device(MateNET.DEVICE_MX)) 34 | 35 | # Check that an MX unit is attached and is responding 36 | mate.scan() 37 | ``` 38 | 39 | You can now communicate with the MX as though you are a MATE device. 40 | 41 | ### Status 42 | 43 | You can query a status with `mate_mx.get_status()`. This will return an [MXStatusPacket](matenet/mx.py#L14) with the following information: 44 | 45 | ```python 46 | status = mate_mx.get_status() 47 | status.amp_hours # 0 - 255 Ah 48 | status.kilowatt_hours # 0.0 - 6553.5 kWh 49 | status.pv_current # 0 - 255 A 50 | status.bat_current # 0 - 255 A 51 | status.pv_voltage # 0.0 - 6553.5 V 52 | status.bat_voltage # 0.0 - 6553.5 V 53 | status.status # A status code. See MXStatusPacket.STATUS_* constants. 54 | status.errors # A 8 bit bit-field (documented in Outback's PDF) 55 | ``` 56 | 57 | All values are floating-point numbers with units attached. You can convert them to real floats with eg. `float(status.pv_voltage) # 123.4`, or display them as a human-friendly string with `str(status.pv_voltage) # '123.4 V'` 58 | 59 | ### Log Pages 60 | 61 | You can also query a log page (just like you can on the MATE), up to 127 days in the past: (Logpages are stored at midnight, 0 is the current day so far) 62 | 63 | ```python 64 | logpage = mate_mx.get_logpage(-1) # Yesterday's logpage 65 | logpage.bat_max # 0.0 - 102.3 V 66 | logpage.bat_min # 0.0 - 102.3 V 67 | logpage.kilowatt_hours # 0.0 - 409.5 kWh 68 | logpage.amp_hours # 0 - 16383 Ah 69 | logpage.volts_peak # 0 - 255 Vpk 70 | logpage.amps_peak # 0.0 - 102.3 Apk 71 | logpage.absorb_time # 4095 min (minutes) 72 | logpage.float_time # 4095 min 73 | logpage.kilowatts_peak # 0.000 - 2.047 kWpk 74 | logpage.day # 0 .. -127 75 | ``` 76 | 77 | ### Properties 78 | 79 | Additionally, you can query individual registers (just like you can on the MATE - though it's buried quite deep in the menus somewhere) 80 | 81 | ```python 82 | mate_mx.charger_watts 83 | mate_mx.charger_kwh 84 | mate_mx.charger_amps_dc 85 | mate_mx.bat_voltage 86 | mate_mx.panel_voltage 87 | mate_mx.status 88 | mate_mx.aux_relay_mode 89 | mate_mx.max_battery 90 | mate_mx.voc 91 | mate_mx.max_voc 92 | mate_mx.total_kwh_dc 93 | mate_mx.total_kah 94 | mate_mx.max_wattage 95 | mate_mx.setpt_absorb 96 | mate_mx.setpt_float 97 | ``` 98 | 99 | Note that to read each of these properties a separate message must be sent, so it will be slower than getting values from a status packet. 100 | 101 | ## FX Inverter Interface 102 | 103 | To set up communication with an FX inverter: 104 | 105 | ```python 106 | mate_bus = MateNET('COM1') # Windows 107 | mate_bus = MateNET('/dev/ttyUSB0') # Linux 108 | 109 | mate_fx = MateDCDevice(bus, port=bus.find_device(MateNET.DEVICE_FX)) 110 | mate_fx.scan() 111 | 112 | status = mate_fx.get_status() 113 | errors = mate_fx.errors 114 | warnings = mate_fx.warnings 115 | ``` 116 | 117 | ### Controls 118 | 119 | You can control an FX unit like you can through the MATE unit: 120 | 121 | ```python 122 | mate_fx.inverter_control = 0 # 0: Off, 1: Search, 2: On 123 | mate_fx.acin_control = 0 # 0: Drop, 1: Use 124 | mate_fx.charge_control = 0 # 0: Off, 1: Auto, 2: On 125 | mate_fx.aux_control = 0 # 0: Off, 1: Auto, 2: On 126 | mate_fx.eq_control = 0 # 0: Off, 1: Auto, 2: On 127 | ``` 128 | 129 | These are implemented as python properties, so you can read and write them. Writing to them affects the FX unit. 130 | 131 | **WARNING**: Setting inverter_control to 0 **WILL** cut power to your house! 132 | 133 | ### Properties 134 | 135 | There are a bunch of interesting properties, many of which are not available from the status packet: 136 | 137 | ```python 138 | mate_fx.disconn_status 139 | mate_fx.sell_status 140 | mate_fx.temp_battery 141 | mate_fx.temp_air 142 | mate_fx.temp_fets 143 | mate_fx.temp_capacitor 144 | mate_fx.output_voltage 145 | mate_fx.input_voltage 146 | mate_fx.inverter_current 147 | mate_fx.charger_current 148 | mate_fx.input_current 149 | mate_fx.sell_current 150 | mate_fx.battery_actual 151 | mate_fx.battery_temp_compensated 152 | mate_fx.absorb_setpoint 153 | mate_fx.absorb_time_remaining 154 | mate_fx.float_setpoint 155 | mate_fx.float_time_remaining 156 | mate_fx.refloat_setpoint 157 | mate_fx.equalize_setpoint 158 | mate_fx.equalize_time_remaining 159 | ``` 160 | 161 | ## FLEXnet DC Power Monitor Interface 162 | 163 | To set up communication with a FLEXnet DC power monitor: 164 | 165 | ```python 166 | mate_bus = MateNET('COM1') # Windows 167 | mate_bus = MateNET('/dev/ttyUSB0') # Linux 168 | 169 | mate_dc = MateDCDevice(bus, port=bus.find_device(MateNET.DEVICE_FLEXNETDC)) 170 | 171 | mate_dc.scan() 172 | 173 | status = mate_dc.get_status() 174 | ``` 175 | 176 | The following information is available through `get_status()`: 177 | - State of Charge (%) 178 | - Battery Voltage (0-80V, 0.1V resolution) 179 | - Current kW/Amps for Shunts A/B/C 180 | - Current kW/Amps for In/Out/Battery (Max +/-1000A, 10W/0.1A resolution) 181 | - Daily kWH/Ah for Shunts A/B/C & In/Out/Battery/Net 182 | - Daily minimum State of Charge 183 | - Days since last full charge (0.1 day resolution) 184 | 185 | You can manually reset daily accumulated values by writing to certain registers, 186 | but this is not yet implemented. 187 | 188 | ## Example Server 189 | 190 | For convenience, a simple server is included that captures data periodically 191 | and uploads it to a remote server via a REST API. 192 | The remote server then stores the received data into a database of your choice. 193 | 194 | 195 | ## PJON Bridge 196 | 197 | The default serial interface doesn't always work well, and it's not the most efficient, 198 | so there is an alternative protocol you can use which pipes the data to an Arduino via PJON protocol. 199 | 200 | To use this alternative protocol: 201 | 202 | ```python 203 | port = MateNETPJON('COM1') 204 | bus = MateNET(port) 205 | ``` 206 | 207 | See [this page](https://github.com/jorticus/uMATE/blob/master/examples/Bridge/Bridge.ino) in my uMATE project for an example bridge implementation. 208 | 209 | ## MATE Protocol RE ### 210 | 211 | For details on the low-level communication protocol and available registers, see [doc/protocol/Protocol.md](doc/protocol/Protocol.md) 212 | 213 | --- 214 | 215 | I am open to contributions, especially if you can test it with any devices I don't have. 216 | -------------------------------------------------------------------------------- /doc/Hub-4-10-manual.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorticus/pymate/a90c973e54f71852bc3f1bb075b3d0a3599c8883/doc/Hub-4-10-manual.pdf -------------------------------------------------------------------------------- /doc/flexnetdc_user_guide.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorticus/pymate/a90c973e54f71852bc3f1bb075b3d0a3599c8883/doc/flexnetdc_user_guide.pdf -------------------------------------------------------------------------------- /doc/fx_mobile_install.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorticus/pymate/a90c973e54f71852bc3f1bb075b3d0a3599c8883/doc/fx_mobile_install.pdf -------------------------------------------------------------------------------- /doc/fx_mobile_operator.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorticus/pymate/a90c973e54f71852bc3f1bb075b3d0a3599c8883/doc/fx_mobile_operator.pdf -------------------------------------------------------------------------------- /doc/fxr_operator.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorticus/pymate/a90c973e54f71852bc3f1bb075b3d0a3599c8883/doc/fxr_operator.pdf -------------------------------------------------------------------------------- /doc/invertercharger_fx2012et.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorticus/pymate/a90c973e54f71852bc3f1bb075b3d0a3599c8883/doc/invertercharger_fx2012et.pdf -------------------------------------------------------------------------------- /doc/mate_mate2_installation_usermanual.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorticus/pymate/a90c973e54f71852bc3f1bb075b3d0a3599c8883/doc/mate_mate2_installation_usermanual.pdf -------------------------------------------------------------------------------- /doc/mate_serial_communicationsguide.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorticus/pymate/a90c973e54f71852bc3f1bb075b3d0a3599c8883/doc/mate_serial_communicationsguide.pdf -------------------------------------------------------------------------------- /doc/protocol/DCRegisters.md: -------------------------------------------------------------------------------- 1 | # FLEXnet DC Registers 2 | 3 | Address |Description | Units / Values | R/W | MATE Screen 4 | --------|-----------------------------------|---------------------------------------------------|---------------|------------- 5 | 0000 | Device type | 0004 = DC | | 6 | 0001 | ?? | 0000 | | 7 | 0002 | FW Rev A (AAA.BBB.CCCC) | AAA | | 8 | 0003 | FW Rev B | BBB | | 9 | 0004 | FW Rev C | CCCC | | 10 | 00D5 | State of Charge | % 0064 = 100% | | 11 | 00D8 | Aux control voltage/SOC | ??? | | 12 | ~ | | | | 13 | 0024 | Shunt A Charged | ?? | | METER/DC/SHUNT 14 | 0026 | Shunt B Charged | ?? | | METER/DC/SHUNT 15 | 0028 | Shunt C Charged | ?? | | METER/DC/SHUNT 16 | 002A | Shunt A Removed | ?? | | METER/DC/SHUNT 17 | 002C | Shunt B Removed | ?? | | METER/DC/SHUNT 18 | 002E | Shunt C Removed | ?? | | METER/DC/SHUNT 19 | 003E | Shunt A Charged | ?? | | METER/DC/SHUNT 20 | 0040 | Shunt B Charged | ?? | | METER/DC/SHUNT 21 | 0042 | Shunt C Charged | ?? | | METER/DC/SHUNT 22 | 0044 | Shunt A Removed | ?? | | METER/DC/SHUNT 23 | 0046 | Shunt B Removed | ?? | | METER/DC/SHUNT 24 | 0048 | Shunt C Removed | ?? | | METER/DC/SHUNT 25 | ~ | | | | 26 | 0066 | Shunt A Max Charged Amps | A 2920 = 1052.8A | RESET | METER/DC/SHUNT 27 | 0068 | Shunt A Max Charged kWatts | kW 1C38 = 72.240kW | RESET | METER/DC/SHUNT 28 | 006A | Shunt B Max Charged Amps | | RESET | METER/DC/SHUNT 29 | 006C | Shunt B Max Charged kWatts | | RESET | METER/DC/SHUNT 30 | 006E | Shunt C Max Charged Amps | | RESET | METER/DC/SHUNT 31 | 0070 | Shunt C Max Charged kWatts | | RESET | METER/DC/SHUNT 32 | 0072 | Shunt A Max Removed Amps | A | RESET | METER/DC/SHUNT 33 | 0074 | Shunt A Max Removed kWatts | kW | RESET | METER/DC/SHUNT 34 | 0076 | Shunt B Max Removed Amps | A | RESET | METER/DC/SHUNT 35 | 0078 | Shunt B Max Removed kWatts | | RESET | METER/DC/SHUNT 36 | 007A | Shunt C Max Removed Amps | | RESET | METER/DC/SHUNT 37 | 007C | Shunt C Max Removed kWatts | | RESET | METER/DC/SHUNT 38 | ~ | | | | 39 | 0010 | Temp comp'd batt setpoint | V DC 011F = 28.7 vdc | | STATUS/DC/BATT 40 | 001C | Lifetime kAh removed | kAh | RESET = 00FF | STATUS/DC/BATT 41 | 0058 | Battery min today | V DC | RESET* | STATUS/DC/BATT 42 | 00EC | RESET Battery min today | - | RESET = 00FF | STATUS/DC/BATT 43 | 005A | Battery max today | V DC 02D2 = 72.2 vdc | RESET* | STATUS/DC/BATT 44 | 00EB | RESET Battery max today | - | RESET = 00FF | STATUS/DC/BATT 45 | 0062 | Days since charge parameters met | days /10 | RESET* | STATUS/DC/BATT 46 | 0064 | Total days at 100 | days | RESET = 0000 | STATUS/DC/BATT 47 | 00D1 | Cycle kWhr charge efficiency | % (0064 = 100%) | | STATUS/DC/BATT 48 | 00D7 | Cycle charge factor | % (0064 = 100%) | | STATUS/DC/BATT 49 | 00F0 | System battery temperature | degC (00FE = Not present) | | STATUS/DC/BATT 50 | ~ | | | | 51 | 0034 | Battery capacity | 0 Ah, 0190 = 400Ah, 0208 = 520Ah | [INC DEC ±10] | ADV/DC/… (Setup) 52 | 00CA | Shunt A Mod | 0:Enabled
1:Disabled
(Default: Enabled) | [EN DIS] | ADV/DC/… (Setup) 53 | 00CB | Shunt B Mode | 0:Enabled
1:Disabled
(Default: Disabled) | [EN DIS] | ADV/DC/… (Setup) 54 | 00CC | Shunt C Mode | 0:Enabled
1:Disabled
(Default: Disabled) | [EN DIS] | ADV/DC/… (Setup) 55 | 005C | Return Amps | 0.0 A (0050 = 8.0A) | [INC DEC ±0.1]| ADV/DC/… (Setup) 56 | 005E | Battery voltage | 00.0 V (011F = 28.7V) | [INC DEC ±0.1]| ADV/DC/… (Setup) 57 | 00DA | Parameters met time | minutes (0001=1 min) | [INC DEC ±1] | ADV/DC/… (Setup) 58 | 00D4 | Charge factor | % (005E = 94%) | [INC DEC ±1] | ADV/DC/… (Setup) 59 | 00D8 | Aux Control | 0:Off, 1:Auto, 2:On (Default: Off) | [OFF AUTO ON] | ADV/DC/… (Setup) 60 | 0060 | High volts | 00.0 V DC (008C = 14.0 vdc) | [INC DEC ±0.1]| ADV/DC/… (Setup) 61 | 007E | Low volts | 00.0 V DC (0078 = 12.0 vdc) | [INC DEC ±0.1]| ADV/DC/… (Setup) 62 | 00D9 | SOC High | % (Default: 0%) | [INC DEC ±1] | ADV/DC/… (Setup) 63 | 00DB | SOC Low | % (Default: 0%) | [INC DEC ±1] | ADV/DC/… (Setup) 64 | 00E0 | High setpoint delay | minutes (0001 = 1 min) | [INC DEC ±1] | ADV/DC/… (Setup) 65 | 00E1 | Low setpoint delay | minutes (0001 = 1 min) | [INC DEC ±1] | ADV/DC/… (Setup) 66 | 00D3 | Aux logic invert | 0:No**, 1:Yes** (Default: No) | [YES:0 NO:1] | ADV/DC/… (Setup) 67 | 68 | **NOTE**: Do not rely on this information as it was determined by poking values at a MATE, not by observing actual communication. Ensure you do your on testing before relying on this information! 69 | 70 | All values are 16-bit signed integers 71 | 72 | For rows marked `RESET`, writing to these registers will reset them to 0. 73 | For rows marked `RESET*`, registers are reset by writing to a *different* register. 74 | 75 | **Bug: In register `00D3` The YES button sends 0000, which is read back as NO. 76 | -------------------------------------------------------------------------------- /doc/protocol/FXRegisters.md: -------------------------------------------------------------------------------- 1 | # FX Registers 2 | 3 | Address |Description | Units / Value | R/W | MATE Screen 4 | --------|---------------------------|------------------------|-----|------------- 5 | 0000 | Device type | 0003 = FX | | 6 | 0001 | FW Revision | | | 7 | 0002 | FW Rev A (AAA.BBB.CCCC) | AAA | | STATUS/FX/METER 8 | 0003 | FW Rev B | BBB | | STATUS/FX/METER 9 | 0004 | FW Rev C | CCC | | STATUS/FX/METER 10 | 000A | Float setpoint | V/10 | | STATUS/FX/BATT 11 | 000B | Absorb setpoint | V/10 | | STATUS/FX/BATT 12 | 000C | Equalize setpoint | V/10 | | STATUS/FX/BATT 13 | 000D | Refloat Setpoint | V/10 | | STATUS/FX/BATT 14 | 0016 | Battery temp compensated | V/10 | | STATUS/FX/BATT 15 | 0019 | Battery actual | V/10 | | STATUS/FX/BATT 16 | 002C | Input voltage | V | | STATUS/FX/METER 17 | 002D | Output voltage | V | | STATUS/FX/METER 18 | 0032 | Battery temperature | 0..255 | | STATUS/FX/BATT 19 | 0033 | Air temperature | 0..255 | | STATUS/FX/WARN 20 | 0034 | MOSFET temperature | 0..255 | | STATUS/FX/WARN 21 | 0035 | Capacitor temperature | 0..255 | | STATUS/FX/WARN 22 | 0038 | Equalize mode | 0: Off | R/W | STATUS/FX/MODE 23 | 0039 | Errors | bitfield | | STATUS/FX/ERROR 24 | 003A | AC IN mode | 0: Drop 1: Use | R/W | STATUS/FX/MODE 25 | 003C | Charger mode | 0: Off 1: Auto 2: On | R/W | STATUS/FX/MODE 26 | 003D | Inverter mode | 0: Off 1: Search 2: On | R/W | STATUS/FX/MODE 27 | 0059 | Warnings | bitfield | | STATUS/FX/WARN 28 | 005A | Aux mode | 0: Off 1: Auto 2: On | R/W | STATUS/FX/MODE 29 | 006A | Charger current | A/10 | | STATUS/FX/METER 30 | 006B | Sell current | A/10 | | STATUS/FX/METER 31 | 006C | Input current | A/10 | | STATUS/FX/METER 32 | 006D | Inverter current | A/10 | | STATUS/FX/METER 33 | 006E | Float time remaining | h/10 | | STATUS/FX/BATT 34 | 0070 | Absorb time remaining | h/10 | | STATUS/FX/BATT 35 | 0071 | Equalize time remaining | h/10 | | STATUS/FX/BATT 36 | 0084 | Disconn status | enum | | STATUS/FX/DISCONN 37 | 008F | Sell status | enum | | STATUS/FX/SELL 38 | 0029 | Search sensitivity | int | R/W [INC/DEC] | ADV/FX/INVERTER 39 | 0062 | Search pulse length | 0 cycles | R/W [INC/DEC] | ADV/FX/INVERTER 40 | 0063 | Search pulse spacing | 0 cycles | R/W [INC/DEC] | ADV/FX/INVERTER 41 | 000E | Low battery cutout setpt | V/10 | R/W [INC/DEC] | ADV/FX/INVERTER 42 | 000F | Low battery cutin setpt | V/10 | R/W [INC/DEC] | ADV/FX/INVERTER 43 | 0083 | Adjust output voltage | VAC | R/W [INC/DEC] | ADV/FX/INVERTER 44 | 0028 | Charger limit | AAC/10 | R/W [INC/DEC] | ADV/FX/CHARGER 45 | 000B | Absorb setpoint | VDC/10 | R/W [INC/DEC] | ADV/FX/CHARGER 46 | 001F | Absorb time limit | hrs/10 | R/W [INC/DEC] | ADV/FX/CHARGER 47 | 000A | Float setpoint | VDC/10 | R/W [INC/DEC] | ADV/FX/CHARGER 48 | 0021 | Float time period | hrs/10 | R/W [INC/DEC] | ADV/FX/CHARGER 49 | 000D | Refloat setpoint | VDC/10 | R/W [INC/DEC] | ADV/FX/CHARGER 50 | 000C | Equalize setpoint | VDC/10 | R/W [INC/DEC] | ADV/FX/CHARGER 51 | 0020 | Equalize time period | hrs/10 | R/W [INC/DEC] | ADV/FX/CHARGER 52 | 002A | AC1/Grid lower limit | VAC | R/W [INC/DEC] | ADV/FX/GRID 53 | 002B | AC1/Grid upper limit | VAC | R/W [INC/DEC] | ADV/FX/GRID 54 | 0027 | AC1/Grid input limit | AAC/10 | R/W [INC/DEC] | ADV/FX/GRID 55 | 004D | AC1/Grid transfer delay | cycles | R/W [INC/DEC] | ADV/FX/GRID 56 | 0037 | Gen input connect delay | min/10 | R/W [INC/DEC] | ADV/FX/GEN 57 | 0044 | AC2/Gen lower limit | VAC | R/W [INC/DEC] | ADV/FX/GEN 58 | 0045 | AC2/Gen upper limit | VAC | R/W [INC/DEC] | ADV/FX/GEN 59 | 007B | AC2/Gen input limit | AAC/10 | R/W [INC/DEC] | ADV/FX/GEN 60 | 0022 | AC2/Gen transfer delay | cycles | R/W [INC/DEC] | ADV/FX/GEN 61 | 003B | AC2/Gen support | ON/OFF | R/W [OFF/ON] | ADV/FX/GEN 62 | 005A | Aux output control | Auto | R/W [INC/DEC] | ADV/FX/AUX 63 | 003E | Aux output function | Remote/... | R/W [INC/DEC] | ADV/FX/AUX 64 | 0011 | Genalert on setpont | VDC/10 | R/W [INC/DEC] | ADV/FX/AUX 65 | 003F | Genalert on delay | minutes | R/W [INC/DEC] | ADV/FX/AUX 66 | 0010 | Genalert off setpoint | VDC/10 | R/W [INC/DEC] | ADV/FX/AUX 67 | 0040 | Genalert off delay | minutes | R/W [INC/DEC] | ADV/FX/AUX 68 | 0012 | Loadshed off setpoint | VDC/10 | R/W [INC/DEC] | ADV/FX/AUX 69 | 0013 | Ventfan on setpoint | VDC/10 | R/W [INC/DEC] | ADV/FX/AUX 70 | 0042 | Ventfan off period | minutes | R/W [INC/DEC] | ADV/FX/AUX 71 | 0014 | Diversion on setpoint | VDC/10 | R/W [INC/DEC] | ADV/FX/AUX 72 | 006F | Diversion off delay | seconds | R/W [INC/DEC] | ADV/FX/AUX 73 | 0079 | Stack 1-2ph phase | Master/... | R/W [INC/DEC] | ADV/FX/STACK 74 | 0075 | Power save level (master) | 0 | R/W [INC/DEC] | ADV/FX/STACK 75 | 0074 | Power save level (slave) | 0 | R/W [INC/DEC] | ADV/FX/STACK 76 | 001B | Sell RE volts | VDC/10 | R/W [INC/DEC] | ADV/FX/SELL 77 | 0067 | Grid tie window | "IEEE"/... | R/W [INC/DEC] | ADV/FX/SELL 78 | 008A | Grid tie authority | "No sell"/... | R/W [INC/DEC] | ADV/FX/SELL 79 | 002C | VAC input adjustment | VAC | R/W [INC/DEC] | ADV/FX/CALIBRATE 80 | 002D | VAC output adjustment | VAC | R/W [INC/DEC] | ADV/FX/CALIBRATE 81 | 0019 | Battery adjustment | VDC/10 | R/W [INC/DEC] | ADV/FX/CALIBRATE 82 | 0058 | RESET SEQUENCE 1 | 0062 | R/W | ADV/FX/INVERTER 83 | ???? | RESET SEQUENCE 2 | ???? | R/W | ADV/FX/INVERTER 84 | 85 | 86 | **NOTE**: Do not rely on this information as it was determined by poking values at a MATE, not by observing actual communication. Ensure you do your on testing before relying on this information! 87 | 88 | **TODO**: The FX has an interesting sequence for performing factory reset. 89 | 90 | All values are 16-bit signed integers 91 | 92 | ## Enums / Bitfields 93 | 94 | ``` python 95 | # Error bit-field 96 | ERROR_LOW_VAC_OUTPUT = 0x01 # Inverter could not supply enough AC voltage to meet demand 97 | ERROR_STACKING_ERROR = 0x02 # Communication error among stacked FX inverters (eg. 3 phase system) 98 | ERROR_OVER_TEMP = 0x04 # FX has reached maximum allowable temperature 99 | ERROR_LOW_BATTERY = 0x08 # Battery voltage below low battery cut-out setpoint 100 | ERROR_PHASE_LOSS = 0x10 101 | ERROR_HIGH_BATTERY = 0x20 # Battery voltage rose above safe level for 10 seconds 102 | ERROR_SHORTED_OUTPUT = 0x40 103 | ERROR_BACK_FEED = 0x80 # Another power source was connected to the FX's AC output 104 | ``` 105 | ``` python 106 | # Warning bit-field 107 | WARN_ACIN_FREQ_HIGH = 0x01 # >66Hz or >56Hz 108 | WARN_ACIN_FREQ_LOW = 0x02 # <54Hz or <44Hz 109 | WARN_ACIN_V_HIGH = 0x04 # >140VAC or >270VAC 110 | WARN_ACIN_V_LOW = 0x08 # <108VAC or <207VAC 111 | WARN_BUY_AMPS_EXCEEDS_INPUT = 0x10 112 | WARN_TEMP_SENSOR_FAILED = 0x20 # Internal temperature sensors have failed 113 | WARN_COMM_ERROR = 0x40 # Communication problem between us and the FX 114 | WARN_FAN_FAILURE = 0x80 # Internal cooling fan has failed 115 | ``` 116 | ``` python 117 | # Operational Mode enum 118 | STATUS_INV_OFF = 0 119 | STATUS_SEARCH = 1 120 | STATUS_INV_ON = 2 121 | STATUS_CHARGE = 3 122 | STATUS_SILENT = 4 123 | STATUS_FLOAT = 5 124 | STATUS_EQ = 6 125 | STATUS_CHARGER_OFF = 7 126 | STATUS_SUPPORT = 8 # FX is drawing power from batteries to support AC 127 | STATUS_SELL_ENABLED = 9 # FX is exporting more power than the loads are drawing 128 | STATUS_PASS_THRU = 10 # FX converter is off, passing through line AC 129 | ``` 130 | ``` python 131 | # Reasons that the FX has stopped selling power to the grid 132 | # (Sell Status) 133 | SELL_STOP_REASONS = { 134 | 1: 'Frequency shift greater than limits', 135 | 2: 'Island-detected wobble', 136 | 3: 'VAC over voltage', 137 | 4: 'Phase lock error', 138 | 5: 'Charge diode battery volt fault', 139 | 7: 'Silent command', 140 | 8: 'Save command', 141 | 9: 'R60 off at go fast', 142 | 10: 'R60 off at silent relay', 143 | 11: 'Current limit sell', 144 | 12: 'Current limit charge', 145 | 14: 'Back feed', 146 | 15: 'Brute sell charge VAC over' 147 | } 148 | ``` -------------------------------------------------------------------------------- /doc/protocol/MATEMaster.md: -------------------------------------------------------------------------------- 1 | # MATE Master Duties 2 | 3 | The MATE itself periodically sends commands by itself to other devices in the system. 4 | 5 | In particular it has the following duties: 6 | 7 | - Date / Time synchronization 8 | - Battery Temperature synchronization 9 | - FBX (Battery Recharging) 10 | - AGS (Automatic Generator System) 11 | - FN-DC Net AmpHours charge float feature 12 | 13 | 14 | If you are replacing the MATE with pyMATE, I recommend you implement the below features, or connect pyMATE as a 2nd mate. 15 | 16 | See `MateDevice.synchronize()`. 17 | 18 | ## Time / Date Synchronization ## 19 | 20 | The registers [`4004`/`4005`] are written every 30 sec to MX/DC devices (not FX), encoded in a particular format: 21 | 22 | ``` 23 | [4004] (TIME) 24 | Bits 15..11 : Hour (24h) 25 | Bits 10..5 : Minute 26 | Bits 4..0 : Second (*2) 27 | 28 | [4005] (DATE) 29 | Bits 15..9 : Year (2000..2127) 30 | Bits 8..5 : Month (0..12) 31 | Bits 4..0 : Day (0..31) 32 | ``` 33 | 34 | 9:09:49 PM would encode as `(21<<11) | (09<<5) | (49>>1) == 0xA938`. 35 | 36 | 2020-04-31 would encode as `((2020-2000)<<9) | (04<<5) | (31) == 0x289F`. 37 | 38 | Presumably this is used to synchronize the MX/DC's internal clock to the MATE, so they know when to do things like resetting counters at midnight. Without this they still seem to be able to function properly, but I imagine they would get out of sync over time. 39 | 40 | 41 | ## Battery Temperature Synchronization ## 42 | 43 | Every 1 minute the MATE will read register [`4000`] from the MX/CC and forward the value to register [`4001`] for attached FX/DC devices. 44 | 45 | I believe this register contains the raw battery NTC temperature sensor value, which the DC converts to DegC. 46 | 47 | The battery temperature can be read from the DC at register [`00f0`], and reports the temperature in DegC. This register gets updated when register [`4001`] is written to. 48 | 49 | ``` 50 | Temperature Mapping: (CC[`4000`] : DC[`00f0`]) 51 | 118 : 28C : 0076 52 | 125 : 25C : 007d 53 | 129 : 24C : 0081 54 | 131 : 23C : 0083 55 | 133 : 23C : 0085 56 | 134 : 22C : 0086 57 | 138 : 21C : 008a 58 | 139 : 20C : 008b 59 | 60 | Approximate formula: 61 | DegC = Round((-0.3576 * raw_temp) + 70.1) 62 | ``` 63 | -------------------------------------------------------------------------------- /doc/protocol/MXRegisters.md: -------------------------------------------------------------------------------- 1 | # MX Registers 2 | 3 | Address |Description | Units / Value | R/W | MATE Screen 4 | --------|---------------------------|-------------------|-----|------------- 5 | 0000 | Device type | 0002 = MX | | 6 | 0001 | ?? | 0000 | | 7 | 0002 | FW Rev A (AAA.BBB.CCCC) | AAA | | STATUS/CC/METER 8 | 0003 | FW Rev B | BBB | | STATUS/CC/METER 9 | 0004 | FW Rev C | CCCC | | STATUS/CC/METER 10 | 0008 | Battery Voltage | V/10 | | STATUS/CC/METER 11 | 000F | Max Battery | V/10 | | STATUS/CC/STAT 12 | 0010 | VOC | V/10 | | STATUS/CC/STAT 13 | 0012 | Max VOC | V/10 | | STATUS/CC/STAT 14 | 0013 | Total kWh DC | kWh | | STATUS/CC/STAT 15 | 0014 | Total kAh | kAH | | STATUS/CC/STAT 16 | 0015 | Max Wattage | W | | STATUS/CC/STAT 17 | 0017 | Output current limit | A (tenths) | R/W | ADV/CC/CHGR 18 | 0018 | Float voltage | V | R/W | ADV/CC/CHGR 19 | 0019 | Absorb voltage | V | R/W | ADV/CC/CHGR 20 | 001E | Eq Voltage | V tenths | R/W | ADV/CC/EQ 21 | 00D2 | Eq Time | Hours | R/W | ADV/CC/EQ 22 | 00D3 | Auto Eq Interval | Days | R/W | ADV/CC/EQ 23 | 00CB | Aux Mode | 0: Float | R/W | ADV/CC/AUX 24 | 01C9 | Aux Output Control | 03: Off, 83: On | R/W | ADV/CC/AUX 25 | 0020 | Absorb end amps | A | R/W | ADV/CC/ADVANCED 26 | 00D4 | Snooze Mode | A (tenths) | R/W | ADV/CC/ADVANCED 27 | 0021 | Wakeup mode VOC change | V (tenths) | R/W | ADV/CC/ADVANCED 28 | 0022 | Wakeup mode time | Minutes | R/W | ADV/CC/ADVANCED 29 | 00D5 | MPPT mode | 0: Auto Track | R/W | ADV/CC/ADVANCED 30 | 00D6 | Grid tie mode | 0: NonGT | R/W | ADV/CC/ADVANCED 31 | 0023 | Park MPP | % tenths | R/W | ADV/CC/ADVANCED 32 | 00D7 | Mpp range limit %VOC | 0: minimum full | R/W | ADV/CC/ADVANCED 33 | 00D8 | Mpp range limit %VOC | 0: maximum 80% | R/W | ADV/CC/ADVANCED 34 | 00D9 | Absorb time | Hours tenths | R/W | ADV/CC/ADVANCED 35 | 001F | Rebulk voltage | VDC tenths | R/W | ADV/CC/ADVANCED 36 | 00DA | Vbatt Calibration | VDC | R/W | ADV/CC/ADVANCED 37 | 00DB | RTS Compensation | 0: Wide | R/W | ADV/CC/ADVANCED 38 | 0025 | RTS comp upper limit | V tenths | R/W | ADV/CC/ADVANCED 39 | 0024 | RTS comp lower limit | V tenths | R/W | ADV/CC/ADVANCED 40 | 00DC | Auto restart mode | ? | R/W | ADV/CC/ADVANCED 41 | 019B | RESET TO FACTORY DEFAULTS | ??? | R | ADV/CC/ADVANCED 42 | 00C8 | RESET TO FACTORY DEFAULTS | 00FF | W | ADV/CC/ADVANCED 43 | 0170 | SetPt Absorb | V/10 | | STATUS/CC/SETPT 44 | 0172 | SetPt Float | V/10 | | STATUS/CC/SETPT 45 | 016A | Charger Watts | W | | STATUS/CC/METER 46 | 01EA | Charger kWh | kWh/10 | | STATUS/CC/METER 47 | 01C6 | Panel Voltage | V | | STATUS/CC/METER 48 | 01C7 | Charger Amps DC | A (0:+128) | | STATUS/CC/METER 49 | 01C8 | Status | 0004:EQ | | STATUS/CC/MODE 50 | 01C9 | Aux Relay Mode / State | 0086:PV Trigger | | STATUS/CC/MODE 51 | 52 | 53 | **NOTE**: Do not rely on this information as it was determined by poking values at a MATE, not by observing actual communication. Ensure you do your on testing before relying on this information! 54 | 55 | All values are 16-bit signed integers 56 | 57 | ## Enums ## 58 | 59 | STATUS_SLEEPING = 0 60 | STATUS_FLOATING = 1 61 | STATUS_BULK = 2 62 | STATUS_ABSORB = 3 63 | STATUS_EQUALIZE = 4 -------------------------------------------------------------------------------- /doc/protocol/Protocol.md: -------------------------------------------------------------------------------- 1 | # MATE Protocol 2 | 3 | he MATE protocol is implemented using 24V logic, where HIGH is >50% of Vsupply, and LOW is <50%. 4 | Data is big-endian (most significant byte first) NOTE: Arduino/AVR is little-endian. 5 | Serial format is 9n1, 9600 baud 6 | 7 | The 9th bit is used to denote the start of the packet. 8 | The MATE is the master and will always drive the communication (the device cannot send any asynchronous responses or commands). Commands and response packets have different formats, and the length of the packet depends on what type of packet it is. There isn't an easy way to determine the length of a packet without knowing something about the protocol. 9 | 10 | For MATE->Device commands, the first byte is the destination port (if there is a hub), or 00 if there is no hub. 11 | For Device->MATE responses, the first byte matches the command ID that it is responding to. 12 | 13 | ``` 14 | Port (00: No Hub) 15 | | Command (02: Read) 16 | | | Register (00: Device Type) 17 | | | | Value (Unused) 18 | | | | | Checksum 19 | | | |………| |………| |………| 20 | TX: 100 02 00 00 00 00 00 03 (Command) 21 | RX: 102 00 04 00 06 (Response) 22 | | |………| |………| 23 | | | Checksum 24 | | Value (04: FLEXnet DC) 25 | Command (02: Read) 26 | ``` 27 | 28 | The above packet is reading register 0000h which always returns the type of device connected. 29 | 30 | The checksum is a simple sum of all bytes, excluding the 9th bit. 31 | 32 | Command Types: 33 | 34 | ``` 35 | 00: Increment/Disable 36 | 01: Decrement/Enable 37 | 02: Read 38 | 03: Write 39 | 04: Retrieve Status Page 40 | 22: Retrieve Log Page (MX Only) 41 | ``` 42 | 43 | Device Types: 44 | 45 | ``` 46 | 01: Hub 47 | 02: FX 48 | 03: MX 49 | 04: DC 50 | ``` 51 | 52 | ## Register Map ## 53 | 54 | Most settings & values are accessible through 16-bit registers. 55 | You can use commands 00 through 03 on registers. All registers can be read with the READ command (`0x02`), which will return the 16 bit value. 56 | 57 | Some registers allow you to directly modify them with a WRITE command (`0x03`). The written value will be returned in the response (if successful). 58 | 59 | Some registers will allow you to control things (eg. AUX relay, turn Inverter on/off) by sending a WRITE command (`0x03`). The value you write will set the state, and the new state will be returned in the response. 60 | 61 | Some registers allow you to Increment/Decrement or Enable/Disable them, by sending the appropriate command to that address. Increment/Decrement will change the value by a predetermined amount (eg. +/- 1.0 units, +/- 0.1 units). The new value will be returned in the response. 62 | 63 | Some registers allow you to Reset them by writing a specific value to the register's address. There are also registers that are reset by writing to a *different* address. This needs to be explored more... 64 | 65 | See the following pages for all known registers for each device type: 66 | 67 | [MXRegisters.md](MXRegisters.md) 68 | 69 | [FXRegisters.md](FXRegisters.md) 70 | 71 | [DCRegisters.md](DCRegisters.md) 72 | 73 | ## Status Pages ## 74 | 75 | Status pages are a special command, and will return a 13-byte response. 76 | The address defines which status page to return (only applicable to the FLEXnet DC). 77 | 78 | See [StatusPages.md](StatusPages.md) 79 | 80 | ## Log Pages ## 81 | 82 | Log pages follow the Status page command, and will return a 13-byte response. 83 | The address defines which day's log to return, where 0 = today so far, 1 = 1 day ago, up to 128 = 128 days ago. 84 | 85 | ## First Connect & Hubs ## 86 | 87 | On startup the MATE will attempt to READ register 0x0000 on port 0. 88 | 89 | If it finds devicetype==0 (Hub), then it will continue to read register 0x0000 on ports 1..B. Connected devices will respond to this with their device type, as defined above. 90 | 91 | The MATE will also re-poll every 30 seconds by repeating this process. 92 | 93 | > ![pyMATE](wireshark-startup-sequence.png "Wireshark Startup Sequence") 94 | 95 | 96 | If a Hub is attached, it will simply look at the first byte coming from the MATE to determine which port to send the packet to. It will not modify the packet. Any responses go back to the MATE port. 97 | 98 | I have not explored to see what happens if two MATEs are attached to a Hub. 99 | 100 | If you have multiple FX Inverters connected, I believe they synchronize using an out-of-band channel on the CAT5 cable. I have not experimented with this as I only have one FX. 101 | -------------------------------------------------------------------------------- /doc/protocol/StatusPages.md: -------------------------------------------------------------------------------- 1 | # Status Packets # 2 | 3 | All Outback devices provide Status packets in response to a Status command: 4 | 5 | ``` 6 | Port (00: No Hub) 7 | | Command (04: Status) 8 | | | Address (01: Status Page 01) 9 | | | | Value (Unused) 10 | | | | | Checksum 11 | | | |………| |………| |………| 12 | TX: 100 02 00 01 00 00 00 03 (Command) 13 | RX: 102 11 22 33 44 55 66 77 88 99 AA BB CC DD EE FF (Response) 14 | | |………………………………………………………………………………………………| |………| 15 | | | Checksum 16 | | Status page 17 | Device Type 18 | ``` 19 | 20 | Status responses are always 13 bytes long, but unlike other commands the first byte indicates the type of device (and therefore the type of status packet) that is being transmitted. 21 | 22 | Checksum is a simple sum of all bytes (not including the 9th bit) 23 | 24 | The MATE asks for a status page once per second, except for the FLEXnet DC status pages 0D..0F which are only queried when a particular screen is shown. 25 | 26 | # MX/CC # 27 | 28 | ``` 29 | 81 22 33 44 55 66 77 88 99 AA BB CC DD 30 | || | | | | | | | | |---| |---| 31 | || | | | | | | | | | +- in_voltage (uint16 / 10.0) 32 | || | | | | | | | | +------- out_voltage (uint16 / 10.0) 33 | || | | | | | | | +------------- kwh (int16 / 10.0, lower byte) 34 | || | | | | | | +---------------- error (bit field) 35 | || | | | | | +------------------- status (01..04) 36 | || | | | | +---------------------- aux mode / state 37 | || | | | +------------------------- AH lower byte (int12) 38 | || | | +---------------------------- kwh (int16 / 10.0, upper byte) 39 | || | +------------------------------- out_amps_dc (int8, 0x80=0.0) 40 | || +---------------------------------- in_amps_dc (int8, 0x80=0.0) 41 | |+------------------------------------- out_amps_dc (tenths, 0x01=0.1A, 0x0F=1.5A) FM80/FM60 only 42 | +-------------------------------------- AH upper nibble (int12, 0x800=0.0) 43 | ``` 44 | 45 | This status packet is quite tightly packed, and the signed integers are not your typical 2's complement. 46 | 47 | Also, the LCD cannot display all possible values and will truncate the top digit. In practice these undisplayable values should never be encountered. 48 | 49 | If bit7 in byte[0] is 0, then AH is not displayed in CC TOTALS screen. 50 | Presumably this is because the LCD can't display negative AmpHours, but the value is signed? Either that or there is a flag jammed into the upper nibble. 51 | 52 | Aux Mode is bits 0..5 (0x3F), Aux State is bit 6 (0x40) 53 | 54 | Sample values: 55 | ``` 56 | Mode: (blank) 57 | In 244.5 vdc (?)62 adc 58 | Out 70.7 vdc (?)79 adc 59 | ``` 60 | 61 | # FX # 62 | 63 | ``` 64 | 11 22 33 44 55 66 77 88 99 AA BB CC DD 65 | | | | | | | | | | |---| | | 66 | | | | | | | | | | | | +- warnings 67 | | | | | | | | | | | +---- misc_byte (bit0: 230V, bit7: Aux State) 68 | | | | | | | | | | +------- battery_voltage (int16 / 10.0) 69 | | | | | | | | | +------------- ac_mode (0: No AC, 1: AC Drop, 2: AC Use) 70 | | | | | | | | +---------------- errors 71 | | | | | | | +------------------- operational_mode 72 | | | | | | +---------------------- sell_current (uint8*) 73 | | | | | +------------------------- output_voltage (uint8*) 74 | | | | +---------------------------- input_voltage (uint8*) 75 | | | +------------------------------- buy_current (uint8*) 76 | +------------------------------------- chg_current (uint8*) 77 | ``` 78 | 79 | **NOTE:** When misc.230V == 1, you must multiply voltages by 2, and divide currents by 2 80 | 81 | 82 | # FLEXnet DC # 83 | 84 | The FLEXnet DC power monitor has multiple status pages, which are queried and combined. Pages 0A..0C are queried once per second, while pages 0D..0F are queried only every ~13 seconds. 85 | 86 | Pages 0A..0C should be combined before parsing, as some values straddle adjacent pages. 87 | 88 | **TODO:** The following is unaccounted for: 89 | - Shunt A/B/C enabled flag 90 | - Battery temperature 91 | - Status flags 92 | - Charge factor corrected battery AH/KWH 93 | 94 | ## PAGE 0A ## 95 | ``` 96 | ff c8 00 5b 00 00 01 00 4c ff f2 00 17 (Capture from real device) 97 | 11 22 33 44 55 66 77 88 99 AA BB CC DD 98 | |---| |---| |---| |---| | |---| |---| 99 | | | | | | | +- shuntb_kw (int16 / 100.0) 100 | | | | | | +------- shunta_kw (int16 / 100.0) 101 | | | | | +------------- soc (uint8) % 102 | | | | +---------------- bat_v (int16 / 10.0) 103 | | | +---------------------- shuntc_cur (int16 / 10.0) 104 | | +---------------------------- shuntb_cur (int16 / 10.0) 105 | +---------------------------------- shunta_cur (int16 / 10.0) 106 | ``` 107 | 108 | Sample values: 109 | ``` 110 | DC NOW 60.0V 153% 111 | DC BAT 60.0V 153% 112 | 113 | Shunt A 438.6A -18.290kW 114 | Shunt B 1312.4A -30.910kW 115 | Shunt C 2186.2A 0.000kW 116 | ``` 117 | 118 | ## PAGE 0B ## 119 | ``` 120 | 00 00 00 21 00 5b 00 38 00 23 00 17 00 (Capture from real device) 121 | 11 22 33 44 55 66 77 88 99 AA BB CC DD 122 | |---| | | |---| |---| |---| |---| +- now_out_kw (upper byte / 100.0) 123 | | | | | | | +---- now_in_kw (int16 / 100.0) 124 | | | | | | +---------- now_bat_cur (int16 / 10.0) 125 | | | | | +---------------- now_out_cur (int16 / 10.0) 126 | | | | +---------------------- now_in_cur (int16 / 10.0) 127 | | | +---------------------------- flags 128 | | +------------------------------- unknown 129 | +---------------------------------- shuntc_kw (int16 / 100.0) 130 | ``` 131 | 132 | Sample values: 133 | ``` 134 | DC NOW 135 | In 2186.2A -74.600kW 136 | Out 3060.0A -89.600kW 137 | Bat -2619.8A -0.000kW 138 | ``` 139 | 140 | flags: 141 | - bit7 : Full settings met 142 | - bit6 : Unknown 143 | - bit5 : Unknown 144 | - bit0 : Unknown 145 | 146 | 147 | ## PAGE 0C ## 148 | ``` 149 | 0e 00 09 00 51 00 6a ff e7 00 cf 01 02 (Capture from real device) 150 | 11 22 33 44 55 66 77 88 99 AA BB CC DD 151 | | |---| |---| |---| |---| |---| |---| 152 | | | | | | | +- today_out_kwh (int16 / 100.0) 153 | | | | | | +------- today_in_kwh (int16 / 100.0) 154 | | | | | +------------- today_bat_ah (int16) 155 | | | | +------------------- today_out_ah (int16) 156 | | | +------------------------- today_in_ah (int16) 157 | | +------------------------------- now_bat_kw (int16 / 100.0) 158 | +------------------------------------- now_out_kw (lower byte / 100.0) 159 | ``` 160 | 161 | Sample values: 162 | ``` 163 | DC NOW BAT KW : 87.550kW 164 | DC NOW OUT KW : .170kW DC TODAY IN AH : 7493AH -18.29kWH 165 | DC TODAY OUT AH : 6231AH -30.91kWH 166 | DC TODAY BAT AH : -567AH 0.00kWH 167 | 168 | DC TODAY OUT KWH : FF FF : 26.39kWH 169 | ``` 170 | 171 | ## PAGE 0D ## 172 | ``` 173 | ff cd 00 13 fe fa 02 76 01 70 ff 91 00 (Capture from real device) 174 | 11 22 33 44 55 66 77 88 00 00 00 00 00 175 | |---| |---| | 176 | | | +---------------------- unknown (0x01) 177 | | +---------------------------- days_since_full (int16 / 10.0) 178 | +---------------------------------- today_bat_kwh (int16 / 100.0) 179 | ``` 180 | 181 | Sample values: 182 | ``` 183 | DC TODAY BAT KWH : 43.86kWH 184 | 185 | Days since full charge: 12.4 186 | ``` 187 | 188 | ## PAGE 0E ## 189 | ``` 190 | ff 00 90 fd 90 01 6a 00 00 ff 05 00 8c (Capture from real device) 191 | 11 22 33 44 55 66 77 88 99 AA BB CC DD 192 | |---| |---| |---| |---| |---| 193 | | | | | +- shuntb_ah (int16) 194 | | | | +------- shunta_ah (int16) 195 | | | +------------- shuntc_kwh (int16 / 100.0) 196 | | +------------------- shuntb_kwh (int16 / 100.0) 197 | +------------------------- shunta_kwh (int16 / 100.0) 198 | ``` 199 | 200 | Sample values: 201 | ``` 202 | Shunt A -1829AH 74.93kWH 203 | Shunt B -3091AH 62.31kWH 204 | Shunt C 0AH -5.67kWH 205 | ``` 206 | 207 | ## PAGE 0F ## 208 | ``` 209 | 00 00 47 ff 88 fe e4 0f 00 00 00 00 00 (Capture from real device) 210 | 55 66 62 11 22 33 44 0f 00 00 00 00 00 211 | |---| | |---| |---| | 212 | | | | | +---------------- unknown (0x0F) 213 | | | | +------------------- bat_net_kwh (int16 / 100.0) 214 | | | +------------------------- bat_net_ah (int16) 215 | | +------------------------------- min_soc_today (uint8) 216 | +---------------------------------- shuntc_ah (int16) 217 | ``` 218 | 219 | Sample values: 220 | ``` 221 | 0x1122 : 4386AH 222 | 0x3344 : (6)31.24kWH (Note: 5th digit truncated) 223 | 0xFFFF : -1AH 224 | 0x5566 : (2)1862AH 225 | ``` 226 | -------------------------------------------------------------------------------- /doc/protocol/wireshark-startup-sequence.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorticus/pymate/a90c973e54f71852bc3f1bb075b3d0a3599c8883/doc/protocol/wireshark-startup-sequence.png -------------------------------------------------------------------------------- /doc/pymate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorticus/pymate/a90c973e54f71852bc3f1bb075b3d0a3599c8883/doc/pymate.png -------------------------------------------------------------------------------- /examples/srv1/README.md: -------------------------------------------------------------------------------- 1 | A very basic collection & upload script for pyMATE, 2 | designed for resource-constrained systems. 3 | 4 | My particular system barely has enough flash space to fit Python! 5 | 6 | This script will collect the status every 10 seconds (configurable), and a log page at the end of every day, 7 | and upload the result to a server as a JSON-formatted packet 8 | 9 | The server should have the following POST handlers which accept JSON: 10 | 11 | ``` 12 | POST /mx-logpage 13 | { 14 | 'type': 'mx-logpage', 15 | 'data': 'BASE64-encoded logpage data', 16 | 'ts': '2017-08-12T17:43:45.029141', 17 | 'tz': 43200, 18 | 'date': '2017-08-11' 19 | } 20 | ``` 21 | 22 | ``` 23 | POST /mx-status 24 | { 25 | 'type': 'mx-status', 26 | 'data': 'BASE64-encoded status data', 27 | 'ts': '2017-08-12T17:43:45.029141', 28 | 'tz': 43200, 29 | 'extra': { 30 | 'chg_w': 0.0 31 | } 32 | } 33 | ``` 34 | 35 | ``` 36 | POST /fx-status 37 | { 38 | 'type': 'fx-status', 39 | 'data': 'BASE64-encoded status data', 40 | 'ts': '2017-08-12T17:43:45.029141', 41 | 'tz': 43200, 42 | 'extra': { 43 | 't_air': 0.0 44 | } 45 | } 46 | ``` 47 | 48 | ``` 49 | POST /dc-status 50 | { 51 | 'type': 'dc-status', 52 | 'data': 'BASE64-encoded status data', 53 | 'ts': '2017-08-12T17:43:45.029141', 54 | 'tz': 43200, 55 | 'extra': { 56 | } 57 | } 58 | ``` -------------------------------------------------------------------------------- /examples/srv1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorticus/pymate/a90c973e54f71852bc3f1bb075b3d0a3599c8883/examples/srv1/__init__.py -------------------------------------------------------------------------------- /examples/srv1/collector.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Author: Jared Sanson 4 | # 5 | # A very basic collection & upload script for pyMATE, 6 | # designed for resource-constrained systems 7 | # (My particular system barely has enough flash space to fit Python!) 8 | # 9 | 10 | from pymate.matenet import MateNET, MateDevice, MateMXDevice, MateFXDevice, MateDCDevice 11 | from time import sleep 12 | from datetime import datetime 13 | from base64 import b64encode 14 | import urllib2 15 | import json 16 | import logging 17 | 18 | from .settings import * 19 | 20 | log = logging.getLogger('main') 21 | log.setLevel(logging.DEBUG) 22 | 23 | if LOGFILE: 24 | fh = logging.FileHandler(LOGFILE) 25 | fh.setLevel(logging.INFO) 26 | fh.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) 27 | log.addHandler(fh) 28 | 29 | ch = logging.StreamHandler() 30 | ch.setLevel(logging.DEBUG) 31 | log.addHandler(ch) 32 | 33 | log.info("MATE Data Collector (MX)") 34 | 35 | # Create a MateNET bus connection 36 | bus = MateNET(SERIAL_PORT) 37 | 38 | mx = None 39 | fx = None 40 | dc = None 41 | 42 | # Find and connect to an MX charge controller 43 | try: 44 | port = bus.find_device(MateNET.DEVICE_MX) 45 | mx = MateMXDevice(bus, port) 46 | mx.scan() 47 | log.info('Connected to MX device on port %d' % port) 48 | log.info('Revision: ' + str(mx.revision)) 49 | except Exception as ex: 50 | log.exception("Error connecting to MX") 51 | 52 | # Find and connect to an FX inverter 53 | try: 54 | port = bus.find_device(MateNET.DEVICE_FX) 55 | fx = MateFXDevice(bus, port) 56 | fx.scan() 57 | log.info('Connected to FX device on port %d' % port) 58 | log.info('Revision: ' + str(fx.revision)) 59 | except Exception as ex: 60 | log.exception("Error connecting to FX") 61 | 62 | # Find and connect to a FLEXnet DC 63 | try: 64 | port = bus.find_device(MateNET.DEVICE_DC) 65 | dc = MateDCDevice(bus, port) 66 | dc.scan() 67 | log.info('Connected to FLEXnet DC device on port %d' % port) 68 | log.info('Revision: ' + str(dc.revision)) 69 | except Exception as ex: 70 | log.exception("Error connecting to FLEXnet DC") 71 | 72 | if not all((fx,mx,dc)): 73 | exit() 74 | 75 | def timestamp(): 76 | """ '2016-06-18T15:13:38.929000' """ 77 | ts = datetime.utcnow() 78 | utcoffset = datetime.now() - ts 79 | return ts.isoformat(), int(utcoffset.total_seconds()) 80 | 81 | def upload_packet(packet): 82 | """ 83 | Upload a JSON packet to the server. 84 | If we can't communicate, just drop the packet. 85 | """ 86 | try: 87 | log.debug("Upload: " + str(packet)) 88 | r = urllib2.Request( 89 | ENDPOINT_URL+'/'+packet['type'], 90 | headers={'Content-Type': 'application/json'}, 91 | data=json.dumps(packet) 92 | ) 93 | urllib2.urlopen(r) 94 | except: 95 | log.exception("EXCEPTION in upload_packet()") 96 | 97 | def collect_logpage(): 98 | """ 99 | Collect a log page. 100 | If this fails, log and continue 101 | """ 102 | try: 103 | logpage = mx.get_logpage(-1) # Get yesterday's logpage 104 | 105 | day = datetime(now.year, now.month, now.day-1) 106 | 107 | ts, tz = timestamp() 108 | return { 109 | 'type': 'mx-logpage', 110 | 'data': b64encode(logpage.raw), 111 | 'ts': ts, 112 | 'tz': tz, 113 | 'date': day.strftime('%Y-%m-%d') 114 | } 115 | except: 116 | log.exception("EXCEPTION in collect_logpage()") 117 | return None 118 | 119 | last_status_b64 = None 120 | def collect_status(): 121 | """ 122 | Collect the current status. 123 | If this fails, log and continue 124 | """ 125 | global last_status_b64 126 | try: 127 | status = mx.get_status() 128 | if not status: 129 | raise Exception("Error reading MX status") 130 | 131 | status_b64 = b64encode(status.raw) 132 | 133 | # Only upload if the status has actually changed (to save bandwidth) 134 | if last_status_b64 != status_b64: 135 | last_status_b64 = status_b64 136 | ts, tz = timestamp() 137 | return { 138 | 'type': 'mx-status', 139 | 'data': status_b64, # Just send the raw data, and decode it server-side 140 | 'ts': ts, 141 | 'tz': tz, 142 | 'extra': { 143 | # To supplement the status packet data 144 | 'chg_w': float(mx.charger_watts) 145 | } 146 | } 147 | else: 148 | log.debug('Status unchanged') 149 | except: 150 | log.exception("EXCEPTION in collect_status()") 151 | return None 152 | 153 | last_fx_status_b64 = None 154 | def collect_fx(): 155 | """ 156 | Collect FX info 157 | """ 158 | global last_fx_status_b64 159 | try: 160 | status = fx.get_status() 161 | if not status: 162 | raise Exception("Error reading FX status") 163 | 164 | status_b64 = b64encode(status.raw) 165 | 166 | if last_fx_status_b64 != status_b64: 167 | last_fx_status_b64 = status_b64 168 | ts, tz = timestamp() 169 | return { 170 | 'type': 'fx-status', 171 | 'data': status_b64, 172 | 'ts': ts, 173 | 'tz': tz, 174 | 'extra': { 175 | 't_air': float(fx.temp_air), 176 | } 177 | } 178 | except: 179 | log.exception("EXCEPTION in collect_fx()") 180 | return None 181 | 182 | last_dc_status_b64 = None 183 | def collect_dc(): 184 | """ 185 | Collect FLEXnet DC status 186 | """ 187 | global last_dc_status_b64 188 | try: 189 | status_raw = dc.get_status_raw() 190 | if not status_raw: 191 | raise Exception("Error reading DC status") 192 | 193 | status_b64 = b64encode(status_raw) 194 | 195 | if last_dc_status_b64 != status_b64: 196 | last_dc_status_b64 = status_b64 197 | ts, tz = timestamp() 198 | return { 199 | 'type': 'dc-status', 200 | 'data': status_b64, 201 | 'ts': ts, 202 | 'tz': tz 203 | } 204 | except: 205 | log.exception("EXCEPTION in collect_dc()") 206 | return None 207 | 208 | def synchronize(): 209 | """ 210 | Synchronize devices 211 | Should be called every 1 minute 212 | """ 213 | try: 214 | bat_temp_raw = MateDevice.synchronize( 215 | master=mx, 216 | devices=(mx,fx,dc) 217 | ) 218 | 219 | log.info('Battery Temperature: %s' % MateMXDevice.convert_battery_temp(bat_temp_raw)) 220 | except: 221 | log.exception("EXCEPTION in synchronize()") 222 | return 223 | 224 | 225 | ##### COLLECTION STARTS ##### 226 | 227 | log.info("Starting collection...") 228 | now = datetime.now() 229 | 230 | # Calculate datetime of next status collection 231 | t_next_status = now + STATUS_INTERVAL 232 | t_next_fx_status = now + FXSTATUS_INTERVAL 233 | t_next_dc_status = now + DCSTATUS_INTERVAL 234 | t_next_sync = now + SYNC_INTERVAL 235 | 236 | # Calculate datetime of next logpage collection 237 | d = now.date() 238 | t = LOGPAGE_RETRIEVAL_TIME 239 | t_next_logpage = datetime(d.year, d.month, d.day, t.hour, t.minute, t.second, t.microsecond) + timedelta(days=1) 240 | log.debug("Next logpage: " + str(t_next_logpage)) 241 | 242 | # Collect status and log pages 243 | while True: 244 | try: 245 | sleep(1.0) 246 | now = datetime.now() 247 | 248 | # Time to collect a log page 249 | if now >= t_next_logpage: 250 | if mx: 251 | t_next_logpage += timedelta(days=1) 252 | log.debug("Next logpage: " + str(t_next_logpage)) 253 | 254 | packet = collect_logpage() 255 | if packet: 256 | upload_packet(packet) 257 | 258 | # Time to collect status 259 | if now >= t_next_status: 260 | t_next_status = now + STATUS_INTERVAL 261 | 262 | if mx: 263 | packet = collect_status() 264 | if packet: 265 | upload_packet(packet) 266 | 267 | if now >= t_next_fx_status: 268 | t_next_fx_status = now + FXSTATUS_INTERVAL 269 | if fx: 270 | packet = collect_fx() 271 | if packet: 272 | upload_packet(packet) 273 | 274 | if now >= t_next_dc_status: 275 | t_next_dc_status = now + DCSTATUS_INTERVAL 276 | if dc: 277 | packet = collect_dc() 278 | if packet: 279 | upload_packet(packet) 280 | 281 | if now >= t_next_sync: 282 | t_next_sync = now + SYNC_INTERVAL 283 | synchronize() 284 | 285 | except Exception as e: 286 | # Don't terminate the program, log and keep collecting. 287 | # sleep will keep things from going out of control. 288 | log.exception("EXCEPTION in main") 289 | 290 | -------------------------------------------------------------------------------- /examples/srv1/mate-collector: -------------------------------------------------------------------------------- 1 | #!/bin/sh /etc/rc.common 2 | NAME=mate-collector 3 | START=60 4 | STOP=60 5 | 6 | start() { 7 | echo "Starting pyMATE collector" 8 | /root/pymate/collector.py & 9 | } 10 | 11 | stop() { 12 | killall collector.py 13 | } 14 | -------------------------------------------------------------------------------- /examples/srv1/requirements.txt: -------------------------------------------------------------------------------- 1 | matplotlib==2.0.2 2 | numpy 3 | -------------------------------------------------------------------------------- /examples/srv1/settings.py: -------------------------------------------------------------------------------- 1 | 2 | from datetime import timedelta, time 3 | 4 | SERIAL_PORT = '/dev/ttyUSB0' 5 | STATUS_INTERVAL = timedelta(seconds=10) 6 | FXSTATUS_INTERVAL = timedelta(seconds=60) 7 | DCSTATUS_INTERVAL = timedelta(seconds=10) 8 | SYNC_INTERVAL = timedelta(minutes=1) 9 | LOGPAGE_RETRIEVAL_TIME = time(hour=0, minute=5) # 5 minutes past midnight (retrieves previous day) 10 | ENDPOINT_URL = 'http://localhost:5000/mate' 11 | LOGFILE = '/tmp/log/mate-collector.log' 12 | -------------------------------------------------------------------------------- /examples/srv2/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorticus/pymate/a90c973e54f71852bc3f1bb075b3d0a3599c8883/examples/srv2/__init__.py -------------------------------------------------------------------------------- /examples/srv2/bachnet.fcgi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | from flup.server.fcgi import WSGIServer 3 | from .receiver import app 4 | 5 | if __name__ == '__main__': 6 | WSGIServer(app).run() 7 | -------------------------------------------------------------------------------- /examples/srv2/environment.py: -------------------------------------------------------------------------------- 1 | # 2 | # SQLAlchemy Environment 3 | # 4 | # Usage: 5 | # python -i environment.py 6 | # >>> with session_scope() as s: 7 | # ... s.query(MxStatus).count() 8 | # 9 | 10 | __author__ = 'Jared Sanson ' 11 | __version__ = 'v0.1' 12 | 13 | import sqlalchemy as sql 14 | import sqlalchemy.orm 15 | from contextlib import contextmanager 16 | from datetime import datetime 17 | from .models import initialize_db, MxStatus, MxLogPage, FxStatus 18 | 19 | engine = initialize_db() 20 | Session = sql.orm.sessionmaker(bind=engine) 21 | 22 | @contextmanager 23 | def session_scope(): 24 | session = Session() 25 | try: 26 | yield session 27 | session.commit() 28 | except: 29 | session.rollback() 30 | raise 31 | finally: 32 | session.close() 33 | 34 | 35 | -------------------------------------------------------------------------------- /examples/srv2/models.py: -------------------------------------------------------------------------------- 1 | 2 | from pymate.matenet.mx import MXStatusPacket, MXLogPagePacket 3 | from pymate.matenet.fx import FXStatusPacket 4 | from pymate.matenet.flexnetdc import DCStatusPacket 5 | import sqlalchemy as sql 6 | from sqlalchemy.engine.url import URL 7 | from sqlalchemy import Column 8 | from sqlalchemy.ext.declarative import declarative_base 9 | from base64 import b64encode, b64decode 10 | 11 | import dateutil.parser 12 | 13 | Base = declarative_base() 14 | 15 | 16 | class MxStatus(Base): 17 | __tablename__ = "mx_status" 18 | 19 | id = Column(sql.Integer, primary_key=True) 20 | timestamp = Column(sql.DateTime) 21 | tzoffset = Column(sql.Integer) 22 | raw_packet = Column(sql.LargeBinary) 23 | 24 | pv_current = Column(sql.Float) 25 | bat_current = Column(sql.Float) 26 | pv_voltage = Column(sql.Float) 27 | bat_voltage = Column(sql.Float) 28 | amp_hours = Column(sql.Float) 29 | kw_hours = Column(sql.Float) 30 | watts = Column(sql.Float) 31 | 32 | status = Column(sql.Integer) 33 | errors = Column(sql.Integer) 34 | 35 | def __init__(self, js): 36 | data = b64decode(js['data']) # To bytestr 37 | 38 | self.timestamp = dateutil.parser.parse(js['ts']) 39 | self.tzoffset = int(js['tz']) 40 | self.raw_packet = data 41 | 42 | status = MXStatusPacket.from_buffer(data) 43 | self.pv_current = float(status.pv_current) 44 | self.bat_current = float(status.bat_current) 45 | self.pv_voltage = float(status.pv_voltage) 46 | self.bat_voltage = float(status.bat_voltage) 47 | self.amp_hours = float(status.amp_hours) 48 | self.kw_hours = float(status.kilowatt_hours) 49 | self.watts = float(js['extra']['chg_w']) 50 | self.status = int(status.status) 51 | self.errors = int(status.errors) 52 | 53 | print "Status:", status 54 | 55 | def to_json(self): 56 | d = {key: getattr(self, key) for key in self.__dict__ if key[0] != '_'} 57 | d['raw_packet'] = b64encode(d['raw_packet']) 58 | return d 59 | 60 | @property 61 | def local_timestamp(self): 62 | return self.timestamp 63 | 64 | 65 | class MxLogPage(Base): 66 | __tablename__ = "mx_logpage" 67 | 68 | id = Column(sql.Integer, primary_key=True) 69 | timestamp = Column(sql.DateTime) 70 | tzoffset = Column(sql.Integer) 71 | raw_packet = Column(sql.LargeBinary) 72 | 73 | date = Column(sql.Date) 74 | 75 | bat_min = Column(sql.Float) 76 | bat_max = Column(sql.Float) 77 | volts_peak = Column(sql.Float) 78 | amps_peak = Column(sql.Float) 79 | amp_hours = Column(sql.Float) 80 | kw_hours = Column(sql.Float) 81 | absorb_time = Column(sql.Float) 82 | float_time = Column(sql.Float) 83 | 84 | def __init__(self, js): 85 | data = b64decode(js['data']) 86 | 87 | self.timestamp = dateutil.parser.parse(js['ts']) 88 | self.tzoffset = int(js['tz']) 89 | self.raw_packet = data 90 | 91 | self.date = dateutil.parser.parse(js['date']).date() 92 | 93 | logpage = MXLogPagePacket.from_buffer(data) 94 | self.bat_min = float(logpage.bat_min) 95 | self.bat_max = float(logpage.bat_max) 96 | self.volts_peak = float(logpage.volts_peak) 97 | self.amps_peak = float(logpage.amps_peak) 98 | self.amp_hours = float(logpage.amp_hours) 99 | self.kw_hours = float(logpage.kilowatt_hours) 100 | self.absorb_time = float(logpage.absorb_time) # Minutes 101 | self.float_time = float(logpage.float_time) # Minutes 102 | 103 | print "Log Page:", logpage 104 | 105 | def to_json(self): 106 | d = {key: getattr(self, key) for key in self.__dict__ if key[0] != '_'} 107 | d['raw_packet'] = b64encode(d['raw_packet']) 108 | return d 109 | 110 | 111 | class FxStatus(Base): 112 | __tablename__ = "fx_status" 113 | 114 | id = Column(sql.Integer, primary_key=True) 115 | timestamp = Column(sql.DateTime) 116 | tzoffset = Column(sql.Integer) 117 | raw_packet = Column(sql.LargeBinary) 118 | 119 | warnings = Column(sql.Integer) 120 | error_mode = Column(sql.Integer) 121 | operational_mode = Column(sql.Integer) 122 | ac_mode = Column(sql.Integer) 123 | aux_on = Column(sql.Boolean) 124 | 125 | charge_power = Column(sql.Float) 126 | inverter_power = Column(sql.Float) 127 | sell_power = Column(sql.Float) 128 | buy_power = Column(sql.Float) 129 | 130 | output_voltage = Column(sql.Float) 131 | input_voltage = Column(sql.Float) 132 | inverter_current = Column(sql.Float) 133 | charger_current = Column(sql.Float) 134 | buy_current = Column(sql.Float) # aka. input_current? 135 | sell_current = Column(sql.Float) 136 | 137 | air_temperature = Column(sql.Float) 138 | 139 | def __init__(self, js): 140 | 141 | extra = js['extra'] 142 | data = b64decode(js['data']) # To bytestr 143 | 144 | self.timestamp = dateutil.parser.parse(js['ts']) 145 | self.tzoffset = int(js['tz']) 146 | self.raw_packet = data 147 | 148 | status = FXStatusPacket.from_buffer(data) 149 | 150 | self.warnings = int(status.warnings) 151 | self.error_mode = int(status.error_mode) 152 | self.operational_mode = int(status.operational_mode) 153 | self.ac_mode = int(status.ac_mode) 154 | self.aux_on = bool(status.aux_on) 155 | 156 | self.charge_power = float(status.chg_power) 157 | self.inverter_power = float(status.inv_power) 158 | self.sell_power = float(status.sell_power) 159 | self.buy_power = float(status.buy_power) 160 | 161 | self.output_voltage = float(status.output_voltage) 162 | self.input_voltage = float(status.input_voltage) 163 | self.inverter_current = float(status.inverter_current) 164 | self.charger_current = float(status.chg_current) 165 | self.buy_current = float(status.buy_current) 166 | self.sell_current = float(status.sell_current) 167 | 168 | # Extra 169 | self.air_temperature = float(extra['t_air']) 170 | 171 | # self.warnings = int(extra['w']) 172 | # self.errors = int(extra['e']) 173 | # self.output_voltage = float(extra['out_v']) 174 | # self.input_voltage = float(extra['in_v']) 175 | # self.inverter_current = float(extra['inv_i']) 176 | # self.charger_current = float(extra['chg_i']) 177 | # self.input_current = float(extra['in_i']) 178 | # self.sell_current = float(extra['sel_i']) 179 | # self.air_temperature = float(extra['t_air']) 180 | 181 | print "Status:", status 182 | 183 | def to_json(self): 184 | d = {key: getattr(self, key) for key in self.__dict__ if key[0] != '_'} 185 | d['raw_packet'] = b64encode(d['raw_packet']) 186 | return d 187 | 188 | def __repr__(self): 189 | return str(self.to_json()) 190 | 191 | @property 192 | def local_timestamp(self): 193 | return self.timestamp 194 | 195 | class DcStatus(Base): 196 | __tablename__ = "dc_status" 197 | 198 | id = Column(sql.Integer, primary_key=True) 199 | timestamp = Column(sql.DateTime) 200 | tzoffset = Column(sql.Integer) 201 | raw_packet = Column(sql.LargeBinary) 202 | 203 | shunta_power = Column(sql.Float) 204 | shuntb_power = Column(sql.Float) 205 | shuntc_power = Column(sql.Float) 206 | 207 | shunta_kwh_today = Column(sql.Float) 208 | shuntb_kwh_today = Column(sql.Float) 209 | shuntc_kwh_today = Column(sql.Float) 210 | 211 | battery_voltage = Column(sql.Float) 212 | state_of_charge = Column(sql.Float) 213 | 214 | in_power = Column(sql.Float) 215 | out_power = Column(sql.Float) 216 | bat_power = Column(sql.Float) 217 | 218 | in_kwh_today = Column(sql.Float) 219 | out_kwh_today = Column(sql.Float) 220 | bat_kwh_today = Column(sql.Float) 221 | 222 | flags = Column(sql.Integer) 223 | 224 | def __init__(self, js): 225 | 226 | data = b64decode(js['data']) # To bytestr 227 | 228 | self.timestamp = dateutil.parser.parse(js['ts']) 229 | self.tzoffset = int(js['tz']) 230 | self.raw_packet = data 231 | 232 | status = DCStatusPacket.from_buffer(data) 233 | 234 | self.flags = int(status.flags) 235 | 236 | self.shunta_power = float(status.shunta_power) 237 | self.shuntb_power = float(status.shuntb_power) 238 | self.shuntc_power = float(status.shuntc_power) 239 | self.shunta_kwh_today = float(status.shunta_kwh_today) 240 | self.shuntb_kwh_today = float(status.shuntb_kwh_today) 241 | self.shuntc_kwh_today = float(status.shuntc_kwh_today) 242 | self.battery_voltage = float(status.bat_voltage) 243 | self.state_of_charge = float(status.state_of_charge) 244 | self.in_power = float(status.in_power) 245 | self.out_power = float(status.out_power) 246 | self.bat_power = float(status.bat_power) 247 | self.in_kwh_today = float(status.in_kwh_today) 248 | self.out_kwh_today = float(status.out_kwh_today) 249 | self.bat_kwh_today = float(status.bat_kwh_today) 250 | 251 | def to_json(self): 252 | d = {key: getattr(self, key) for key in self.__dict__ if key[0] != '_'} 253 | d['raw_packet'] = b64encode(d['raw_packet']) 254 | return d 255 | 256 | def __repr__(self): 257 | return str(self.to_json()) 258 | 259 | @property 260 | def local_timestamp(self): 261 | return self.timestamp 262 | 263 | 264 | def initialize_db(): 265 | import settings 266 | 267 | print "Create DB Engine" 268 | engine = sql.create_engine(URL(**settings.DATABASE)) 269 | Base.metadata.create_all(engine) 270 | 271 | return engine -------------------------------------------------------------------------------- /examples/srv2/receiver.py: -------------------------------------------------------------------------------- 1 | 2 | __author__ = 'Jared Sanson ' 3 | __version__ = 'v0.1' 4 | 5 | from flask import Flask, abort, jsonify, make_response, request 6 | from flask.json import JSONEncoder 7 | import sqlalchemy as sql 8 | import sqlalchemy.orm 9 | from contextlib import contextmanager 10 | from datetime import datetime 11 | from .models import initialize_db, MxStatus, MxLogPage, FxStatus, DcStatus 12 | 13 | engine = initialize_db() 14 | Session = sql.orm.sessionmaker(bind=engine) 15 | 16 | @contextmanager 17 | def session_scope(): 18 | session = Session() 19 | try: 20 | yield session 21 | session.commit() 22 | except: 23 | session.rollback() 24 | raise 25 | finally: 26 | session.close() 27 | 28 | 29 | class CustomJSONEncoder(JSONEncoder): 30 | def default(self, o): 31 | try: 32 | if isinstance(o, datetime): 33 | return o.isoformat() 34 | if isinstance(o, MxStatus): 35 | return o.to_json() 36 | if isinstance(o, MxLogPage): 37 | return o.to_json() 38 | if isinstance(o, FxStatus): 39 | return o.to_json() 40 | if isinstance(o, DcStatus): 41 | return o.to_json() 42 | iterable = iter(o) 43 | except TypeError: 44 | pass 45 | else: 46 | return list(iterable) 47 | return JSONEncoder.default(self, o) 48 | 49 | 50 | app = Flask(__name__) 51 | app.json_encoder = CustomJSONEncoder 52 | 53 | @app.route('/') 54 | def index(): 55 | return "BachNET API {version}".format(version=__version__) 56 | 57 | 58 | @app.route('/mate/mx-status', methods=['POST']) 59 | def add_mx_status(): 60 | if not request.json: 61 | abort(400) 62 | 63 | if not all(x in request.json for x in ['type', 'data', 'ts', 'tz']): 64 | raise Exception("Invalid schema - missing a required field") 65 | 66 | if request.json['type'] != 'mx-status': 67 | raise Exception("Invalid packet type") 68 | 69 | with session_scope() as session: 70 | status = MxStatus(request.json) 71 | session.add(status) 72 | 73 | return jsonify({'status': 'success'}), 201 74 | 75 | 76 | @app.route('/mate/mx-status', methods=['GET']) 77 | def get_current_mx_status(): 78 | with session_scope() as session: 79 | status = session.query(MxStatus).order_by(sql.desc(MxStatus.timestamp)).first() 80 | 81 | if status: 82 | print "Status:", status 83 | return jsonify(status) 84 | else: 85 | return jsonify({}) 86 | 87 | 88 | @app.route('/mate/mx-logpage', methods=['POST']) 89 | def add_mx_logpage(): 90 | if not request.json: 91 | abort(400) 92 | 93 | if not all(x in request.json for x in ['type', 'data', 'ts', 'tz', 'date']): 94 | raise Exception("Invalid schema - missing a required field") 95 | 96 | if request.json['type'] != 'mx-logpage': 97 | raise Exception("Invalid packet type") 98 | 99 | with session_scope() as session: 100 | logpage = MxLogPage(request.json) 101 | session.add(logpage) 102 | return jsonify({'status': 'success', 'id': logpage.id}), 201 103 | 104 | 105 | @app.route('/mate/mx-logpage', methods=['GET']) 106 | def get_mx_logpages(): 107 | with session_scope() as session: 108 | page = session.query(MxLogPage).order_by(sql.desc(MxLogPage.timestamp)).first() 109 | return jsonify(page) 110 | 111 | 112 | @app.route('/mate/fx-status', methods=['POST']) 113 | def add_fx_status(): 114 | if not request.json: 115 | abort(400) 116 | 117 | if not all(x in request.json for x in ['type', 'data', 'ts', 'tz']): 118 | raise Exception("Invalid schema - missing a required field") 119 | 120 | if request.json['type'] != 'fx-status': 121 | raise Exception("Invalid packet type") 122 | 123 | with session_scope() as session: 124 | status = FxStatus(request.json) 125 | session.add(status) 126 | 127 | return jsonify({'status': 'success'}), 201 128 | 129 | 130 | @app.route('/mate/fx-status', methods=['GET']) 131 | def get_current_fx_status(): 132 | with session_scope() as session: 133 | status = session.query(FxStatus).order_by(sql.desc(FxStatus.timestamp)).first() 134 | 135 | if status: 136 | print "Status:", status 137 | return jsonify(status) 138 | else: 139 | return jsonify({}) 140 | 141 | @app.route('/mate/dc-status', methods=['POST']) 142 | def add_dc_status(): 143 | if not request.json: 144 | abort(400) 145 | 146 | if not all(x in request.json for x in ['type', 'data', 'ts', 'tz']): 147 | raise Exception("Invalid schema - missing a required field") 148 | 149 | if request.json['type'] != 'dc-status': 150 | raise Exception("Invalid packet type") 151 | 152 | with session_scope() as session: 153 | status = DcStatus(request.json) 154 | session.add(status) 155 | 156 | return jsonify({'status': 'success'}), 201 157 | 158 | 159 | @app.route('/mate/dc-status', methods=['GET']) 160 | def get_current_dc_status(): 161 | with session_scope() as session: 162 | status = session.query(DcStatus).order_by(sql.desc(DcStatus.timestamp)).first() 163 | 164 | if status: 165 | print "Status:", status 166 | return jsonify(status) 167 | else: 168 | return jsonify({}) 169 | 170 | # @app.route('/mate/mx-logpage/', methods=['GET']) 171 | # def get_logpage(day): 172 | # return jsonify(logpage_table[day]) 173 | 174 | 175 | @app.errorhandler(404) 176 | def not_found(error): 177 | return make_response(jsonify({'error': 'Not found'}), 404) 178 | 179 | 180 | @app.errorhandler(403) 181 | def unauthorized(): 182 | return make_response(jsonify({'error': 'Unauthorized'}), 403) 183 | 184 | 185 | if __name__ == "__main__": 186 | app.run(debug=True) 187 | -------------------------------------------------------------------------------- /examples/srv2/requirements.txt: -------------------------------------------------------------------------------- 1 | sqlalchemy 2 | flask 3 | flup 4 | psycopg2-binary 5 | -------------------------------------------------------------------------------- /examples/srv2/run-fcgi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Usage: 4 | # run-fcgi.py 127.0.0.1 3001 5 | # run-fcgi.py /tmp/mate-collector.sock 6 | 7 | import sys 8 | from flup.server.fcgi import WSGIServer 9 | from .receiver import app 10 | 11 | bindAddress=None 12 | if len(sys.argv) == 2: 13 | bindAddress = sys.argv[1] 14 | elif len(sys.argv) == 3: 15 | bindAddress = sys.argv[1:] 16 | 17 | if __name__ == '__main__': 18 | WSGIServer(app, bindAddress=bindAddress).run() 19 | -------------------------------------------------------------------------------- /examples/srv2/settings.py: -------------------------------------------------------------------------------- 1 | 2 | DATABASE = { 3 | 'drivername': 'postgres', 4 | 'host': 'localhost', 5 | 'port': '5432', 6 | 'username': '', 7 | 'password': '', 8 | 'database': '' 9 | } 10 | -------------------------------------------------------------------------------- /plot.py: -------------------------------------------------------------------------------- 1 | #from matecom import MateCom # For use with MATE RS232 interface 2 | from pymate.matenet import MateNET, MateMXDevice # For use with proprietry MateNET protocol 3 | import numpy as np 4 | import matplotlib.pyplot as plt 5 | import matplotlib.animation as animation 6 | import time 7 | from threading import Thread, Lock 8 | from collections import deque 9 | 10 | N = 1000 # History length, in samples 11 | 12 | class DynamicAxes: 13 | def __init__(self, axes, windowSize): 14 | self.mate = mate 15 | self.windowSize = windowSize 16 | self.i = 0 17 | self.x = np.arange(windowSize) 18 | self.yi = [] 19 | self.axes = axes 20 | for ai in axes: 21 | self.yi.append(deque([0.0]*windowSize)) 22 | 23 | self.fig = None 24 | self.ax = None 25 | self.mutex = Lock() 26 | 27 | def _addToBuf(self, buf, val): 28 | if len(buf) < self.windowSize: 29 | buf.append(val) 30 | else: 31 | buf.pop() 32 | buf.appendleft(val) 33 | 34 | def update(self, data): 35 | """ 36 | Call this in a separate thread to add new samples 37 | to the internal buffers. By keeping this separate 38 | from the animation function, the GUI is not blocked. 39 | """ 40 | assert len(data) == len(self.axes) 41 | with self.mutex: 42 | for i in range(len(self.axes)): 43 | self._addToBuf(self.yi[i], data[i]) 44 | 45 | def anim(self, *args): 46 | """ Used by matplotlib's FuncAnimation controller """ 47 | # Update the plot data, even if it hasn't changed 48 | with self.mutex: 49 | for i, ai in enumerate(self.axes): 50 | ai.set_data(self.x, self.yi[i]) 51 | 52 | 53 | 54 | 55 | if __name__ == "__main__": 56 | #bus = MateNET('COM2') # RS232 57 | bus = MateNET('COM2') # MateNET 58 | mate = MateMXDevice(bus, port=0) 59 | 60 | # Set up plot 61 | fig = plt.figure() 62 | ax = plt.axes(xlim=(0, N), ylim=(0, 30)) 63 | a1, = ax.plot([],[]) 64 | a2, = ax.plot([],[]) 65 | 66 | plt.legend([a1, a2], ["Battery V", "PV V"]) 67 | 68 | data = DynamicAxes([a1, a2]) 69 | 70 | # Set up acquisition thread 71 | def acquire(): 72 | while True: 73 | #status = mate.read_status() # RS232 74 | status = mate.get_status() # MateNET 75 | print "BV:%s, PV:%s" % (status.bat_voltage, status.pv_voltage) 76 | data.update([float(status.bat_voltage), float(status.pv_voltage)]) 77 | thread = Thread(target=acquire) 78 | thread.start() 79 | 80 | # Show plot 81 | anim = animation.FuncAnimation(fig, data.anim, interval=1000/25) 82 | plt.show() 83 | 84 | -------------------------------------------------------------------------------- /pymate/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from matenet import * -------------------------------------------------------------------------------- /pymate/cstruct.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Jared' 2 | 3 | from struct import calcsize, unpack_from, Struct 4 | 5 | 6 | def struct(fmt, fields): 7 | """ 8 | Construct a new struct class which matches the provided format and fields. 9 | The fields must be defined in the same order as the struct format elements. 10 | fmt: a python struct format (str) 11 | fields: a tuple of names to match to each member in the struct (tuple of str) 12 | """ 13 | fmt = Struct(fmt) 14 | test = fmt.unpack_from(''.join('\0' for i in range(fmt.size))) 15 | nfields = len(test) 16 | 17 | if len(fields) != nfields: 18 | raise RuntimeError("Number of fields provided does not match the struct format (Format: %d, Fields: %d)" % (nfields, len(fields))) 19 | 20 | class _struct(object): 21 | """ 22 | C-style struct class 23 | """ 24 | _fmt = fmt 25 | _fields = fields 26 | _size = fmt.size 27 | _nfields = nfields 28 | 29 | size = fmt.size 30 | 31 | def __init__(self, *args, **kwargs): 32 | """ 33 | Initialize the struct's fields from the provided args. 34 | Named arguments take presedence over un-named args. 35 | """ 36 | assert(len(args) <= self._nfields) 37 | 38 | # Default values 39 | for name in self._fields: 40 | setattr(self, name, None) 41 | 42 | # Un-named args 43 | for name, value in zip(self._fields, args): 44 | if not hasattr(self, name): 45 | raise RuntimeError("Struct does not have a field named '%s'" % name) 46 | setattr(self, name, value) 47 | 48 | # Named args 49 | for name, value in kwargs.iteritems(): 50 | if not hasattr(self, name): 51 | raise RuntimeError("Struct does not have a field named '%s'" % name) 52 | setattr(self, name, value) 53 | 54 | @classmethod 55 | def from_buffer(cls, data): 56 | """ 57 | Unpack a buffer of data into a struct object 58 | """ 59 | 60 | if data is None: 61 | raise RuntimeError("Error parsing struct - no data provided") 62 | 63 | # Length validation 64 | data_len = len(data) 65 | if data_len != cls._size: 66 | raise RuntimeError("Error parsing struct - invalid length (Got %d bytes, expected %d)" % (data_len, cls._size)) 67 | 68 | # Convert to binary string if necessary 69 | if not isinstance(data, (str, unicode)): 70 | data = ''.join(chr(c) for c in data) 71 | 72 | # Construct new struct class 73 | values = cls._fmt.unpack(data) 74 | return cls(*values) 75 | 76 | def to_buffer(self): 77 | """ 78 | Convert the struct into a packed data format 79 | """ 80 | values = [getattr(self, name) for name in self._fields] 81 | return self._fmt.pack(*values) 82 | 83 | def __repr__(self): 84 | return "struct:%s" % self.__dict__ 85 | 86 | return _struct 87 | -------------------------------------------------------------------------------- /pymate/matecom.py: -------------------------------------------------------------------------------- 1 | # pyMate controller 2 | # Author: Jared Sanson 3 | # 4 | # Allows communication with an Outback Systems MATE controller panel, 5 | # which provides diagnostic information of current charge state and power use 6 | # 7 | # Currently only supports the MX status page (from an Outback MX charge controller) 8 | # 9 | # NOTE: This is intended for communication with the MATE's RS232 port, not Outback's proprietary protocol. 10 | 11 | import serial 12 | from .value import Value 13 | 14 | class MXStatusPacket(object): 15 | """ 16 | Represents an MX status packet, containing useful information 17 | such as charge current and PV voltage. 18 | """ 19 | def __init__(self, packet): 20 | fields = packet.split(',') 21 | self.address = fields[0] 22 | # fields[1] unused 23 | self.charge_current = Value(float(fields[2]) + (float(fields[6]) / 10.0), 'A', resolution=1) 24 | self.pv_current = Value(fields[3], 'A', resolution=0) 25 | self.pv_voltage = Value(fields[4], 'V', resolution=0) 26 | self.daily_kwh = Value(float(fields[5]) / 10.0, 'kWh', resolution=1) 27 | self.aux_mode = fields[7] 28 | self.error_mode = fields[8] 29 | self.charger_mode = fields[9] 30 | self.bat_voltage = Value(float(fields[10]) / 10, 'V', resolution=1) 31 | self.daily_ah = Value(float(fields[11]), 'Ah', resolution=1) 32 | # fields[12] unused 33 | 34 | chk_expected = int(fields[13]) 35 | chk_actual = sum(ord(x)-48 for x in packet[:-4] if ord(x)>=48) 36 | if chk_expected != chk_actual: 37 | raise Exception("Checksum error in received packet") 38 | 39 | 40 | class MateCom(object): 41 | """ 42 | Interfaces with the MATE controller on a specific COM port. 43 | Must be a proper RS232 port with RTS/DTR pins. 44 | """ 45 | def __init__(self, port, baudrate=19200): 46 | self.ser = serial.Serial(port, baudrate, timeout=2) 47 | 48 | # Provide power to the Mate controller 49 | self.ser.setDTR(True) 50 | self.ser.setRTS(False) 51 | 52 | self.ser.readline() 53 | 54 | def read_status(self): 55 | ln = self.ser.readline().strip() 56 | return MXStatusPacket(ln) if ln else None 57 | 58 | 59 | if __name__ == "__main__": 60 | # Test 61 | mate = MateCom('COM1') 62 | status = mate.read_status() 63 | print status.__dict__ 64 | -------------------------------------------------------------------------------- /pymate/matenet/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Jared' 2 | 3 | 4 | from matedevice import MateDevice 5 | from matenet_pjon import MateNETPJON 6 | from matenet_ser import MateNETSerial 7 | from matenet import MateNET 8 | from mx import MateMXDevice 9 | from fx import MateFXDevice 10 | from flexnetdc import MateDCDevice 11 | 12 | # DEPRECATED: 13 | from matedevice import Mate 14 | from mx import MateMX 15 | from fx import MateFX 16 | -------------------------------------------------------------------------------- /pymate/matenet/flexnetdc.py: -------------------------------------------------------------------------------- 1 | # pyMATE FLEXnet-DC interface 2 | # Author: Jared Sanson 3 | # 4 | # Provides access to an Outback FLEXnet DC power monitor 5 | # 6 | # NOTE: You will need a MATE to program the FLEXnet DC before use. 7 | # See the OutBack user guide for the product. 8 | # 9 | 10 | __author__ = 'Jared' 11 | 12 | from pymate.value import Value 13 | from pymate.cstruct import Struct 14 | from . import MateDevice, MateNET 15 | 16 | class DCStatusPacket(object): 17 | fmt = Struct('>'+ 18 | 'hhhhBhh'+ # Page A (7 values) 19 | 'hBBhhhh'+ # Page B (7 values) 20 | 'h'+ # Shared between page B/C 21 | 'hhhhhh'+ # Page C (6 values) 22 | 'hhBBBBBBBBB'+ # Page D (11 values) 23 | 'BBBhhhhh'+ # Page E (8 values) 24 | 'hBhhB5B' # Page F (6 values) 25 | ) 26 | 27 | def __init__(self): 28 | 29 | # User Guide mentions: 30 | # Volts for one battery bank (0-80V in 0.1V resolution) 31 | # Current range: 2000A (+/- 1000A DC), 0.1A resolution 32 | 33 | self.bat_voltage = None 34 | self.state_of_charge = None 35 | 36 | self.shunta_power = None 37 | self.shuntb_power = None 38 | self.shuntc_power = None 39 | self.shunta_current = None 40 | self.shuntb_current = None 41 | self.shuntc_current = None 42 | 43 | self.shunta_kwh_today = None 44 | self.shuntb_kwh_today = None 45 | self.shuntc_kwh_today = None 46 | self.shunta_ah_today = None 47 | self.shuntb_ah_today = None 48 | self.shuntc_ah_today = None 49 | 50 | self.bat_net_kwh = None 51 | self.bat_net_ah = None 52 | self.min_soc_today = None 53 | self.in_ah_today = None 54 | self.out_ah_today = None 55 | self.bat_ah_today = None 56 | self.in_kwh_today = None 57 | self.out_kwh_today = None 58 | self.bat_kwh_today = None 59 | 60 | self.in_power = None 61 | self.out_power = None 62 | self.bat_power = None 63 | 64 | self.in_current = None 65 | self.out_current = None 66 | self.bat_current = None 67 | 68 | self.days_since_full = None 69 | 70 | self.flags = None 71 | 72 | pass 73 | 74 | @classmethod 75 | def from_buffer(cls, data): 76 | values = cls.fmt.unpack(data) 77 | status = DCStatusPacket() 78 | 79 | # Page A 80 | status.shunta_current = Value(values[0] / 10.0, units='A', resolution=1) 81 | status.shuntb_current = Value(values[1] / 10.0, units='A', resolution=1) 82 | status.shuntc_current = Value(values[2] / 10.0, units='A', resolution=1) 83 | status.bat_voltage = Value(values[3] / 10.0, units='V', resolution=1) 84 | status.state_of_charge = Value(values[4], units='%', resolution=0) 85 | status.shunta_power = Value(values[5] / 100.0, units='kW', resolution=2) 86 | status.shuntb_power = Value(values[6] / 100.0, units='kW', resolution=2) 87 | 88 | # Page B 89 | status.shuntc_power = Value(values[7] / 100.0, units='kW', resolution=2) 90 | # unknown values[8] 91 | status.flags = values[9] 92 | status.in_current = Value(values[10] / 10.0, units='A', resolution=1) 93 | status.out_current = Value(values[11] / 10.0, units='A', resolution=1) 94 | status.bat_current = Value(values[12] / 10.0, units='A', resolution=1) 95 | status.in_power = Value(values[13] / 100.0, units='kW', resolution=2) 96 | status.out_power = Value(values[14] / 100.0, units='kW', resolution=2) # NOTE: Split between Page B/C 97 | 98 | # Page C 99 | status.bat_power = Value(values[15] / 100.0, units='kW', resolution=2) 100 | status.in_ah_today = Value(values[16], units='Ah', resolution=0) 101 | status.out_ah_today = Value(values[17], units='Ah', resolution=0) 102 | status.bat_ah_today = Value(values[18], units='Ah', resolution=0) 103 | status.in_kwh_today = Value(values[19] / 100.0, units='kWh', resolution=2) 104 | status.out_kwh_today = Value(values[20] / 100.0, units='kWh', resolution=2) 105 | 106 | # Page D 107 | status.bat_kwh_today = Value(values[21] / 100.0, units='kWh', resolution=2) 108 | status.days_since_full = Value(values[22] / 10.0, units='days', resolution=1) 109 | # values[23..31] (9 values) unknown 110 | 111 | # Page E 112 | # values[32..34] (3 values) unknown 113 | status.shunta_kwh_today = Value(values[35] / 100.0, units='kWh', resolution=2) 114 | status.shuntb_kwh_today = Value(values[36] / 100.0, units='kWh', resolution=2) 115 | status.shuntc_kwh_today = Value(values[37] / 100.0, units='kWh', resolution=2) 116 | status.shunta_ah_today = Value(values[38], units='Ah', resolution=0) 117 | status.shuntb_ah_today = Value(values[39], units='Ah', resolution=0) 118 | 119 | # Page F 120 | status.shuntc_ah_today = Value(values[40], units='Ah', resolution=0) 121 | status.min_soc_today = Value(values[41], units='%', resolution=0) 122 | status.bat_net_ah = Value(values[42], units='Ah', resolution=0) 123 | status.bat_net_kwh = Value(values[43] / 100.0, units='kWh', resolution=2) 124 | 125 | return status 126 | 127 | def __repr__(self): 128 | return "" 129 | 130 | def __str__(self): 131 | fmt = """DC Status: 132 | 133 | """ 134 | return fmt.format(**self.__dict__) 135 | 136 | class MateDCDevice(MateDevice): 137 | """ 138 | Communicate with a FLEXnet DC unit attached to the MateNET bus 139 | """ 140 | DEVICE_TYPE = MateNET.DEVICE_DC 141 | 142 | def scan(self): 143 | """ 144 | Query the attached device to make sure we're communicating with an FLEXnet DC unit 145 | """ 146 | devid = super(MateDCDevice, self).scan() 147 | if devid == None: 148 | raise RuntimeError("No response from the FLEXnet DC unit") 149 | if devid != self.DEVICE_TYPE: 150 | raise RuntimeError("Attached device is not a FLEXnet DC unit! (DeviceID: %s)" % devid) 151 | 152 | def get_status(self): 153 | """ 154 | Request a status packet from the FLEXnet DC 155 | :return: A DCStatusPacket 156 | """ 157 | 158 | data = self.get_status_raw() 159 | return DCStatusPacket.from_buffer(data) 160 | 161 | def get_status_raw(self): 162 | data = '' 163 | for i in range(0x0A,0x0F+1): 164 | resp = self.send(MateNET.TYPE_STATUS, addr=i, response_len=(13*6)) 165 | if not resp: 166 | return None 167 | data += str(resp) 168 | 169 | if len(data) != 13*6: 170 | raise Exception('Size of status packets invalid') 171 | 172 | return data 173 | 174 | def get_logpage(self, day): 175 | """ 176 | Get a log page for the specified day 177 | :param day: The day, counting backwards from today (0:Today, -1..-255) 178 | :return: A DCLogPagePacket 179 | """ 180 | # TODO: This doesn't return anything. It must have a different command. 181 | # The UserGuide does mention having access to log pages 182 | #resp = self.send(MateNET.TYPE_LOG, addr=0, param=-day) 183 | #if resp: 184 | # print 'RAW:', (' '.join("{:02x}".format(ord(c)) for c in resp[1:])) 185 | # #return DCLogPagePacket.from_buffer(resp) 186 | -------------------------------------------------------------------------------- /pymate/matenet/fx.py: -------------------------------------------------------------------------------- 1 | # pyMATE FX interface 2 | # Author: Jared Sanson 3 | # 4 | # Provides access to an Outback FX inverter 5 | # 6 | # UNTESTED - implementation determined by poking values at a MATE controller 7 | 8 | __author__ = 'Jared' 9 | 10 | from pymate.value import Value 11 | from struct import Struct 12 | from . import MateDevice, MateNET 13 | 14 | 15 | class FXStatusPacket(object): 16 | fmt = Struct('>BBBBBBBBBhBB') 17 | size = fmt.size 18 | 19 | def __init__(self, misc=None): 20 | self.raw = None 21 | 22 | self.misc = misc 23 | self.warnings = None # See MateFXDevice.WARN_ enum 24 | self.error_mode = None # See MateFXDevice.ERROR_ bitfield 25 | self.ac_mode = None # 0: No AC, 1: AC Drop, 2: AC Use 26 | self.operational_mode = None # See MateFXDevice.STATUS_ enum 27 | 28 | self.is_230v = None 29 | self.aux_on = None 30 | if misc is not None: 31 | self.is_230v = (misc & 0x01 == 0x01) 32 | self.aux_on = (misc & 0x80 == 0x80) 33 | 34 | self.inverter_current = None # Ouptut/Inverter AC Current the FX is delivering to loads 35 | self.output_voltage = None # Output/Inverter AC Voltage (to loads) 36 | self.input_voltage = None # Input/Line AC Voltage (from grid) 37 | self.sell_current = None # AC Current the FX is delivering from batteries to AC input (sell) 38 | self.chg_current = None # AC Current the FX is taking from AC input and delivering to batteries 39 | self.buy_current = None # AC Current the FX is taking from AC input and delivering to batteries + loads 40 | self.battery_voltage = None # Battery Voltage 41 | 42 | @property 43 | def inv_power(self): 44 | """ 45 | BATTERIES -> AC_OUTPUT 46 | Power produced by the inverter from the battery 47 | """ 48 | if self.inverter_current is not None and self.output_voltage is not None: 49 | return Value((float(self.inverter_current) * float(self.output_voltage)) / 1000.0, units='kW', resolution=2) 50 | return None 51 | 52 | @property 53 | def sell_power(self): 54 | """ 55 | BATTERIES -> AC_INPUT 56 | Power produced by the inverter from the batteries, sold back to the grid (AC input) 57 | """ 58 | if self.sell_current is not None and self.output_voltage is not None: 59 | return Value((float(self.sell_current) * float(self.output_voltage)) / 1000.0, units='kW', resolution=2) 60 | return None 61 | 62 | @property 63 | def chg_power(self): 64 | """ 65 | AC_INPUT -> BATTERIES 66 | Power consumed by the inverter from the AC input to charge the battery bank 67 | """ 68 | if self.chg_current is not None and self.input_voltage is not None: 69 | return Value((float(self.chg_current) * float(self.input_voltage)) / 1000.0, units='kW', resolution=2) 70 | return None 71 | 72 | @property 73 | def buy_power(self): 74 | """ 75 | AC_INPUT -> BATTERIES + AC_OUTPUT 76 | """ 77 | if self.buy_current is not None and self.input_voltage is not None: 78 | return Value((float(self.buy_current) * float(self.input_voltage)) / 1000.0, units='kW', resolution=2) 79 | return None 80 | 81 | @classmethod 82 | def from_buffer(cls, data): 83 | values = cls.fmt.unpack(data) 84 | 85 | # Need this to determine whether the system is 230v or 110v 86 | misc = values[10] 87 | 88 | status = FXStatusPacket(misc) 89 | 90 | # When misc:0 == 1, you must multiply voltages by 2, and divide currents by 2 91 | if status.is_230v: 92 | vmul = 2.0; imul = 0.5 93 | else: 94 | vmul = 1.0; imul = 1.0 95 | 96 | # From MATE2 doc the status packet contains: 97 | # Inverter address 98 | # Inverter current - AC current the FX is delivering to loads 99 | # Charger current - AC current the FX is taking from AC input and delivering to batteries 100 | # Buy current - AC current the FX is taking from AC input and delivering to batteries AND loads 101 | # AC input voltage 102 | # AC output voltage 103 | # Sell current - AC current the FX is delivering from batteries to AC input 104 | # FX operational mode (0..10) 105 | # FX error mode 106 | # FX AC mode 107 | # FX Bat Voltage 108 | # FX Misc 109 | # FX Warnings 110 | 111 | status.inverter_current = Value(values[0] * imul, units='A', resolution=1) 112 | status.chg_current = Value(values[1] * imul, units='A', resolution=1) 113 | status.buy_current = Value(values[2] * imul, units='A', resolution=1) 114 | status.input_voltage = Value(values[3] * vmul, units='V', resolution=0) 115 | status.output_voltage = Value(values[4] * vmul, units='V', resolution=0) 116 | status.sell_current = Value(values[5] * imul, units='A', resolution=1) 117 | status.operational_mode = values[6] 118 | status.error_mode = values[7] 119 | status.ac_mode = values[8] 120 | status.battery_voltage = Value(values[9] / 10.0, units='V', resolution=1) 121 | # values[10]: misc byte 122 | status.warnings = values[11] 123 | 124 | # Also add the raw packet, in case any of the above changes 125 | status.raw = data 126 | 127 | return status 128 | 129 | def __repr__(self): 130 | return "" 131 | 132 | def __str__(self): 133 | # Format matches MATE2 LCD readout (FX->STATUS->METER) 134 | fmt = """FX Status: 135 | Battery: {battery_voltage} 136 | Inv: {inv} Zer: {sell} 137 | Chg: {chg} Buy: {buy} 138 | """ 139 | return fmt.format( 140 | battery_voltage=self.battery_voltage, 141 | inv=self.inv_power, 142 | chg=self.chg_power, 143 | sell=self.sell_power, 144 | buy=self.buy_power 145 | ) 146 | 147 | 148 | class MateFXDevice(MateDevice): 149 | """ 150 | Communicate with an FX unit attached to the MateNET bus 151 | """ 152 | DEVICE_TYPE = MateNET.DEVICE_FX 153 | 154 | # Error bit-field 155 | ERROR_LOW_VAC_OUTPUT = 0x01 # Inverter could not supply enough AC voltage to meet demand 156 | ERROR_STACKING_ERROR = 0x02 # Communication error among stacked FX inverters (eg. 3 phase system) 157 | ERROR_OVER_TEMP = 0x04 # FX has reached maximum allowable temperature 158 | ERROR_LOW_BATTERY = 0x08 # Battery voltage below low battery cut-out setpoint 159 | ERROR_PHASE_LOSS = 0x10 160 | ERROR_HIGH_BATTERY = 0x20 # Battery voltage rose above safe level for 10 seconds 161 | ERROR_SHORTED_OUTPUT = 0x40 162 | ERROR_BACK_FEED = 0x80 # Another power source was connected to the FX's AC output 163 | 164 | # Warning bit-field 165 | WARN_ACIN_FREQ_HIGH = 0x01 # >66Hz or >56Hz 166 | WARN_ACIN_FREQ_LOW = 0x02 # <54Hz or <44Hz 167 | WARN_ACIN_V_HIGH = 0x04 # >140VAC or >270VAC 168 | WARN_ACIN_V_LOW = 0x08 # <108VAC or <207VAC 169 | WARN_BUY_AMPS_EXCEEDS_INPUT = 0x10 170 | WARN_TEMP_SENSOR_FAILED = 0x20 # Internal temperature sensors have failed 171 | WARN_COMM_ERROR = 0x40 # Communication problem between us and the FX 172 | WARN_FAN_FAILURE = 0x80 # Internal cooling fan has failed 173 | 174 | # Operational Mode enum 175 | STATUS_INV_OFF = 0 176 | STATUS_SEARCH = 1 177 | STATUS_INV_ON = 2 178 | STATUS_CHARGE = 3 179 | STATUS_SILENT = 4 180 | STATUS_FLOAT = 5 181 | STATUS_EQ = 6 182 | STATUS_CHARGER_OFF = 7 183 | STATUS_SUPPORT = 8 # FX is drawing power from batteries to support AC 184 | STATUS_SELL_ENABLED = 9 # FX is exporting more power than the loads are drawing 185 | STATUS_PASS_THRU = 10 # FX converter is off, passing through line AC 186 | 187 | # Reasons that the FX has stopped selling power to the grid 188 | # (see self.sell_status) 189 | SELL_STOP_REASONS = { 190 | 1: 'Frequency shift greater than limits', 191 | 2: 'Island-detected wobble', 192 | 3: 'VAC over voltage', 193 | 4: 'Phase lock error', 194 | 5: 'Charge diode battery volt fault', 195 | 7: 'Silent command', 196 | 8: 'Save command', 197 | 9: 'R60 off at go fast', 198 | 10: 'R60 off at silent relay', 199 | 11: 'Current limit sell', 200 | 12: 'Current limit charge', 201 | 14: 'Back feed', 202 | 15: 'Brute sell charge VAC over' 203 | } 204 | 205 | def __init__(self, *args, **kwargs): 206 | super(MateFXDevice, self).__init__(*args, **kwargs) 207 | self._is_230v = None 208 | 209 | def scan(self, *args): 210 | """ 211 | Query the attached device to make sure we're communicating with an FX unit 212 | """ 213 | devid = super(MateFXDevice, self).scan() 214 | if devid == None: 215 | raise RuntimeError("No response from the FX unit") 216 | if devid != self.DEVICE_TYPE: 217 | raise RuntimeError("Attached device is not an FX unit! (DeviceID: %s)" % devid) 218 | 219 | def get_status(self): 220 | """ 221 | Request a status packet from the inverter 222 | :return: A FXStatusPacket 223 | """ 224 | resp = self.send(MateNET.TYPE_STATUS, addr=1, response_len=FXStatusPacket.size) 225 | if resp: 226 | status = FXStatusPacket.from_buffer(resp) 227 | self._is_230v = status.is_230v 228 | return status 229 | 230 | @property 231 | def is_230v(self): 232 | if self._is_230v is not None: 233 | return self._is_230v 234 | else: 235 | s = self.get_status() 236 | if not s: 237 | raise Exception('No response received when trying to read status') 238 | return self._is_230v 239 | 240 | @property 241 | def revision(self): 242 | # The FX doesn't return a revision; 243 | # instead it returns a firmware version number 244 | fw = self.query(0x0001) 245 | return 'FW:%.3d' % (fw) 246 | 247 | @property 248 | def errors(self): 249 | """ Errors bit-field (See ERROR_* constants) """ 250 | return self.query(0x0039) 251 | 252 | @property 253 | def warnings(self): 254 | """ Warnings bit-field (See WARN_* constants) """ 255 | return self.query(0x0059) 256 | 257 | @property 258 | def inverter_control(self): 259 | """ Inverter mode (0: Off, 1: Search, 2: On) """ 260 | return self.query(0x003D) 261 | @inverter_control.setter 262 | def inverter_control(self, value): 263 | ## WARNING: THIS CAN TURN OFF THE INVERTER! ## 264 | self.control(0x003D, value) 265 | 266 | @property 267 | def acin_control(self): 268 | """ AC IN mode (0: Drop, 1: Use) """ 269 | return self.query(0x003A) 270 | @acin_control.setter 271 | def acin_control(self, value): 272 | self.control(0x003A, value) 273 | 274 | @property 275 | def charge_control(self): 276 | """ Charger mode (0: Off, 1: Auto, 2: On) """ 277 | return self.query(0x003C) 278 | @charge_control.setter 279 | def charge_control(self, value): 280 | self.control(0x003C, value) 281 | 282 | @property 283 | def aux_control(self): 284 | """ AUX mode (0: Off, 1: Auto, 2: On) """ 285 | return self.query(0x005A) 286 | @aux_control.setter 287 | def aux_control(self, value): 288 | self.control(0x005A, value) 289 | 290 | @property 291 | def eq_control(self): 292 | """ Equalize mode (0:Off, 1: Auto?, 2: On?) """ 293 | return self.query(0x0038) 294 | @eq_control.setter 295 | def eq_control(self, value): 296 | self.control(0x0038, value) 297 | 298 | @property 299 | def disconn_status(self): 300 | return self.query(0x0084) 301 | 302 | @property 303 | def sell_status(self): 304 | return self.query(0x008F) 305 | 306 | @property 307 | def temp_battery(self): 308 | """ Temperature of the battery (Raw, 0..255) """ 309 | # Not verified. I don't have a battery thermometer. 310 | return self.query(0x0032) 311 | 312 | @property 313 | def temp_air(self): 314 | """ Temperature of the air (Raw, 0..255) """ 315 | return self.query(0x0033) 316 | 317 | @property 318 | def temp_fets(self): 319 | """ Temperature of the MOSFET switches (Raw, 0..255) """ 320 | return self.query(0x0034) 321 | 322 | @property 323 | def temp_capacitor(self): 324 | """ Temperature of the capacitor (Raw, 0..255) """ 325 | return self.query(0x0035) 326 | 327 | @property 328 | def output_voltage(self): 329 | x = self.query(0x002D) 330 | if self.is_230v: 331 | x *= 2.0 332 | return Value(x, units='V', resolution=0) 333 | 334 | @property 335 | def input_voltage(self): 336 | x = self.query(0x002C) 337 | if self.is_230v: 338 | x *= 2.0 339 | return Value(x, units='V', resolution=0) 340 | 341 | @property 342 | def inverter_current(self): 343 | x = self.query(0x006D) 344 | if self.is_230v: 345 | x /= 2.0 346 | return Value(x, units='A', resolution=1) 347 | 348 | @property 349 | def charger_current(self): 350 | x = self.query(0x006A) 351 | if self.is_230v: 352 | x /= 2.0 353 | return Value(x, units='A', resolution=1) 354 | 355 | @property 356 | def input_current(self): 357 | x = self.query(0x006C) 358 | if self.is_230v: 359 | x /= 2.0 360 | return Value(x, units='A', resolution=1) 361 | 362 | @property 363 | def sell_current(self): 364 | x = self.query(0x006B) 365 | if self.is_230v: 366 | x /= 2.0 367 | return Value(x, units='A', resolution=1) 368 | 369 | @property 370 | def battery_actual(self): 371 | return Value(self.query(0x0019) / 10.0, units='V', resolution=1) 372 | 373 | @property 374 | def battery_temp_compensated(self): 375 | return Value(self.query(0x0016) / 10.0, units='V', resolution=1) 376 | 377 | @property 378 | def absorb_setpoint(self): 379 | return Value(self.query(0x000B) / 10.0, units='V', resolution=1) 380 | 381 | @property 382 | def absorb_time_remaining(self): 383 | return Value(self.query(0x0070), units='h', resolution=0) 384 | 385 | @property 386 | def float_setpoint(self): 387 | return Value(self.query(0x000A) / 10.0, units='V', resolution=1) 388 | 389 | @property 390 | def float_time_remaining(self): 391 | return Value(self.query(0x006E), units='h', resolution=0) 392 | 393 | @property 394 | def refloat_setpoint(self): 395 | return Value(self.query(0x000D) / 10.0, units='V', resolution=1) 396 | 397 | @property 398 | def equalize_setpoint(self): 399 | return Value(self.query(0x000C) / 10.0, units='V', resolution=1) 400 | 401 | @property 402 | def equalize_time_remaining(self): 403 | return Value(self.query(0x0071), units='h', resolution=0) 404 | 405 | # For backwards compatibility 406 | # DEPRECATED 407 | def MateFX(comport, supports_spacemark=None, port=0): 408 | bus = MateNET(comport, supports_spacemark) 409 | return MateFXDevice(bus, port) 410 | 411 | 412 | if __name__ == "__main__": 413 | status = FXStatusPacket.from_buffer('\x28\x0A\x00\x00\x0A\x00\x64\x00\x00\xDC\x14\x0A') 414 | print status 415 | -------------------------------------------------------------------------------- /pymate/matenet/matedevice.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | class MateDevice(object): 4 | """ 5 | Abstract class representing a device attached to the MateNET bus or to a hub. 6 | 7 | Usage: 8 | bus = MateNET('COM1') 9 | dev = MateDevice(bus, port=0) 10 | dev.scan() 11 | """ 12 | DEVICE_TYPE = None 13 | 14 | DEVICE_HUB = 1 15 | DEVICE_FX = 2 16 | DEVICE_MX = 3 17 | DEVICE_DC = 4 18 | 19 | # Common registers 20 | REG_DEVID = 0x0000 21 | REG_REV_A = 0x0002 22 | REG_REV_B = 0x0003 23 | REG_REV_C = 0x0004 24 | 25 | REG_SET_BATTERY_TEMP = 0x4001 26 | REG_TIME = 0x4004 27 | REG_DATE = 0x4005 28 | 29 | def __init__(self, matenet, port=0): 30 | #assert(isinstance(matenet, [MateNET])) 31 | self.matenet = matenet 32 | self.port = port 33 | 34 | # def __init__(self, comport, supports_spacemark=None): 35 | # super(Mate, self).__init__(comport, supports_spacemark) 36 | 37 | def scan(self): 38 | return self.matenet.scan(self.port) 39 | 40 | def send(self, ptype, addr, param=0, response_len=None): 41 | return self.matenet.send(ptype, addr, param=param, port=self.port, response_len=None) 42 | 43 | def query(self, reg, param=0): 44 | return self.matenet.query(reg, param=param, port=self.port) 45 | 46 | def control(self, reg, value): 47 | return self.matenet.control(reg, value, port=self.port) 48 | 49 | def read(self, register, param=0): 50 | # Alias of control() 51 | return self.matenet.read(register, param, port=self.port) 52 | 53 | def write(self, register, value): 54 | # Alias of query() 55 | return self.matenet.write(register, value, port=self.port) 56 | 57 | @property 58 | def revision(self): 59 | """ 60 | :return: The revision of the attached device (Format "000.000.000") 61 | """ 62 | a = self.query(self.REG_REV_A) 63 | b = self.query(self.REG_REV_B) 64 | c = self.query(self.REG_REV_C) 65 | return '%.3d.%.3d.%.3d' % (a, b, c) 66 | 67 | def update_time(self, dt): 68 | """ 69 | Update the time on the connected device 70 | This should be sent every 15 seconds. 71 | NOTE: not supported on FX devices. 72 | """ 73 | assert(isinstance(dt, datetime.datetime)) 74 | x1 = ( 75 | ((dt.hour & 0x1F) << 11) | 76 | ((dt.minute & 0x3F) << 5) | 77 | ((dt.second & 0x1F) >> 1) 78 | ) 79 | x2 = ( 80 | (((dt.year-2000) & 0x7F) << 9) | 81 | ((dt.month & 0x0F) << 5) | 82 | (dt.day & 0x1F) 83 | ) 84 | self.write(self.REG_TIME, x1) 85 | self.write(self.REG_DATE, x2) 86 | 87 | def update_battery_temp(self, raw_temp): 88 | """ 89 | Update the battery temperature for FX/DC devices 90 | """ 91 | self.write(self.REG_SET_BATTERY_TEMP, raw_temp) 92 | 93 | @staticmethod 94 | def synchronize(master, devices): 95 | """ 96 | Synchronize connected outback devices. 97 | Should be called once per minute. 98 | 99 | :param master: The master MateMXDevice 100 | :param devices: All connected MateDevices (including master) 101 | """ 102 | assert(all([isinstance(d, MateDevice) for d in devices])) 103 | assert(isinstance(master, MateDevice)) # Should be MateMXDevice 104 | assert(master.DEVICE_TYPE == MateDevice.DEVICE_MX) 105 | 106 | # 1. Update date & time for attached MX/DC units 107 | dt = datetime.datetime.now() 108 | for dev in devices: 109 | if (dev is not None): 110 | if (dev.DEVICE_TYPE in (MateDevice.DEVICE_MX, MateDevice.DEVICE_DC)): 111 | dev.update_time(dt) 112 | 113 | # 2. Update battery temperature for attached FX/DC units 114 | bat_temp = master.battery_temp_raw 115 | for dev in devices: 116 | if (dev is not None) and (dev is not master): 117 | if (dev.DEVICE_TYPE in (MateDevice.DEVICE_FX, MateDevice.DEVICE_DC)): 118 | dev.update_battery_temp(bat_temp) 119 | 120 | return bat_temp 121 | 122 | # For backwards compatibility 123 | # DEPRECATRED 124 | class Mate(MateDevice): 125 | def __init__(self, comport, supports_spacemark=None): 126 | self.bus = MateNET(comport, supports_spacemark) 127 | super(Mate, self).__init__(self.bus, port=0) 128 | -------------------------------------------------------------------------------- /pymate/matenet/matenet.py: -------------------------------------------------------------------------------- 1 | # pyMATE controller 2 | # Author: Jared Sanson 3 | # 4 | # Emulates an Outback MATE controller panel, allowing direct communication with 5 | # an attached device (no MATE needed!) 6 | # 7 | 8 | 9 | __author__ = 'Jared' 10 | 11 | from serial import Serial, PARITY_SPACE, PARITY_MARK, PARITY_ODD, PARITY_EVEN 12 | from pymate.cstruct import struct 13 | from matenet_ser import MateNETSerial 14 | from matenet_pjon import MateNETPJON 15 | from time import sleep 16 | import logging 17 | 18 | class MateNET(object): 19 | """ 20 | Interface for the MATE RJ45 bus ("MateNET") 21 | This class only handles the low level protocol, 22 | it does not care what is attached to the bus. 23 | """ 24 | TxPacket = struct('>BBHH', ('port', 'ptype', 'addr', 'param')) # Payload is always 4 bytes? 25 | QueryPacket = struct('>HH', ('reg', 'param')) 26 | QueryResponse = struct('>H', ('value',)) 27 | 28 | DEVICE_HUB = 1 29 | DEVICE_FX = 2 30 | DEVICE_MX = 3 31 | DEVICE_FLEXNETDC = 4 32 | DEVICE_DC = 4 # Alias of FLEXNETDC 33 | 34 | TYPE_QUERY = 2 35 | TYPE_CONTROL = 3 36 | TYPE_STATUS = 4 37 | TYPE_LOG = 22 38 | 39 | TYPE_READ = 2 40 | TYPE_WRITE = 3 41 | 42 | TYPE_DEC = 0 43 | TYPE_DIS = 0 44 | TYPE_INC = 1 45 | TYPE_EN = 1 46 | TYPE_READ = 2 47 | TYPE_WRITE = 3 48 | 49 | DEVICE_TYPES = { 50 | DEVICE_HUB: 'Hub', 51 | DEVICE_MX: 'MX', 52 | DEVICE_FX: 'FX', 53 | DEVICE_FLEXNETDC: 'FLEXnet DC', 54 | } 55 | 56 | def __init__(self, port, supports_spacemark=None, tap=None): 57 | if isinstance(port, (MateNETSerial, MateNETPJON)): 58 | self.port = port 59 | else: 60 | self.port = MateNETSerial(port, supports_spacemark) 61 | 62 | self.log = logging.getLogger('mate.net') 63 | 64 | # Retry command this many times if we read back an invalid packet (eg. bad CRC) 65 | self.RETRY_PACKET = 2 66 | 67 | self.tap = tap 68 | 69 | def send(self, ptype, addr, param=0, port=0, response_len=None): 70 | """ 71 | Send a MateNET packet to the bus (as if it was sent by a MATE unit) and return the response 72 | :param port: Port to send to, if a hub is present (0 if no hub or talking to the hub) 73 | :param ptype: Type of the packet 74 | :param param: Optional parameter (16-bit uint) 75 | :return: The raw response (str) 76 | """ 77 | if self.log.isEnabledFor(logging.DEBUG): 78 | self.log.debug('Send [Port%d, Type=0x%.2x, Addr=0x%.4x, Param=0x%.4x]', port, ptype, addr, param) 79 | 80 | if response_len is not None: 81 | response_len += 1 # Account for command ack byte 82 | 83 | packet = MateNET.TxPacket(port, ptype, addr, param) 84 | data = None 85 | for i in range(self.RETRY_PACKET+1): 86 | try: 87 | txbuf = packet.to_buffer() 88 | self.port.send(txbuf) 89 | 90 | rxbuf = self.port.recv(response_len) 91 | if not rxbuf: 92 | self.log.debug('RETRY') 93 | continue # No response - try again 94 | #return None 95 | 96 | if self.tap: 97 | # Send the packet to the wireshark tap pipe, if present 98 | 99 | self.tap.capture( 100 | txbuf+'\xFF\xFF', # Dummy checksum 101 | rxbuf+'\xFF\xFF' # Dummy checksum 102 | ) 103 | 104 | break # Received successfully 105 | except: 106 | if i < self.RETRY_PACKET: 107 | self.log.debug('RETRY') 108 | continue # Transmission error - try again 109 | 110 | if self.tap: 111 | # No response, just capture the TX packet for wireshark 112 | self.tap.capture_tx(txbuf+'\xFF\xFF') 113 | 114 | raise # Retry limit reached 115 | 116 | if not rxbuf: 117 | return None 118 | 119 | # Validation 120 | if len(rxbuf) < 2: 121 | raise RuntimeError("Error receiving packet - not enough data received") 122 | 123 | if ord(rxbuf[0]) & 0x80 == 0x80: 124 | raise RuntimeError("Invalid command 0x%.2x" % (ord(rxbuf[0]) & 0x7F)) 125 | 126 | return rxbuf[1:] 127 | 128 | ### Higher level protocol functions ### 129 | 130 | def query(self, reg, param=0, port=0): 131 | """ 132 | Query a register and retrieve its value 133 | :param reg: The register (16-bit address) 134 | :param param: Optional parameter 135 | :return: The value (16-bit uint) 136 | """ 137 | resp = self.send(MateNET.TYPE_QUERY, addr=reg, param=param, port=port, response_len=MateNET.QueryResponse.size) 138 | if resp: 139 | response = MateNET.QueryResponse.from_buffer(resp) 140 | return response.value 141 | 142 | def control(self, reg, value, port=0): 143 | """ 144 | Control a register 145 | :param reg: The register (16-bit address) 146 | :param value: The value (16-bit uint) 147 | :param port: Port (0-10) 148 | :return: ??? 149 | """ 150 | resp = self.send(MateNET.TYPE_CONTROL, addr=reg, param=value, port=port, response_len=MateNET.QueryResponse.size) 151 | if resp: 152 | return None # TODO: What kind of response do we get from a control packet? 153 | 154 | def read(self, register, param=0, port=0): 155 | """ 156 | Read a register 157 | """ 158 | return self.query(register, param, port) 159 | 160 | def write(self, register, value, port=0): 161 | """ 162 | Write to a register 163 | """ 164 | return self.control(register, value, port) 165 | 166 | def scan(self, port=0): 167 | """ 168 | Scan for device attached to the specified port 169 | :param port: int, 0-10 (root:0) 170 | :return: int, the type of device that is attached (see MateNET.DEVICE_*) 171 | """ 172 | result = self.query(0x00, port=port) 173 | if result is not None: 174 | # TODO: Don't know what the upper byte is for, but it is seen on some MX units 175 | result = result & 0x00FF 176 | return result 177 | 178 | def enumerate(self): 179 | """ 180 | Scan for device(s) on the bus. 181 | Returns a list of device types at each port location 182 | """ 183 | devices = [0]*10 184 | 185 | # Port 0 will either be a device or a hub. 186 | devices[0] = self.query(0x00, port=0) 187 | if not devices[0]: 188 | raise Exception('No devices found on the bus') 189 | 190 | # Only scan for other devices if a hub is attached to port 0 191 | if devices[0] == MateNET.DEVICE_HUB: 192 | for i in range(1,len(devices)): 193 | self.log.info('Scanning port %d', i) 194 | devices[i] = self.query(0x00, port=i) 195 | 196 | return devices 197 | 198 | def find_device(self, device_type): 199 | """ 200 | Find which port a device is connected to. 201 | 202 | Note: If you have a hub, you should fill the ports starting from 1, 203 | not leaving any gaps. Any empty ports will introduce delay as we wait 204 | for a timeout. 205 | 206 | KeyError is thrown if the device is not connected. 207 | 208 | Usage: 209 | port = bus.find_device(MateNET.DEVICE_MX) 210 | mx = MateMXDevice(bus, port) 211 | """ 212 | for i in range(0,10): 213 | dtype = self.scan(port=i) 214 | if dtype and dtype == device_type: 215 | self.log.info('Found %s device at port %d', 216 | MateNET.DEVICE_TYPES[dtype], 217 | i 218 | ) 219 | return i 220 | raise KeyError('%s device not found' % MateNET.DEVICE_TYPES[device_type]) 221 | -------------------------------------------------------------------------------- /pymate/matenet/matenet_pjon.py: -------------------------------------------------------------------------------- 1 | 2 | # pyMATE over PJON interface 3 | # Author: Jared Sanson 4 | # 5 | # Specifications: 6 | # SFSP v1.0 - https://github.com/gioblu/PJON/blob/master/specification/SFSP-frame-separation-specification-v1.0.md 7 | # TSDL v2.1 - https://github.com/gioblu/PJON/blob/master/src/strategies/ThroughSerial/specification/TSDL-specification-v2.1.md 8 | # PJON v3.1 - https://github.com/gioblu/PJON/blob/master/specification/PJON-protocol-specification-v3.1.md 9 | 10 | from serial import Serial 11 | from time import sleep, time 12 | import logging 13 | 14 | SFSP_START = 0x95 15 | SFSP_END = 0xEA 16 | SFSP_ESC = 0xBB 17 | 18 | # [START:8][H:8][I:8][END:8]...[ACK:8] 19 | 20 | TSDL_ACK = 6 21 | 22 | RX_IDLE = 0 23 | RX_RECV = 1 24 | 25 | ID_BROADCAST = 0 26 | 27 | TARGET_DEVICE = 0x0A 28 | TARGET_MATE = 0x0B 29 | 30 | class MateNETPJON(object): 31 | def __init__(self, comport, baud=9600, target=TARGET_DEVICE): 32 | if isinstance(comport, Serial): 33 | self.ser = comport 34 | else: 35 | self.ser = Serial(comport, baud) 36 | self.ser.timeout = 1.0 37 | 38 | self.device_id = 1 39 | self.log = logging.getLogger('mate.pjon') 40 | self.rx_buffer = [] 41 | self.rx_state = RX_IDLE 42 | self.target = target 43 | 44 | def _build_frame(self, data): 45 | yield SFSP_START 46 | for b in data: 47 | if b in [SFSP_START, SFSP_END, SFSP_ESC]: 48 | b ^= SFSP_ESC 49 | yield SFSP_ESC 50 | yield b 51 | yield SFSP_END 52 | 53 | def send(self, data, target_device_id=0): 54 | """ 55 | Send a packet to PJON bus 56 | """ 57 | data = [self.target] + [ord(c) for c in data] # TODO: Hacky 58 | 59 | # NOTE: We are using a very watered down version of the PJON spec 60 | # since this is intended to be used as a 1:1 communication over a USB serial bus. 61 | 62 | header_len = 5 63 | total_len = len(data) + header_len 64 | use_crc32 = ((len(data) + header_len) > 15) 65 | 66 | header = 0x02 # PJON_TX_INFO_BIT 67 | if use_crc32: 68 | header |= 0b00100000 # PJON_CRC_BIT 69 | total_len += 4 70 | else: 71 | total_len += 1 72 | 73 | # Prepare header 74 | buffer = [] 75 | buffer.append(target_device_id) 76 | buffer.append(header) 77 | buffer.append(total_len) 78 | crc_h = self._crc8(buffer) 79 | buffer.append(crc_h) 80 | buffer.append(self.device_id) # PJON_TX_INFO_BIT in header must be set 81 | 82 | # Add payload 83 | buffer += list(data) 84 | 85 | # Compute CRC(Header + Payload) 86 | if use_crc32: 87 | # If packet > 15 bytes then we must use a 32-bit CRC 88 | crc_p = self._crc32(buffer) 89 | buffer.append((crc_p >> 24) & 0xFF) 90 | buffer.append((crc_p >> 16) & 0xFF) 91 | buffer.append((crc_p >> 8) & 0xFF) 92 | buffer.append((crc_p) & 0xFF) 93 | else: 94 | crc_p = self._crc8(buffer) 95 | buffer.append(crc_p) 96 | 97 | if self.log.isEnabledFor(logging.DEBUG): 98 | self.log.debug('TX: %s', (' '.join('%.2x' % c for c in buffer))) 99 | 100 | # Escape & Frame data 101 | buffer = list(self._build_frame(buffer)) 102 | 103 | self.ser.write(buffer) 104 | 105 | def _crc8(self, data): 106 | crc = 0 107 | for b in data: 108 | if b < 0: b += 256 109 | for i in range(8): 110 | odd = ((b ^ crc) & 1) == 1 111 | crc >>= 1 112 | b >>= 1 113 | if odd: crc ^= 0x97 114 | return crc 115 | 116 | def _crc32(self, data): 117 | """ 118 | See PJON\src\utils\crc\PJON_CRC32.h 119 | """ 120 | crc = 0xFFFFFFFF 121 | for b in data: 122 | crc ^= (b & 0xFF) 123 | for i in range(8): 124 | odd = crc & 1 125 | crc >>= 1 126 | if odd: 127 | crc ^= 0xEDB88320 128 | return crc ^ 0xFFFFFFFF 129 | 130 | def _recv_frame(self, timeout=1.0): 131 | """ 132 | Receive an escaped frame from PJON bus 133 | :param timeout: seconds to wait until returning, 0 to return immediately, None to block indefinitely 134 | :return: bytes if packet received, None if timeout 135 | """ 136 | # Example RX packet: 137 | # 149 0 2 11 226 44 72 69 76 76 79 69 234 138 | buffer = [] 139 | escape_next = False 140 | t_start = time() 141 | 142 | self.ser.timeout = 0.01 143 | self.rx_state = RX_IDLE 144 | 145 | while (time() - t_start < timeout): 146 | if self.rx_state == RX_IDLE: 147 | # Locate start of frame 148 | data = self.ser.read(1) 149 | if data: 150 | b = ord(data[0]) 151 | if b == SFSP_START: 152 | self.rx_state = RX_RECV 153 | self.log.debug('RX START') 154 | continue 155 | 156 | elif self.rx_state == RX_RECV: 157 | # Read until SFSP_END encountered 158 | data = self.ser.read() 159 | if data: 160 | for c in data: 161 | b = ord(c) 162 | if b == SFSP_END: 163 | return buffer # Complete frame received! 164 | 165 | else: 166 | # Unescape 167 | if b == SFSP_ESC: 168 | escape_next = True 169 | continue 170 | 171 | elif b == SFSP_START: 172 | self.log.debug('RX UNEXPECTED START') 173 | self.rx_state = RX_IDLE 174 | buffer = [] 175 | escape_next = False 176 | continue 177 | 178 | if escape_next: 179 | b ^= SFSP_ESC 180 | escape_next = False 181 | 182 | buffer.append(b) 183 | 184 | self.log.info('RX TIMEOUT') 185 | return None 186 | 187 | def recv(self, expected_len=None, timeout=1.0): 188 | """ 189 | Receive a packet from PJON bus 190 | :param timeout: seconds to wait until returning, 0 to return immediately, None to block indefinitely 191 | :return: bytes if packet received, None if timeout 192 | """ 193 | data = self._recv_frame(timeout) 194 | if data: 195 | if self.log.isEnabledFor(logging.DEBUG): 196 | self.log.debug('RX: %s', (' '.join('%.2x' % b for b in data))) 197 | 198 | if len(data) < 5: 199 | raise RuntimeError('PJON error: Not enough bytes') 200 | 201 | # [ID:8][Header:8][Length:8][CRC:8][Data...][CRC:8] 202 | 203 | i = 0 204 | 205 | device_id = data[i]; i += 1 206 | header = data[i]; i += 1 207 | packet_len = data[i]; i += 1 208 | 209 | if device_id != ID_BROADCAST and device_id != self.device_id: 210 | self.log.debug('PJON: Ignoring packet for ID:0x%.2x', device_id) 211 | return None # Not addressed to us 212 | 213 | if packet_len < 4: 214 | raise RuntimeError('PJON error: Invalid length') 215 | if packet_len > len(data): 216 | raise RuntimeError('PJON error: Not enough bytes') 217 | 218 | use_crc32 = (header & 0b00100000) 219 | 220 | # Validate header CRC 221 | header_crc_actual = self._crc8(data[0:i]) 222 | header_crc = data[i]; i += 1 223 | if header_crc != header_crc_actual: 224 | raise RuntimeError('PJON error: Bad header CRC (%.2x != %.2x)' % (header_crc, header_crc_actual)) 225 | 226 | # Header bits change how the packet is parsed 227 | if header & 0b00000001: 228 | raise RuntimeError('PJON error: Shared mode not supported') 229 | if header & 0b00000010: 230 | tx_id = data[i]; i += 1 231 | if header & 0b00000100: 232 | raise RuntimeError('PJON error: ACK requested but not supported') 233 | if header & 0b00010000: 234 | raise RuntimeError('PJON error: Network services not supported') 235 | if header & 0b01000000: 236 | raise RuntimeError('PJON error: Extended length (>=200 bytes) not supported') 237 | if header & 0b10000000: 238 | raise RuntimeError('PJON error: Packet identification not supported') 239 | 240 | payload = data[i:packet_len-1]; 241 | 242 | # Validate CRC(Header + Payload) 243 | if use_crc32: 244 | payload_crc_actual = self._crc32(data[0:-4]) 245 | payload_crc = (data[-4]<<24) | (data[-3]<<16) | (data[-2]<<8) | data[-1] 246 | if payload_crc != payload_crc_actual: 247 | raise RuntimeError('PJON error: Bad CRC32 (%.8x != %.8x)' % (payload_crc, payload_crc_actual)) 248 | else: 249 | payload_crc_actual = self._crc8(data[0:-1]) 250 | payload_crc = data[packet_len-1] 251 | if payload_crc != payload_crc_actual: 252 | raise RuntimeError('PJON error: Bad CRC8 (%.2x != %.2x)' % (payload_crc, payload_crc_actual)) 253 | 254 | if self.log.isEnabledFor(logging.DEBUG): 255 | self.log.debug('RX: [I:%.2X, H:%.2X, Len:%d, Data:[%s]]', 256 | device_id, 257 | header, 258 | packet_len, 259 | (' '.join('%.2x' % b for b in payload)) 260 | ) 261 | 262 | if len(payload) == 1: 263 | raise RuntimeError("PJON error: Error returned from controller: %.2x" % (payload[0])) 264 | 265 | # TODO: Validate payload length against expected_len 266 | 267 | return ''.join(chr(c) for c in payload) # TODO: Hacky 268 | 269 | if __name__ == "__main__": 270 | ch = logging.StreamHandler() 271 | ch.setLevel(logging.DEBUG) 272 | 273 | port = MateNETOverPJON('COM6') 274 | port.log.setLevel(logging.DEBUG) 275 | port.log.addHandler(ch) 276 | while True: 277 | data = port._recv() 278 | if data: 279 | port.log.debug('RX: %s', (' '.join('%.2x' % ord(b) for b in data))) 280 | port.log.debug(' %s', (''.join(data))) 281 | -------------------------------------------------------------------------------- /pymate/matenet/matenet_ser.py: -------------------------------------------------------------------------------- 1 | 2 | # pyMATE serial interface (emulated 9-bit) 3 | # Author: Jared Sanson 4 | # 5 | # Emulates an Outback MATE controller panel, allowing direct communication with 6 | # an attached device (no MATE needed!) 7 | # 8 | # The proprietary protocol between the MATE controller and an attached Outback product 9 | # (from now on referred to as "MateNET"), is just serial (UART) with 0-24V logic levels. 10 | # Serial format: 9600 baud, 9n1 11 | # Pinout: 12 | # 1: +V (battery voltage) 13 | # 2: GND 14 | # 3: TX (From MATE to unit) 15 | # 6: RX (From unit to MATE) 16 | # Note that the above pinout matches the pairs in a CAT5 cable (Green/Orange pairs) 17 | # 18 | # The protocol itself consists of raw binary data, in big-endian format. 19 | # It uses 9-bit serial communication, where the 9th bit denotes the start of a packet. 20 | # The rest of the implementation details are explained throughout the following code... 21 | # 22 | 23 | __author__ = 'Jared' 24 | 25 | from serial import Serial, PARITY_SPACE, PARITY_MARK, PARITY_ODD, PARITY_EVEN 26 | from pymate.cstruct import struct 27 | from time import sleep 28 | import logging 29 | 30 | class MateNETSerial(object): 31 | """ 32 | Interface for the MATE RJ45 bus ("MateNET") 33 | This class only handles the low level protocol, 34 | it does not care what is attached to the bus. 35 | """ 36 | def __init__(self, comport, supports_spacemark=None): 37 | """ 38 | :param comport: The hardware serial port to use (eg. /dev/ttyUSB0 or COM1) 39 | :param supports_spacemark: 40 | True-Port supports Space/Mark parity. 41 | False-Port does not support Space/Mark parity. 42 | None-Try detect whether the port supports Space/Mark parity. 43 | """ 44 | if isinstance(comport, Serial): 45 | self.ser = comport 46 | else: 47 | self.ser = Serial(comport, 9600, parity=PARITY_ODD) 48 | self.ser.timeout = 1.0 49 | 50 | self.log = logging.getLogger('mate.ser') 51 | 52 | # Delay between bytes when space/mark is not supported 53 | # This is needed to ensure changing the parity between even/odd only affects one byte at a time 54 | # (Essentially forces 1 byte in the TX buffer at a time) 55 | self.FUDGE_FACTOR = 0.002 # seconds 56 | 57 | # Amount of time with no communication that signifies the end of the packet 58 | self.END_OF_PACKET_TIMEOUT = 0.02 # seconds 59 | 60 | # Set to true to workaround issue where some received packets are too large 61 | self.TRIM_LARGE_PACKETS = True 62 | 63 | self.supports_spacemark = supports_spacemark 64 | if self.supports_spacemark is None: 65 | self.supports_spacemark = ( 66 | (PARITY_SPACE in self.ser.PARITIES) and 67 | (PARITY_MARK in self.ser.PARITIES) 68 | ) 69 | 70 | def _odd_parity(self, b): 71 | p = False 72 | while b: 73 | p = not p 74 | b = b & (b - 1) 75 | return p 76 | 77 | def _write_9b(self, data, bit8): 78 | if self.log.isEnabledFor(logging.DEBUG): 79 | self.log.debug('TX: [%d] %s', bit8, (' '.join('%.2x' % ord(c) for c in data))) 80 | 81 | if self.supports_spacemark: 82 | self.ser.parity = (PARITY_MARK if bit8 else PARITY_SPACE) 83 | self.ser.write(data) 84 | sleep(self.FUDGE_FACTOR) 85 | else: 86 | # Emulate SPACE/MARK parity using EVEN/ODD parity 87 | for b in data: 88 | p = self._odd_parity(ord(b)) ^ bit8 89 | self.ser.parity = (PARITY_ODD if p else PARITY_EVEN) 90 | self.ser.write(b) 91 | sleep(self.FUDGE_FACTOR) 92 | 93 | @staticmethod 94 | def _calc_checksum(data): 95 | """ 96 | Calculate the checksum of some raw data. 97 | The checksum is a simple 16-bit sum over all the bytes in the packet, 98 | including the 9-bit start-of-packet byte (though the 9th bit is not counted) 99 | """ 100 | return sum(ord(c) for c in data) % 0xFFFF 101 | 102 | @staticmethod 103 | def _parse_packet(data, expected_len=None): 104 | """ 105 | Parse a MATE packet, validatin the length and checksum 106 | :param data: Raw string data 107 | :return: Raw string data of the packet itself (excluding SOF and checksum) 108 | """ 109 | # Validation 110 | if not data or len(data) == 0: 111 | raise RuntimeError("Error receiving mate packet - No data received") 112 | if len(data) < 3: 113 | raise RuntimeError("Error receiving mate packet - Received packet too small (%d bytes)" % (len(data))) 114 | 115 | if expected_len is not None: 116 | if len(data) < expected_len: 117 | raise RuntimeError("Error receiving mate packet - Received packet too small (%d bytes, expected %d)" % (len(data), expected_len)) 118 | if len(data) > expected_len: 119 | RuntimeError("Error receiving mate packet - Received packet too large (%d bytes, expected %d)" % (len(data), expected_len)) 120 | 121 | # Checksum 122 | packet = data[0:-2] 123 | expected_chksum = (ord(data[-2]) << 8) | ord(data[-1]) 124 | actual_chksum = MateNETSerial._calc_checksum(packet) 125 | if actual_chksum != expected_chksum: 126 | raise RuntimeError("Error receiving mate packet - Invalid checksum (Expected:%.4x, Actual:%.4x)" 127 | % (expected_chksum, actual_chksum)) 128 | return packet 129 | 130 | def send(self, data): 131 | """ 132 | Send a packet to the MateNET bus 133 | :param data: str containing the raw data to send (excluding checksum) 134 | """ 135 | 136 | checksum = self._calc_checksum(data) 137 | footer = chr((checksum >> 8) & 0xFF) + chr(checksum & 0xFF) 138 | 139 | # First byte has bit8 set (address byte) 140 | self._write_9b(data[0], 1) 141 | 142 | # Rest of the bytes have bit8 cleared (data byte) 143 | self._write_9b(data[1:] + footer, 0) 144 | 145 | def recv(self, expected_len=None, timeout=1.0): 146 | """ 147 | Receive a packet from the MateNET bus, waiting if necessary 148 | :param timeout: seconds to wait until returning, 0 to return immediately, None to block indefinitely 149 | :return: str if packet received, None if timeout 150 | """ 151 | # Wait for packet 152 | # TODO: Check parity? 153 | self.ser.timeout = timeout 154 | rawdata = self.ser.read(1) 155 | if not rawdata: 156 | return None 157 | 158 | # Get rest of packet (timeout set to ~10ms to detect end of packet) 159 | self.ser.timeout = self.END_OF_PACKET_TIMEOUT 160 | b = 1 161 | while b: 162 | b = self.ser.read() 163 | rawdata += b 164 | 165 | if self.log.isEnabledFor(logging.DEBUG): 166 | self.log.debug('RX: %s', (' '.join('%.2x' % ord(c) for c in rawdata))) 167 | 168 | if expected_len is not None: 169 | expected_len += 2 # Account for checksum 170 | 171 | if self.TRIM_LARGE_PACKETS and (len(rawdata) > expected_len): 172 | rawdata = rawdata[-expected_len:] 173 | 174 | return MateNETSerial._parse_packet(rawdata, expected_len) 175 | -------------------------------------------------------------------------------- /pymate/matenet/mx.py: -------------------------------------------------------------------------------- 1 | # pyMATE MX interface 2 | # Author: Jared Sanson 3 | # 4 | # Provides access to an Outback MX solar charge controller 5 | # 6 | 7 | __author__ = 'Jared' 8 | 9 | from struct import Struct 10 | from pymate.value import Value 11 | from . import MateDevice, MateNET 12 | 13 | 14 | class MXStatusPacket(object): 15 | fmt = Struct('>BbbbBBBBBHH') 16 | size = fmt.size 17 | 18 | STATUS_SLEEPING = 0 19 | STATUS_FLOATING = 1 20 | STATUS_BULK = 2 21 | STATUS_ABSORB = 3 22 | STATUS_EQUALIZE = 4 23 | 24 | # NOTE: MX Manual doesn't match real-world values: 25 | AUX_MODE_DIVERSION_RELAY = 1 26 | AUX_MODE_REMOTE = 4 27 | AUX_MODE_VENTFAN = 5 28 | AUX_MODE_PVTRIGGER = 6 29 | AUX_MODE_FLOAT = 0 30 | AUX_MODE_ERROR_OUT = 7 31 | AUX_MODE_NIGHT_LIGHT = 8 32 | AUX_MODE_PWM_DIVERSION = 2 33 | AUX_MODE_LOW_BATTERY = 3 34 | AUX_MODE_MANUAL = 0x3F # If Aux is not configured for Auto on MX unit. 35 | 36 | def __init__(self): 37 | self.amp_hours = None 38 | self.kilowatt_hours = None 39 | self.pv_current = None 40 | self.bat_current = None 41 | self.pv_voltage = None 42 | self.bat_voltage = None 43 | self.status = None 44 | self.errors = None 45 | self.aux_state = None 46 | self.aux_mode = None 47 | self.raw = None 48 | 49 | @classmethod 50 | def from_buffer(cls, data): 51 | values = cls.fmt.unpack(data) 52 | status = MXStatusPacket() 53 | # The following was determined by poking values at the MATE unit... 54 | raw_ah = ((values[0] & 0x70) >> 4) | values[4] # Ignore bit7 (if 0, MATE hides the AH reading) 55 | bat_current_milli = (values[0] & 0x0F) / 10.0 56 | status.amp_hours = Value(raw_ah, units='Ah', resolution=0) 57 | status.pv_current = Value((128 + values[1]) % 256, units='A', resolution=0) 58 | status.bat_current = Value(((128 + values[2]) % 256 + bat_current_milli), units='A', resolution=1) 59 | raw_kwh = (values[3] << 8) | values[8] 60 | status.kilowatt_hours = Value(raw_kwh / 10.0, units='kWh', resolution=1) 61 | status.aux_state = ((values[5] & 0x40) == 0x40) # 0: Off, 1: On 62 | status.aux_mode = (values[5] & 0x3F) 63 | status.status = values[6] 64 | status.errors = values[7] 65 | status.bat_voltage = Value(values[9] / 10.0, units='V', resolution=1) 66 | status.pv_voltage = Value(values[10] / 10.0, units='V', resolution=1) 67 | 68 | # Also add the raw packet, in case any of the above changes 69 | status.raw = data 70 | 71 | return status 72 | 73 | def __repr__(self): 74 | return "" 75 | 76 | def __str__(self): 77 | fmt = """MX Status: 78 | PV: {pv_voltage} {pv_current} 79 | Bat: {bat_voltage} {bat_current} 80 | Today: {kilowatt_hours} {amp_hours} 81 | """ 82 | return fmt.format(**self.__dict__) 83 | 84 | 85 | class MXLogPagePacket(object): 86 | fmt = Struct('>BBBBBBBBBBBBBB') 87 | size = fmt.size 88 | 89 | def __init__(self): 90 | self.day = None 91 | self.amp_hours = None 92 | self.kilowatt_hours = None 93 | self.volts_peak = None 94 | self.amps_peak = None 95 | self.kilowatts_peak = None 96 | self.bat_min = None 97 | self.bat_max = None 98 | self.absorb_time = None 99 | self.float_time = None 100 | self.raw = None 101 | 102 | @classmethod 103 | def from_buffer(cls, data): 104 | values = cls.fmt.unpack(data) 105 | page = MXLogPagePacket() 106 | 107 | # Parse the mess of binary values 108 | page.bat_max = ((values[1] & 0xFC) >> 2) | ((values[2] & 0x0F) << 6) 109 | page.bat_min = ((values[9] & 0xC0) >> 6) | (values[10] << 2) | ((values[11] & 0x03) << 10) 110 | page.kilowatt_hours = ((values[2] & 0xF0) >> 4) | (values[3] << 4) 111 | page.amp_hours = values[8] | ((values[9] & 0x3F) << 8) 112 | page.volts_peak = values[4] 113 | page.amps_peak = values[0] | ((values[1] & 0x03) << 8) 114 | page.absorb_time = values[5] | ((values[6] & 0x0F) << 8) 115 | page.float_time = ((values[6] & 0xF0) >> 4) | (values[7] << 4) 116 | page.kilowatts_peak = ((values[12] & 0xFC) >> 2) | (values[11] << 6) 117 | page.day = values[13] 118 | 119 | # Convert to human-readable values 120 | page.bat_max = Value(page.bat_max / 10.0, units='V', resolution=1) 121 | page.bat_min = Value(page.bat_min / 10.0, units='V', resolution=1) 122 | page.volts_peak = Value(page.volts_peak, units='Vpk') 123 | page.amps_peak = Value(page.amps_peak / 10.0, units='Apk', resolution=1) 124 | page.kilowatts_peak = Value(page.kilowatts_peak / 1000.0, units='kWpk', resolution=3) 125 | page.amp_hours = Value(page.amp_hours, units='Ah') 126 | page.kilowatt_hours = Value(page.kilowatt_hours / 10.0, units='kWh', resolution=1) 127 | page.absorb_time = Value(page.absorb_time, units='min') 128 | page.float_time = Value(page.float_time, units='min') 129 | 130 | # Also add the raw packet 131 | page.raw = data 132 | 133 | return page 134 | 135 | def __str__(self): 136 | fmt = """MX Log Page: 137 | Day: -{day} 138 | {amp_hours} {kilowatt_hours} 139 | {volts_peak} {amps_peak} {kilowatts_peak} 140 | Min: {bat_min} Max: {bat_max} 141 | Absorb: {absorb_time} Float: {float_time} 142 | """ 143 | return fmt.format(**self.__dict__) 144 | 145 | 146 | class MateMXDevice(MateDevice): 147 | DEVICE_TYPE = MateNET.DEVICE_MX 148 | 149 | """ 150 | Communicate with an MX unit attached to the MateNET bus 151 | """ 152 | def scan(self, *args): 153 | """ 154 | Query the attached device to make sure we're communicating with an MX unit 155 | """ 156 | devid = super(MateMXDevice, self).scan() 157 | if devid == None: 158 | raise RuntimeError("No response from the MX unit") 159 | if devid != self.DEVICE_TYPE: 160 | raise RuntimeError("Attached device is not an MX unit! (DeviceID: %s)" % devid) 161 | 162 | def get_status(self): 163 | """ 164 | Request a status packet from the controller 165 | :return: A MXStatusPacket 166 | """ 167 | resp = self.send(MateNET.TYPE_STATUS, addr=1, param=0x00, response_len=MXStatusPacket.size) 168 | if resp: 169 | return MXStatusPacket.from_buffer(resp) 170 | 171 | def get_logpage(self, day): 172 | """ 173 | Get a log page for the specified day 174 | :param day: The day, counting backwards from today (0:Today, -1..-255) 175 | :return: A MXLogPagePacket 176 | """ 177 | resp = self.send(MateNET.TYPE_LOG, addr=0, param=-day, response_len=MXLogPagePacket.size) 178 | if resp: 179 | return MXLogPagePacket.from_buffer(resp) 180 | 181 | @property 182 | def charger_watts(self): 183 | return Value(self.query(0x016A), units='W', resolution=0) 184 | 185 | @property 186 | def charger_kwh(self): 187 | return Value(self.query(0x01EA) / 10.0, units='kWh', resolution=1) 188 | 189 | @property 190 | def charger_amps_dc(self): 191 | return Value(self.query(0x01C7) - 128, units='A', resolution=0) 192 | 193 | @property 194 | def bat_voltage(self): 195 | return Value(self.query(0x0008) / 10.0, units='V', resolution=1) 196 | 197 | @property 198 | def panel_voltage(self): 199 | return Value(self.query(0x01C6), units='V', resolution=0) 200 | 201 | @property 202 | def status(self): 203 | return self.query(0x01C8) 204 | 205 | @property 206 | def aux_relay_mode(self): 207 | x = self.query(0x01C9) 208 | mode = x & 0x7F 209 | on = (x & 0x80 == 0x80) 210 | return mode, on 211 | 212 | @property 213 | def max_battery(self): 214 | return Value(self.query(0x000F) / 10.0, units='V', resolution=1) 215 | 216 | @property 217 | def voc(self): 218 | return Value(self.query(0x0010) / 10.0, resolution=1) 219 | 220 | @property 221 | def max_voc(self): 222 | return Value(self.query(0x0012) / 10.0, resolution=1) 223 | 224 | @property 225 | def total_kwh_dc(self): 226 | return Value(self.query(0x0013), units='kWh', resolution=0) 227 | 228 | @property 229 | def total_kah(self): 230 | return Value(self.query(0x0014), units='kAh', resolution=1) 231 | 232 | @property 233 | def max_wattage(self): 234 | return Value(self.query(0x0015), units='W', resolution=0) 235 | 236 | @property 237 | def setpt_absorb(self): 238 | return Value(self.query(0x0170) / 10.0, units='V', resolution=1) 239 | 240 | @property 241 | def setpt_float(self): 242 | return Value(self.query(0x0172) / 10.0, units='V', resolution=1) 243 | 244 | @property 245 | def battery_temp_raw(self): 246 | return self.query(0x4000) 247 | 248 | @staticmethod 249 | def convert_battery_temp(raw_temp): 250 | return Value((-0.3576 * raw_temp) + 70.1, units='C', resolution=0) 251 | 252 | # For backwards compatibility 253 | # DEPRECATED 254 | def MateMX(comport, supports_spacemark=None, port=0): 255 | bus = MateNET(comport, supports_spacemark) 256 | return MateMXDevice(bus, port) 257 | 258 | 259 | if __name__ == "__main__": 260 | status = MXStatusPacket.from_buffer('\x85\x82\x85\x00\x69\x3f\x01\x00\x1d\x01\x0c\x02\x6a') 261 | print status 262 | 263 | #logpage = MXLogPagePacket.from_buffer('\x02\xFF\x17\x01\x16\x3C\x00\x01\x01\x40\x00\x10\x10\x01') 264 | logpage = MXLogPagePacket.from_buffer('\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x01') 265 | print logpage 266 | -------------------------------------------------------------------------------- /pymate/packet_capture/Capture Hub FX CC DC.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorticus/pymate/a90c973e54f71852bc3f1bb075b3d0a3599c8883/pymate/packet_capture/Capture Hub FX CC DC.pcapng -------------------------------------------------------------------------------- /pymate/packet_capture/README.md: -------------------------------------------------------------------------------- 1 | # Setup # 2 | 3 | 1. Install Wireshark 4 | 5 | 2. Copy dissector.lua to `%APPDATA%\Wireshark\plugins` 6 | (Note: CTRL+SHIFT+L to re-load plugins when Wireshark is running) 7 | 8 | 3. Launch Wireshark and add the custom user protocol: 9 | 1. Edit -> Preferences 10 | 2. Protocols -> DLT_USER, Edit Encapsulations Table 11 | 3. Add (+): `User 0 (DLT = 147)`, Payload protocol: `matenet` 12 | (https://wiki.wireshark.org/HowToDissectAnything) 13 | 14 | 4. Install Python 3.x + prerequisites: 15 | - pySerial 16 | - pyWin32 17 | 18 | 5. Flash an Arduino Mega 2560 with the Sniffer.ino sketch. 19 | 20 | ``` 21 | [MATE] --------\ /-------- [MX/FX/DC] 22 | | | 23 | [ Arduino ] 24 | | 25 | [ Tap.py ] 26 | | 27 | [ Wireshark ] 28 | ``` 29 | 30 | # Usage # 31 | 32 | 1. Connect the sniffer tap circuit to the Arduino Mega, Outback MATE, and Outback Device under test. 33 | 34 | 2. Run: 35 | `python -m pymate.packet_capture.wireshark_tap COM1` 36 | 37 | 3. The script will print the name of the PCAP pipe, and automatically launch Wireshark. 38 | Wireshark should connect to the named pipe and the Tap script should print 39 | `Serial port opened, listening for MateNET data...` 40 | 41 | You can manually launch wireshark with the following command-line: 42 | `Wireshark.exe -i\\.\pipe\wireshark-mate -k` 43 | Or add the named pipe under Capture -> Options -> Input -> Manage Interfaces -> Pipes 44 | 45 | When capturing packets in Wireshark, you can filter out uninteresting packets. 46 | For example, `matenet.cmd != 4` will filter out all STATUS packets (both TX & RX) 47 | 48 | # PCAPNG Anaylsis # 49 | 50 | Once you've captured some traffic and saved it to a PCAPNG file, you can open it back up for analysis 51 | with the custom mate dissector: 52 | 53 | Wireshark.exe -X lua_script:.\mate_dissector.lua 54 | 55 | -------------------------------------------------------------------------------- /pymate/packet_capture/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jorticus/pymate/a90c973e54f71852bc3f1bb075b3d0a3599c8883/pymate/packet_capture/__init__.py -------------------------------------------------------------------------------- /pymate/packet_capture/mate_dissector.lua: -------------------------------------------------------------------------------- 1 | 2 | DissectorTable.new("matenet") 3 | mate_proto = Proto("matenet", "Outback MATE serial protocol") 4 | 5 | local COMMANDS = { 6 | [0] = "Inc/Dis", -- Increment or Disable (depending on the register) 7 | [1] = "Dec/En", -- Decrement or Enable 8 | [2] = "Read", 9 | [3] = "Write", 10 | [4] = "Status", 11 | [22] = "Get Logpage" 12 | } 13 | 14 | local CMD_READ = 2 15 | local CMD_WRITE = 3 16 | local CMD_STATUS = 4 17 | 18 | local DEVICE_TYPES = { 19 | [1] = "Hub", 20 | [2] = "(FX) FX Inverter", 21 | [3] = "(CC) MX Charge Controller", 22 | [4] = "(DC) FLEXnet DC" 23 | } 24 | local DEVICE_TYPES_SHORT = { 25 | [1] = "HUB", 26 | [2] = "FX", 27 | [3] = "CC", 28 | [4] = "DC" 29 | } 30 | 31 | local DTYPE_HUB = 1 32 | local DTYPE_FX = 2 33 | local DTYPE_CC = 3 34 | local DTYPE_DC = 4 35 | 36 | local REG_DEVICE_TYPE = 0x0000 37 | 38 | local MX_STATUS = { 39 | [0] = "Sleeping", 40 | [1] = "Floating", 41 | [2] = "Bulk", 42 | [3] = "Absorb", 43 | [4] = "Equalize", 44 | } 45 | 46 | local MX_AUX_MODE = { 47 | [0] = "Disabled", 48 | [1] = "Diversion", 49 | [2] = "Remote", 50 | [3] = "Manual", 51 | [4] = "Fan", 52 | [5] = "PV Trigger", 53 | [6] = "Float", 54 | [7] = "ERROR Output", 55 | [8] = "Night Light", 56 | [9] = "PWM Diversion", 57 | [10] = "Low Battery", 58 | 59 | -- If MX sets mode to On/Off (not Auto) 60 | [0x3F] = "Manual", 61 | } 62 | 63 | local MX_AUX_STATE = { 64 | [0] = "Off", 65 | [0x40] = "On", 66 | } 67 | 68 | local FX_OPERATIONAL_MODE = { 69 | [0] = "Inverter Off", 70 | [1] = "Inverter Search", 71 | [2] = "Inverter On", 72 | [3] = "Charge", 73 | [4] = "Silent", 74 | [5] = "Float", 75 | [6] = "Equalize", 76 | [7] = "Charger Off", 77 | [8] = "Support AC", -- FX is drawing power from batteries to support AC 78 | [9] = "Sell Enabled", -- FX is exporting more power than the loads are drawing 79 | [10] = "Pass Through", -- FX converter is off, passing through line AC 80 | } 81 | 82 | local FX_AC_MODE = { 83 | [0] = "No AC", 84 | [1] = "AC Drop", 85 | [2] = "AC Use", 86 | } 87 | 88 | local QUERY_REGISTERS = { 89 | -- MX/FX (Not DC) 90 | [0x0000] = "Device Type", 91 | -- [0x0001] = "FW Revision", 92 | 93 | -- FX 94 | -- [0x0039] = "Errors", 95 | -- [0x0059] = "Warnings", 96 | -- [0x003D] = "Inverter Control", 97 | -- [0x003A] = "AC In Control", 98 | -- [0x003C] = "Charge Control", 99 | -- [0x005A] = "AUX Mode", 100 | -- [0x0038] = "Equalize Control", 101 | -- [0x0084] = "Disconn Status", 102 | -- [0x008F] = "Sell Status", 103 | -- [0x0032] = "Battery Temperature", 104 | -- [0x0033] = "Air Temperature", 105 | -- [0x0034] = "MOSFET Temperature", 106 | -- [0x0035] = "Capacitor Temperature", 107 | -- [0x002D] = "Output Voltage", 108 | -- [0x002C] = "Input Voltage", 109 | -- [0x006D] = "Inverter Current", 110 | -- [0x006A] = "Charger Current", 111 | -- [0x006C] = "Input Current", 112 | -- [0x006B] = "Sell Current", 113 | -- [0x0019] = "Battery Actual", 114 | -- [0x0016] = "Battery Temperature Compensated", 115 | -- [0x000B] = "Absorb Setpoint", 116 | -- [0x0070] = "Absorb Time Remaining", 117 | -- [0x000A] = "Float Setpoint", 118 | -- [0x006E] = "Float Time Remaining", 119 | -- [0x000D] = "Refloat Setpoint", 120 | -- [0x000C] = "Equalize Setpoint", 121 | -- [0x0071] = "Equalize Time Remaining", 122 | 123 | -- MX 124 | -- [0x0008] = "Battery Voltage", 125 | -- [0x000F] = "Max Battery", 126 | -- [0x0010] = "V OC", 127 | -- [0x0012] = "Max V OC", 128 | -- [0x0013] = "Total kWh DC", 129 | -- [0x0014] = "Total kAh", 130 | -- [0x0015] = "Max Wattage", 131 | -- [0x016A] = "Charger Watts", 132 | -- [0x01EA] = "Charger kWh", 133 | -- [0x01C7] = "Charger Amps DC", 134 | -- [0x01C6] = "Panel Voltage", 135 | -- [0x01C8] = "Status", 136 | -- [0x01C9] = "Aux Relay Mode", 137 | -- [0x0170] = "Setpoint Absorb", 138 | -- [0x0172] = "Setpont Float", 139 | } 140 | 141 | -- Remember which device types are attached to each port 142 | -- (Only available if you capture this data on startup!) 143 | local device_table = {} 144 | local device_table_available = false 145 | 146 | 147 | local pf = { 148 | --bus = ProtoField.uint8("matenet.bus", "Bus", base.HEX), 149 | port = ProtoField.uint8("matenet.port", "Port", base.DEC), 150 | cmd = ProtoField.uint8("matenet.cmd", "Command", base.HEX, COMMANDS), 151 | device_type = ProtoField.uint8("matenet.device_type", "Device Type", base.HEX, DEVICE_TYPES_SHORT), 152 | data = ProtoField.bytes("matenet.data", "Data", base.NONE), 153 | addr = ProtoField.uint16("matenet.addr", "Address", base.HEX), 154 | reg_addr = ProtoField.uint16("matenet.register", "Register", base.HEX, QUERY_REGISTERS), 155 | value = ProtoField.uint16("matenet.value", "Value", base.HEX), 156 | check = ProtoField.uint16("matenet.checksum", "Checksum", base.HEX), 157 | 158 | mxstatus_ah = ProtoField.float("matenet.mxstatus.amp_hours", "Amp Hours", {"Ah"}), 159 | mxstatus_pv_current = ProtoField.int8("matenet.mxstatus.pv_current", "PV Current", base.UNIT_STRING, {"A"}), 160 | mxstatus_bat_current = ProtoField.float("matenet.mxstatus.bat_current", "Battery Current", {"A"}), 161 | mxstatus_kwh = ProtoField.float("matenet.mxstatus.kwh", "Kilowatt Hours", {"kWh"}), 162 | mxstatus_bat_voltage = ProtoField.float("matenet.mxstatus.bat_voltage", "Battery Voltage", {"V"}), 163 | mxstatus_pv_voltage = ProtoField.float("matenet.mxstatus.pv_voltage", "PV Voltage", {"V"}), 164 | mxstatus_aux = ProtoField.uint8("matenet.mxstatus.aux", "Aux", base.DEC), 165 | mxstatus_aux_state = ProtoField.uint8("matenet.mxstatus.aux_state", "Aux State", base.DEC, MX_AUX_STATE, 0x40), 166 | mxstatus_aux_mode = ProtoField.uint8("matenet.mxstatus.aux_mode", "Aux Mode", base.DEC, MX_AUX_MODE, 0x3F), 167 | mxstatus_status = ProtoField.uint8("matenet.mxstatus.status", "Status", base.DEC, MX_STATUS), 168 | mxstatus_errors = ProtoField.uint8("matenet.mxstatus.errors", "Errors", base.DEC), 169 | mxstatus_errors_1 = ProtoField.uint8("matenet.mxstatus.errors.e3", "High VOC", base.DEC, NULL, 128), 170 | mxstatus_errors_2 = ProtoField.uint8("matenet.mxstatus.errors.e2", "Too Hot", base.DEC, NULL, 64), 171 | mxstatus_errors_3 = ProtoField.uint8("matenet.mxstatus.errors.e1", "Shorted Battery Sensor", base.DEC, NULL, 32), 172 | 173 | fxstatus_misc = ProtoField.uint8("matenet.fxstatus.flags", "Flags", base.DEC), 174 | fxstatus_is_230v = ProtoField.uint8("matenet.fxstatus.misc.is_230v", "Is 230V", base.DEC, NULL, 0x01), 175 | fxstatus_aux_on = ProtoField.uint8("matenet.fxstatus.misc.aux_on", "Aux On", base.DEC, NULL, 0x80), 176 | fxstatus_warnings = ProtoField.uint8("matenet.fxstatus.warnings", "Warnings", base.DEC), 177 | fxstatus_errors = ProtoField.uint8("matenet.fxstatus.errors", "Errors", base.DEC), 178 | fxstatus_ac_mode = ProtoField.uint8("matenet.fxstatus.ac_mode", "AC Mode", base.DEC, FX_AC_MODE), 179 | fxstatus_op_mode = ProtoField.uint8("matenet.fxstatus.op_mode", "Operational Mode", base.DEC, FX_OPERATIONAL_MODE), 180 | fxstatus_inv_current = ProtoField.float("matenet.fxstatus.inv_current", "Inverter Current", {"A"}), 181 | fxstatus_out_voltage = ProtoField.float("matenet.fxstatus.out_voltage", "Out Voltage", {"V"}), 182 | fxstatus_in_voltage = ProtoField.float("matenet.fxstatus.in_voltage", "In Voltage", {"V"}), 183 | fxstatus_sell_current = ProtoField.float("matenet.fxstatus.sell_current", "Sell Current", {"A"}), 184 | fxstatus_chg_current = ProtoField.float("matenet.fxstatus.chg_current", "Charge Current", {"A"}), 185 | fxstatus_buy_current = ProtoField.float("matenet.fxstatus.buy_current", "Buy Current", {"A"}), 186 | fxstatus_bat_voltage = ProtoField.float("matenet.fxstatus.bat_voltage", "Battery Voltage", {"V"}), 187 | 188 | -- dcstatus_shunta_kw 189 | -- dcstatus_shuntb_kw 190 | -- dcstatus_shuntc_kw 191 | -- dcstatus_shunta_cur 192 | -- dcstatus_shuntb_cur 193 | -- dcstatus_shuntc_cur 194 | -- dcstatus_bat_v 195 | -- dcstatus_soc 196 | -- dcstatus_now_out_kw_lo 197 | -- dcstatus_now_out_kw_hi 198 | -- dcstatus_now_in_kw 199 | -- dcstatus_bat_cur 200 | -- dcstatus_out_cur 201 | -- dcstatus_in_cur 202 | -- dcstatus_flags 203 | -- dcstatus_unknown1 204 | -- dcstatus_today_out_kwh 205 | -- dcstatus_today_in_kwh 206 | -- dcstatus_today_bat_ah 207 | -- dcstatus_today_out_ah 208 | -- dcstatus_today_in_ah 209 | -- dcstatus_now_bat_kw 210 | -- dcstatus_unknown2 211 | -- dcstatus_days_since_full 212 | -- dcstatus_today_bat_kwh 213 | -- dcstatus_shunta_ah 214 | -- dcstatus_shuntb_ah 215 | -- dcstatus_shuntc_ah 216 | -- dcstatus_shunta_kwh 217 | -- dcstatus_shuntb_kwh 218 | -- dcstatus_shuntc_kwh 219 | -- dcstatus_unknown3 220 | -- dcstatus_bat_net_kwh 221 | -- dcstatus_bat_net_ah 222 | -- dcstatus_min_soc_today 223 | } 224 | mate_proto.fields = pf 225 | 226 | function fmt_cmd(cmd, prior_cmd) 227 | if prior_cmd then 228 | end 229 | return COMMANDS[cmd:uint()] 230 | end 231 | 232 | 233 | 234 | function fmt_addr(cmd) 235 | -- INC/DEC/READ/WRITE : Return readable register name 236 | if cmd:uint() <= 3 then 237 | name = QUERY_REGISTERS[addr:uint()] 238 | if name then 239 | return name 240 | end 241 | end 242 | 243 | return addr 244 | end 245 | 246 | function fmt_mx_status() 247 | -- TODO: Friendly MX status string 248 | return "MX STATUS" 249 | end 250 | 251 | function fmt_response(port, cmd, addr, resp_data) 252 | cmd = cmd:uint() 253 | addr = addr:uint() 254 | 255 | -- QUERY DEVICE TYPE 256 | if (cmd == CMD_READ) and (addr == REG_DEVICE_TYPE) then 257 | -- Remember the device attached to this port 258 | local dtype = resp_data:uint() 259 | device_table[port:uint()] = dtype 260 | device_table_available = true 261 | 262 | return DEVICE_TYPES[dtype] 263 | end 264 | 265 | if device_table_available then 266 | local dtype = device_table[port:uint()] 267 | if (cmd == CMD_STATUS) then 268 | -- Format status packets 269 | if dtype == DTYPE_CC then 270 | --return fmt_mx_status(resp_data) 271 | end 272 | end 273 | end 274 | 275 | return resp_data 276 | end 277 | 278 | function fmt_dest(port) 279 | local dtype = device_table[port:uint()] 280 | if dtype then 281 | return "Port " .. port .. " (" .. DEVICE_TYPES_SHORT[dtype] .. ")" 282 | else 283 | return "Port " .. port 284 | end 285 | end 286 | 287 | function parse_mx_status(addr, data, tree) 288 | local raw_ah = bit.bor( 289 | bit.rshift(bit.band(data(0,1):uint(), 0x70), 4), -- ignore bit7 290 | data(4,1):uint() 291 | ) 292 | 293 | local raw_kwh = bit.bor( 294 | bit.lshift(data(3,1):uint(), 8), 295 | data(8,1):uint() 296 | ) / 10.0 297 | 298 | local bat_curr_milli = bit.band(data(0,1):uint(), 0x0F) / 10.0 299 | 300 | tree:add(pf.mxstatus_pv_current, data(1,1), (data(1,1):int()+128)) 301 | tree:add(pf.mxstatus_bat_current, data(2,1), (data(2,1):int()+128 + bat_curr_milli)) 302 | 303 | tree:add(pf.mxstatus_ah, data(4,1), raw_ah) -- composite value 304 | tree:add(pf.mxstatus_kwh, data(8,1), raw_kwh) -- composite value 305 | 306 | tree:add(pf.mxstatus_status, data(6,1)) 307 | 308 | local error_node = tree:add(pf.mxstatus_errors, data(7,1)) 309 | error_node:add(pf.mxstatus_errors_1, data(7,1)) 310 | error_node:add(pf.mxstatus_errors_2, data(7,1)) 311 | error_node:add(pf.mxstatus_errors_3, data(7,1)) 312 | 313 | local aux_node = tree:add(pf.mxstatus_aux, data(5,1)) 314 | aux_node:add(pf.mxstatus_aux_state, data(5,1)) 315 | aux_node:add(pf.mxstatus_aux_mode, data(5,1)) 316 | 317 | tree:add(pf.mxstatus_bat_voltage, data(9,2), (data(9,2):uint()/10.0)) 318 | tree:add(pf.mxstatus_pv_voltage, data(11,2), (data(11,2):uint()/10.0)) 319 | end 320 | 321 | function parse_fx_status(addr, data, tree) 322 | local misc_node = tree:add(pf.fxstatus_misc, data(11,1)) 323 | misc_node:add(pf.fxstatus_is_230v, data(11,1)) 324 | misc_node:add(pf.fxstatus_aux_on, data(11,1)) 325 | 326 | -- If 230V bit is set, voltages must be multiplied by 2, and currents divided by 2. 327 | local is_230v = bit.band(data(11,1):uint(), 0x01) 328 | local vmul = 1.0 329 | local imul = 1.0 330 | if is_230v then 331 | vmul = 2.0 332 | imul = 0.5 333 | end 334 | 335 | tree:add(pf.fxstatus_inv_current, data(0,1), data(0,1):uint()*imul) 336 | tree:add(pf.fxstatus_chg_current, data(1,1), data(1,1):uint()*imul) 337 | tree:add(pf.fxstatus_buy_current, data(2,1), data(2,1):uint()*imul) 338 | tree:add(pf.fxstatus_in_voltage, data(3,1), data(3,1):uint()*vmul) 339 | tree:add(pf.fxstatus_out_voltage, data(4,1), data(4,1):uint()*vmul) 340 | tree:add(pf.fxstatus_sell_current, data(5,1), data(5,1):uint()*imul) 341 | 342 | tree:add(pf.fxstatus_op_mode, data(6,1)) 343 | tree:add(pf.fxstatus_ac_mode, data(8,1)) 344 | tree:add(pf.fxstatus_bat_voltage, data(9,2), (data(9,2):uint()/10.0)) 345 | 346 | local warn_node = tree:add(pf.fxstatus_warnings, data(12,1)) 347 | -- TODO: Add warning bitfield 348 | -- WARN_ACIN_FREQ_HIGH = 0x01 # >66Hz or >56Hz 349 | -- WARN_ACIN_FREQ_LOW = 0x02 # <54Hz or <44Hz 350 | -- WARN_ACIN_V_HIGH = 0x04 # >140VAC or >270VAC 351 | -- WARN_ACIN_V_LOW = 0x08 # <108VAC or <207VAC 352 | -- WARN_BUY_AMPS_EXCEEDS_INPUT = 0x10 353 | -- WARN_TEMP_SENSOR_FAILED = 0x20 # Internal temperature sensors have failed 354 | -- WARN_COMM_ERROR = 0x40 # Communication problem between us and the FX 355 | -- WARN_FAN_FAILURE = 0x80 # Internal cooling fan has failed 356 | 357 | local err_node = tree:add(pf.fxstatus_errors, data(7,1)) 358 | -- TODO: Add error bitfield 359 | -- ERROR_LOW_VAC_OUTPUT = 0x01 # Inverter could not supply enough AC voltage to meet demand 360 | -- ERROR_STACKING_ERROR = 0x02 # Communication error among stacked FX inverters (eg. 3 phase system) 361 | -- ERROR_OVER_TEMP = 0x04 # FX has reached maximum allowable temperature 362 | -- ERROR_LOW_BATTERY = 0x08 # Battery voltage below low battery cut-out setpoint 363 | -- ERROR_PHASE_LOSS = 0x10 364 | -- ERROR_HIGH_BATTERY = 0x20 # Battery voltage rose above safe level for 10 seconds 365 | -- ERROR_SHORTED_OUTPUT = 0x40 366 | -- ERROR_BACK_FEED = 0x80 # Another power source was connected to the FX's AC output 367 | end 368 | 369 | function parse_dc_status(addr, data, tree) 370 | if addr == 0x0A then 371 | 372 | 373 | elseif addr == 0x0B then 374 | 375 | elseif addr == 0x0C then 376 | 377 | elseif addr == 0x0D then 378 | 379 | elseif addr == 0x0E then 380 | 381 | elseif addr == 0x0F then 382 | 383 | end 384 | end 385 | 386 | --local ef_too_short = ProtoExpert.new("mate.too_short.expert", "MATE packet too short", 387 | -- expert.group.MALFORMED, expert.severity.ERROR) 388 | 389 | local prior_cmd = nil 390 | local propr_cmd_port = nil 391 | local prior_cmd_addr = nil 392 | 393 | function dissect_frame(bus, buffer, pinfo, tree, combine) 394 | -- MATE TX (Command) 395 | if bus == 0xA then 396 | if not combine then 397 | pinfo.cols.src = "MATE" 398 | pinfo.cols.dst = "Device" 399 | end 400 | 401 | local subtree = tree:add(mate_proto, buffer(), "Command") 402 | 403 | if buffer:len() <= 7 then 404 | return 405 | end 406 | 407 | port = buffer(0, 1) 408 | cmd = buffer(1, 1) 409 | addr = buffer(2, 2) 410 | value = buffer(4, 2) 411 | check = buffer(6, 2) 412 | --data = buffer(4, buffer:len()-4) 413 | subtree:add(pf.port, port) 414 | subtree:add(pf.cmd, cmd) 415 | --subtree:add(pf.data, data) 416 | 417 | --pinfo.cols.info:set("Command") 418 | info = fmt_cmd(cmd) 419 | if info then 420 | pinfo.cols.info:prepend(info .. " ") 421 | end 422 | 423 | -- INC/DEC/READ/WRITE 424 | if cmd:uint() <= 3 then 425 | subtree:add(pf.reg_addr, addr) 426 | pinfo.cols.info:append(" ["..fmt_addr(addr).."]") 427 | else 428 | subtree:add(pf.addr, addr) 429 | end 430 | 431 | subtree:add(pf.value, value) 432 | subtree:add(pf.check, check) 433 | 434 | pinfo.cols.dst = fmt_dest(port) 435 | 436 | prior_cmd = cmd 437 | prior_cmd_port = port 438 | prior_cmd_addr = addr 439 | 440 | return -1 441 | 442 | -- MATE RX (Response) 443 | elseif bus == 0xB then 444 | if not combine then 445 | pinfo.cols.src = "Device" 446 | pinfo.cols.dst = "MATE" 447 | end 448 | 449 | local subtree = tree:add(mate_proto, buffer(), "Response") 450 | 451 | if buffer:len() <= 3 then 452 | return 453 | end 454 | 455 | cmd = buffer(0, 1) 456 | if combine and (prior_cmd:uint() == CMD_STATUS) then 457 | -- For STATUS responses, this is the type of device that sent the status 458 | subtree:add(pf.device_type, cmd) 459 | else 460 | -- Otherwise it should match the command that this is responding to 461 | subtree:add(pf.cmd, cmd) 462 | end 463 | 464 | data = buffer(1, buffer:len()-3) 465 | check = buffer(buffer:len()-2, 2) 466 | local data_node = subtree:add(pf.data, data) 467 | subtree:add(pf.check, check) 468 | 469 | if not combine then 470 | pinfo.cols.info:set("Response") 471 | 472 | info = fmt_cmd(cmd, prior_cmd) 473 | if info then 474 | pinfo.cols.info:prepend(info .. " ") 475 | end 476 | else 477 | -- append the response value 478 | -- INC/DEC/READ/WRITE 479 | if cmd:uint() <= 3 then 480 | pinfo.cols.info:append(" : " .. fmt_response( 481 | prior_cmd_port, 482 | prior_cmd, 483 | prior_cmd_addr, 484 | data 485 | )) 486 | end 487 | 488 | -- We know what type of device is attached to this port, 489 | -- so do some additional parsing... 490 | if device_table_available then 491 | local cmd = prior_cmd:uint() 492 | local addr = prior_cmd_addr:uint() 493 | local dtype = device_table[port:uint()] 494 | 495 | -- Parse status packets 496 | if (cmd == CMD_STATUS) then 497 | if dtype == DTYPE_CC then 498 | parse_mx_status(addr, data, data_node) 499 | elseif dtype == DTYPE_FX then 500 | parse_fx_status(addr, data, data_node) 501 | elseif dtype == DTYPE_DC then 502 | parse_dc_status(addr, data, data_node) 503 | end 504 | end 505 | end 506 | end 507 | end 508 | end 509 | 510 | function mate_proto.dissector(buffer, pinfo, tree) 511 | len = buffer:len() 512 | if len == 0 then return end 513 | 514 | pinfo.cols.protocol = mate_proto.name 515 | 516 | --local subtree = tree:add(mate_proto, buffer(), "MATE Data") 517 | 518 | -- if len < 5 then 519 | -- subtree.add_proto_expert_info(ef_too_short) 520 | -- return 521 | -- end 522 | 523 | bus = buffer(0, 1):uint() 524 | --subtree:add(pf.bus, bus) 525 | buffer = buffer(1, buffer:len()-1) 526 | 527 | -- local data = {} 528 | -- for i=0,buffer:len() do 529 | -- data[i] = i 530 | -- end 531 | 532 | -- Combined RX/TX 533 | if bus == 0x0F then 534 | len_a = buffer(0, 1):uint() 535 | len_b = buffer(1, 1):uint() 536 | 537 | buf_a = buffer(2, len_a) 538 | buf_b = buffer(2+len_a, len_b) 539 | 540 | r_a = dissect_frame(0xA, buf_a, pinfo, tree, true) 541 | r_b = dissect_frame(0xB, buf_b, pinfo, tree, true) 542 | --return r_a + r_b 543 | 544 | pinfo.cols.src = "MATE" 545 | --pinfo.cols.dst = "Device" 546 | 547 | else 548 | return dissect_frame(bus, buffer, pinfo, tree, false) 549 | end 550 | 551 | 552 | end 553 | 554 | -- This function will be invoked by Wireshark during initialization, such as 555 | -- at program start and loading a new file 556 | function mate_proto.init() 557 | device_table = {} 558 | device_table_available = false 559 | end 560 | 561 | 562 | DissectorTable.get("matenet"):add(147, mate_proto) -- DLT_USER0 -------------------------------------------------------------------------------- /pymate/packet_capture/wireshark_tap.py: -------------------------------------------------------------------------------- 1 | # 2 | # Utility library for piping protocol data to wireshark 3 | # 4 | # Usage: 5 | # python -m pymate.packet_capture.wireshark_tap COMxx 6 | # 7 | # NOTE: Currently only supported on Windows. 8 | # Linux support should be possible with modifications to the named pipe interface. 9 | # 10 | # https://wiki.wireshark.org/CaptureSetup/Pipes 11 | # 12 | 13 | from time import sleep 14 | from serial import Serial # pySerial 15 | from datetime import datetime 16 | import os 17 | import sys 18 | import time 19 | import win32pipe, win32file, win32event, pywintypes, winerror # pywin32 20 | import subprocess 21 | import struct 22 | import errno 23 | 24 | LUA_SCRIPT_PATH = os.path.join( 25 | os.path.dirname(os.path.realpath(__file__)), 26 | 'mate_dissector.lua' 27 | ) 28 | 29 | # TODO: Don't hard-code this... 30 | WIRESHARK_PATH = r'C:\Program Files\Wireshark\Wireshark.exe' 31 | 32 | class WiresharkTap(object): 33 | WIRESHARK_PIPENAME = r'\\.\pipe\wireshark-mate' 34 | WIRESHARK_DLT = 147 # DLT_USER0 35 | 36 | FRAME_TX = 0x0A 37 | FRAME_RX = 0x0B 38 | 39 | def __init__(self): 40 | self._pipe = None 41 | self._prev_bus = None 42 | self._prev_pkt = None 43 | 44 | self.combine_frames = True 45 | 46 | def open(self, timeout_ms=30000): 47 | """ 48 | Start the wireshark pipe 49 | """ 50 | # Create named pipe 51 | self._pipe = self._create_pipe(self.WIRESHARK_PIPENAME) 52 | 53 | # Connect pipe 54 | print("PCAP pipe created: %s" % self.WIRESHARK_PIPENAME) 55 | print("Waiting for connection...") 56 | self._connect_pipe(timeout_ms) 57 | print("Pipe opened") 58 | 59 | self._send_wireshark_header() 60 | 61 | def close(self): 62 | """ 63 | Close the wireshark pipe 64 | """ 65 | if self._pipe is not None: 66 | self._pipe.close() 67 | self._pipe = None 68 | 69 | @staticmethod 70 | def launch_wireshark(sideload_dissector=True, path=WIRESHARK_PATH): 71 | if sideload_dissector: 72 | if not os.path.exists(LUA_SCRIPT_PATH): 73 | raise Exception('mate_dissector.lua not found: ' + LUA_SCRIPT_PATH) 74 | 75 | #open Wireshark, configure pipe interface and start capture (not mandatory, you can also do this manually) 76 | wireshark_cmd=[ 77 | path, 78 | '-i'+WiresharkTap.WIRESHARK_PIPENAME, 79 | '-k', 80 | '-o','capture.no_interface_load:TRUE' 81 | ] 82 | if sideload_dissector: 83 | wireshark_cmd += ['-X','lua_script:'+LUA_SCRIPT_PATH] 84 | proc=subprocess.Popen(wireshark_cmd) 85 | 86 | @staticmethod 87 | def _create_pipe(name): 88 | # WINDOWS: 89 | return win32pipe.CreateNamedPipe( 90 | name, 91 | win32pipe.PIPE_ACCESS_OUTBOUND | win32file.FILE_FLAG_OVERLAPPED, 92 | win32pipe.PIPE_TYPE_MESSAGE | win32pipe.PIPE_WAIT, 93 | 1, 65536, 65536, 94 | 300, 95 | None) 96 | # UNIX: 97 | # try: 98 | # return os.mkfifo(name); 99 | # except FileExistsError: 100 | # pass 101 | # except: 102 | # raise 103 | 104 | def _connect_pipe(self, timeoutMillisec = 1000): 105 | # WINDOWS: 106 | overlapped = pywintypes.OVERLAPPED() 107 | overlapped.hEvent = win32event.CreateEvent(None, 0, 0, None) 108 | rc = win32pipe.ConnectNamedPipe(self._pipe, overlapped) 109 | if rc == winerror.ERROR_PIPE_CONNECTED: 110 | win32event.SetEvent(overlapped.hEvent) 111 | rc = win32event.WaitForSingleObject(overlapped.hEvent, timeoutMillisec) 112 | overlapped = None 113 | if rc != win32event.WAIT_OBJECT_0: 114 | raise TimeoutError("Timeout while waiting for pipe to connect") 115 | # UNIX: 116 | #self._pipe.open() 117 | 118 | def _write_pipe(self, buf): 119 | try: 120 | # WINDOWS: 121 | win32file.WriteFile(self._pipe, bytes(buf)) 122 | # UNIX: 123 | #self._pipe.write(buf) 124 | except OSError as e: 125 | # SIGPIPE indicates the fifo was closed 126 | if e.errno == errno.SIGPIPE: 127 | return False 128 | return True 129 | 130 | def _send_wireshark_header(self): 131 | # Send PCAP header 132 | buf = struct.pack("=IHHiIII", 133 | 0xa1b2c3d4, # magic number 134 | 2, # major version number 135 | 4, # minor version number 136 | 0, # GMT to local correction 137 | 0, # accuracy of timestamps 138 | 65535, # max length of captured packets, in octets 139 | self.WIRESHARK_DLT, # data link type (DLT) 140 | ) 141 | if not self._write_pipe(buf): 142 | raise Exception('Could not write to wireshark pipe') 143 | 144 | def send_frame(self, bus, data): 145 | # send pcap packet through the pipe 146 | now = datetime.now() 147 | timestamp = int(time.mktime(now.timetuple())) 148 | pcap_header = struct.pack("=iiiiB", 149 | timestamp, # timestamp seconds 150 | now.microsecond, # timestamp microseconds 151 | len(data)+1, # number of octets of packet saved in file 152 | len(data)+1, # actual length of packet 153 | bus 154 | ) 155 | if not self._write_pipe(pcap_header + bytes(data)): 156 | return 157 | 158 | def send_combined_frames(self, busa, busb): 159 | if not busa: busa = [] 160 | if not busb: busb = [] 161 | 162 | data = struct.pack("=BBB", 0x0F, len(busa), len(busb)) 163 | data += bytes(busa) 164 | data += bytes(busb) 165 | 166 | # send pcap packet through the pipe 167 | now = datetime.now() 168 | timestamp = int(time.mktime(now.timetuple())) 169 | pcap_header = struct.pack("=iiii", 170 | timestamp, # timestamp seconds 171 | now.microsecond, # timestamp microseconds 172 | len(data), # number of octets of packet saved in file 173 | len(data), # actual length of packet 174 | ) 175 | if not self._write_pipe(pcap_header + bytes(data)): 176 | return 177 | 178 | def capture_tx(self, packet): 179 | if not self.combine_frames: 180 | self.send_frame(0x0A, packet) 181 | else: 182 | if (self._prev_bus == 0x0A) and (self._prev_pkt is not None): 183 | # Previous frame was from the same bus, better 184 | # send this to wireshark even though it has 185 | # no corresponding frame from bus B 186 | self.send_frame(0x0A, self._prev_pkt) 187 | 188 | self._prev_bus = 0x0A 189 | self._prev_pkt = packet 190 | 191 | def capture_rx(self, packet): 192 | if not self.combine_frames: 193 | self.send_frame(0x0B, packet) 194 | else: 195 | # Combine packet from A & B 196 | self.send_combined_frames(self._prev_pkt, packet) 197 | 198 | self._prev_pkt = None 199 | self._prev_bus = 0x0B 200 | 201 | def capture(self, packet_tx, packet_rx): 202 | self.send_combined_frames(packet_tx, packet_rx) 203 | 204 | def main(): 205 | """ 206 | Demo wireshark tap program to be used in conjunction with 207 | uMATE/examples/Sniffer/Sniffer.ino 208 | """ 209 | if len(sys.argv) < 2: 210 | print("Usage:") 211 | print(os.path.basename(sys.argv[0]) + " COM1") 212 | exit(1) 213 | 214 | COM_PORT = sys.argv[1] 215 | COM_BAUD = 115200 216 | 217 | BUS_A = 'A' 218 | BUS_B = 'B' 219 | 220 | SIDELOAD_DISSECTOR = True 221 | 222 | END_OF_PACKET_TIMEOUT = 0.02 # seconds 223 | PIPE_CONNECT_TIMEOUT = 30000 # millisec 224 | 225 | s = Serial(COM_PORT, COM_BAUD) 226 | s.timeout = 1.0 # seconds 227 | try: 228 | 229 | tap = WiresharkTap() 230 | try: 231 | tap.launch_wireshark(SIDELOAD_DISSECTOR) 232 | tap.open(PIPE_CONNECT_TIMEOUT) 233 | 234 | print("Serial port opened, listening for MateNET data...") 235 | 236 | prev_bus = None 237 | prev_packet = None 238 | 239 | while True: 240 | s.timeout = END_OF_PACKET_TIMEOUT 241 | try: 242 | ln = s.readline() 243 | if ln: 244 | ln = ln.decode('ascii', 'ignore').strip() 245 | if ln and ':' in ln: 246 | print(ln) 247 | bus, rest = ln.split(': ') 248 | payload = [int(h, 16) for h in rest.split(' ')] 249 | 250 | data = bytes([]) 251 | 252 | if len(payload) > 2: 253 | if (payload[0] & 0x100) == 0: 254 | print("Invalid frame: bit9 not set!") 255 | continue 256 | if any([b & 0x100 for b in payload[1:]]): 257 | print("Invalid frame: bit9 set in middle of frame!") 258 | continue 259 | 260 | for b in payload: 261 | # Discard 9th bit before encoding PCAP 262 | data += bytes([b & 0x0FF]) 263 | 264 | if bus == 'A': 265 | tap.capture_tx(data) 266 | elif bus == 'B': 267 | tap.capture_rx(data) 268 | 269 | except ValueError as e: 270 | raise 271 | continue 272 | 273 | sleep(0.001) 274 | 275 | finally: 276 | tap.close() 277 | finally: 278 | s.close() 279 | 280 | if __name__ == "__main__": 281 | main() -------------------------------------------------------------------------------- /pymate/util.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Jared' 2 | 3 | def hexstr2bin(s): 4 | return ''.join([chr(int(x, 16)) for x in s.split()]) 5 | 6 | def bin2hexstr(s): 7 | return ' '.join('%.2x' % ord(x) for x in s) 8 | -------------------------------------------------------------------------------- /pymate/value.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Jared' 2 | 3 | class Value(object): 4 | """ 5 | Formatted value with units 6 | Provides a way to represent a number with units such as Volts and Watts. 7 | """ 8 | def __init__(self, value, units=None, resolution=0): 9 | self.value = float(value) 10 | self.units = units 11 | self.resolution = resolution 12 | self.fmt = "%%.%df" % resolution 13 | if self.units: 14 | self.fmt += str(self.units) 15 | 16 | def __str__(self): 17 | return self.fmt % self.value 18 | 19 | def __repr__(self): 20 | return self.__str__() 21 | 22 | def __float__(self): 23 | return float(self.value) 24 | 25 | def __int__(self): 26 | return int(self.value) 27 | -------------------------------------------------------------------------------- /readout.py: -------------------------------------------------------------------------------- 1 | from pymate.matenet import MateNET, MateMXDevice 2 | from time import sleep 3 | from settings import SERIAL_PORT 4 | 5 | print "MATE emulator (MX)" 6 | 7 | 8 | # Create a MateNET bus connection 9 | bus = MateNET(SERIAL_PORT) 10 | 11 | # Find an MX device on the bus 12 | port = bus.find_device(MateNET.DEVICE_MX) 13 | 14 | # Create a new MATE emulator attached to the specified port 15 | mate = MateMXDevice(bus, port) 16 | 17 | # Check that an MX unit is attached and is responding 18 | mate.scan() 19 | 20 | # Query the device revision 21 | print "Revision:", mate.revision 22 | 23 | 24 | print "Getting log page... (day:-1)" 25 | logpage = mate.get_logpage(-1) 26 | print logpage 27 | 28 | while True: 29 | print "Status:" 30 | status = mate.get_status() 31 | print status 32 | 33 | sleep(1.0) 34 | 35 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyserial 2 | -------------------------------------------------------------------------------- /scan.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Scans the Mate bus for any attached devices, 4 | # and displays the result. 5 | # 6 | 7 | from pymate.matenet import MateNET, MateNETPJON, MateDevice 8 | import settings 9 | import serial 10 | import logging 11 | import time 12 | 13 | #log = logging.getLogger('mate') 14 | #log.setLevel(logging.DEBUG) 15 | #log.addHandler(logging.StreamHandler()) 16 | 17 | print("MATE Bus Scan") 18 | 19 | # Create a MateNET bus connection 20 | 21 | if settings.SERIAL_PROTO == 'PJON': 22 | port = MateNETPJON(settings.SERIAL_PORT) 23 | bus = MateNET(port) 24 | 25 | # PJON is more reliable, so we don't need to retry packets 26 | bus.RETRY_PACKET = 0 27 | 28 | # Time for the Arduino to boot (connecting serial may reset it) 29 | time.sleep(1.0) 30 | 31 | else: 32 | bus = MateNET(settings.SERIAL_PORT) 33 | 34 | 35 | def print_device(d): 36 | dtype = d.scan() 37 | 38 | # No response from the scan command. 39 | # there is nothing at this port. 40 | if dtype is None: 41 | print('Port%d: -' % ( 42 | d.port 43 | )) 44 | else: 45 | try: 46 | rev = d.revision 47 | except Exception as e: 48 | rev = str(e) 49 | 50 | if dtype not in MateNET.DEVICE_TYPES: 51 | print("Port%d: Unknown device type: %d" % ( 52 | d.port, 53 | dtype 54 | )) 55 | else: 56 | print("Port%d: %s (Rev: %s)" % ( 57 | d.port, 58 | MateNET.DEVICE_TYPES[dtype], 59 | rev 60 | )) 61 | return dtype 62 | 63 | # The root device 64 | d0 = MateDevice(bus, port=0) 65 | dtype = d0.scan() 66 | if not dtype: 67 | print('No device connected!') 68 | exit() 69 | print_device(d0) 70 | 71 | # Child devices attached to a hub 72 | # (Only valid if the root device is a hub) 73 | if dtype == MateNET.DEVICE_HUB: 74 | for i in range(1,10): 75 | subdev = MateDevice(bus, port=i) 76 | print_device(subdev) 77 | 78 | print 79 | print('Finished!') -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | #SERIAL_PORT = '/dev/ttyUSB0' 2 | SERIAL_PORT = 'COM1' 3 | SERIAL_PROTO = 'MATE' # 'PJON' or 'MATE' 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | from setuptools import setup 5 | 6 | if sys.version_info[0] != 2: 7 | sys.stderr.write("Only Python 2 is supported! Please use Python 2!\n") 8 | sys.exit(1) 9 | 10 | 11 | setup( 12 | name='pymate', 13 | version='v2.2', 14 | description='Outback MATE python interface', 15 | author='Jared', 16 | author_email='jared@jared.geek.nz', 17 | url='https://github.com/jorticus/pymate', 18 | keywords=['outback', 'mate', 'pymate'], 19 | classifiers=[ 20 | "Programming Language :: Python :: 2", 21 | "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", 22 | "Operating System :: OS Independent", 23 | ], 24 | packages=['pymate', 'pymate.matenet'], 25 | install_requires=['pyserial'], 26 | python_requires='>=2.7,!=3.*', 27 | ) 28 | -------------------------------------------------------------------------------- /testflexnet.py: -------------------------------------------------------------------------------- 1 | from pymate.matenet import MateNET, MateDCDevice 2 | from time import sleep 3 | 4 | print "MATE emulator (FLEXnet DC)" 5 | 6 | # Create a MateNET bus connection 7 | bus = MateNET('/dev/ttyUSB0', supports_spacemark=False) 8 | 9 | # Create a new MATE emulator attached to the specified port 10 | mate = MateDCDevice(bus, port=bus.find_device(MateNET.DEVICE_FLEXNETDC)) 11 | 12 | # Check that an FX unit is attached and is responding 13 | mate.scan() 14 | 15 | # Query the device revision 16 | print "Revision:", mate.revision 17 | 18 | print mate.get_status() 19 | 20 | print mate.get_logpage(-2) 21 | -------------------------------------------------------------------------------- /testfx.py: -------------------------------------------------------------------------------- 1 | from pymate.matenet import MateNET, MateFXDevice 2 | from time import sleep 3 | 4 | print "MATE emulator (FX)" 5 | 6 | # Create a MateNET bus connection 7 | bus = MateNET('/dev/ttyUSB0', supports_spacemark=False) 8 | 9 | # Create a new MATE emulator attached to the specified port: 10 | mate = MateFXDevice(bus, port=bus.find_device(MateNET.DEVICE_FX)) 11 | 12 | # Check that an FX unit is attached and is responding 13 | mate.scan() 14 | 15 | # Query the device revision 16 | print "Revision:", mate.revision 17 | -------------------------------------------------------------------------------- /x.py: -------------------------------------------------------------------------------- 1 | from settings import * 2 | from pymate.matenet import * 3 | 4 | b = MateNET(SERIAL_PORT) 5 | 6 | mx = MateMXDevice(b,b.find_device(MateNET.DEVICE_MX)) 7 | fx = MateFXDevice(b,b.find_device(MateNET.DEVICE_FX)) 8 | --------------------------------------------------------------------------------