├── .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 | 
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 | > 
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 |
--------------------------------------------------------------------------------