├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── img ├── main_screen.png ├── settings_screen.png └── simu.gif ├── modbus_simulator ├── __init__.py ├── assets │ ├── Control-Panel.png │ ├── favicon.ico │ └── riptideLogo.png ├── main.py ├── templates │ ├── datamodel.kv │ └── modbussimu.kv ├── ui │ ├── __init__.py │ ├── datamodel.py │ ├── gui.py │ └── settings.py ├── utils │ ├── __init__.py │ ├── backgroundJob.py │ ├── common.py │ ├── constants.py │ ├── modbus.py │ └── pymodbus_server.py └── version.py ├── requirements ├── setup.py └── tools └── launcher /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #PyCharms 62 | .idea 63 | 64 | #IniFiles 65 | *.ini 66 | 67 | #JSONFiles 68 | *.json 69 | 70 | /.venv 71 | 72 | **/*.ini 73 | instructions 74 | read 75 | .vscode/ 76 | 77 | .venv 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include requirements 2 | include README.md 3 | include modbus_simulator/assets/*.png 4 | include modbus_simulator/assets/*.ico 5 | include modbus_simulator/templates/*.kv 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Modbus Simulator 2 | 3 | Modbus Simulator with GUI based on modbus-tk and Pymodbus 4 | 5 | ## Checking Out the Source 6 | $ git clone https://github.com/riptideio/modbus-simulator.git 7 | $ cd modbus-simulator 8 | 9 | 10 | ## Development Instructions 11 | 1. create virtualenv and install requirements 12 | 13 | ``` 14 | $ # Kivy depends on Cython, Install Cython before running the requirements 15 | $ pip install Cython==0.29.2 16 | $ pip install -r requirements 17 | $ # Choose Modbus Backend modbus_tk or pymodbus (default) 18 | $ # To install pymodbus 19 | $ pip install pymodbus==1.5.2 20 | $ # To install modbus tk 21 | $ Pip install modbus-tk 22 | 23 | ``` 24 | 25 | 26 | 3. [Setup development environment](https://github.com/kivy/kivy/wiki/Setting-Up-Kivy-with-various-popular-IDE's) 27 | 28 | ## Running/Testing application 29 | 30 | 1. To run simulation with pymodbus backend, run `./tools/launcher` 31 | 2. To run sumulation with modbus-tk as backend run `./tools/launcher mtk` 32 | 33 | 34 | 35 | A GUi should show up if all the requirements are met !! 36 | 37 | ![main_screen.png](/img/main_screen.png) 38 | 39 | All the settings for various modbus related settings (block size/minimum/maximun values/logging) could be set and accessed from settings panel (use F1 or click on Settings icon at the bottom) 40 | ![settings_screen.png](img/settings_screen.png) 41 | 42 | ## Usage instructions 43 | [![Demo Modbus Simulator](/img/simu.gif)](https://www.youtube.com/watch?v=a5-OridSlt8) 44 | 45 | ## Packaging for different OS (Standalone applications) 46 | A standalone application specific to target OS can be created with Kivy package manager 47 | 48 | 1. [OSX](https://kivy.org/docs/guide/packaging-osx.html) 49 | 2. [Linux](http://bitstream.io/packaging-and-distributing-a-kivy-application-on-linux.html) 50 | 3. [Windows](http://kivy.org/docs/guide/packaging-windows.html) 51 | 52 | 53 | # NOTE: 54 | A cli version supporting both Modbus_RTU and Modbus_TCP is available here [modbus_simu_cli](https://github.com/dhoomakethu/modbus_sim_cli) 55 | -------------------------------------------------------------------------------- /img/main_screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymodbus-dev/modbus-simulator/c0869f86d65875823b82158c044966b06e531e71/img/main_screen.png -------------------------------------------------------------------------------- /img/settings_screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymodbus-dev/modbus-simulator/c0869f86d65875823b82158c044966b06e531e71/img/settings_screen.png -------------------------------------------------------------------------------- /img/simu.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymodbus-dev/modbus-simulator/c0869f86d65875823b82158c044966b06e531e71/img/simu.gif -------------------------------------------------------------------------------- /modbus_simulator/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymodbus-dev/modbus-simulator/c0869f86d65875823b82158c044966b06e531e71/modbus_simulator/__init__.py -------------------------------------------------------------------------------- /modbus_simulator/assets/Control-Panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymodbus-dev/modbus-simulator/c0869f86d65875823b82158c044966b06e531e71/modbus_simulator/assets/Control-Panel.png -------------------------------------------------------------------------------- /modbus_simulator/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymodbus-dev/modbus-simulator/c0869f86d65875823b82158c044966b06e531e71/modbus_simulator/assets/favicon.ico -------------------------------------------------------------------------------- /modbus_simulator/assets/riptideLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pymodbus-dev/modbus-simulator/c0869f86d65875823b82158c044966b06e531e71/modbus_simulator/assets/riptideLogo.png -------------------------------------------------------------------------------- /modbus_simulator/main.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Modbus Simu App 3 | =============== 4 | ''' 5 | import click 6 | import sys 7 | import six 8 | 9 | if six.PY2: 10 | import __builtin__ 11 | else: 12 | import builtins as __builtin__ 13 | 14 | 15 | @click.command() 16 | @click.option("-p", is_flag=True, help="use pymodbus as modbus backend") 17 | def _run(p): 18 | __builtin__.USE_PYMODBUS = p 19 | if "-p" in sys.argv: 20 | # cleanup before kivy gets confused 21 | sys.argv.remove("-p") 22 | from modbus_simulator.ui.gui import run 23 | run() 24 | 25 | 26 | if __name__ == "__main__": 27 | _run() 28 | -------------------------------------------------------------------------------- /modbus_simulator/templates/datamodel.kv: -------------------------------------------------------------------------------- 1 | #!text 2 | : -------------------------------------------------------------------------------- /modbus_simulator/templates/modbussimu.kv: -------------------------------------------------------------------------------- 1 | #:kivy 1.9 2 | #:import KivyLexer kivy.extras.highlight.KivyLexer 3 | #:import ListItemButton kivy.uix.listview.ListItemButton 4 | #:import sla kivy.adapters.listadapter 5 | 6 | 7 | size_hint_y: None 8 | thickness: 2 9 | margin: 2 10 | height: self.thickness + 2 * self.margin 11 | color: .5, .5, .5 12 | canvas: 13 | Color: 14 | rgb: self.color 15 | Rectangle: 16 | pos: self.x + self.margin, self.y + self.margin + 1 17 | size: self.width - 2 * self.margin , self.thickness 18 | 19 | 20 | : 21 | : 22 | : 23 | slave_list: slave_list 24 | data_models: data_model_screen 25 | info_label: info_lbl 26 | interfaces: interfaces 27 | tcp: chkbx 28 | serial: chkbx2 29 | port: txtBox 30 | interface_settings: interface_settings 31 | orientation: 'vertical' 32 | start_stop_server: start_stop_server 33 | data_count: data_count 34 | data_model_coil: coils 35 | data_model_discrete_inputs: discrete_inputs 36 | data_model_input_registers: input_registers 37 | data_model_holding_registers: holding_registers 38 | slave_count: slave_count 39 | data_model_loc: data_model_loc 40 | min_value: 20 41 | slave_pane: slave_pane 42 | slave_start_add: slave_start_add 43 | slave_end_add: slave_end_add 44 | action_bar: action_bar 45 | settings: settings 46 | riptide_logo: riptide_logo 47 | reset_sim_btn: reset_simulation 48 | 49 | BoxLayout: 50 | padding: '2sp' 51 | canvas: 52 | Color: 53 | rgba: 1, 1, 1, .25 54 | Rectangle: 55 | size: self.size 56 | pos: self.pos 57 | size_hint: 1, None 58 | height: '45sp' 59 | BoxLayout: 60 | id: interfaces 61 | size_hint: None, 1 62 | orientation: "vertical" 63 | width: '150sp' 64 | padding: '5sp' 65 | spacing: '10sp' 66 | BoxLayout: 67 | size_hint: None, 1 68 | Label: 69 | text: "TCP" 70 | CheckBox: 71 | id: chkbx 72 | active: True 73 | group: "interface" 74 | on_active: root.update_tcp_connection_info(*args) 75 | BoxLayout: 76 | size_hint: None, 1 77 | Label: 78 | text: "Serial" 79 | CheckBox: 80 | id: chkbx2 81 | group: "interface" 82 | on_active: root.update_serial_connection_info(*args) 83 | Widget: 84 | BoxLayout: 85 | id: interface_settings 86 | orientation: 'vertical' if self.width < self.height else 'horizontal' 87 | size_hint: None, 1 88 | width: '200sp' 89 | padding: '2sp' 90 | Label: 91 | text: "Port" 92 | TextInput: 93 | id: txtBox 94 | text: "5440" 95 | multiline: False 96 | 97 | Widget: 98 | ToggleButton: 99 | id: start_stop_server 100 | size_hint: None, 1 101 | width: '108sp' 102 | text: 'Start' 103 | on_release: root.start_server(*args) 104 | Separator: 105 | BoxLayout: 106 | id: reactive_layout 107 | orientation: 'vertical' if self.width < self.height else 'horizontal' 108 | 109 | Splitter: 110 | id: slave_pane 111 | max_size: self.parent.width - 500 112 | min_size: 200 113 | #max_size: (reactive_layout.height if self.vertical else reactive_layout.width) - self.strip_size 114 | #min_size: sp(30) + self.strip_size 115 | vertical: 1 if reactive_layout.width < reactive_layout.height else 0 116 | sizable_from: 'bottom' if self.vertical else 'right' 117 | size_hint: (1, None) if self.vertical else (None, 1) 118 | size: 400, 400 119 | 120 | on_vertical: 121 | mid_size = self.max_size/2 122 | if args[1]: self.height = mid_size 123 | if not args[1]: self.width = mid_size 124 | BoxLayout: 125 | orientation: 'vertical' 126 | padding: '2sp' 127 | canvas: 128 | Color: 129 | rgba: 1, 1, 1, .12 130 | Rectangle: 131 | size: self.size 132 | pos: self.pos 133 | Label: 134 | text: "modbus slaves" 135 | size_hint_y: .05 136 | Separator: 137 | BoxLayout: 138 | orientation: 'horizontal' 139 | padding: '2sp' 140 | canvas: 141 | Color: 142 | rgba: 1, 1, 1, .25 143 | Rectangle: 144 | size: self.size 145 | pos: self.pos 146 | size_hint: 1, None 147 | height: '45sp' 148 | Button: 149 | id: add_slave 150 | text: "add" 151 | on_release: root.add_slaves(*args) 152 | Button: 153 | id: delete_slave 154 | text: "delete" 155 | on_release: root.delete_slaves(*args) 156 | ToggleButton: 157 | id: enable_slaves 158 | text: "enable all" 159 | Separator: 160 | BoxLayout: 161 | orientation: 'vertical' 162 | padding: '2sp' 163 | canvas: 164 | Color: 165 | rgba: 1, 1, 1, .25 166 | Rectangle: 167 | size: self.size 168 | pos: self.pos 169 | size_hint: 1, None 170 | height: '100sp' 171 | BoxLayout: 172 | orientation: "horizontal" 173 | Label: 174 | text: 'from' 175 | size_hint: .25, 1 176 | FloatInput: 177 | id: slave_start_add 178 | text: "1" 179 | size_hint: 1, .8 180 | multiline: False 181 | BoxLayout: 182 | orientation: "horizontal" 183 | Label: 184 | text: 'to' 185 | size_hint: .25, 1 186 | FloatInput: 187 | id: slave_end_add 188 | text: "1" 189 | size_hint: 1, .8 190 | multiline: False 191 | BoxLayout: 192 | orientation: "horizontal" 193 | Label: 194 | text: 'count' 195 | size_hint: .25, 1 196 | FloatInput: 197 | id: slave_count 198 | text: "1" 199 | size_hint: 1, .8 200 | multiline: False 201 | Label: 202 | text: "" 203 | size_hint_y: .05 204 | #ListViewModal: 205 | #id: slave_list 206 | ListView: 207 | id: slave_list 208 | size_hint: 1, 1 209 | adapter: 210 | sla.ListAdapter( 211 | data=[], 212 | cls=ListItemButton, 213 | selection_mode="single", 214 | 215 | ) 216 | BoxLayout: 217 | id: data_model_loc 218 | orientation: 'vertical' 219 | 220 | padding: '2sp' 221 | #padding: 20 222 | #spacing: 10 223 | BoxLayout: 224 | orientation: 'horizontal' 225 | padding: '2sp' 226 | canvas: 227 | Color: 228 | rgba: 1, 1, 1, .25 229 | Rectangle: 230 | size: self.size 231 | pos: self.pos 232 | size_hint: 1, None 233 | height: '45sp' 234 | Label: 235 | id: add_data 236 | text: "Count" 237 | Widget: 238 | FloatInput: 239 | id: data_count 240 | text: "10" 241 | Separator: 242 | BoxLayout: 243 | orientation: 'horizontal' 244 | padding: '2sp' 245 | canvas: 246 | Color: 247 | rgba: 1, 1, 1, .25 248 | Rectangle: 249 | size: self.size 250 | pos: self.pos 251 | size_hint: 1, None 252 | height: '45sp' 253 | Button: 254 | id: add_data 255 | text: "add" 256 | on_release: root.update_data_models(*args) 257 | # Widget: 258 | # Button: 259 | # id: delete_data 260 | # text: "delete" 261 | # disabled: True 262 | # on_release: root.delete_data_entry(*args) 263 | # hide: True 264 | Separator: 265 | TabbedPanel: 266 | id: data_model_screen 267 | fullscreen: True 268 | tab_width: self.size[0]/len(self.tab_list) 269 | do_default_tab: False 270 | TabbedPanelItem: 271 | text: 'coils' 272 | DataModel: 273 | id: coils 274 | minval: 0 275 | maxval: 1 276 | TabbedPanelItem: 277 | text: 'discrete inputs' 278 | DataModel: 279 | id: discrete_inputs 280 | minval: 0 281 | maxval: 100 282 | TabbedPanelItem: 283 | text: 'input registers' 284 | DataModel: 285 | id: input_registers 286 | minval: 0 287 | maxval: 100 288 | TabbedPanelItem: 289 | text: 'holding registers' 290 | DataModel: 291 | id: holding_registers 292 | minval: 0 293 | maxval: 100 294 | ActionBar: 295 | id: action_bar 296 | pos_hint: {'top':1} 297 | ActionView: 298 | use_separator: True 299 | ActionPrevious: 300 | id: riptide_logo 301 | with_previous: False 302 | disabled: True 303 | title: ' v2.0.0' 304 | ActionOverflow: 305 | ActionButton: 306 | id: reset_simulation 307 | text: 'Reset Simulation' 308 | on_release: root.reset_simulation(*args) 309 | ActionToggleButton: 310 | text: 'Simulate' 311 | on_release: root.start_stop_simulation(*args) 312 | ActionButton: 313 | id: settings 314 | text: 'settings' 315 | on_release: app.show_settings(*args) 316 | 317 | 318 | 319 | FloatLayout: 320 | size_hint: 1, None 321 | height: 0 322 | TextInput: 323 | id:info_lbl 324 | readonly: True 325 | font_size: '14sp' 326 | background_color: (0, 0, 0, 1) 327 | foreground_color: (1, 1, 1, 1) 328 | opacity:0 329 | size_hint: 1, None 330 | text_size: self.size 331 | height: '150pt' 332 | top: 0 333 | -------------------------------------------------------------------------------- /modbus_simulator/ui/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals -------------------------------------------------------------------------------- /modbus_simulator/ui/datamodel.py: -------------------------------------------------------------------------------- 1 | from random import randint, uniform 2 | from copy import deepcopy 3 | from kivy.adapters.dictadapter import DictAdapter 4 | from kivy.event import EventDispatcher 5 | from kivy.lang import Builder 6 | from kivy.logger import Logger 7 | from kivy.properties import BooleanProperty, NumericProperty 8 | from kivy.uix.boxlayout import BoxLayout 9 | from kivy.uix.button import Button 10 | from kivy.uix.gridlayout import GridLayout 11 | from kivy.uix.label import Label 12 | from kivy.uix.listview import ListItemButton, CompositeListItem, ListView, SelectableView 13 | from kivy.uix.popup import Popup 14 | from kivy.uix.textinput import TextInput 15 | from kivy.uix.dropdown import DropDown 16 | from modbus_simulator.utils.backgroundJob import BackgroundJob 17 | from pkg_resources import resource_filename 18 | 19 | datamodel_template = resource_filename(__name__, "../templates/datamodel.kv") 20 | Builder.load_file(datamodel_template) 21 | 22 | integers_dict = {} 23 | 24 | 25 | class DropBut(SelectableView, Button): 26 | # drop_list = None 27 | types = ['int16', 'int32', 'int64', 'uint16', 'uint32', 'uint64', 28 | 'float32', 'float64'] 29 | drop_down = None 30 | 31 | def __init__(self, data_model, **kwargs): 32 | super(DropBut, self).__init__(**kwargs) 33 | self.data_model = data_model 34 | self.drop_down = DropDown() 35 | for i in self.types: 36 | btn = Button(text=i, size_hint_y=None, height=45, 37 | background_color=(0.0, 0.5, 1.0, 1.0)) 38 | btn.bind(on_release=lambda b: self.drop_down.select(b.text)) 39 | self.drop_down.add_widget(btn) 40 | 41 | self.bind(on_release=self.drop_down.open) 42 | self.drop_down.bind(on_select=self.on_formatter_select) 43 | 44 | def select_from_composite(self, *args): 45 | # self.bold = True 46 | pass 47 | 48 | def deselect_from_composite(self, *args): 49 | # self.bold = False 50 | pass 51 | 52 | def on_formatter_select(self, instance, value): 53 | self.data_model.on_formatter_update(self.index, self.text, value) 54 | self.text = value 55 | 56 | 57 | class ErrorPopup(Popup): 58 | """ 59 | Popup class to display error messages 60 | """ 61 | def __init__(self, **kwargs): 62 | # print kwargs 63 | super(ErrorPopup, self).__init__() 64 | # super(ErrorPopup, self).__init__(**kwargs) 65 | content = BoxLayout(orientation="vertical") 66 | content.add_widget(Label(text=kwargs['text'], font_size=20)) 67 | mybutton = Button(text="Dismiss", size_hint=(1,.20), font_size=20) 68 | content.add_widget(mybutton) 69 | self.content = content 70 | self.title = kwargs["title"] 71 | self.auto_dismiss = False 72 | self.size_hint = .7, .5 73 | self.font_size = 20 74 | mybutton.bind(on_release=self.exit_popup) 75 | self.open() 76 | 77 | def exit_popup(self, *args): 78 | self.dismiss() 79 | 80 | 81 | class ListItemReprMixin(Label): 82 | """ 83 | repr class for ListItem Composite class 84 | """ 85 | def __repr__(self): 86 | text = self.text.encode('utf-8') if isinstance(self.text, unicode) \ 87 | else self.text 88 | return '<%s text=%s>' % (self.__class__.__name__, text) 89 | 90 | 91 | class NumericTextInput(SelectableView, TextInput): 92 | """ 93 | :class:`~kivy.uix.listview.NumericTextInput` mixes 94 | :class:`~kivy.uix.listview.SelectableView` with 95 | :class:`~kivy.uix.label.TextInput` to produce a label suitable for use in 96 | :class:`~kivy.uix.listview.ListView`. 97 | """ 98 | edit = BooleanProperty(False) 99 | 100 | def __init__(self, data_model, minval, maxval, **kwargs): 101 | self.minval = minval 102 | self.maxval = maxval 103 | self.data_model = data_model 104 | super(NumericTextInput, self).__init__(**kwargs) 105 | try: 106 | self.val = int(self.text) 107 | except ValueError: 108 | error = "Only numeric value in range {0}-{1} to be used".format(minval, maxval) 109 | self.hint_text = error 110 | 111 | self._update_width() 112 | self.disabled = True 113 | 114 | def _update_width(self): 115 | if self.data_model.blockname not in ['input_registers', 116 | 'holding_registers']: 117 | self.padding_x = self.width 118 | 119 | def on_touch_down(self, touch): 120 | if self.collide_point(*touch.pos) and not self.edit: 121 | self.edit = True 122 | self.select() 123 | return super(NumericTextInput, self).on_touch_down(touch) 124 | 125 | def select(self, *args): 126 | self.disabled = False 127 | self.bold = True 128 | if isinstance(self.parent, CompositeListItem): 129 | for child in self.parent.children: 130 | # print child.children 131 | pass 132 | self.parent.select_from_child(self, *args) 133 | 134 | def deselect(self, *args): 135 | self.bold = False 136 | self.disabled = True 137 | if isinstance(self.parent, CompositeListItem): 138 | self.parent.deselect_from_child(self, *args) 139 | 140 | def select_from_composite(self, *args): 141 | self.bold = True 142 | 143 | def deselect_from_composite(self, *args): 144 | self.bold = False 145 | 146 | def on_text_validate(self, *args): 147 | 148 | try: 149 | float(self.text) 150 | 151 | if not(self.minval <= float(self.text) <= self.maxval): 152 | raise ValueError 153 | self.edit = False 154 | self.data_model.on_data_update(self.index, self.text) 155 | self.deselect() 156 | except ValueError: 157 | error_text = ("Only numeric value " 158 | "in range {0}-{1} to be used".format(self.minval, 159 | self.maxval)) 160 | ErrorPopup(title="Error", text=error_text) 161 | self.text = "" 162 | self.hint_text = error_text 163 | return 164 | 165 | def on_text_focus(self, instance, focus): 166 | if focus is False: 167 | self.text = instance.text 168 | self.edit = False 169 | self.deselect() 170 | 171 | 172 | class UpdateEventDispatcher(EventDispatcher): 173 | ''' 174 | Event dispatcher for updates in Data Model 175 | ''' 176 | def __init__(self, **kwargs): 177 | self.register_event_type('on_update') 178 | super(UpdateEventDispatcher, self).__init__(**kwargs) 179 | 180 | def on_update(self, _parent, blockname, data): 181 | Logger.debug("In UpdateEventDispatcher " 182 | "on_update {parent:%s," 183 | " blockname: %s, data:%s,}" % (_parent, blockname, data)) 184 | event = data.pop('event', None) 185 | if event == 'sync_data': 186 | _parent.sync_data_callback(blockname, data.get('data', {})) 187 | else: 188 | old_formatter = data.pop("old_formatter", None) 189 | _parent.sync_formatter_callback(blockname, data.get('data', {}), 190 | old_formatter) 191 | 192 | 193 | class DataModel(GridLayout): 194 | """ 195 | Uses :class:`CompositeListItem` for list item views comprised by two 196 | :class:`ListItemButton`s and one :class:`ListItemLabel`. Illustrates how 197 | to construct the fairly involved args_converter used with 198 | :class:`CompositeListItem`. 199 | """ 200 | minval = NumericProperty(0) 201 | maxval = NumericProperty(0) 202 | simulate = False 203 | time_interval = 1 204 | dirty_thread = False 205 | dirty_model = False 206 | simulate_timer = None 207 | simulate = False 208 | dispatcher = None 209 | list_view = None 210 | _parent = None 211 | is_simulating = False 212 | blockname = "" 213 | 214 | def __init__(self, **kwargs): 215 | kwargs['cols'] = 3 216 | kwargs['size_hint'] = (1.0, 1.0) 217 | super(DataModel, self).__init__(**kwargs) 218 | self.init() 219 | 220 | def init(self, simulate=False, time_interval=1, **kwargs): 221 | """ 222 | Initializes Datamodel 223 | 224 | """ 225 | self.minval = kwargs.get("minval", self.minval) 226 | self.maxval = kwargs.get("maxval", self.maxval) 227 | self.blockname = kwargs.get("blockname", self.blockname) 228 | self.clear_widgets() 229 | self.simulate = simulate 230 | self.time_interval = time_interval 231 | dict_adapter = DictAdapter(data={}, 232 | args_converter=self.arg_converter, 233 | selection_mode='single', 234 | allow_empty_selection=True, 235 | cls=CompositeListItem 236 | ) 237 | 238 | # Use the adapter in our ListView: 239 | self.list_view = ListView(adapter=dict_adapter) 240 | self.add_widget(self.list_view) 241 | self.dispatcher = UpdateEventDispatcher() 242 | self._parent = kwargs.get('_parent', None) 243 | self.simulate_timer = BackgroundJob( 244 | "simulation", 245 | self.time_interval, 246 | self._simulate_block_values 247 | ) 248 | 249 | def clear_widgets(self, make_dirty=False, **kwargs): 250 | """ 251 | Overidden Clear widget function used while deselecting/deleting slave 252 | :param make_dirty: 253 | :param kwargs: 254 | :return: 255 | """ 256 | if make_dirty: 257 | self.dirty_model = True 258 | super(DataModel, self).clear_widgets(**kwargs) 259 | 260 | def reinit(self, **kwargs): 261 | """ 262 | Re-initializes Datamodel on change in model configuration from settings 263 | :param kwargs: 264 | :return: 265 | """ 266 | self.minval = kwargs.get("minval", self.minval) 267 | self.maxval = kwargs.get("maxval", self.maxval) 268 | time_interval = kwargs.get("time_interval", None) 269 | try: 270 | if time_interval and int(time_interval) != self.time_interval: 271 | self.time_interval = time_interval 272 | if self.is_simulating: 273 | self.simulate_timer.cancel() 274 | self.simulate_timer = BackgroundJob("simulation", self.time_interval, 275 | self._simulate_block_values) 276 | self.dirty_thread = False 277 | self.start_stop_simulation(self.simulate) 278 | except ValueError: 279 | Logger.debug("Error while reinitializing DataModel %s" % kwargs) 280 | 281 | def update_view(self): 282 | """ 283 | Updates view with listview again 284 | :return: 285 | """ 286 | if self.dirty_model: 287 | self.add_widget(self.list_view) 288 | self.dirty_model = False 289 | 290 | def get_address(self, offset, as_string=False): 291 | offset = int(offset) 292 | if self.blockname == "coils": 293 | offset = offset 294 | elif self.blockname == "discrete_inputs": 295 | offset = 10001 + offset if offset < 10001 else offset 296 | elif self.blockname == "input_registers": 297 | offset = 30001 + offset if offset < 30001 else offset 298 | else: 299 | offset = 40001 + offset if offset < 40001 else offset 300 | return str(offset) if as_string else offset 301 | 302 | def arg_converter(self, index, data): 303 | """ 304 | arg converter to convert data to list view 305 | :param index: 306 | :param data: 307 | :return: 308 | """ 309 | _id = self.get_address(self.list_view.adapter.sorted_keys[index]) 310 | 311 | payload = { 312 | 'text': str(_id), 313 | 'size_hint_y': None, 314 | 'height': 30, 315 | 'cls_dicts': [ 316 | { 317 | 'cls': ListItemButton, 318 | 'kwargs': {'text': str(_id)} 319 | } 320 | ] 321 | } 322 | if self.blockname in ['input_registers', 'holding_registers']: 323 | payload['cls_dicts'].extend([ 324 | { 325 | 'cls': NumericTextInput, 326 | 'kwargs': { 327 | 'data_model': self, 328 | 'minval': self.minval, 329 | 'maxval': self.maxval, 330 | 'text': str(data['value']), 331 | 'multiline': False, 332 | 'is_representing_cls': True, 333 | 334 | } 335 | }, 336 | { 337 | 'cls': DropBut, 338 | 'kwargs': { 339 | 'data_model': self, 340 | 'text': data.get('formatter', 'uint16') 341 | } 342 | } 343 | ] 344 | ) 345 | else: 346 | payload['cls_dicts'].append( 347 | { 348 | 'cls': NumericTextInput, 349 | 'kwargs': { 350 | 'data_model': self, 351 | 'minval': self.minval, 352 | 'maxval': self.maxval, 353 | 'text': str(data['value']), 354 | 'multiline': False, 355 | 'is_representing_cls': True, 356 | 357 | } 358 | } 359 | ) 360 | 361 | return payload 362 | 363 | def add_data(self, data): 364 | """ 365 | Adds data to the Data model 366 | :param data: 367 | :param item_strings: 368 | :return: 369 | """ 370 | item_strings = [] 371 | self.update_view() 372 | current_keys = self.list_view.adapter.sorted_keys 373 | next_index = 0 374 | key_as_string = False 375 | if current_keys: 376 | next_index = int(max(current_keys)) + 1 377 | if not isinstance(current_keys[0], int): 378 | key_as_string = True 379 | data = {self.get_address(int(offset) + next_index, key_as_string): v 380 | for offset, v in data.items()} 381 | for offset, d in data.items(): 382 | # offset = self.get_address(offset) 383 | item_strings.append(offset) 384 | if int(offset) >= 30001: 385 | if not d.get('formatter'): 386 | d['formatter'] = 'uint16' 387 | 388 | self.list_view.adapter.data.update(data) 389 | self.list_view._trigger_reset_populate() 390 | return self.list_view.adapter.data, item_strings 391 | 392 | def delete_data(self, item_strings): 393 | """ 394 | Delete data from data model 395 | :param item_strings: 396 | :return: 397 | """ 398 | selections = self.list_view.adapter.selection 399 | items_popped = [] 400 | for item in selections: 401 | index_popped = item_strings.pop(item_strings.index(int(item.text))) 402 | self.list_view.adapter.data.pop(int(item.text), None) 403 | self.list_view.adapter.update_for_new_data() 404 | self.list_view._trigger_reset_populate() 405 | items_popped.append(index_popped) 406 | return items_popped, self.list_view.adapter.data 407 | 408 | def on_selection_change(self, item): 409 | pass 410 | 411 | def on_data_update(self, index, data): 412 | """ 413 | Call back function to update data when data is changed in the list view 414 | :param index: 415 | :param data: 416 | :return: 417 | """ 418 | index = self.get_address(self.list_view.adapter.sorted_keys[index]) 419 | try: 420 | self.list_view.adapter.data[index] 421 | except KeyError: 422 | index = str(index) 423 | if self.blockname in ['input_registers', 'holding_registers']: 424 | self.list_view.adapter.data[index]['value'] = float(data) 425 | else: 426 | self.list_view.adapter.data[index]['value'] = int(data) 427 | self.list_view._trigger_reset_populate() 428 | data = {'event': 'sync_data', 429 | 'data': {index: self.list_view.adapter.data[index]}} 430 | self.dispatcher.dispatch('on_update', 431 | self._parent, 432 | self.blockname, 433 | data) 434 | 435 | def on_formatter_update(self, index, old, new): 436 | """ 437 | Callback function to use the formatter selected in the list view 438 | Args: 439 | index: 440 | data: 441 | 442 | Returns: 443 | 444 | """ 445 | index = self.get_address(self.list_view.adapter.sorted_keys[index]) 446 | # index = self.get_address(int(index)) 447 | try: 448 | self.list_view.adapter.data[index]['formatter'] = new 449 | except KeyError: 450 | index = str(index) 451 | self.list_view.adapter.data[index]['formatter'] = new 452 | _data = {'event': 'sync_formatter', 453 | 'old_formatter': old, 454 | 'data': {index: self.list_view.adapter.data[index]}} 455 | self.dispatcher.dispatch('on_update', self._parent, 456 | self.blockname, _data) 457 | self.list_view._trigger_reset_populate() 458 | 459 | def update_registers(self, new_values, update_info): 460 | # new_values = deepcopy(new_values) 461 | offset = update_info.get('offset') 462 | count = update_info.get('count') 463 | to_remove = None 464 | if count > 1: 465 | offset = int(offset) 466 | to_remove = [str(o) for o in list(range(offset+1, offset+count))] 467 | 468 | self.list_view.adapter.update_for_new_data() 469 | self.refresh(new_values, to_remove) 470 | return self.list_view.adapter.data 471 | 472 | def refresh(self, data={}, to_remove=None): 473 | """ 474 | Data model refresh function to update when the view when slave is 475 | selected 476 | :param data: 477 | :param to_remove: 478 | :return: 479 | """ 480 | self.update_view() 481 | if not data or len(data) != len(self.list_view.adapter.data): 482 | self.list_view.adapter.data = data 483 | else: 484 | self.list_view.adapter.data.update(data) 485 | if to_remove: 486 | for entry in to_remove: 487 | removed = self.list_view.adapter.data.pop(entry, None) 488 | if not removed: 489 | self.list_view.adapter.data.pop(int(entry), None) 490 | self.list_view.disabled = False 491 | self.list_view._trigger_reset_populate() 492 | 493 | def start_stop_simulation(self, simulate): 494 | """ 495 | Starts or stops simulating data 496 | :param simulate: 497 | :return: 498 | """ 499 | self.simulate = simulate 500 | 501 | if self.simulate: 502 | if self.dirty_thread: 503 | self.simulate_timer = BackgroundJob( 504 | "simulation", 505 | self.time_interval, 506 | self._simulate_block_values 507 | ) 508 | self.simulate_timer.start() 509 | self.dirty_thread = False 510 | self.is_simulating = True 511 | else: 512 | self.simulate_timer.cancel() 513 | self.dirty_thread = True 514 | self.is_simulating = False 515 | 516 | def _simulate_block_values(self): 517 | if self.simulate: 518 | data = self.list_view.adapter.data 519 | if data: 520 | for index, value in data.items(): 521 | if self.blockname in ['input_registers', 522 | 'holding_registers']: 523 | if 'float' in data[index]['formatter']: 524 | value = round(uniform(self.minval, self.maxval), 2) 525 | else: 526 | value = randint(self.minval, self.maxval) 527 | if 'uint' in data[index]['formatter']: 528 | value = abs(value) 529 | else: 530 | value = randint(self.minval, self.maxval) 531 | 532 | data[index]['value'] = value 533 | self.refresh(data) 534 | data = {'event': 'sync_data', 535 | 'data': data} 536 | self.dispatcher.dispatch('on_update', 537 | self._parent, 538 | self.blockname, 539 | data) 540 | 541 | def reset_block_values(self): 542 | if not self.simulate: 543 | data = self.list_view.adapter.data 544 | if data: 545 | for index, value in data.items(): 546 | data[index]['value'] = 1 547 | self.list_view.adapter.data.update(data) 548 | self.list_view.disabled = False 549 | self.list_view._trigger_reset_populate() 550 | self._parent.sync_data_callback(self.blockname, 551 | self.list_view.adapter.data) 552 | -------------------------------------------------------------------------------- /modbus_simulator/ui/gui.py: -------------------------------------------------------------------------------- 1 | """' 2 | Modbus Simu App 3 | =============== 4 | """ 5 | import kivy 6 | import six 7 | import struct 8 | from kivy.app import App 9 | from kivy.properties import ObjectProperty 10 | from kivy.uix.boxlayout import BoxLayout 11 | from kivy.animation import Animation 12 | from kivy.uix.textinput import TextInput 13 | from kivy.uix.settings import SettingsWithSidebar 14 | from kivy.uix.listview import ListView, ListItemButton 15 | from kivy.adapters.listadapter import ListAdapter 16 | from modbus_simulator.utils.constants import BLOCK_TYPES 17 | from modbus_simulator.utils.common import configure_modbus_logger 18 | from modbus_simulator.ui.settings import SettingIntegerWithRange 19 | from modbus_simulator.utils.backgroundJob import BackgroundJob 20 | import re 21 | import os 22 | import platform 23 | 24 | from json import load, dump 25 | from kivy.config import Config 26 | from kivy.lang import Builder 27 | import modbus_simulator.ui.datamodel #noqa 28 | from pkg_resources import resource_filename 29 | from serial.serialutil import SerialException 30 | 31 | from distutils.version import LooseVersion 32 | 33 | kivy.require('1.4.2') 34 | 35 | if six.PY3: 36 | xrange = range 37 | 38 | IS_DARWIN = platform.system().lower() == "darwin" 39 | OSX_SIERRA = LooseVersion("10.12") 40 | if IS_DARWIN: 41 | IS_HIGH_SIERRA_OR_ABOVE = LooseVersion(platform.mac_ver()[0]) 42 | else: 43 | IS_HIGH_SIERRA_OR_ABOVE = False 44 | 45 | DEFAULT_SERIAL_PORT = '/dev/ptyp0' if not IS_HIGH_SIERRA_OR_ABOVE else '/dev/ttyp0' 46 | 47 | if USE_PYMODBUS: 48 | from modbus_simulator.utils.pymodbus_server import ModbusSimu 49 | else: 50 | from modbus_simulator.utils.modbus import ModbusSimu 51 | 52 | 53 | MAP = { 54 | "coils": "coils", 55 | 'discrete inputs': 'discrete_inputs', 56 | 'input registers': 'input_registers', 57 | 'holding registers': 'holding_registers' 58 | } 59 | PARENT = __name__.split(".")[0] 60 | settings_icon = resource_filename(PARENT, "assets/Control-Panel.png") 61 | app_icon = resource_filename(PARENT, "assets/riptideLogo.png") 62 | modbus_template = resource_filename(PARENT, "templates/modbussimu.kv") 63 | Builder.load_file(modbus_template) 64 | 65 | SLAVES_FILE = resource_filename(__name__, "slaves.json") 66 | 67 | 68 | class FloatInput(TextInput): 69 | pat2 = re.compile(r'\d+(?:,\d+)?') 70 | pat = re.compile('[^0-9]') 71 | 72 | def insert_text(self, substring, from_undo=False): 73 | pat = self.pat 74 | if '.' in self.text: 75 | s = re.sub(pat, '', substring) 76 | else: 77 | s = '.'.join([re.sub(pat, '', s) for s in substring.split('.', 1)]) 78 | return super(FloatInput, self).insert_text(s, from_undo=from_undo) 79 | 80 | 81 | class Gui(BoxLayout): 82 | """ 83 | Gui of widgets. This is the root widget of the app. 84 | """ 85 | 86 | # ---------------------GUI------------------------ # 87 | # Checkbox to select between tcp/serial 88 | interfaces = ObjectProperty() 89 | 90 | tcp = ObjectProperty() 91 | serial = ObjectProperty() 92 | 93 | # Boxlayout to hold interface settings 94 | interface_settings = ObjectProperty() 95 | 96 | # TCP port 97 | port = ObjectProperty() 98 | 99 | # Toggle button to start/stop modbus server 100 | start_stop_server = ObjectProperty() 101 | 102 | # Container for slave list 103 | slave_pane = ObjectProperty() 104 | # slave start address textbox 105 | slave_start_add = ObjectProperty() 106 | # slave end address textbox 107 | slave_end_add = ObjectProperty() 108 | # Slave device count text box 109 | slave_count = ObjectProperty() 110 | # Slave list 111 | slave_list = ObjectProperty() 112 | 113 | # Container for modbus data models 114 | data_model_loc = ObjectProperty() 115 | # Tabbed panel to hold various modbus datamodels 116 | data_models = ObjectProperty() 117 | 118 | # Data models 119 | data_count = ObjectProperty() 120 | data_model_coil = ObjectProperty() 121 | data_model_discrete_inputs = ObjectProperty() 122 | data_model_input_registers = ObjectProperty() 123 | data_model_holding_registers = ObjectProperty() 124 | 125 | settings = ObjectProperty() 126 | riptide_logo = ObjectProperty() 127 | 128 | reset_sim_btn = ObjectProperty() 129 | 130 | # Helpers 131 | # slaves = ["%s" %i for i in xrange(1, 248)] 132 | _data_map = {"tcp": {}, "rtu": {}} 133 | active_slave = None 134 | server_running = False 135 | simulating = False 136 | simu_time_interval = None 137 | anim = None 138 | restart_simu = False 139 | sync_modbus_thread = None 140 | sync_modbus_time_interval = 5 141 | _modbus_device = {"tcp": None, 'rtu': None} 142 | _slaves = {"tcp": None, "rtu": None} 143 | 144 | last_active_port = {"tcp": "", "serial": ""} 145 | active_server = "tcp" 146 | _serial_settings_changed = False 147 | 148 | def __init__(self, time_interval=1, modbus_log=None, **kwargs): 149 | super(Gui, self).__init__(**kwargs) 150 | # time_interval = kwargs.get("time_interval", 1) 151 | self.settings.icon = settings_icon 152 | self.riptide_logo.app_icon = app_icon 153 | self.config = Config.get_configparser('app') 154 | self.slave_list.adapter.bind(on_selection_change=self.select_slave) 155 | self.data_model_loc.disabled = True 156 | self.slave_pane.disabled = True 157 | self._init_coils() 158 | self._init_registers() 159 | self._register_config_change_callback( 160 | self._update_serial_connection, 161 | 'Modbus Serial' 162 | ) 163 | self.data_model_loc.disabled = True 164 | cfg = { 165 | 'no_modbus_log': not bool(eval( 166 | self.config.get("Logging", "logging"))), 167 | 'no_modbus_console_log': not bool( 168 | eval(self.config.get("Logging", "console logging"))), 169 | 'modbus_console_log_level': self.config.get("Logging", 170 | "console log level"), 171 | 'modbus_file_log_level': self.config.get("Logging", 172 | "file log level"), 173 | 'no_modbus_file_log': not bool(eval( 174 | self.config.get("Logging", "file logging"))), 175 | 176 | 'modbus_log': modbus_log 177 | } 178 | mod_lib = "modbus_tk" if not USE_PYMODBUS else "pymodbus" 179 | configure_modbus_logger(cfg, protocol_logger=mod_lib) 180 | self.simu_time_interval = time_interval 181 | self.sync_modbus_thread = BackgroundJob( 182 | "modbus_sync", 183 | self.sync_modbus_time_interval, 184 | self._sync_modbus_block_values 185 | ) 186 | self.sync_modbus_thread.start() 187 | self._slave_misc = {"tcp": [self.slave_start_add.text, 188 | self.slave_end_add.text, 189 | self.slave_count.text], 190 | "rtu": [self.slave_start_add.text, 191 | self.slave_end_add.text, 192 | self.slave_count.text]} 193 | 194 | @property 195 | def modbus_device(self): 196 | return self._modbus_device[self.active_server] 197 | 198 | @modbus_device.setter 199 | def modbus_device(self, value): 200 | self._modbus_device[self.active_server] = value 201 | 202 | @property 203 | def slave(self): 204 | return self._slaves[self.active_server] 205 | 206 | @slave.setter 207 | def slave(self, value): 208 | self._slaves[self.active_server] = value 209 | 210 | @property 211 | def data_map(self): 212 | return self._data_map[self.active_server] 213 | 214 | @data_map.setter 215 | def data_map(self, value): 216 | self._data_map[self.active_server] = value 217 | 218 | def _init_coils(self): 219 | time_interval = int(eval(self.config.get("Simulation", 220 | "time interval"))) 221 | minval = int(eval(self.config.get("Modbus Protocol", 222 | "bin min"))) 223 | maxval = int(eval(self.config.get("Modbus Protocol", 224 | "bin max"))) 225 | 226 | self.data_model_coil.init( 227 | blockname="coils", 228 | simulate=self.simulating, 229 | time_interval=time_interval, 230 | minval=minval, 231 | maxval=maxval, 232 | _parent=self 233 | ) 234 | self.data_model_discrete_inputs.init( 235 | blockname="discrete_inputs", 236 | simulate=self.simulating, 237 | time_interval=time_interval, 238 | minval=minval, 239 | maxval=maxval, 240 | _parent=self 241 | ) 242 | 243 | def _init_registers(self): 244 | time_interval = int(eval(self.config.get("Simulation", 245 | "time interval"))) 246 | minval = int(eval(self.config.get("Modbus Protocol", 247 | "reg min"))) 248 | maxval = int(eval(self.config.get("Modbus Protocol", 249 | "reg max"))) 250 | self.block_start = int(eval(self.config.get("Modbus Protocol", 251 | "block start"))) 252 | self.block_size = int(eval(self.config.get("Modbus Protocol", 253 | "block size"))) 254 | self.word_order = self.config.get("Modbus Protocol", "word order") 255 | self.byte_order = self.config.get("Modbus Protocol", "byte order") 256 | 257 | self.data_model_input_registers.init( 258 | blockname="input_registers", 259 | simulate=self.simulating, 260 | time_interval=time_interval, 261 | minval=minval, 262 | maxval=maxval, 263 | _parent=self 264 | ) 265 | self.data_model_holding_registers.init( 266 | blockname="holding_registers", 267 | simulate=self.simulating, 268 | time_interval=time_interval, 269 | minval=minval, 270 | maxval=maxval, 271 | _parent=self 272 | ) 273 | 274 | def _register_config_change_callback(self, callback, section, key=None): 275 | self.config.add_callback(callback, section, key) 276 | 277 | def _update_serial_connection(self, *args): 278 | self._serial_settings_changed = True 279 | 280 | def _create_modbus_device(self): 281 | kwargs = {} 282 | create_new = False 283 | kwargs['byte_order'] = self.byte_order 284 | kwargs['word_order'] = self.word_order 285 | if self.active_server == "rtu": 286 | 287 | kwargs["baudrate"] = int(eval( 288 | self.config.get('Modbus Serial', "baudrate"))) 289 | kwargs["bytesize"] = int(eval( 290 | self.config.get('Modbus Serial', "bytesize"))) 291 | kwargs["parity"] = self.config.get('Modbus Serial', "parity") 292 | kwargs["stopbits"] = int(eval( 293 | self.config.get('Modbus Serial', "stopbits"))) 294 | kwargs["xonxoff"] = bool(eval( 295 | self.config.get('Modbus Serial', "xonxoff"))) 296 | kwargs["rtscts"] = bool(eval( 297 | self.config.get('Modbus Serial', "rtscts"))) 298 | kwargs["dsrdtr"] = bool(eval( 299 | self.config.get('Modbus Serial', "dsrdtr"))) 300 | kwargs["writetimeout"] = int(eval( 301 | self.config.get('Modbus Serial', "writetimeout"))) 302 | kwargs["timeout"] = bool(eval( 303 | self.config.get('Modbus Serial', "timeout"))) 304 | elif self.active_server == 'tcp': 305 | kwargs['address'] = self.config.get('Modbus Tcp', 'ip') 306 | if not self.modbus_device: 307 | create_new = True 308 | else: 309 | if self.modbus_device.server_type == self.active_server: 310 | 311 | if str(self.modbus_device.port) != str(self.port.text): 312 | create_new = True 313 | if self._serial_settings_changed: 314 | create_new = True 315 | else: 316 | create_new = True 317 | if create_new: 318 | 319 | self.modbus_device = ModbusSimu(server=self.active_server, 320 | port=self.port.text, 321 | **kwargs 322 | ) 323 | if self.slave is None: 324 | 325 | adapter = ListAdapter( 326 | data=[], 327 | cls=ListItemButton, 328 | selection_mode='single' 329 | ) 330 | self.slave = ListView(adapter=adapter) 331 | 332 | self._serial_settings_changed = False 333 | elif self.active_server == "rtu": 334 | if not USE_PYMODBUS: 335 | self.modbus_device._serial.open() 336 | 337 | def start_server(self, btn): 338 | if btn.state == "down": 339 | try: 340 | self._start_server() 341 | except SerialException as e: 342 | btn.state = "normal" 343 | self.show_error("Error in opening Serial port: %s" % e) 344 | return 345 | btn.text = "Stop" 346 | else: 347 | self._stop_server() 348 | btn.text = "Start" 349 | 350 | def _start_server(self): 351 | self._create_modbus_device() 352 | 353 | self.modbus_device.start() 354 | self.server_running = True 355 | self.interface_settings.disabled = True 356 | self.interfaces.disabled = True 357 | self.slave_pane.disabled = False 358 | if len(self.slave_list.adapter.selection): 359 | self.data_model_loc.disabled = False 360 | if self.simulating: 361 | self._simulate() 362 | 363 | def _stop_server(self): 364 | self.simulating = False 365 | self._simulate() 366 | self.modbus_device.stop() 367 | self.server_running = False 368 | self.interface_settings.disabled = False 369 | self.interfaces.disabled = False 370 | self.slave_pane.disabled = True 371 | self.data_model_loc.disabled = True 372 | 373 | def update_tcp_connection_info(self, checkbox, value): 374 | self.active_server = "tcp" 375 | if value: 376 | self.interface_settings.current = checkbox 377 | if self.last_active_port['tcp'] == "": 378 | self.last_active_port['tcp'] = 5440 379 | self.port.text = self.last_active_port['tcp'] 380 | self._restore() 381 | else: 382 | self.last_active_port['tcp'] = self.port.text 383 | self._backup() 384 | 385 | def update_serial_connection_info(self, checkbox, value): 386 | self.active_server = "rtu" 387 | if value: 388 | self.interface_settings.current = checkbox 389 | if self.last_active_port['serial'] == "": 390 | self.last_active_port['serial'] = DEFAULT_SERIAL_PORT 391 | self.port.text = self.last_active_port['serial'] 392 | self._restore() 393 | else: 394 | self.last_active_port['serial'] = self.port.text 395 | self._backup() 396 | 397 | def show_error(self, e): 398 | self.info_label.text = str(e) 399 | self.anim = Animation(top=190.0, opacity=1, d=2, t='in_back') +\ 400 | Animation(top=190.0, d=3) +\ 401 | Animation(top=0, opacity=0, d=2) 402 | self.anim.start(self.info_label) 403 | 404 | def add_slaves(self, *args): 405 | selected = self.slave_list.adapter.selection 406 | data = self.slave_list.adapter.data 407 | ret = self._process_slave_data(data) 408 | self._add_slaves(selected, data, ret) 409 | 410 | def _add_slaves(self, selected, data, ret): 411 | if ret[0]: 412 | start_slave_add, slave_count = ret[1:] 413 | else: 414 | return 415 | 416 | for slave_to_add in xrange(start_slave_add, 417 | start_slave_add + slave_count): 418 | if str(slave_to_add) in self.data_map: 419 | return 420 | self.data_map[str(slave_to_add)] = { 421 | "coils": { 422 | 'data': {}, 423 | 'item_strings': [], 424 | "instance": self.data_model_coil, 425 | "dirty": False 426 | }, 427 | "discrete_inputs": { 428 | 'data': {}, 429 | 'item_strings': [], 430 | "instance": self.data_model_discrete_inputs, 431 | "dirty": False 432 | }, 433 | "input_registers": { 434 | 'data': {}, 435 | 'item_strings': [], 436 | "instance": self.data_model_input_registers, 437 | "dirty": False 438 | }, 439 | "holding_registers": { 440 | 'data': {}, 441 | 'item_strings': [], 442 | "instance": self.data_model_holding_registers, 443 | "dirty": False 444 | } 445 | } 446 | self.modbus_device.add_slave(slave_to_add) 447 | for block_name, block_type in BLOCK_TYPES.items(): 448 | self.modbus_device.add_block(slave_to_add, 449 | block_name, block_type, 450 | self.block_start, 451 | self.block_size) 452 | 453 | data.append(str(slave_to_add)) 454 | self.slave_list.adapter.data = data 455 | self.slave_list._trigger_reset_populate() 456 | 457 | for item in selected: 458 | index = self.slave_list.adapter.data.index(item.text) 459 | if not self.slave_list.adapter.get_view(index).is_selected: 460 | self.slave_list.adapter.get_view(index).trigger_action( 461 | duration=0 462 | ) 463 | self.slave_start_add.text = str(start_slave_add + slave_count) 464 | self.slave_end_add.text = self.slave_start_add.text 465 | self.slave_count.text = "1" 466 | 467 | def _process_slave_data(self, data): 468 | success = True 469 | data = sorted(data, key=int) 470 | # last_slave = 1 if not len(data) else data[-1] 471 | starting_address = int(self.slave_start_add.text) 472 | end_address = int(self.slave_end_add.text) 473 | if end_address < starting_address: 474 | end_address = starting_address 475 | try: 476 | slave_count = int(self.slave_count.text) 477 | except ValueError: 478 | slave_count = 1 479 | 480 | if str(starting_address) in data: 481 | self.show_error("slave already present (%s)" % starting_address) 482 | success = False 483 | return [success] 484 | if starting_address < 1: 485 | self.show_error("slave address (%s)" 486 | " should be greater than 0 "% starting_address) 487 | success = False 488 | return [success] 489 | if starting_address > 247: 490 | self.show_error("slave address (%s)" 491 | " beyond supported modbus slave " 492 | "device address (247)" % starting_address) 493 | success = False 494 | return [success] 495 | 496 | size = (end_address - starting_address) + 1 497 | size = slave_count if slave_count > size else size 498 | 499 | if (size + starting_address) > 247: 500 | self.show_error("address range (%s) beyond " 501 | "allowed modbus slave " 502 | "devices(247)" % (size + starting_address)) 503 | success = False 504 | return [success] 505 | self.slave_end_add.text = str(starting_address + size - 1) 506 | self.slave_count.text = str(size) 507 | return success, starting_address, size 508 | 509 | def delete_slaves(self, *args): 510 | selected = self.slave_list.adapter.selection 511 | slave = self.active_slave 512 | ct = self.data_models.current_tab 513 | for item in selected: 514 | self.modbus_device.remove_slave(int(item.text)) 515 | self.slave_list.adapter.data.remove(item.text) 516 | self.slave_list._trigger_reset_populate() 517 | ct.content.clear_widgets(make_dirty=True) 518 | if self.simulating: 519 | self.simulating = False 520 | self.restart_simu = True 521 | self._simulate() 522 | self.data_map.pop(slave) 523 | 524 | def update_data_models(self, *args): 525 | active = self.active_slave 526 | tab = self.data_models.current_tab 527 | count = int(self.data_count.text) 528 | value = {} 529 | for i in xrange(count): 530 | _value = {'value': 1} 531 | if tab in ['input_registers', 'holding_registers']: 532 | _value['formatter'] = 'uint16' 533 | 534 | value[i] = _value 535 | 536 | self._update_data_models(active, tab, value) 537 | 538 | def _update_data_models(self, active, tab, value): 539 | ct = tab 540 | current_tab = MAP[ct.text] 541 | 542 | ct.content.update_view() 543 | _data = self.data_map[active][current_tab] 544 | registers = sum( 545 | map( 546 | lambda val: int( 547 | ''.join(list(filter( 548 | str.isdigit, str(val.get('formatter', '16'))))) 549 | ), _data['data'].values()))/16 550 | 551 | # Old schema 552 | if isinstance(value, list): 553 | _new_value = list(value) 554 | value = {} 555 | for index, v in enumerate(_new_value): 556 | if not isinstance(v, dict): 557 | value[index] = {'value': v} 558 | if current_tab in ['input_registers', 'holding_registers']: 559 | value[index]['formatter'] = 'uint16' 560 | if registers+len(value) <= self.block_size: 561 | list_data, item_strings = ct.content.add_data(value) 562 | _data['item_strings'].extend(item_strings) 563 | _data['item_strings'] = list(set(_data['item_strings'])) 564 | _data['data'].update(list_data) 565 | self.update_backend(int(active), current_tab, list_data) 566 | else: 567 | msg = ("OutOfModbusBlockError: address %s" 568 | " is out of block size %s" % (len(value), 569 | self.block_size)) 570 | self.show_error(msg) 571 | 572 | def sync_data_callback(self, blockname, data): 573 | ct = self.data_models.current_tab 574 | current_tab = MAP[ct.text] 575 | if blockname != current_tab: 576 | current_tab = blockname 577 | try: 578 | _data = self.data_map[self.active_slave][current_tab] 579 | _data['data'].update(data) 580 | for k, v in data.items(): 581 | # v = v if not isinstance(v, dict) else v['value'] 582 | if blockname in ['holding_registers', 'input_registers']: 583 | self.modbus_device.encode( 584 | int(self.active_slave), 585 | current_tab, 586 | k, 587 | float(v['value']), 588 | v['formatter'] 589 | ) 590 | else: 591 | # v = dict(value=int(v)) 592 | if not isinstance(v, dict): 593 | v = dict(value=v) 594 | self.modbus_device.set_values(int(self.active_slave), 595 | current_tab, 596 | k, v.get('value')) 597 | except KeyError: 598 | pass 599 | except struct.error: 600 | self.show_error("Invalid value supplied , Check the formatter!") 601 | 602 | def sync_formatter_callback(self, blockname, data, old_formatter): 603 | ct = self.data_models.current_tab 604 | current_tab = MAP[ct.text] 605 | if blockname != current_tab: 606 | current_tab = blockname 607 | try: 608 | _data = self.data_map[self.active_slave][current_tab] 609 | _updated = {} 610 | current = list(data.keys()) 611 | for k in current: 612 | old_wc = int(''.join(list( 613 | filter(str.isdigit, str(old_formatter)) 614 | )))/16 615 | new_wc = int(''.join(list( 616 | filter(str.isdigit, data[k].get('formatter')) 617 | )))/16 618 | new_val, count = self.modbus_device.decode( 619 | int(self.active_slave), 620 | current_tab, k, data[k]['formatter'] 621 | ) 622 | data[k]['value'] = new_val 623 | _updated['offset'] = k 624 | _updated['count'] = count 625 | if old_wc > new_wc: 626 | missing = self.modbus_device.get_values( 627 | int(self.active_slave), 628 | current_tab, int(k) + new_wc, 629 | old_wc-new_wc 630 | ) 631 | for i, val in enumerate(missing): 632 | o = int(k) + new_wc + i 633 | o = int(o) 634 | if not isinstance(k, int): 635 | o = str(o) 636 | data[o] = {'value': val, 'formatter': 'uint16'} 637 | _data['data'].update(data) 638 | _data['data'] = dict(ct.content.update_registers(_data['data'], 639 | _updated) 640 | ) 641 | 642 | except KeyError: 643 | pass 644 | 645 | def delete_data_entry(self, *args): 646 | ct = self.data_models.current_tab 647 | current_tab = MAP[ct.text] 648 | _data = self.data_map[self.active_slave][current_tab] 649 | item_strings = _data['item_strings'] 650 | deleted, data = ct.content.delete_data(item_strings) 651 | dm = _data['data'] 652 | for index in deleted: 653 | dm.pop(index, None) 654 | 655 | if deleted: 656 | self.update_backend(int(self.active_slave), current_tab, data) 657 | msg = ("Deleting individual modbus register/discrete_inputs/coils " 658 | "is not supported. The data is removed from GUI and " 659 | "the corresponding value is updated to '0' in backend . ") 660 | self.show_error(msg) 661 | 662 | def select_slave(self, adapter): 663 | ct = self.data_models.current_tab 664 | if len(adapter.selection) != 1: 665 | # Multiple selection - No Data Update 666 | ct.content.clear_widgets(make_dirty=True) 667 | if self.simulating: 668 | self.simulating = False 669 | self.restart_simu = True 670 | self._simulate() 671 | self.data_model_loc.disabled = True 672 | self.active_slave = None 673 | 674 | else: 675 | self.data_model_loc.disabled = False 676 | if self.restart_simu: 677 | self.simulating = True 678 | self.restart_simu = False 679 | self._simulate() 680 | self.active_slave = self.slave_list.adapter.selection[0].text 681 | self.refresh() 682 | 683 | def refresh(self): 684 | for child in self.data_models.tab_list: 685 | dm = self.data_map[self.active_slave][MAP[child.text]]['data'] 686 | child.content.refresh(dm) 687 | 688 | def update_backend(self, slave_id, blockname, new_data): 689 | self.modbus_device.remove_block(slave_id, blockname) 690 | self.modbus_device.add_block(slave_id, blockname, 691 | BLOCK_TYPES[blockname], 0, 692 | self.block_size) 693 | for k, v in new_data.items(): 694 | if blockname in ['holding_registers', 'input_registers']: 695 | self.modbus_device.encode( 696 | slave_id, 697 | blockname, 698 | k, 699 | float(v['value']), 700 | v['formatter'] 701 | ) 702 | else: 703 | self.modbus_device.set_values(slave_id, blockname, 704 | k, int(v['value'])) 705 | 706 | def change_simulation_settings(self, **kwargs): 707 | self.data_model_coil.reinit(**kwargs) 708 | self.data_model_discrete_inputs.reinit(**kwargs) 709 | self.data_model_input_registers.reinit(**kwargs) 710 | self.data_model_holding_registers.reinit(**kwargs) 711 | 712 | def change_datamodel_settings(self, key, value): 713 | if "max" in key: 714 | data = {"maxval": float(value)} 715 | else: 716 | data = {"minval": float(value)} 717 | 718 | if "bin" in key: 719 | self.data_model_coil.reinit(**data) 720 | self.data_model_discrete_inputs.reinit(**data) 721 | else: 722 | self.data_model_input_registers.reinit(**data) 723 | self.data_model_holding_registers.reinit(**data) 724 | 725 | def start_stop_simulation(self, btn): 726 | if btn.state == "down": 727 | self.simulating = True 728 | self.reset_sim_btn.disabled = True 729 | else: 730 | self.simulating = False 731 | self.reset_sim_btn.disabled = False 732 | if self.restart_simu: 733 | self.restart_simu = False 734 | self._simulate() 735 | 736 | def _simulate(self): 737 | self.data_model_coil.start_stop_simulation(self.simulating) 738 | self.data_model_discrete_inputs.start_stop_simulation(self.simulating) 739 | self.data_model_input_registers.start_stop_simulation(self.simulating) 740 | self.data_model_holding_registers.start_stop_simulation( 741 | self.simulating) 742 | 743 | def reset_simulation(self, *args): 744 | if not self.simulating: 745 | self.data_model_coil.reset_block_values() 746 | self.data_model_discrete_inputs.reset_block_values() 747 | self.data_model_input_registers.reset_block_values() 748 | self.data_model_holding_registers.reset_block_values() 749 | 750 | def _sync_modbus_block_values(self): 751 | """ 752 | track external changes in modbus block values and sync GUI 753 | ToDo: 754 | A better way to update GUI when simulation is on going !! 755 | """ 756 | if not self.simulating: 757 | if self.active_slave: 758 | _data_map = self.data_map[self.active_slave] 759 | for block_name, value in _data_map.items(): 760 | updated = {} 761 | for k, v in value['data'].items(): 762 | if block_name in ['input_registers', 763 | 'holding_registers']: 764 | actual_data, count = self.modbus_device.decode( 765 | int(self.active_slave), block_name, k, 766 | v['formatter'] 767 | ) 768 | else: 769 | actual_data = self.modbus_device.get_values( 770 | int(self.active_slave), 771 | block_name, 772 | int(k), 773 | 774 | ) 775 | actual_data = actual_data[0] 776 | try: 777 | if actual_data != float(v['value']): 778 | v['value'] = actual_data 779 | updated[k] = v 780 | except TypeError: 781 | pass 782 | if updated: 783 | value['data'].update(updated) 784 | self.refresh() 785 | 786 | def _backup(self): 787 | if self.slave is not None: 788 | self.slave.adapter.data = self.slave_list.adapter.data 789 | self._slave_misc[self.active_server] = [ 790 | self.slave_start_add.text, 791 | self.slave_end_add.text, 792 | self.slave_count.text 793 | ] 794 | 795 | def _restore(self): 796 | if self.slave is None: 797 | 798 | adapter = ListAdapter( 799 | data=[], 800 | cls=ListItemButton, 801 | selection_mode='single' 802 | ) 803 | self.slave = ListView(adapter=adapter) 804 | self.slave_list.adapter.data = self.slave.adapter.data 805 | (self.slave_start_add.text, 806 | self.slave_end_add.text, 807 | self.slave_count.text) = self._slave_misc[self.active_server] 808 | self.slave_list._trigger_reset_populate() 809 | 810 | def save_state(self): 811 | with open(SLAVES_FILE, 'w') as f: 812 | slave = [int(slave_no) for slave_no in self.slave_list.adapter.data] 813 | slaves_memory = [] 814 | for slaves, mem in self.data_map.items(): 815 | for name, value in mem.items(): 816 | if len(value['data']) != 0: 817 | slaves_memory.append((slaves, name, 818 | value['data'] 819 | )) 820 | 821 | dump(dict( 822 | slaves_list=slave, active_server=self.active_server, 823 | port=self.port.text, slaves_memory=slaves_memory 824 | ), f, indent=4) 825 | 826 | def load_state(self): 827 | if not bool(eval(self.config.get("State", "load state"))) or \ 828 | not os.path.isfile(SLAVES_FILE): 829 | return 830 | 831 | with open(SLAVES_FILE, 'r') as f: 832 | try: 833 | data = load(f) 834 | except ValueError as e: 835 | self.show_error( 836 | "LoadError: Failed to load previous simulation state : %s " 837 | % e.message 838 | ) 839 | return 840 | 841 | if ('active_server' not in data 842 | or 'port' not in data 843 | or 'slaves_list' not in data 844 | or 'slaves_memory' not in data): 845 | self.show_error("LoadError: Failed to load previous " 846 | "simulation state : JSON Key Missing") 847 | return 848 | 849 | slaves_list = data['slaves_list'] 850 | if not len(slaves_list): 851 | return 852 | 853 | if data['active_server'] == 'tcp': 854 | self.tcp.active = True 855 | self.serial.active = False 856 | self.interface_settings.current = self.tcp 857 | else: 858 | self.tcp.active = False 859 | self.serial.active = True 860 | self.interface_settings.current = self.serial 861 | 862 | self.active_server = data['active_server'] 863 | self.port.text = data['port'] 864 | self.word_order = self.config.get("Modbus Protocol", "word order") 865 | self.byte_order = self.config.get("Modbus Protocol", "byte order") 866 | self._create_modbus_device() 867 | 868 | start_slave = 0 869 | temp_list = [] 870 | slave_count = 1 871 | for first, second in zip(slaves_list[:-1], slaves_list[1:]): 872 | if first+1 == second: 873 | slave_count += 1 874 | else: 875 | temp_list.append((slaves_list[start_slave], slave_count)) 876 | start_slave += slave_count 877 | slave_count = 1 878 | temp_list.append((slaves_list[start_slave], slave_count)) 879 | 880 | for start_slave, slave_count in temp_list: 881 | self._add_slaves( 882 | self.slave_list.adapter.selection, 883 | self.slave_list.adapter.data, 884 | (True, start_slave, slave_count) 885 | ) 886 | 887 | memory_map = { 888 | 'coils': self.data_models.tab_list[3], 889 | 'discrete_inputs': self.data_models.tab_list[2], 890 | 'input_registers': self.data_models.tab_list[1], 891 | 'holding_registers': self.data_models.tab_list[0] 892 | } 893 | slaves_memory = data['slaves_memory'] 894 | for slave_memory in slaves_memory: 895 | active_slave, memory_type, memory_data = slave_memory 896 | _data = self.data_map[active_slave][memory_type] 897 | _data['data'].update(memory_data) 898 | _data['item_strings'] = list(sorted(memory_data.keys())) 899 | self.update_backend(int(active_slave), memory_type, memory_data) 900 | # self._update_data_models(active_slave, 901 | # memory_map[memory_type], 902 | # memory_data) 903 | 904 | 905 | setting_panel = """ 906 | [ 907 | { 908 | "type": "title", 909 | "title": "Modbus TCP Settings" 910 | }, 911 | { 912 | "type": "string", 913 | "title": "IP", 914 | "desc": "Modbus Server IP address", 915 | "section": "Modbus Tcp", 916 | "key": "IP" 917 | }, 918 | { 919 | "type": "title", 920 | "title": "Modbus Serial Settings" 921 | }, 922 | { 923 | "type": "numeric", 924 | "title": "baudrate", 925 | "desc": "Modbus Serial baudrate", 926 | "section": "Modbus Serial", 927 | "key": "baudrate" 928 | }, 929 | { 930 | "type": "options", 931 | "title": "bytesize", 932 | "desc": "Modbus Serial bytesize", 933 | "section": "Modbus Serial", 934 | "key": "bytesize", 935 | "options": ["5", "6", "7", "8"] 936 | 937 | }, 938 | { 939 | "type": "options", 940 | "title": "parity", 941 | "desc": "Modbus Serial parity", 942 | "section": "Modbus Serial", 943 | "key": "parity", 944 | "options": ["N", "E", "O", "M", "S"] 945 | }, 946 | { 947 | "type": "options", 948 | "title": "stopbits", 949 | "desc": "Modbus Serial stopbits", 950 | "section": "Modbus Serial", 951 | "key": "stopbits", 952 | "options": ["1", "1.5", "2"] 953 | 954 | }, 955 | { 956 | "type": "bool", 957 | "title": "xonxoff", 958 | "desc": "Modbus Serial xonxoff", 959 | "section": "Modbus Serial", 960 | "key": "xonxoff" 961 | }, 962 | { 963 | "type": "bool", 964 | "title": "rtscts", 965 | "desc": "Modbus Serial rtscts", 966 | "section": "Modbus Serial", 967 | "key": "rtscts" 968 | }, 969 | { 970 | "type": "bool", 971 | "title": "dsrdtr", 972 | "desc": "Modbus Serial dsrdtr", 973 | "section": "Modbus Serial", 974 | "key": "dsrdtr" 975 | }, 976 | { 977 | "type": "numeric", 978 | "title": "timeout", 979 | "desc": "Modbus Serial timeout", 980 | "section": "Modbus Serial", 981 | "key": "timeout" 982 | }, 983 | { 984 | "type": "numeric", 985 | "title": "write timeout", 986 | "desc": "Modbus Serial write timeout", 987 | "section": "Modbus Serial", 988 | "key": "writetimeout" 989 | }, 990 | { 991 | "type": "title", 992 | "title": "Modbus Protocol Settings" 993 | }, 994 | { 995 | "type": "numeric", 996 | "title": "Block Start", 997 | "desc": "Modbus Block Start index", 998 | "section": "Modbus Protocol", 999 | "key": "Block Start" 1000 | }, 1001 | { "type": "options", 1002 | "title": "Byte Order", 1003 | "desc": "Modbus Byte Order", 1004 | "section": "Modbus Protocol", 1005 | "key": "Byte Order", 1006 | "options": ["big", "little"] 1007 | }, 1008 | { "type": "options", 1009 | "title": "Word Order", 1010 | "desc": "Modbus Word Order", 1011 | "section": "Modbus Protocol", 1012 | "key": "Word Order", 1013 | "options": ["big", "little"] 1014 | }, 1015 | { "type": "numeric", 1016 | "title": "Block Size", 1017 | "desc": "Modbus Block Size for various registers/coils/inputs", 1018 | "section": "Modbus Protocol", 1019 | "key": "Block Size" 1020 | }, 1021 | { 1022 | "type": "numeric_range", 1023 | "title": "Coil/Discrete Input MinValue", 1024 | "desc": "Minimum value a coil/discrete input can hold (0).An invalid value will be discarded unless Override flag is set", 1025 | "section": "Modbus Protocol", 1026 | "key": "bin min", 1027 | "range": [0,0] 1028 | }, 1029 | { 1030 | "type": "numeric_range", 1031 | "title": "Coil/Discrete Input MaxValue", 1032 | "desc": "Maximum value a coil/discrete input can hold (1). An invalid value will be discarded unless Override flag is set", 1033 | "section": "Modbus Protocol", 1034 | "key": "bin max", 1035 | "range": [1,1] 1036 | 1037 | }, 1038 | { 1039 | "type": "numeric_range", 1040 | "title": "Holding/Input register MinValue", 1041 | "desc": "Minimum value a registers can hold (0).An invalid value will be discarded unless Override flag is set", 1042 | "section": "Modbus Protocol", 1043 | "key": "reg min", 1044 | "range": [0,65535] 1045 | }, 1046 | { 1047 | "type": "numeric_range", 1048 | "title": "Holding/Input register MaxValue", 1049 | "desc": "Maximum value a register input can hold (65535). An invalid value will be discarded unless Override flag is set", 1050 | "section": "Modbus Protocol", 1051 | "key": "reg max", 1052 | "range": [0,65535] 1053 | }, 1054 | { 1055 | "type": "title", 1056 | "title": "Logging" 1057 | }, 1058 | { "type": "bool", 1059 | "title": "Modbus Master Logging Control", 1060 | "desc": " Enable/Disable Modbus Logging (console/file)", 1061 | "section": "Logging", 1062 | "key": "logging" 1063 | }, 1064 | { "type": "bool", 1065 | "title": "Modbus Console Logging", 1066 | "desc": " Enable/Disable Modbus Console Logging", 1067 | "section": "Logging", 1068 | "key": "console logging" 1069 | }, 1070 | { 1071 | "type": "options", 1072 | "title": "Modbus console log levels", 1073 | "desc": "Log levels for modbus_tk", 1074 | "section": "Logging", 1075 | "key": "console log level", 1076 | "options": ["INFO", "WARNING", "DEBUG", "CRITICAL"] 1077 | }, 1078 | { "type": "bool", 1079 | "title": "Modbus File Logging", 1080 | "desc": " Enable/Disable Modbus File Logging", 1081 | "section": "Logging", 1082 | "key": "file logging" 1083 | }, 1084 | { 1085 | "type": "options", 1086 | "title": "Modbus file log levels", 1087 | "desc": "file Log levels for modbus_tk", 1088 | "section": "Logging", 1089 | "key": "file log level", 1090 | "options": ["INFO", "WARNING", "DEBUG", "CRITICAL"] 1091 | }, 1092 | 1093 | { 1094 | "type": "path", 1095 | "title": "Modbus log file", 1096 | "desc": "Modbus log file (changes takes place only after next start of app)", 1097 | "section": "Logging", 1098 | "key": "log file" 1099 | }, 1100 | { 1101 | "type": "title", 1102 | "title": "Simulation" 1103 | }, 1104 | { 1105 | "type": "numeric", 1106 | "title": "Time interval", 1107 | "desc": "When simulation is enabled, data is changed for every 'n' seconds defined here", 1108 | "section": "Simulation", 1109 | "key": "time interval" 1110 | }, 1111 | { 1112 | "type": "title", 1113 | "title": "State" 1114 | }, 1115 | { 1116 | "type": "bool", 1117 | "title": "Load State", 1118 | "desc": "Whether the previous state should be loaded or not, if not the original state is loaded", 1119 | "section": "State", 1120 | "key": "load state" 1121 | } 1122 | 1123 | ] 1124 | """ 1125 | 1126 | 1127 | class ModbusSimuApp(App): 1128 | '''The kivy App that runs the main root. All we do is build a Gui 1129 | widget into the root.''' 1130 | gui = None 1131 | title = "Modbus Simulator" 1132 | settings_cls = None 1133 | use_kivy_settings = True 1134 | settings_cls = SettingsWithSidebar 1135 | 1136 | def build(self): 1137 | self.gui = Gui( 1138 | modbus_log=os.path.join(self.user_data_dir, 'modbus.log') 1139 | ) 1140 | self.gui.load_state() 1141 | return self.gui 1142 | 1143 | def on_pause(self): 1144 | return True 1145 | 1146 | def on_stop(self): 1147 | if self.gui.server_running: 1148 | if self.gui.simulating: 1149 | self.gui.simulating = False 1150 | self.gui._simulate() 1151 | self.gui.modbus_device.stop() 1152 | self.gui.sync_modbus_thread.cancel() 1153 | self.config.write() 1154 | self.gui.save_state() 1155 | 1156 | def show_settings(self, btn): 1157 | self.open_settings() 1158 | 1159 | def build_config(self, config): 1160 | config.add_section('Modbus Tcp') 1161 | config.add_section('Modbus Protocol') 1162 | config.add_section('Modbus Serial') 1163 | config.set('Modbus Tcp', "ip", '127.0.0.1') 1164 | config.set('Modbus Protocol', "block start", 0) 1165 | config.set('Modbus Protocol', "block size", 100) 1166 | config.set('Modbus Protocol', "byte order", 'big') 1167 | config.set('Modbus Protocol', "word order", 'big') 1168 | config.set('Modbus Protocol', "bin min", 0) 1169 | config.set('Modbus Protocol', "bin max", 1) 1170 | config.set('Modbus Protocol', "reg min", 0) 1171 | config.set('Modbus Protocol', "reg max", 65535) 1172 | config.set('Modbus Serial', "baudrate", 9600) 1173 | config.set('Modbus Serial', "bytesize", "8") 1174 | config.set('Modbus Serial', "parity", 'N') 1175 | config.set('Modbus Serial', "stopbits", "1") 1176 | config.set('Modbus Serial', "xonxoff", 0) 1177 | config.set('Modbus Serial', "rtscts", 0) 1178 | config.set('Modbus Serial', "dsrdtr", 0) 1179 | config.set('Modbus Serial', "writetimeout", 2) 1180 | config.set('Modbus Serial', "timeout", 2) 1181 | 1182 | config.add_section('Logging') 1183 | config.set('Logging', "log file", os.path.join(self.user_data_dir, 1184 | 'modbus.log')) 1185 | 1186 | config.set('Logging', "logging", 1) 1187 | config.set('Logging', "console logging", 1) 1188 | config.set('Logging', "console log level", "DEBUG") 1189 | config.set('Logging', "file log level", "DEBUG") 1190 | config.set('Logging', "file logging", 1) 1191 | 1192 | config.add_section('Simulation') 1193 | config.set('Simulation', 'time interval', 1) 1194 | 1195 | config.add_section('State') 1196 | config.set('State', 'load state', 1) 1197 | 1198 | def build_settings(self, settings): 1199 | settings.register_type("numeric_range", SettingIntegerWithRange) 1200 | settings.add_json_panel('Modbus Settings', self.config, 1201 | data=setting_panel) 1202 | 1203 | def on_config_change(self, config, section, key, value): 1204 | if config is not self.config: 1205 | return 1206 | token = section, key 1207 | if token == ("Simulation", "time interval"): 1208 | self.gui.change_simulation_settings(time_interval=eval(value)) 1209 | if section == "Modbus Protocol" and key in ("bin max", 1210 | "bin min", "reg max", 1211 | "reg min", "override", 1212 | "word order", "byte order"): 1213 | self.gui.change_datamodel_settings(key, value) 1214 | if section == "Modbus Protocol" and key == "block start": 1215 | self.gui.block_start = int(value) 1216 | if section == "Modbus Protocol" and key == "block size": 1217 | self.gui.block_size = int(value) 1218 | 1219 | def close_settings(self, *args): 1220 | super(ModbusSimuApp, self).close_settings() 1221 | 1222 | 1223 | def run(): 1224 | ModbusSimuApp().run() 1225 | -------------------------------------------------------------------------------- /modbus_simulator/ui/settings.py: -------------------------------------------------------------------------------- 1 | from kivy.uix.settings import SettingItem 2 | from kivy.properties import (ListProperty, 3 | ObjectProperty) 4 | from kivy.compat import text_type 5 | from kivy.lang import Builder 6 | 7 | kv = ''': 8 | textinput: textinput 9 | 10 | ToggleButton: 11 | id: override 12 | text: 'Override' 13 | pos: root.pos 14 | on_release: root.override(*args) 15 | size_hint: (1, .5) 16 | pos_hint:{'center_x': .5, 'y': 0.25} 17 | TextInput: 18 | id:textinput 19 | text: root.value or '' 20 | pos: root.pos 21 | font_size: "15sp" 22 | multiline: False 23 | on_text_validate: root._validate(*args) 24 | size_hint: (1, .5) 25 | pos_hint:{'center_x': .5, 'y': 0.25} 26 | 27 | 28 | ''' 29 | Builder.load_string(kv) 30 | 31 | 32 | class SettingIntegerWithRange(SettingItem): 33 | '''Implementation of a numeric setting with range on top of a 34 | :class:`SettingNumeric`. It is visualized with a 35 | :class:`~kivy.uix.label.Label` widget that, when 36 | clicked, will open a :class:`~kivy.uix.popup.Popup` with a 37 | :class:`~kivy.uix.textinput.Textinput` so the user can enter a custom 38 | value. 39 | ''' 40 | override_values = ListProperty(['0', '1']) 41 | # override = ObjectProperty(None) 42 | _override = False 43 | 44 | textinput = ObjectProperty(None) 45 | default = {} 46 | 47 | def __init__(self, **kwargs): 48 | self._range = kwargs.get("range", None) 49 | self.default_key = kwargs.get("default_key", "min") 50 | 51 | if self._range: 52 | if not isinstance(self._range, (list, tuple)): 53 | self._range = None 54 | else: 55 | if len(self._range) > 1: 56 | 57 | self.minval = min(self._range) 58 | self.maxval = max(self._range) 59 | if self.default_key == "min": 60 | self.default[self.default_key] = self.minval 61 | else: 62 | self.default[self.default_key] = self.maxval 63 | else: 64 | self._range = None 65 | self.default[self.default_key] = 0 66 | kwargs.pop("range", None) 67 | kwargs.pop("default", None) 68 | super(SettingIntegerWithRange, self).__init__(**kwargs) 69 | 70 | def _dismiss(self, *largs): 71 | if self.textinput: 72 | self.textinput.focus = False 73 | 74 | def override(self, btn): 75 | if btn.state == "down": 76 | self._override = True 77 | else: 78 | self._override = False 79 | 80 | def _validate(self, instance): 81 | if self._range: 82 | self._dismiss() 83 | try: 84 | value = self.textinput.text.strip() 85 | value = text_type(float(value)) 86 | except ValueError: 87 | self.textinput.text = self.value 88 | return 89 | if not self._override: 90 | if self.minval <= float(value) <= self.maxval: 91 | self.value = value 92 | else: 93 | self.value = str(self.default[self.default_key]) 94 | else: 95 | self.value = str(value) 96 | 97 | self.textinput.text = self.value 98 | 99 | 100 | -------------------------------------------------------------------------------- /modbus_simulator/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals -------------------------------------------------------------------------------- /modbus_simulator/utils/backgroundJob.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import logging 3 | 4 | 5 | class BackgroundJob(threading.Thread): 6 | def __init__(self, name, interval, function): 7 | threading.Thread.__init__(self, name=name) 8 | self._name = name 9 | self._logger = logging.getLogger(__name__) 10 | self.interval = interval 11 | self.simulate_func = function 12 | self.stop_timer = threading.Event() 13 | self.daemon = True 14 | 15 | def run(self): 16 | self._logger.info("Start %s thread" % self._name) 17 | while not self.stop_timer.is_set(): 18 | if not self.stop_timer.is_set(): 19 | self.simulate_func() 20 | self.stop_timer.wait(self.interval) 21 | self._logger.info("Stop %s thread" % self._name) 22 | 23 | def cancel(self): 24 | self.stop_timer.set() 25 | 26 | -------------------------------------------------------------------------------- /modbus_simulator/utils/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from __future__ import absolute_import 3 | 4 | import os 5 | import shutil 6 | import logging 7 | 8 | 9 | def path(name=""): 10 | """ 11 | Retrieve the relative filepath to a supplied name 12 | 13 | """ 14 | if name.startswith(os.path.sep): 15 | return name 16 | actual = os.path.dirname(os.path.abspath(__file__)).split(os.path.sep)[:-3] 17 | if name: 18 | actual.extend(name.split(os.path.sep)) 19 | return os.path.join(os.path.sep, *actual) 20 | 21 | 22 | def from_this_dir(cur_dir, filename=""): 23 | """ 24 | Returns the absolute path to a relative `filename` by joining it with 25 | the absolute dir from `cur_dir`. 26 | 27 | If the filename starts with the os path separator, it is considered an 28 | absolute path and returned as is. 29 | 30 | Args: 31 | cur_dir: The path to a dir whose directory name must be 32 | used as the base. 33 | filename: The relative filename or path which must be joined with 34 | cur_dir. 35 | 36 | """ 37 | if filename.startswith(os.path.sep): 38 | return filename 39 | else: 40 | return os.path.join(os.path.abspath(cur_dir), filename) 41 | 42 | 43 | def from_this_file(cur_file, filename=""): 44 | """ 45 | Returns the absolute path to a relative `filename` by joining it with 46 | the absolute dir from `cur_file`. To get a file in the current script's 47 | directory call `from_this_file(__file__, "filename")`. 48 | 49 | If the filename starts with the os path separator, it is considered an 50 | absolute path and returned as is. 51 | 52 | Args: 53 | cur_file: The path to a file whose directory name must be 54 | used as the base. 55 | filename: The relative filename or path which must be joined to the 56 | cur_file's dirname. 57 | 58 | """ 59 | cur_dir = os.path.dirname(cur_file) 60 | return from_this_dir(cur_dir, filename) 61 | 62 | 63 | def from_cwd(filename=""): 64 | """ 65 | Returns the absolute path to a relative `filename` by joining it with 66 | the current working directory. 67 | 68 | If the filename starts with the os path separator, it is considered an 69 | absolute path and returned as is. 70 | 71 | Args: 72 | filename: The relative filename or path which must be joined with 73 | the current working directory. 74 | 75 | """ 76 | if filename.startswith(os.path.sep): 77 | return filename 78 | else: 79 | return os.path.join(os.getcwd(), filename) 80 | 81 | 82 | def make_dir(name): 83 | """ 84 | mkdir -p if it doesn't exist. 85 | """ 86 | if not os.path.exists(name): 87 | os.makedirs(name) 88 | 89 | 90 | def remove_file(name): 91 | if os.path.exists(name): 92 | os.remove(name) 93 | 94 | 95 | def remove_dir(name): 96 | """ 97 | rm -rf if it exists. 98 | """ 99 | if os.path.exists(name): 100 | shutil.rmtree(name) 101 | 102 | 103 | 104 | class Configuration: 105 | def __init__(self, no_modbus_log=False, no_modbus_console_log=False, 106 | no_modbus_file_log=True, modbus_console_log_level="DEBUG", 107 | modbus_file_log_level="DEBUG", modbus_log=""): 108 | self.no_modbus_log = no_modbus_log 109 | self.no_modbus_console_log = no_modbus_console_log 110 | self.no_modbus_file_log = no_modbus_file_log 111 | self.modbus_console_log_level = modbus_console_log_level 112 | self.modbus_file_log_level = modbus_file_log_level 113 | self.modbus_log = modbus_log 114 | 115 | def to_dict(self): 116 | return vars(self) 117 | 118 | 119 | def configure_modbus_logger(cfg, protocol_logger ="modbus_tk", 120 | recycle_logs=True): 121 | """ 122 | Configure the logger. 123 | 124 | Args: 125 | cfg (Namespace): The PUReST config namespace. 126 | """ 127 | 128 | logger = logging.getLogger(protocol_logger) 129 | if isinstance(cfg, dict): 130 | cfg = Configuration(**cfg) 131 | 132 | if cfg.no_modbus_log: 133 | logger.setLevel(logging.ERROR) 134 | logger.addHandler(logging.NullHandler()) 135 | else: 136 | logger.setLevel(logging.DEBUG) 137 | fmt = ( 138 | "%(asctime)s - %(levelname)s - " 139 | "%(module)s::%(funcName)s @ %(lineno)d - %(message)s" 140 | ) 141 | fmtr = logging.Formatter(fmt) 142 | 143 | if not cfg.no_modbus_console_log: 144 | sh = logging.StreamHandler() 145 | sh.setFormatter(fmtr) 146 | sh.setLevel(cfg.modbus_console_log_level.upper()) 147 | logger.addHandler(sh) 148 | 149 | if not cfg.no_modbus_file_log: 150 | modbus_log = path(cfg.modbus_log) 151 | if recycle_logs: 152 | remove_file(modbus_log) 153 | make_dir(os.path.dirname(modbus_log)) 154 | fh = logging.FileHandler(modbus_log) 155 | fh.setFormatter(fmtr) 156 | fh.setLevel(cfg.modbus_file_log_level.upper()) 157 | logger.addHandler(fh) 158 | 159 | -------------------------------------------------------------------------------- /modbus_simulator/utils/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2018 Riptide IO, Inc. All Rights Reserved. 3 | 4 | """ 5 | #supported block types 6 | COILS = 1 7 | DISCRETE_INPUTS = 2 8 | HOLDING_REGISTERS = 3 9 | ANALOG_INPUTS = 4 10 | 11 | ADDRESS_RANGE = { 12 | COILS: 0, 13 | DISCRETE_INPUTS: 10001, 14 | HOLDING_REGISTERS: 40001, 15 | ANALOG_INPUTS: 30001 16 | 17 | } 18 | 19 | REGISTER_QUERY_FIELDS = {"bit": range(0, 16), 20 | "byteorder": ["big", "little"], 21 | "formatter": ["default", "float1"], 22 | "scaledivisor": 1, 23 | "scalemultiplier": 1, 24 | "wordcount": 1, 25 | "wordorder": ["big", "little"]} 26 | 27 | 28 | BLOCK_TYPES = {"coils": COILS, 29 | "discrete_inputs": DISCRETE_INPUTS, 30 | "holding_registers": HOLDING_REGISTERS, 31 | "input_registers": ANALOG_INPUTS} 32 | MODBUS_TCP_PORT = 5440 -------------------------------------------------------------------------------- /modbus_simulator/utils/modbus.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, unicode_literals 2 | 3 | import logging 4 | import os 5 | 6 | import serial 7 | import struct 8 | from modbus_tk.defines import ( 9 | COILS, DISCRETE_INPUTS, HOLDING_REGISTERS, ANALOG_INPUTS) 10 | from modbus_tk.modbus_rtu import RtuServer, RtuMaster 11 | from modbus_tk.modbus_tcp import TcpServer, TcpMaster 12 | 13 | from modbus_simulator.utils.common import path, make_dir, remove_file 14 | 15 | ADDRESS_RANGE = { 16 | COILS: 0, 17 | DISCRETE_INPUTS: 10001, 18 | HOLDING_REGISTERS: 40001, 19 | ANALOG_INPUTS: 30001 20 | 21 | } 22 | 23 | REGISTER_QUERY_FIELDS = {"bit": range(0, 16), 24 | "byteorder": ["big", "little"], 25 | "formatter": ["default", "float1"], 26 | "scaledivisor": 1, 27 | "scalemultiplier": 1, 28 | "wordcount": 1, 29 | "wordorder": ["big", "little"]} 30 | 31 | SERVERS = { 32 | "tcp": TcpServer, 33 | "rtu": RtuServer 34 | } 35 | 36 | MASTERS = { 37 | "tcp": TcpMaster, 38 | "rtu": RtuMaster 39 | } 40 | 41 | BLOCK_TYPES = {"coils": COILS, 42 | "discrete_inputs": DISCRETE_INPUTS, 43 | "holding_registers": HOLDING_REGISTERS, 44 | "input_registers": ANALOG_INPUTS} 45 | MODBUS_TCP_PORT = 5440 46 | 47 | 48 | class PseudoSerial(object): 49 | def __init__(self, tty_name, **kwargs): 50 | self.ser = serial.Serial() 51 | self.ser.port = tty_name 52 | 53 | self.serial_conf(**kwargs) 54 | self.open() 55 | 56 | def serial_conf(self, **kwargs): 57 | self.ser.baudrate = kwargs.get('baudrate', 9600) 58 | self.ser.bytesize = kwargs.get('bytesize', serial.EIGHTBITS) 59 | self.ser.parity = kwargs.get('parity', serial.PARITY_NONE) 60 | self.ser.stopbits = kwargs.get('stopbits', serial.STOPBITS_ONE) 61 | self.ser.timeout = kwargs.get('timeout', 2) # Non-Block reading 62 | self.ser.xonxoff = kwargs.get('xonxoff', False) # Disable Software Flow Control 63 | self.ser.rtscts = kwargs.get('rtscts', False) # Disable (RTS/CTS) flow Control 64 | self.ser.dsrdtr = kwargs.get('dsrdtr', False) # Disable (DSR/DTR) flow Control 65 | self.ser.writeTimeout = kwargs.get('writetimeout', 2) 66 | 67 | def open(self): 68 | self.ser.open() 69 | self.ser.flushInput() 70 | self.ser.flushOutput() 71 | 72 | def close(self): 73 | self.ser.close() 74 | 75 | def get_serial_object(self): 76 | return self.ser 77 | 78 | 79 | class ModbusSimu(object): 80 | _server_add = () 81 | 82 | def __init__(self, server="tcp", *args, **kwargs): 83 | self._server_type = server 84 | self._port = kwargs.get('port', None) 85 | if server == 'rtu': 86 | tty_name = kwargs['port'] 87 | kwargs.pop('port', None) 88 | self._serial = PseudoSerial(tty_name, **kwargs) 89 | kwargs = {k: v for k, v in kwargs.iteritems() if k == "serial"} 90 | kwargs['serial'] = self._serial.ser 91 | else: 92 | kwargs['port'] = int(kwargs['port']) 93 | self.server = SERVERS.get(server, None)(*args, **kwargs) 94 | self.simulate = kwargs.get('simulate', False) 95 | 96 | @property 97 | def server_type(self): 98 | return self._server_type 99 | 100 | @property 101 | def port(self): 102 | return self._port 103 | 104 | def add_slave(self, slave_id): 105 | self.server.add_slave(slave_id) 106 | 107 | def remove_slave(self, slave_id): 108 | self.server.remove_slave(slave_id) 109 | 110 | def remove_all_slave(self): 111 | self.server.remove_all_slaves() 112 | 113 | def add_block(self, slave_id, block_name, block_type, starting_add, size): 114 | slave = self.server.get_slave(slave_id) 115 | slave.add_block(block_name, block_type, starting_add, size) 116 | 117 | def remove_block(self, slave_id, block_name): 118 | slave = self.server.get_slave(slave_id) 119 | slave.remove_block(block_name) 120 | 121 | def remove_all_blocks(self, slave_id): 122 | slave = self.server.get_slave(slave_id) 123 | slave.remove_all_blocks() 124 | 125 | def set_values(self, slave_id, block_name, address, values): 126 | slave = self.server.get_slave(slave_id) 127 | slave.set_values(block_name, address, values) 128 | 129 | def get_values(self, slave_id, block_name, address, size=1): 130 | slave = self.server.get_slave(slave_id) 131 | return slave.get_values(block_name, address, size) 132 | 133 | def start(self): 134 | self.server.start() 135 | if self._server_type == "tcp": 136 | self._server_add = self.server._sa 137 | 138 | def stop(self): 139 | self.server.stop() 140 | if self._server_type == 'rtu': 141 | self._serial.close() 142 | self._server_add = () 143 | 144 | def get_slaves(self): 145 | if self.server is not None: 146 | return self.server._databank._slaves 147 | 148 | 149 | def swap_bytes(byte_array): 150 | temp = [] 151 | for x in byte_array: 152 | temp.append(float(struct.unpack("H", x))[0])) 153 | return temp 154 | 155 | 156 | def process_words(byte_array): 157 | temp = "" 158 | for x in byte_array: 159 | temp += "%04x" % x 160 | return [int(temp, 16)] 161 | 162 | 163 | def change_word_endianness(words): 164 | pack_str = ">I" if len(words) > 1 else ">H" 165 | unpack_str = " 1 else "= 10001 else address 206 | elif block_name == "input_registers": 207 | return address - 30001 if address >= 30001 else address 208 | else: 209 | return address - 40001 if address >= 40001 else address 210 | 211 | def add_slave(self, slave_id): 212 | self.context[slave_id] = self._add_default_slave_context() 213 | 214 | def remove_slave(self, slave_id): 215 | del self.context[slave_id] 216 | 217 | def remove_all_slave(self): 218 | self.context = ModbusServerContext(single=False) 219 | 220 | def add_block(self, slave_id, block_name, block_type, starting_add, size): 221 | slave = self.get_slave(slave_id) 222 | if not slave.validate(_FX_MAPPER[block_name], starting_add, count=size): 223 | slave.store[_STORE_MAPPER[block_name]].update(size) 224 | else: 225 | log.debug("Block '{}' on slave '{}' already exists".format(block_name, slave_id)) 226 | 227 | def remove_block(self, slave_id, block_name): 228 | slave = self.get_slave(slave_id) 229 | slave.store[_STORE_MAPPER[block_name]].reset() 230 | 231 | def remove_all_blocks(self, slave_id): 232 | slave = self.get_slave(slave_id) 233 | slave.remove_all_blocks() 234 | 235 | def set_values(self, slave_id, block_name, address, values): 236 | values = values if isinstance(values, (list, tuple)) else [values] 237 | slave = self.get_slave(slave_id) 238 | address = self._calc_offset(block_name, address) 239 | if slave.validate(_FX_MAPPER[block_name], address, count=len(values)): 240 | slave.setValues(_FX_MAPPER[block_name], address, values) 241 | 242 | def get_values(self, slave_id, block_name, address, size=1): 243 | slave = self.get_slave(slave_id) 244 | address = self._calc_offset(block_name, address) 245 | if slave.validate(_FX_MAPPER[block_name], address, count=size): 246 | return slave.getValues(_FX_MAPPER[block_name], address, int(size)) 247 | 248 | def get_slave(self, slave_id): 249 | return self.context[slave_id] 250 | 251 | def decode(self, slave_id, block_name, offset, formatter): 252 | count = 1 253 | if '32' in formatter: 254 | count = 2 255 | elif '64' in formatter: 256 | count = 4 257 | values = self.get_values(slave_id, block_name, offset, count) 258 | if values: 259 | decoder = BinaryPayloadDecoder.fromRegisters( 260 | values, byteorder=self.byte_order, wordorder=self.word_order) 261 | values = getattr(decoder, DECODERS.get(formatter))() 262 | return values, count 263 | 264 | def encode(self, slave_id, block_name, offset, value, formatter): 265 | builder = BinaryPayloadBuilder(byteorder=self.byte_order, 266 | wordorder=self.word_order) 267 | add_method = ENCODERS.get(formatter) 268 | if 'int' in add_method: # Temp fix 269 | value = int(value) 270 | getattr(builder, add_method)(value) 271 | payload = builder.to_registers() 272 | return self.set_values(slave_id, block_name, offset, payload) 273 | 274 | def start(self): 275 | if self.dirty: 276 | self.server_thread = ThreadedModbusServer(self.server) 277 | self.server_thread.start() 278 | 279 | def stop(self): 280 | self.server_thread.stop() 281 | self.dirty = True 282 | # if self._server_type == 'rtu': 283 | # self._serial.close() 284 | # self._server_add = () 285 | 286 | def get_slaves(self): 287 | if self.server is not None: 288 | return self.server._databank._slaves 289 | 290 | -------------------------------------------------------------------------------- /modbus_simulator/version.py: -------------------------------------------------------------------------------- 1 | 2 | __VERSION__ = "2.0.0" 3 | -------------------------------------------------------------------------------- /requirements: -------------------------------------------------------------------------------- 1 | Click==7.0 2 | Cython==0.29.2 3 | docutils==0.13.1 4 | Kivy==1.10.1 5 | Kivy-Garden==0.1.4 6 | pygame==1.9.4 7 | pyglet==1.2.4 8 | Pygments==2.1.3 9 | pymodbus==2.1.0 10 | pyserial==3.2.1 11 | requests==2.12.4 12 | six==1.10.0 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | from modbus_simulator.version import __VERSION__ 4 | 5 | from setuptools.command.install import install 6 | import sys 7 | from modbus_simulator.version import __VERSION__ 8 | 9 | 10 | 11 | def install_requires(): 12 | with open('requirements') as reqs: 13 | install_req = [ 14 | line for line in reqs.read().split('\n') 15 | ] 16 | return install_req 17 | 18 | 19 | def readme(): 20 | with open("README.md") as f: 21 | return f.read() 22 | 23 | setup( 24 | name="modbus_simulator", 25 | url="https://github.com/riptideio/modbus-simulator.git", 26 | description="Modbus Simulator uing Kivy, Pymodbus, Modbus-tk", 27 | version=__VERSION__, 28 | long_description=readme(), 29 | keywords="Modbus Simulator", 30 | author="riptideio", 31 | packages=find_packages(), 32 | install_requires=install_requires(), 33 | entry_points={ 34 | 'console_scripts': [ 35 | 'modbus.simu = modbus_simulator.main:_run', 36 | ], 37 | }, 38 | include_package_data=True 39 | ) 40 | -------------------------------------------------------------------------------- /tools/launcher: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo $0 4 | if [[ $1 == "mtk" ]] 5 | then 6 | BACKEND="" 7 | else 8 | BACKEND="-p" 9 | fi 10 | root_dir="$(cd $(dirname $0)/../; pwd)" 11 | simu_dir="$root_dir/modbus_simulator" 12 | export PYTHONPATH=$root_dir 13 | launch_cmd="$simu_dir/main.py $BACKEND" 14 | python $launch_cmd 15 | --------------------------------------------------------------------------------