├── pid-controller-gui ├── __init__.py ├── util.py ├── about.py ├── errorssettings.py ├── miscgraphics.py ├── settings.py ├── graphs.py ├── main.py └── remotecontroller.py ├── requirements.txt ├── img ├── exit.png ├── icon.png ├── info.png ├── eeprom.png ├── error.png ├── refresh.png ├── restore.png ├── LICENSE ├── settings.png ├── play_pause.png ├── set_errors.png ├── refresh_hover.png └── refresh_pressed.png ├── pyinstaller ├── mac │ ├── icon.icns │ ├── README.txt │ └── PID_controller_GUI.spec ├── win │ ├── icon.ico │ ├── README.txt │ └── PID_controller_GUI.spec ├── unix │ ├── README.txt │ └── PID_controller_GUI.spec └── README.md ├── screenshots ├── grid_guide.png ├── mac │ ├── dark_demo_multi.png │ └── light_online_multi.png ├── pid-controller-gui.gif ├── unix │ ├── light_demo_multi.png │ └── dark_lost_connection.png └── win │ ├── dark_online_multi.png │ ├── light_demo_multi.png │ └── light_demo_single.png ├── uml ├── pid-controller-gui.pdf └── pid-controller-gui.xml ├── ISSUES.md ├── pid-controller-server ├── Makefile ├── README.md ├── commandmanager.h ├── main.c └── commandmanager.c ├── CHANGELOG ├── defaultSettings.json ├── LICENSE ├── INSTRUCTIONSET ├── TODO.md ├── .gitignore └── README.md /pid-controller-gui/__init__.py: -------------------------------------------------------------------------------- 1 | """pid-cintroller-gui""" 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | PyQt5 3 | pyqtgraph 4 | qdarkstyle 5 | # PyInstaller 6 | -------------------------------------------------------------------------------- /img/exit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/img/exit.png -------------------------------------------------------------------------------- /img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/img/icon.png -------------------------------------------------------------------------------- /img/info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/img/info.png -------------------------------------------------------------------------------- /img/eeprom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/img/eeprom.png -------------------------------------------------------------------------------- /img/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/img/error.png -------------------------------------------------------------------------------- /img/refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/img/refresh.png -------------------------------------------------------------------------------- /img/restore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/img/restore.png -------------------------------------------------------------------------------- /img/LICENSE: -------------------------------------------------------------------------------- 1 | (C) All icons belongs to their respective authors from https://www.flaticon.com 2 | -------------------------------------------------------------------------------- /img/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/img/settings.png -------------------------------------------------------------------------------- /img/play_pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/img/play_pause.png -------------------------------------------------------------------------------- /img/set_errors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/img/set_errors.png -------------------------------------------------------------------------------- /img/refresh_hover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/img/refresh_hover.png -------------------------------------------------------------------------------- /img/refresh_pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/img/refresh_pressed.png -------------------------------------------------------------------------------- /pyinstaller/mac/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/pyinstaller/mac/icon.icns -------------------------------------------------------------------------------- /pyinstaller/win/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/pyinstaller/win/icon.ico -------------------------------------------------------------------------------- /screenshots/grid_guide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/screenshots/grid_guide.png -------------------------------------------------------------------------------- /uml/pid-controller-gui.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/uml/pid-controller-gui.pdf -------------------------------------------------------------------------------- /screenshots/mac/dark_demo_multi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/screenshots/mac/dark_demo_multi.png -------------------------------------------------------------------------------- /screenshots/pid-controller-gui.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/screenshots/pid-controller-gui.gif -------------------------------------------------------------------------------- /screenshots/unix/light_demo_multi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/screenshots/unix/light_demo_multi.png -------------------------------------------------------------------------------- /screenshots/win/dark_online_multi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/screenshots/win/dark_online_multi.png -------------------------------------------------------------------------------- /screenshots/win/light_demo_multi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/screenshots/win/light_demo_multi.png -------------------------------------------------------------------------------- /screenshots/win/light_demo_single.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/screenshots/win/light_demo_single.png -------------------------------------------------------------------------------- /ISSUES.md: -------------------------------------------------------------------------------- 1 | # Known issues 2 | 3 | - [ ] Does not recover from connection breaks on Windows (OK on UNIX, macOS), stays in the Offline mode 4 | -------------------------------------------------------------------------------- /screenshots/mac/light_online_multi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/screenshots/mac/light_online_multi.png -------------------------------------------------------------------------------- /screenshots/unix/dark_lost_connection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ussserrr/pid-controller-gui/HEAD/screenshots/unix/dark_lost_connection.png -------------------------------------------------------------------------------- /pid-controller-server/Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | gcc-8 main.c commandmanager.c -o pid-controller-server -D _POSIX_C_SOURCE=200809L -Wall -std=c18 -lsodium -lpthread -lm -Ofast 3 | 4 | clean: 5 | rm pid-controller-server 6 | -------------------------------------------------------------------------------- /pyinstaller/unix/README.txt: -------------------------------------------------------------------------------- 1 | Make .spec file: 2 | 3 | pyi-makespec main.py --onefile --name PID_controller_GUI --add-data ./img/eeprom.png:./img ... --add-data ./defaultSettings.json:. 4 | 5 | Build dist: 6 | 7 | pyinstaller --clean --noconfirm PID_controller_GUI.spec 8 | -------------------------------------------------------------------------------- /pyinstaller/mac/README.txt: -------------------------------------------------------------------------------- 1 | Make .spec file: 2 | 3 | pyi-makespec main.py --onefile --name PID_controller_GUI --add-data ./img/eeprom.png:./img ... --add-data ./defaultSettings.json:. --windowed --icon ./pyinstaller/mac/icon.icns 4 | 5 | Build dist: 6 | 7 | pyinstaller --clean --noconfirm PID_controller_GUI.spec 8 | -------------------------------------------------------------------------------- /pyinstaller/win/README.txt: -------------------------------------------------------------------------------- 1 | Make .spec file: 2 | 3 | pyi-makespec main.py --onefile --name PID_controller_GUI --add-data .\img\eeprom.png;.\img ... --add-data .\defaultSettings.json;.\ --windowed --icon .\pyinstaller\win\icon.ico 4 | 5 | Build dist: 6 | 7 | pyinstaller --clean --noconfirm PID_controller_GUI.spec 8 | 9 | Notes: 10 | Do not use UPX, it may corrupt your executable 11 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | PID controller GUI changelog: 2 | 3 | ver. 2.0 (07.12.18): 4 | - New: live graphs at 60 FPS (migrate from matplotlib to PyQtGraph) 5 | - New: fully rethought RemoteController module (previously MCUconn) (asynchronous operations etc.) 6 | - New: efficient instruction set 7 | - New: C-written simulator (pid-controller-server) 8 | - Changed: less entangled architecture 9 | -------------------------------------------------------------------------------- /pyinstaller/README.md: -------------------------------------------------------------------------------- 1 | # PyInstaller configuration 2 | 3 | Packing into standalone application is performing via [PyInstaller](https://www.pyinstaller.org/): 4 | 5 | 1. Pick an appropriate `.spec` file 6 | 2. Correct user-specific options if needed (path) 7 | 3. Place it in the repository' root 8 | 4. Run the command from README file 9 | 10 | You can also generate `.spec` file by yourself using template command string from README file. It should be useful if 11 | 12 | - you want custom distribution options 13 | - new major version of PyInstaller has been released that uses incompatible format of `.spec` files. 14 | 15 | Command-line options are more general to use for PyInstaller. 16 | -------------------------------------------------------------------------------- /defaultSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | "network": { 4 | "ip": "127.0.0.1", 5 | "port": 1200, 6 | "checkInterval": 5000 7 | }, 8 | 9 | 10 | "appearance": { 11 | "theme": "light" 12 | }, 13 | 14 | 15 | "graphs": { 16 | "updateInterval": 19, 17 | "numberOfPoints": 200 18 | }, 19 | 20 | 21 | "pid": { 22 | "processVariable": { 23 | "name": "Process Variable", 24 | "limits": { 25 | "min": -2.0, 26 | "max": 2.0 27 | }, 28 | "unit": "Monkeys" 29 | }, 30 | 31 | "controllerOutput": { 32 | "name": "Controller Output", 33 | "limits": { 34 | "min": -2.0, 35 | "max": 2.0 36 | }, 37 | "unit": "Parrots" 38 | }, 39 | 40 | "valueFormat": "{:.3f}" 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pid-controller-gui/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | util.py - utility things for all other modules 3 | 4 | 5 | function resource_path 6 | routine to correct a given path to some resource in accordance to whether the program is running in frozen mode or 7 | not (see https://stackoverflow.com/questions/7674790/bundling-data-files-with-pyinstaller-onefile) 8 | """ 9 | 10 | import os 11 | import sys 12 | 13 | 14 | def resource_path(relative_path: str) -> str: 15 | """ 16 | Use it to correct a given path to some resource in accordance to whether the program is running in frozen mode or 17 | not. Wrap an every request for a local file by this function to be able to run the script both in normal and bundle 18 | mode without any changes (see https://stackoverflow.com/questions/7674790) 19 | 20 | :param relative_path: string representing some relative path 21 | :return: appropriate path 22 | """ 23 | 24 | if hasattr(sys, '_MEIPASS'): 25 | if relative_path[:2] == '..': 26 | # remove parent directory identifier 27 | return os.path.join(sys._MEIPASS, relative_path[3:]) 28 | else: 29 | return os.path.join(sys._MEIPASS, relative_path) 30 | 31 | return relative_path 32 | -------------------------------------------------------------------------------- /INSTRUCTIONSET: -------------------------------------------------------------------------------- 1 | PID controller remote interface instruction set 2 | 3 | 4 | 5 | Request: 1 byte, MSB to LSB 6 | 7 | _ _ _ _ _ _ _ _ [optional] FLOAT VALUES 8 | 4 byte each 9 | opcode variable/ reserved 10 | command 11 | 12 | 13 | opcode 14 | 0 - read 15 | 1 - write 16 | 17 | 18 | variable/command 19 | 0100 - setpoint 20 | 21 | 0101 - Kp 22 | 0110 - Ki 23 | 0111 - Kd 24 | 25 | 1000 - Ei 26 | 27 | 1001 - EpL 28 | 1010 - EiL 29 | 30 | 31 | specials (opcode is always 0) 32 | 33 | 0000 - stream stop 34 | 0001 - stream start 35 | 36 | 1011 - save to EEPROM 37 | 38 | 39 | 40 | Response: 1 byte, MSB to LSB 41 | 42 | _ _ _ _ _ _ _ _ [optional] FLOAT VALUES 43 | 4 byte each 44 | opcode variable result reserved/ 45 | stream prefix 46 | 47 | 48 | opcode, variable/command 49 | see above 50 | 51 | 52 | result 53 | 0 - success 54 | 1 - error 55 | 56 | 57 | stream prefix 58 | 01 (so entire response byte is 0x01, then float values follows) 59 | 60 | 61 | 62 | Notes 63 | 64 | One can define custom commands following specified rules and using vacant values 65 | -------------------------------------------------------------------------------- /pid-controller-server/README.md: -------------------------------------------------------------------------------- 1 | # Controller simulation 2 | 3 | For debug and development purposes this remote PID regulator simulator has been created. It is a C-written UDP server implementing the same instruction set interface so it acting as a real remote controller. 4 | 5 | Architecture is based on 2-threaded execution: 6 | 7 | 1. Main thread after initializing of the whole program goes to the forever loop where it constantly polling the socket for available messages. If some message (request) is arrived, it is processed by `process_request()` function, the response is preparing and transmitting to the sender. Otherwise the thread is sleeping for a small elementary duration. 8 | 9 | 2. In coexistence, the second - stream thread - is starting at the program initialization time. It works only for transmitting and serves streaming purpose - constantly sends points to the socket right after the `stream_start` command was received by the main thread. The rest of the time the thread is sleeping waiting the signal to wake up. 10 | 11 | The simulator has been tested under all 3 platforms: 12 | 13 | 1. macOS: works both for Xcode (clang) and standalone GCC (Homebrew' GCC was used) 14 | 2. UNIX: GCC under Ubuntu OS 15 | 3. Windows: Ubuntu/GCC under WSL works perfectly 16 | 17 | Simply run `make` to build and `./pid-controller-server` to start the program. 18 | 19 | It can also be used as a reference design of how to implement the communicating path of your real device embedded firmware. 20 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # pid-controller-gui 2 | 3 | - [ ] Delimit connection-related stuff from the PID-related (e.g. `snapshots`, `reset_pid_error(err)` should be in a dedicated class). There is a rough scheme of the improved RemoteController class: 4 | ```text 5 | RemoteController 6 | | 7 | +---- Connection(socket.socket) allows easily switch interfaces 8 | | read() (e.g. from Ethernet to serial) 9 | | write() 10 | | check() 11 | | 12 | +---- PID 13 | | snapshots 14 | | take_snapshot() 15 | | restore_snapshot(snapshot) 16 | | 17 | | 18 | +---- Stream 19 | | 20 | +---- Signal(QObject) no more present in the class (zero external 21 | dependencies, only Python library) 22 | ``` 23 | - [ ] Pack response, request, result etc. into corresponding classes, not dictionaries 24 | - [ ] Add logging to easily trace the execution flow (several verbosity levels) 25 | - [ ] Apply settings on-the-fly (not requiring a reboot) 26 | - [ ] Make QT signals propagate from children to parent and vice versa (more ease and transparent code) 27 | - [ ] Get rid of entangled logic of handling connection and its breaks 28 | - [ ] Display the lag (in points) of plots instead of bullet mark 29 | 30 | 31 | # pid-controller-server 32 | 33 | - [ ] Store the connection information on the first communication to eliminate the need in determination of the client's IP on every incoming request (reset them after specified inactivity timeout) (maybe this is closer to TCP nature...) 34 | -------------------------------------------------------------------------------- /pyinstaller/unix/PID_controller_GUI.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | 3 | block_cipher = None 4 | 5 | 6 | a = Analysis(['./pid-controller-gui/main.py'], 7 | pathex=['/home/chufyrev/PycharmProjects/pid-gui/pid-controller-gui'], 8 | binaries=[], 9 | datas=[('./img/eeprom.png', './img'), 10 | ('./img/error.png', './img'), 11 | ('./img/exit.png', './img'), 12 | ('./img/icon.png', './img'), 13 | ('./img/info.png', './img'), 14 | ('./img/play_pause.png', './img'), 15 | ('./img/refresh.png', './img'), 16 | ('./img/refresh_hover.png', './img'), 17 | ('./img/refresh_pressed.png', './img'), 18 | ('./img/restore.png', './img'), 19 | ('./img/set_errors.png', './img'), 20 | ('./img/settings.png', './img'), 21 | ('./defaultSettings.json', '.') 22 | ], 23 | hiddenimports=[], 24 | hookspath=[], 25 | runtime_hooks=[], 26 | excludes=[], 27 | win_no_prefer_redirects=False, 28 | win_private_assemblies=False, 29 | cipher=block_cipher, 30 | noarchive=False) 31 | pyz = PYZ(a.pure, a.zipped_data, 32 | cipher=block_cipher) 33 | exe = EXE(pyz, 34 | a.scripts, 35 | a.binaries, 36 | a.zipfiles, 37 | a.datas, 38 | [], 39 | name='PID_controller_GUI', 40 | debug=False, 41 | bootloader_ignore_signals=False, 42 | strip=False, 43 | upx=True, 44 | runtime_tmpdir=None, 45 | console=True ) 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # macOS-specific 107 | .DS_Store 108 | 109 | # Repository-specific 110 | pid-controller-server 111 | -------------------------------------------------------------------------------- /pyinstaller/win/PID_controller_GUI.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | 3 | block_cipher = None 4 | 5 | 6 | a = Analysis(['.\\pid-controller-gui\\main.py'], 7 | pathex=['C:\\Users\\Chufyrev\\PycharmProjects\\pid-gui\\pid-controller-gui'], 8 | binaries=[], 9 | datas=[('.\\img\\eeprom.png', '.\\img'), 10 | ('.\\img\\error.png', '.\\img'), 11 | ('.\\img\\exit.png', '.\\img'), 12 | ('.\\img\\icon.png', '.\\img'), 13 | ('.\\img\\info.png', '.\\img'), 14 | ('.\\img\\play_pause.png', '.\\img'), 15 | ('.\\img\\refresh.png', '.\\img'), 16 | ('.\\img\\refresh_hover.png', '.\\img'), 17 | ('.\\img\\refresh_pressed.png', '.\\img'), 18 | ('.\\img\\restore.png', '.\\img'), 19 | ('.\\img\\set_errors.png', '.\\img'), 20 | ('.\\img\\settings.png', '.\\img'), 21 | ('.\\defaultSettings.json', '.\\') 22 | ], 23 | hiddenimports=[], 24 | hookspath=[], 25 | runtime_hooks=[], 26 | excludes=[], 27 | win_no_prefer_redirects=False, 28 | win_private_assemblies=False, 29 | cipher=block_cipher, 30 | noarchive=False) 31 | pyz = PYZ(a.pure, a.zipped_data, 32 | cipher=block_cipher) 33 | exe = EXE(pyz, 34 | a.scripts, 35 | a.binaries, 36 | a.zipfiles, 37 | a.datas, 38 | [], 39 | name='PID_controller_GUI', 40 | debug=False, 41 | bootloader_ignore_signals=False, 42 | strip=False, 43 | upx=True, 44 | runtime_tmpdir=None, 45 | console=False , icon='pyinstaller\\win\\icon.ico') 46 | -------------------------------------------------------------------------------- /pyinstaller/mac/PID_controller_GUI.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | 3 | block_cipher = None 4 | 5 | 6 | a = Analysis(['./pid-controller-gui/main.py'], 7 | pathex=['/Users/chufyrev/PycharmProjects/pid-gui/pid-controller-gui'], 8 | binaries=[], 9 | datas=[('./img/eeprom.png', './img'), 10 | ('./img/error.png', './img'), 11 | ('./img/exit.png', './img'), 12 | ('./img/icon.png', './img'), 13 | ('./img/info.png', './img'), 14 | ('./img/play_pause.png', './img'), 15 | ('./img/refresh.png', './img'), 16 | ('./img/refresh_hover.png', './img'), 17 | ('./img/refresh_pressed.png', './img'), 18 | ('./img/restore.png', './img'), 19 | ('./img/set_errors.png', './img'), 20 | ('./img/settings.png', './img'), 21 | ('./defaultSettings.json', '.') 22 | ], 23 | hiddenimports=[], 24 | hookspath=[], 25 | runtime_hooks=[], 26 | excludes=[], 27 | win_no_prefer_redirects=False, 28 | win_private_assemblies=False, 29 | cipher=block_cipher, 30 | noarchive=False) 31 | pyz = PYZ(a.pure, a.zipped_data, 32 | cipher=block_cipher) 33 | exe = EXE(pyz, 34 | a.scripts, 35 | a.binaries, 36 | a.zipfiles, 37 | a.datas, 38 | [], 39 | name='PID_controller_GUI', 40 | debug=False, 41 | bootloader_ignore_signals=False, 42 | strip=False, 43 | upx=True, 44 | runtime_tmpdir=None, 45 | console=False , icon='pyinstaller/mac/icon.icns') 46 | app = BUNDLE(exe, 47 | name='PID_controller_GUI.app', 48 | icon='./pyinstaller/mac/icon.icns', 49 | bundle_identifier=None) 50 | -------------------------------------------------------------------------------- /pid-controller-server/commandmanager.h: -------------------------------------------------------------------------------- 1 | // 2 | // commandmanager.h 3 | // pid-controller-server 4 | // 5 | // Created by Andrey Chufyrev on 18.11.2018. 6 | // Copyright © 2018 Andrey Chufyrev. All rights reserved. 7 | // 8 | 9 | #ifndef commandmanager_h 10 | #define commandmanager_h 11 | 12 | 13 | #include 14 | #include 15 | #include // for memset() 16 | #include 17 | #include 18 | #include 19 | #include 20 | #include 21 | #include 22 | #include 23 | 24 | // #include "sodium.h" 25 | 26 | #ifndef M_PI 27 | #define M_PI 3.14159265358979323846264338328 28 | #endif 29 | 30 | 31 | extern int sockfd; 32 | extern struct sockaddr_in clientaddr; // client address 33 | extern socklen_t clientlen; // byte size of client's address 34 | 35 | // extern pthread_mutex_t sock_mutex; 36 | extern pthread_t stream_thread_id; 37 | 38 | 39 | enum { 40 | OPCODE_read, 41 | OPCODE_write 42 | }; 43 | 44 | enum { 45 | VAR_setpoint = 0b0100, 46 | 47 | VAR_kP = 0b0101, 48 | VAR_kI = 0b0110, 49 | VAR_kD = 0b0111, 50 | 51 | VAR_err_I = 0b1000, 52 | 53 | VAR_err_P_limits = 0b1001, 54 | VAR_err_I_limits = 0b1010, 55 | 56 | // special 57 | CMD_stream_start = 0b0001, 58 | CMD_stream_stop = 0b0000, 59 | 60 | CMD_save_to_eeprom = 0b1011 61 | }; 62 | 63 | enum { 64 | RESULT_ok, 65 | RESULT_error 66 | }; 67 | 68 | #define STREAM_PREFIX 0b00000001 69 | 70 | 71 | typedef struct request { 72 | unsigned char _reserved: 3; 73 | unsigned char var_cmd : 4; 74 | unsigned char opcode : 1; 75 | } request_t; 76 | 77 | typedef struct response { 78 | unsigned char _reserved: 2; 79 | unsigned char result : 1; 80 | unsigned char var_cmd : 4; 81 | unsigned char opcode : 1; 82 | } response_t; 83 | 84 | 85 | // void _print_bin_hex(unsigned char byte); 86 | 87 | void error(char *msg); 88 | 89 | void *_stream_thread(void *data); 90 | void stream_start(void); 91 | void stream_stop(void); 92 | 93 | int process_request(unsigned char *request_buf); 94 | // int process_request(unsigned char *request_buf, unsigned char *response_buf); 95 | 96 | 97 | #endif /* commandmanager_h */ 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PID controller GUI 2 | ![screenshot](/screenshots/pid-controller-gui.gif) 3 | 4 | ## Features 5 | - Get and set PID parameters: 6 | - Setpoint 7 | - kP, kI and kD coefficients 8 | - Limits of proportional & integral components errors 9 | - Reset accumulated integral error 10 | - Monitor process variable and controller output through live graphs @ 60 FPS 11 | - Save PID parameters to the controller' non-volatile memory 12 | - Demo mode: activates when no network connection established 13 | 14 | ## Overview 15 | The application is supposed to connects to some kind of bare-metal MCU or OS-based controller that uses a proportional-integral-derivative algorithm as a core to drive some parameter in physical system. PID is a generic efficient-proven method for such tasks, find more about it in topic resources. 16 | 17 | It can be used as a general tool to help: 18 | - Setup and adjust the PID regulator for the first time 19 | - Monitor its stability during a long operation 20 | - Diagnose and find issues 21 | 22 | Main advantage of the dedicated interface is the ability to establish a connection of any type while not changing the behavior of other logic. For example, current active UDP/IP stack can be replaced by a serial UART protocol and so on. The application does not contain any hard-coded specific definitions that allows to assign them by an end-user. 23 | 24 | ## Architecture 25 | Besides modular structure the app also applies multithreaded approach to split resources on different tasks. For example, in communication protocol (described in [INSTRUCTIONSET](/INSTRUCTIONSET)) 2 types of messages are defined: 'normal' and stream. Normal variables/commands are delivering by demand, using request/response semantics. In contrast, stream messages are constantly pouring from the controller so the client should plot them onto graph. Such scene is perfectly lays on the concept of dedicated input listening thread that concurrently runs alongside the main thread and redirect incoming messages according to their type. It is implemented through `multiprocessing` API (processes and pipes). 26 | 27 | For detailed description on particular things please refer to in-code documentation. 28 | 29 | ## `remotecontroller.py` 30 | This file provides communication interface from/to remote controller. It has zero external dependencies (only Python standard library) and therefore can, in general, be used in any, perhaps non-GUI, applications. 31 | 32 | ## Simulator 33 | For debug and development purposes the PID regulator simulator has been created. It is a C-written UDP server implementing the same instruction set interface so it acting as a real remote controller. See [pid-controller-server](/pid-controller-server) for more information. 34 | 35 | ## Dependencies 36 | - Python 3 37 | - PyQt5 38 | - PyQtGraph 39 | - qdarkstyle (provides dark theme) 40 | - *[optional]* PyInstaller (provides packing into bundle) 41 | 42 | ## Usage 43 | Run 44 | ```sh 45 | pid-controller-gui/pid-controller-gui $ python3 main.py 46 | ``` 47 | 48 | Default settings are located in `defaultSettings.json` file and currently available only in non-bundled mode. Different timeouts are placed directly in modules' code and so also can be edited only programmatically. 49 | 50 | ## Packing into standalone app 51 | It is possible to bundle the application into a standalone executable using [PyInstaller](https://www.pyinstaller.org/). See respective README in [pyinstaller](/pyinstaller) folder. 52 | 53 | ## UML 54 | You can find a sketch diagram in [uml](/uml) folder (created in [draw.io](https://www.draw.io/)). 55 | -------------------------------------------------------------------------------- /uml/pid-controller-gui.xml: -------------------------------------------------------------------------------- 1 | 7V1rc9ssFv41nkl3ph5dfP0YO2m7u8k0abrt++4XD5GIzVYSKkKJ3V+/INAVbCupHNlqPjSVMJfDAc7zHG7q2XN//ZGAcHWNXej1LGNJkNuzL3qWZbJ/LCBaARc/iSBDBPmArkoBIVjCTxAtV1QET6xx/sN35KbRTXM0zX+4c4AHS2Xx0FLAA/bK0gBC8FNUCnJwEECHlsIoxh5FYTniMkYurASx2t6hX2mhsjruRryPbCmtu5b1Gls8wL7s2XOCMRVP/noOPa67VHMym571YUuErLoEBrRmmttfn+Jx8HSx/M8nRCf+eONdgvcTkeoReLGsQ88aeSzLWcibjm6kgkc/Yy7tzAdkiYKefc5+NcI1L9CeJS3Kw99THIrfBoXfKFzT98BDS5nOYTJDkufJnpby/6Tk+2rAA2aVZG0JnLIwn6D3CClygJrXnJVBgMe6zhLSNB+mG5FVOXsWrBTJwsJq2Glr5Oxmc0uH/VsqdBL1b8XDu4a0syJcPelQSEs3t1fqRer04AMt6FPNe5YMSmaPIhFNPPfTQqMQBFkfiSOK/cR4ISe6AhscU6XDFBM03jlq1gbH4QyvoaxRbzjzUeQspdz9b3zsfpSResOLE2mkOHQBhRcoCj2wQcEyqUZ09k4vftXapdiSiWY9QsI7vncuhtVFMvBmcpBdCJFmmMV68BI0ekDMOoqRJA24acn3D8BHHjfhV7GDXDaWjDkOIsxLmq2o7/G4mVS8XLguSCWt70eIfUjJhmOOxIxlFsQTrgp4Z46k+X7KsW5gy7AUTFITLwsbVy0+e5BG/xkAkEJWAQF8gIJ+uKmjdIbtIX+Mfe8DAT5X0NMKUXgXCsv0xDppWWlpBSe8y2QqsI0aqm9Q32NTo+9pWd+mWdb3oBl9m11F3DrW9DTh9wXaUDR7tqu6WUC4+UkTw97fo8jtWbzbo9ctTXDMUAHYmGfE/grcQy8HwVxXCXQkv+6Bv8OJyHwISrB3g7g55HH9mPsPBDswihi69efCyUA4aEfAiBIIfC7fl7+SyCPgc9Mc3EdhQYAi3alVhXb5kSp0kVx8RT4b1tsru0X8YlhKmueYwP6tyPD5lT+isbQQqtnGsw7fDQGh7RWOw7bKpni59LaqveP0lqlNoVt2hd6awwq9HQwb4VsjhW9Jl/DQDHfaKsO1NB6FPd7NcMdWMyofd5Ti3kFKGRBGJ09om1DGmYucLk4eufABMOIjeSavZDuAEUISoYiylryjmCTz2jxFmZBoeuQx63ZBmLFC8LE9+gG2l91xDDYHOgwelgBhOKhisNVvBhK6us6QDsDvKOCLXG/A8EctNIBQ6jyZMr1mf87D0GNaadHPlx3ynMEGc7XaRjEPBfDSRTSftlF6x5WMcjpLFxxF0oHfFpIRyFp6nxBdhzSdj5P6NBLSxnYF0obNANpUAbR05B3cseRuW4ue5cDSaL2ydjI0Klo3G9F6WnLnaMQlIZhEb2TiTyYTaQnFSeW6BOMoZ+FfPOn+YtJwlGrgc940jmagoaWITAd3Wb4nvSLBcLNVEvWHkidLswWiOidvjczU/0+Ls1Kk/00kV7dBwAQDX49FDdudn5/sZ1Ese0X9o2bUb3WUSN0gZxZTWgLHN/aUY8b5fUQJcKhQUgdZVIjWPgjLc+UfY9S/vRE/tIEz+7daCKkXKw4WdWQ/SqLzYmYjax8yOI6g22T9j6ljMvJOLx/ZqD+D/G9LlCexOkKMliTwIHiEOyXoOPMyxxroN8rQbxqV3b6mbTQzhWJ3FPmvmfFIDgu9zZ1sRX+poxledxD5hZstojGScwJSy6YmuxXddWuo2RtmDSvW0DKr1jD1VH/TGg46ag3Lh4LerKHOGqbq6aAt5EdaRSQCfUyh3J7uQdL/kgTMs4CWSGCyW7/ePnR18jdJfCzeUFKddDr6pTVK05+0i7N7a3nXcUy3v8qq4JhdWY02rWkzrH6o4FjpjOjBJ1MHrU6mDnVL0pOy7qeTqupHzahe3WDeDcDcARQdQckIOz+kv8CeIO2L/9qBEHFAS0S7E8+tyPEIyMLx3UWIQrggLNoHupZr0JXzYMlht1ZkREEY0wVdMS25C8ltNALroHdPJdqnEsW6bdG7eGxHPM4sFx6O6CJi1gg8l8Ml29b5yc07kfqk+c7CBz9Yl4M/YxjRMxxCIvdlzDkUrQAVT/9IoCFqabp1wTA/4lJGIYNeuF3MNEZra/HAPePStFT+E2FM5+xYWm3XQYVX2BVB2TBt84zGguLF5eXNl8/XbUkRgjhqswViv7XSnRV0frRWOMOWP9WFtHW3kljlIzrm2Cy7MRPDbuaMjtXZY5tVNt0R98XJ72NIoh7HzF5Gw+fpy0v9hyNSNYoWJA7eLk145bJ3X5rwBkUHnM1MJ8dKs5mTEhSNjWl1e+JkaDUCRduPi7rokVdAajFvutpA8hAH1TtsdIY/Kaccuq3opCnrI5iuQHUNeBvOKT1AKTidFxETCSsQuAwLWDdOHHW1tIrrbmiCUIAK5keVtayrGhk+rRCX3PhKePOe71b7FsDWxHy5zuqJXTfI7vEVIX52X6Ig98BzQNxTMe1+AL1eSqH7LdKBTIVuI7kxKE++V9fvJ6NBI4ZCPYanLHgefO1j3Orax1Sz9qFcZWhXnIZxM5dHpqtZBfWDexzTw+vcaFXn2h18lct1RpPJgTbvp43ZOUft/D656rATG/gauTNS2SfwFdyr5x91mjjFWx5567MKlvd/V859tuQDbbLVluIyxTOEPbHVjR1M5A/ygXS31lWvF7BUK9/MCTlb3agdU+S99rV1w1dGVg2byb5tsGUnR9Ymv6lwdS9o1VdUB0Rt2NvvgxAY4Zg4cBECujp7yabEonhteQPpBECh/czqtYOGVR4y2TVUtRvwQ2R+Mb/9d0nAL/fzAMb/mn/8/n64tdaUmT1IdySVTS8aYEc8OSahW/nMSEGt/Lc7+YoJXeElDoB3mYfOCI4Dl59zujDKQxCuEf2LB/eH8u3v9BfmzWwKP/HXv2UG/4OUbqS1BDHF3KJm5V5hbnGTPOqM4rotTqAHKHosK+H3Wq+z51K2XtpwmsS2CZ1ozqXmGnrbjt0CwU3vkRS74dIz+HWulzycUPzOE+kNJlGv8/fWtj7N+dp4fq+4seN+cA1Z2MOnX6MCVzhK916pFdBuzDr2gcVr9Kk0y63d+KdW1MPP89CO0d0pc4nsU2v8ZemBKJLPDvaRI5+bZOsTzSpR9ezWsOIfPZuta+nCqBbZ2/XNs71kb/BG9g5F9rp67E4LUW88r3ACOVNQB2leVL6/T8+l2mQwoDDBnsQVqzX6efdXlAvqrj9MElUuhdpzT+JrcqnSByaT2Nu+OXnUXTY5NH8PWvI7+GdWWeFtORgwiN+oXl2qp1lmrlC97P4yWdizlzu1XKHNbanFn13mtsuTE0kc3hqEH/ytgaGXaweGPGmxp9eGt3JI7FVDPJSGLGYggllhhXzzKGqiL+IAyz8DpmLkfs7Ph9RKrfO1yjn+G272+Flax3i3yOKsSoIHz0zzDZC5716jyAfUWT0zcaadavqaVWCBxfZrx87kjO2YDMxQ9SUt2yoZmGH6aaG0sKnRiIlRtxtCX2x5e+mCgq36mDtPD+90MgP3nH+1nL05vB249ovttg9D9jupuS9qGKNe7o0afXs8lu83kCDWdHziK2n6zEvNXFaRYjzsNeBelpaUVOQRQbKEG4wSzyTNXihdhhZ6jzWZlnvPWAEoNTPR0mpmduWG8+zrvFlerM1AsdYhzyPSyJ2JN6mIp8yUKGlGxu8mGSmftVUFmw72JGEPorb5EMyaXTcq2SvBHDHz6Pymgmvs8ktBLv8P -------------------------------------------------------------------------------- /pid-controller-gui/about.py: -------------------------------------------------------------------------------- 1 | """ 2 | about.py - holds packages information, credits and so on 3 | 4 | 5 | ABOUT_TEXT 6 | HTML-formatted 'about' text 7 | 8 | SYS_TEXT 9 | HTML-formatted 'system' text 10 | 11 | 12 | AboutWindow 13 | QTabWidget window 14 | """ 15 | 16 | import platform 17 | import sys 18 | 19 | from PyQt5.Qt import PYQT_VERSION_STR 20 | from PyQt5.QtCore import Qt 21 | from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QTabWidget, QLabel, QTextBrowser, QPushButton, QStyle 22 | from PyQt5.QtGui import QIcon, QPixmap 23 | 24 | # local imports 25 | import util 26 | 27 | 28 | 29 | LOGO_SIZE = 96 30 | 31 | 32 | 33 | ABOUT_TEXT = """ 34 | 35 | 36 | 37 |

PID controller GUI

38 | 39 |

(C) Andrey Chufyrev

40 | 41 |

2016-2018

42 | 43 |

Repository: https://github.com/ussserrr/pid-controller-gui

44 | 45 | 46 | 47 | """ 48 | 49 | 50 | 51 | SYS_TEXT = f""" 52 | 53 | 54 | 55 |

Python

56 |
    57 |
  • {sys.version}
  • 58 |
59 | 60 |

Platform

61 |
    62 |
  • {platform.platform(aliased=True)}
  • 63 |
64 | 65 |

PyQt

66 |
    67 |
  • {PYQT_VERSION_STR}
  • 68 |
69 | 70 |

PyQtGraph

71 | 74 | 75 |

QDarkStylesheet

76 | 80 | 81 |

Icons

82 |
    83 |
  • All icons belongs to their respective authors from FlatIcon.com
  • 84 |
85 | 86 | 87 | 88 | """ 89 | 90 | 91 | 92 | class AboutWindow(QTabWidget): 93 | """ 94 | Some service information, credits using html documents 95 | """ 96 | 97 | def __init__(self, parent=None): 98 | """ 99 | AboutWindow constructor 100 | 101 | :param parent: [optional] parent class 102 | """ 103 | 104 | super(AboutWindow, self).__init__(parent) 105 | 106 | self.setWindowTitle("Info & About") 107 | self.setWindowIcon(QIcon(util.resource_path('../img/info.png'))) 108 | 109 | self.aboutTab = QWidget() 110 | self.sysTab = QWidget() 111 | 112 | self.addTab(self.aboutTab, 'About') 113 | self.addTab(self.sysTab, 'System') 114 | 115 | self.initAboutTabUI() 116 | self.initSysTabUI() 117 | 118 | 119 | def initAboutTabUI(self) -> None: 120 | """ 121 | Initialize 'About' tab 122 | 123 | :return: None 124 | """ 125 | 126 | layout = QVBoxLayout() 127 | self.aboutTab.setLayout(layout) 128 | 129 | # prepare logo view: create pixmap, scale it using nicer method 130 | iconPixmap = QPixmap(util.resource_path('../img/icon.png')).scaledToWidth(LOGO_SIZE, Qt.SmoothTransformation) 131 | iconLabel = QLabel() # QLabel is used to store the pixmap 132 | iconLabel.setPixmap(iconPixmap) 133 | iconLabel.setAlignment(Qt.AlignCenter) 134 | 135 | aboutTextBrowser = QTextBrowser() # read-only text holder with rich text support 136 | aboutTextBrowser.setHtml(ABOUT_TEXT) 137 | aboutTextBrowser.setOpenExternalLinks(True) 138 | 139 | layout.addSpacing(40) 140 | layout.addWidget(iconLabel, Qt.AlignCenter) 141 | layout.addSpacing(40) 142 | layout.addWidget(aboutTextBrowser) 143 | 144 | 145 | def initSysTabUI(self) -> None: 146 | """ 147 | Initialize 'System' tab 148 | 149 | :return: None 150 | """ 151 | 152 | layout = QVBoxLayout() 153 | self.sysTab.setLayout(layout) 154 | 155 | sysTextBrowser = QTextBrowser() 156 | sysTextBrowser.setHtml(SYS_TEXT) 157 | sysTextBrowser.setOpenExternalLinks(True) 158 | 159 | aboutQtButton = QPushButton(QIcon(self.style().standardIcon(QStyle.SP_TitleBarMenuButton)), 'About QT') 160 | aboutQtButton.clicked.connect(QApplication.instance().aboutQt) 161 | 162 | layout.addWidget(sysTextBrowser) 163 | layout.addWidget(aboutQtButton) 164 | 165 | 166 | 167 | if __name__ == '__main__': 168 | """ 169 | Use this block for testing purposes (run the module as a standalone script) 170 | """ 171 | 172 | app = QApplication(sys.argv) 173 | 174 | aboutWindow = AboutWindow() 175 | aboutWindow.show() 176 | 177 | sys.exit(app.exec_()) 178 | -------------------------------------------------------------------------------- /pid-controller-server/main.c: -------------------------------------------------------------------------------- 1 | // 2 | // main.c 3 | // pid-controller-server 4 | // 5 | // Created by Andrey Chufyrev on 18.11.2018. 6 | // Copyright © 2018 Andrey Chufyrev and authors of corresponding code. All rights reserved. 7 | // 8 | 9 | #include "commandmanager.h" 10 | 11 | #include 12 | #include 13 | #include 14 | 15 | 16 | #define REQUEST_RESPONSE_BUF_SIZE (sizeof(char)+2*(sizeof(float))) // same size for both requests and responses 17 | #define SERVER_TASK_SLEEP_TIME_MS 5 18 | #define NO_MSG_TIMEOUT_SECONDS 15.0 19 | 20 | 21 | int sockfd; 22 | struct sockaddr_in clientaddr; // client address 23 | socklen_t clientlen; // byte size of client's address 24 | 25 | 26 | /* 27 | * Wrapper for perror 28 | */ 29 | void error(char *msg) { 30 | perror(msg); 31 | exit(1); 32 | } 33 | 34 | 35 | int main() { 36 | 37 | // if (sodium_init() < 0) 38 | // error("ERROR initializing libsodium"); // panic! the library couldn't be initialized, it is not safe to use 39 | 40 | // if (pthread_mutex_init(&sock_mutex, NULL) != 0) 41 | // error("ERROR initializing mutex"); 42 | 43 | // /* 44 | // * check command line arguments 45 | // * usage: 46 | // * $ 47 | // */ 48 | // 49 | // int main(int argc, char **argv) { 50 | // 51 | // if (argc != 2) { 52 | // fprintf(stderr, "usage: %s \n", argv[0]); 53 | // exit(1); 54 | // } 55 | // 56 | // int portno = atoi(argv[1]); 57 | // 58 | // ... 59 | // 60 | int portno = 1200; // port to listen on 61 | 62 | /* 63 | * socket: create the parent socket 64 | */ 65 | sockfd = socket(AF_INET, SOCK_DGRAM, 0); 66 | if (sockfd < 0) 67 | error("ERROR opening socket"); 68 | 69 | /* 70 | * setsockopt: Handy debugging trick that lets us rerun the server immediately after we kill it; 71 | * otherwise we have to wait about 20 secs. Eliminates "ERROR on binding: Address already in use" error. 72 | */ 73 | int optval = 1; // flag value for setsockopt 74 | setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, (const void *)&optval, sizeof(int)); 75 | 76 | /* 77 | * build the server's Internet address 78 | */ 79 | struct sockaddr_in serveraddr; // server address 80 | memset((unsigned char *)&serveraddr, 0, sizeof(serveraddr)); 81 | serveraddr.sin_family = AF_INET; 82 | serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); 83 | serveraddr.sin_port = htons((unsigned short)portno); 84 | 85 | /* 86 | * bind: associate the parent socket with a port 87 | */ 88 | if (bind(sockfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr)) < 0) 89 | error("ERROR on binding"); 90 | 91 | clientlen = sizeof(clientaddr); // byte size of client's address 92 | 93 | unsigned char buf[REQUEST_RESPONSE_BUF_SIZE]; // message buffer (both for receiving and sending) 94 | memset(buf, 0, REQUEST_RESPONSE_BUF_SIZE); // explicitly reset the buffer 95 | // unsigned char response_buf[REQUEST_RESPONSE_BUF_SIZE]; 96 | // memset(response_buf, 0, REQUEST_RESPONSE_BUF_SIZE); 97 | 98 | int err = pthread_create(&stream_thread_id, NULL, _stream_thread, NULL); 99 | if (err) { 100 | printf("%s\n", strerror(err)); 101 | error("ERROR cannot create thread"); 102 | } 103 | 104 | struct timespec server_response_delay = { 105 | /* seconds */ .tv_sec = 0, 106 | /* nanoseconds */ .tv_nsec = SERVER_TASK_SLEEP_TIME_MS * 1000000 107 | }; 108 | 109 | int no_msg_cnt = 0; 110 | int const no_msg_cnt_warn = NO_MSG_TIMEOUT_SECONDS/(SERVER_TASK_SLEEP_TIME_MS/1000.0); 111 | bool is_stream_stop = false; 112 | 113 | 114 | printf("Server listening on port %d\n", portno); 115 | 116 | /* 117 | * main loop: wait for a datagram, process it, reply 118 | */ 119 | while (1) { 120 | 121 | if ((no_msg_cnt >= no_msg_cnt_warn) && (!is_stream_stop)) { 122 | printf("No incoming messages within a timeout, stop the stream\n"); 123 | stream_stop(); 124 | is_stream_stop = true; 125 | } 126 | 127 | 128 | int data_len = 0; 129 | // pthread_mutex_lock(&sock_mutex); 130 | ioctl(sockfd, FIONREAD, &data_len); // check for available data in socket 131 | if (data_len > 0) { 132 | 133 | /* 134 | * recvfrom: receive a UDP datagram from a client 135 | * n: message byte size 136 | */ 137 | ssize_t n = recvfrom(sockfd, buf, REQUEST_RESPONSE_BUF_SIZE, 0, (struct sockaddr *)&clientaddr, &clientlen); 138 | if (n < 0) 139 | error("ERROR in recvfrom"); 140 | // printf("server received %zd bytes\n", n); 141 | 142 | /* 143 | * gethostbyaddr: determine who sent the datagram 144 | * hostp: client host info 145 | */ 146 | struct hostent *hostp = gethostbyaddr((const char *)&clientaddr.sin_addr.s_addr, 147 | sizeof(clientaddr.sin_addr.s_addr), AF_INET); 148 | if (hostp == NULL) 149 | error("ERROR on gethostbyaddr"); 150 | char *hostaddrp = inet_ntoa(clientaddr.sin_addr); // dotted decimal host addr string 151 | if (hostaddrp == NULL) 152 | error("ERROR on inet_ntoa"); 153 | 154 | /* 155 | * print raw received data 156 | */ 157 | // printf(buf); 158 | 159 | process_request(buf); 160 | // process_request(buf, response_buf); 161 | 162 | /* 163 | * sendto: reply to the client 164 | */ 165 | n = sendto(sockfd, (const void *)buf, REQUEST_RESPONSE_BUF_SIZE, 0, (struct sockaddr *)&clientaddr, clientlen); 166 | if (n < 0) 167 | error("ERROR in sendto"); 168 | 169 | // pthread_mutex_unlock(&sock_mutex); 170 | memset(buf, 0, REQUEST_RESPONSE_BUF_SIZE); // reset the buffer 171 | 172 | no_msg_cnt = 0; 173 | is_stream_stop = false; 174 | } 175 | 176 | // no available data 177 | else { 178 | // pthread_mutex_unlock(&sock_mutex); 179 | 180 | if (!is_stream_stop) 181 | no_msg_cnt++; 182 | 183 | // sleep only if there were not available data, this should allows to reply on several continuous requests 184 | // without any lag 185 | nanosleep(&server_response_delay, NULL); // pause the main thread, then repeat 186 | } 187 | } 188 | 189 | return 0; 190 | } 191 | -------------------------------------------------------------------------------- /pid-controller-gui/errorssettings.py: -------------------------------------------------------------------------------- 1 | """ 2 | errorssettings.py - PID components errors settings window 3 | 4 | 5 | STATUSBAR_MSG_TIMEOUT 6 | time for which the statusbar' widget is displaying 7 | 8 | 9 | ErrorsSettingsWindow 10 | QWidget window to control PID components errors (proportional and integral) 11 | """ 12 | 13 | import functools 14 | 15 | from PyQt5.QtCore import QTimer, QEvent 16 | from PyQt5.QtWidgets import QWidget, QGridLayout, QHBoxLayout, QVBoxLayout, QGroupBox, QLabel, QLineEdit, QPushButton, \ 17 | QStatusBar, QStyle 18 | from PyQt5.QtGui import QIcon 19 | 20 | # local imports 21 | import util 22 | import remotecontroller 23 | 24 | 25 | 26 | STATUSBAR_MSG_TIMEOUT = 5000 27 | 28 | 29 | 30 | class ErrorsSettingsWindow(QWidget): 31 | """ 32 | Window to control PID components errors (proportional and integral). QStatusBar is used to display service messages 33 | """ 34 | 35 | def __init__(self, app, parent=None): 36 | """ 37 | ErrorsSettingsWindow constructor 38 | 39 | :param app: parent MainApplication instance 40 | :param parent: [optional] parent class 41 | """ 42 | 43 | super(ErrorsSettingsWindow, self).__init__(parent) 44 | 45 | self.app = app 46 | 47 | self.setWindowTitle("PID errors settings") 48 | self.setWindowIcon(QIcon(util.resource_path('../img/set_errors.png'))) 49 | 50 | grid = QGridLayout() 51 | self.setLayout(grid) 52 | 53 | 54 | self.lineEdits = { 55 | 'err_P_limits': { 56 | 'min': QLineEdit(), 57 | 'max': QLineEdit() 58 | }, 59 | 'err_I_limits': { 60 | 'min': QLineEdit(), 61 | 'max': QLineEdit() 62 | } 63 | } 64 | 65 | 66 | # we have 2 similar parameters but for integral error also declare reset button with the value tooltip 67 | for key, name in (('err_P_limits', "P error limits"), 68 | ('err_I_limits', "I error limits")): 69 | 70 | setButton = QPushButton(QIcon(self.style().standardIcon(QStyle.SP_DialogApplyButton)), 'Set') 71 | setButton.clicked.connect(functools.partial(self.setErrLimits, key)) 72 | 73 | hBox = QHBoxLayout() 74 | hBox.addWidget(QLabel("Min:")) 75 | hBox.addWidget(self.lineEdits[key]['min']) 76 | hBox.addWidget(QLabel("Max:")) 77 | hBox.addWidget(self.lineEdits[key]['max']) 78 | hBox.addWidget(setButton) 79 | 80 | groupBox = QGroupBox(name) 81 | 82 | if key == 'err_I_limits': 83 | self.resetButton = QPushButton(QIcon(self.style().standardIcon(QStyle.SP_DialogCancelButton)), 84 | "Reset I error") 85 | self.resetButton.clicked.connect(self.resetIerr) 86 | self.event(QEvent(QEvent.ToolTip)) 87 | 88 | hBox2 = QHBoxLayout() 89 | hBox2.addWidget(self.resetButton) 90 | 91 | vBox = QVBoxLayout() 92 | vBox.addLayout(hBox) 93 | vBox.addLayout(hBox2) 94 | 95 | groupBox.setLayout(vBox) 96 | 97 | else: 98 | groupBox.setLayout(hBox) 99 | 100 | grid.addWidget(groupBox) 101 | 102 | 103 | self.statusBar = QStatusBar() 104 | grid.addWidget(self.statusBar) 105 | 106 | 107 | def event(self, event: QEvent) -> bool: 108 | """ 109 | Overridden method is used to catch QEvent.ToolTip to display current value 110 | 111 | :param event: QEvent instance 112 | :return: bool 113 | """ 114 | 115 | if event.type() == QEvent.ToolTip: 116 | self.resetButton.setToolTip(f"Current I error: " + 117 | self.app.settings['pid']['valueFormat'].format(self.app.conn.read('err_I'))) 118 | 119 | return super(ErrorsSettingsWindow, self).event(event) 120 | 121 | 122 | def show(self): 123 | """ 124 | Overridden method to update displaying widgets before showing the window itself 125 | 126 | :return: None 127 | """ 128 | 129 | self.updateDisplayingValues('err_P_limits', 'err_I_limits') 130 | 131 | super(ErrorsSettingsWindow, self).show() 132 | 133 | 134 | def removeStatusBarWidget(self, widget) -> None: 135 | """ 136 | Callback function to remove given widget from the status bar after timeout 137 | 138 | :param widget: widget to remove 139 | :return: None 140 | """ 141 | 142 | self.statusBar.removeWidget(widget) 143 | 144 | 145 | def updateDisplayingValues(self, *what) -> None: 146 | """ 147 | Refresh one or more widgets displaying values 148 | 149 | :param what: strings representing values names that need to be updated 150 | :return: None 151 | """ 152 | 153 | for item in what: 154 | valMin, valMax = self.app.conn.read(item) 155 | self.lineEdits[item]['min'].setText(self.app.settings['pid']['valueFormat'].format(valMin)) 156 | self.lineEdits[item]['max'].setText(self.app.settings['pid']['valueFormat'].format(valMax)) 157 | 158 | 159 | def setErrLimits(self, what: str) -> None: 160 | """ 161 | 'Set' button clicked slot 162 | 163 | :param what: string representing PID component error to set (proportional or integral) 164 | :return: None 165 | """ 166 | 167 | try: 168 | # QDoubleValidator doesn't work in combine with explicitly set value 169 | valMin = float(self.lineEdits[what]['min'].text()) 170 | valMax = float(self.lineEdits[what]['max'].text()) 171 | except ValueError: # user enters not valid number or NaN 172 | pass 173 | else: 174 | if valMax < valMin: 175 | resultLabel = QLabel("Upper limit value is less than lower") 176 | else: 177 | self.app.conn.write(what, valMin, valMax) 178 | resultLabel = QLabel('Success') 179 | self.statusBar.addWidget(resultLabel) 180 | QTimer().singleShot(STATUSBAR_MSG_TIMEOUT, functools.partial(self.removeStatusBarWidget, resultLabel)) 181 | 182 | self.updateDisplayingValues(what) 183 | 184 | 185 | def resetIerr(self) -> None: 186 | """ 187 | Set integral PID component to 0 188 | 189 | :return: None 190 | """ 191 | 192 | if self.app.conn.reset_i_err() == remotecontroller.result['ok']: 193 | statusLabel = QLabel("I error has been reset") 194 | else: 195 | statusLabel = QLabel("I error reset failed") 196 | self.statusBar.addWidget(statusLabel) 197 | QTimer().singleShot(STATUSBAR_MSG_TIMEOUT, functools.partial(self.removeStatusBarWidget, statusLabel)) 198 | -------------------------------------------------------------------------------- /pid-controller-gui/miscgraphics.py: -------------------------------------------------------------------------------- 1 | """ 2 | miscgraphics.py - some util widgets 3 | 4 | 5 | PicButton 6 | custom button with 3 different appearances: for normal, when mouse is over it and pressed states 7 | 8 | MessageWindow 9 | standalone window representing Info, Warning or Error with corresponding system icon and user text 10 | 11 | ValueGroupBox 12 | QGroupBox widget centered around a single numerical variable 13 | """ 14 | 15 | import random 16 | import string 17 | 18 | from PyQt5.QtCore import QSize 19 | from PyQt5.QtGui import QPainter, QIcon, QPixmap, QDoubleValidator 20 | from PyQt5.QtWidgets import QHBoxLayout, QVBoxLayout, QMessageBox, QAbstractButton, QPushButton, QGroupBox, QLabel, \ 21 | QLineEdit, QStyle 22 | 23 | # local imports 24 | import util 25 | import remotecontroller 26 | 27 | 28 | 29 | class PicButton(QAbstractButton): 30 | """ 31 | Custom button with 3 different looks: for normal, when mouse is over it and pressed states 32 | 33 | Usage example: 34 | 35 | import sys 36 | from PyQt5.QtWidgets import QApplication, QWidget, QHBoxLayout 37 | 38 | app = QApplication(sys.argv) 39 | window = QWidget() 40 | button = PicButton("img/refresh.png", "img/refresh_hover.png", "img/refresh_pressed.png") 41 | layout = QHBoxLayout(window) 42 | layout.addWidget(button) 43 | window.show() 44 | sys.exit(app.exec_()) 45 | 46 | """ 47 | 48 | def __init__(self, img_normal: str, img_hover: str, img_pressed: str, parent=None): 49 | """ 50 | PicButton constructor 51 | 52 | :param img_normal: path to an image that should represent a normal state of the button 53 | :param img_hover: path to an image that should represent a state of the button when the mouse cursor is hovering 54 | over it 55 | :param img_pressed: path to an image that should represent a pressed state of the button 56 | :param parent: [optional] parent class 57 | """ 58 | 59 | super(PicButton, self).__init__(parent) 60 | 61 | self.pixmap = QPixmap(img_normal) 62 | self.pixmap_hover = QPixmap(img_hover) 63 | self.pixmap_pressed = QPixmap(img_pressed) 64 | 65 | self.pressed.connect(self.update) 66 | self.released.connect(self.update) 67 | 68 | 69 | def paintEvent(self, event) -> None: 70 | if self.isDown(): 71 | pix = self.pixmap_pressed 72 | elif self.underMouse(): 73 | pix = self.pixmap_hover 74 | else: 75 | pix = self.pixmap 76 | 77 | QPainter(self).drawPixmap(event.rect(), pix) 78 | 79 | 80 | def enterEvent(self, event) -> None: 81 | self.update() 82 | 83 | def leaveEvent(self, event) -> None: 84 | self.update() 85 | 86 | def sizeHint(self) -> QSize: 87 | return QSize(24, 24) 88 | 89 | 90 | 91 | class MessageWindow(QMessageBox): 92 | 93 | """ 94 | Standalone window representing Info, Warning or Error with corresponding system icon and user text 95 | 96 | Usage example: 97 | 98 | if __name__ == '__main__': 99 | import sys 100 | from PyQt5.QtWidgets import QApplication 101 | 102 | app = QApplication(sys.argv) 103 | MessageWindow("Hello", status='Info') 104 | 105 | """ 106 | 107 | def __init__(self, text: str, status: str='Warning', parent=None): 108 | """ 109 | MessageWindow constructor 110 | 111 | :param text: string to show 112 | :param status: string representing message urgency ('Info', 'Warning' or 'Error') 113 | :param parent: [optional] parent class 114 | """ 115 | 116 | super(MessageWindow, self).__init__(parent) 117 | 118 | self.setWindowTitle(status) 119 | self.setWindowIcon(QIcon(util.resource_path('../img/error.png'))) 120 | 121 | if status == 'Info': 122 | self.setIcon(QMessageBox.Information) 123 | elif status == 'Warning': 124 | self.setIcon(QMessageBox.Warning) 125 | elif status == 'Error': 126 | self.setIcon(QMessageBox.Critical) 127 | 128 | self.setText(text) 129 | self.setStandardButtons(QMessageBox.Ok) 130 | 131 | self.exec_() 132 | 133 | 134 | 135 | class ValueGroupBox(QGroupBox): 136 | """ 137 | QGroupBox widget centered around a single numerical variable. It combines a QLabel to show the current value, a 138 | refresh PicButton to explicitly update it and a QLineEdit with an associated QPushButton to set a new value. 139 | """ 140 | 141 | def __init__(self, label: str, float_fmt: str='{:.3f}', conn: remotecontroller.RemoteController=None, parent=None): 142 | """ 143 | ValueGroupBox constructor 144 | 145 | :param label: name of the GroupBox 146 | :param conn: RemoteController instance to connect to 147 | :param parent: [optional] parent class 148 | """ 149 | 150 | super(ValueGroupBox, self).__init__(parent) 151 | 152 | self.setTitle(f"{label.capitalize()} control") 153 | 154 | self.label = label 155 | self.conn = conn 156 | 157 | # prepare a template string using another template string :) 158 | self.valLabelTemplate = string.Template(f"Current $label: {float_fmt}").safe_substitute(label=label) 159 | self.valLabel = QLabel() 160 | self.refreshVal() 161 | 162 | refreshButton = PicButton(util.resource_path('../img/refresh.png'), 163 | util.resource_path('../img/refresh_hover.png'), 164 | util.resource_path('../img/refresh_pressed.png')) 165 | refreshButton.clicked.connect(self.refreshVal) 166 | 167 | self.writeLine = QLineEdit() 168 | self.writeLine.setPlaceholderText(f"Enter new '{label}'") 169 | self.writeLine.setValidator(QDoubleValidator()) # we can set a Locale() to correctly process floats 170 | self.writeLine.setToolTip("Float value") 171 | 172 | writeButton = QPushButton(QIcon(self.style().standardIcon(QStyle.SP_DialogApplyButton)), 'Send') 173 | writeButton.clicked.connect(self.writeButtonClicked) 174 | 175 | hBox1 = QHBoxLayout() 176 | hBox1.addWidget(self.valLabel) 177 | hBox1.addStretch() # need to not distort the button when resizing 178 | hBox1.addSpacing(25) 179 | hBox1.addWidget(refreshButton) 180 | 181 | hBox2 = QHBoxLayout() 182 | hBox2.addWidget(self.writeLine) 183 | hBox2.addWidget(writeButton) 184 | 185 | vBox1 = QVBoxLayout() 186 | vBox1.addLayout(hBox1) 187 | vBox1.addLayout(hBox2) 188 | 189 | self.setLayout(vBox1) 190 | 191 | 192 | def refreshVal(self) -> None: 193 | """ 194 | Read a value from the RemoteController 195 | 196 | :return: None 197 | """ 198 | 199 | if self.conn is not None: 200 | self.valLabel.setText(self.valLabelTemplate.format(self.conn.read(self.label))) 201 | else: 202 | self.valLabel.setText(self.valLabelTemplate.format(random.random())) 203 | 204 | 205 | def writeButtonClicked(self) -> None: 206 | """ 207 | Send a new value to the RemoteController 208 | 209 | :return: None 210 | """ 211 | 212 | try: 213 | if self.conn is not None: 214 | self.conn.write(self.label, float(self.writeLine.text())) 215 | except ValueError: # user enters not valid number or NaN 216 | pass 217 | self.writeLine.clear() 218 | # read a value back to make sure that writing was successful (not really necessary to perform) 219 | self.refreshVal() 220 | 221 | 222 | 223 | if __name__ == '__main__': 224 | """ 225 | Use this block for testing purposes (run the module as a standalone script) 226 | """ 227 | 228 | import sys 229 | from PyQt5.QtWidgets import QApplication, QWidget 230 | 231 | app = QApplication(sys.argv) 232 | window = QWidget() 233 | 234 | button = PicButton(util.resource_path('../img/refresh.png'), 235 | util.resource_path('../img/refresh_hover.png'), 236 | util.resource_path('../img/refresh_pressed.png')) 237 | button.clicked.connect(lambda: print("PicButton has been clicked")) 238 | 239 | valueBox = ValueGroupBox("My value") 240 | 241 | invokeMessageWindow = QPushButton("Message window") 242 | invokeMessageWindow.clicked.connect(lambda: MessageWindow("I am MessageWindow", status='Info')) 243 | 244 | layout = QHBoxLayout(window) 245 | layout.addWidget(button) 246 | layout.addWidget(valueBox) 247 | layout.addWidget(invokeMessageWindow) 248 | 249 | window.show() 250 | 251 | sys.exit(app.exec_()) 252 | -------------------------------------------------------------------------------- /pid-controller-server/commandmanager.c: -------------------------------------------------------------------------------- 1 | // 2 | // commandmanager.c 3 | // pid-controller-server 4 | // 5 | // Created by Andrey Chufyrev on 18.11.2018. 6 | // Copyright © 2018 Andrey Chufyrev. All rights reserved. 7 | // 8 | 9 | #include "commandmanager.h" 10 | 11 | 12 | // void _print_bin_hex(unsigned char byte) { 13 | // printf("0b"); 14 | // for (int i=7; i>=0; i--) { 15 | // if ((byte & (1< 2.0*M_PI) 62 | x = 0.0; 63 | stream_values[0] = (float)sin(x); // Process Variable 64 | stream_values[1] = (float)cos(x); // Controller Output 65 | x = x + dx; 66 | 67 | memcpy(&stream_buf[1], stream_values, 2*sizeof(float)); 68 | 69 | // datagram sockets support multiple readers/writers even simultaneously so we do not need any mutex in 70 | // this simple case 71 | ssize_t n = sendto(sockfd, (const void *)stream_buf, STREAM_BUF_SIZE, 0, 72 | (const struct sockaddr *)&clientaddr, clientlen); 73 | if (n < 0) 74 | error("ERROR on sendto"); 75 | 76 | points_cnt++; 77 | 78 | // pthread_mutex_unlock(&sock_mutex); 79 | } 80 | 81 | nanosleep(&pv_thread_delay, NULL); 82 | } 83 | } 84 | 85 | 86 | void stream_start(void) { 87 | if (!stream_run) 88 | stream_run = true; 89 | } 90 | 91 | void stream_stop(void) { 92 | if (stream_run) { 93 | stream_run = false; 94 | 95 | printf("points: %d\n", points_cnt); 96 | points_cnt = 0; 97 | } 98 | } 99 | 100 | 101 | /* 102 | * Sample values, not constants so client can read/write them 103 | */ 104 | static float setpoint = 1238.0f; 105 | static float kP = 19.4f; 106 | static float kI = 8.7f; 107 | static float kD = 1.6f; 108 | static float err_I = 2055.0f; 109 | static float err_P_limits[2] = {-3500.0f, 3500.0f}; 110 | static float err_I_limits[2] = {-6500.0f, 6500.0f}; 111 | 112 | 113 | int process_request(unsigned char *request_response_buf) { 114 | // int process_request(unsigned char *request_buf, unsigned char *response_buf) { 115 | 116 | int result = 0; 117 | 118 | /* 119 | * Currently we use the same one buffer for both parsing the request and constructing the response. As 120 | * corresponding bit fields are match each other we can map the real request byte to the response structure. Also, 121 | * the first byte of the response buffer is the same as the first one of the request except the result field. 122 | * 123 | * Such approach looks more messy but, guess, should be faster to execute in hardware 124 | */ 125 | response_t request; 126 | // request_t request; 127 | memcpy(&request, &request_response_buf[0], sizeof(char)); 128 | 129 | // response_t response; 130 | // memcpy(&response, request_buf, sizeof(char)); 131 | 132 | // float values[2]; 133 | // memset(values, 0, 2*sizeof(float)); 134 | 135 | // printf("OPCODE: 0x%X\n", opcode); 136 | // printf("VAR CMD: 0x%X\n", var_cmd); 137 | 138 | if (request.opcode == OPCODE_read) { 139 | printf("read: "); 140 | // 'read' request from the client - we do not need cells allocated for values (doesn't care whether they were 141 | // supplied or not). Instead, we will use them to return values 142 | memset(&request_response_buf[1], 0, 2*sizeof(float)); 143 | 144 | switch (request.var_cmd) { 145 | case CMD_stream_stop: 146 | printf("CMD_stream_stop\n"); 147 | stream_stop(); 148 | result = RESULT_ok; 149 | break; 150 | case CMD_stream_start: 151 | printf("CMD_stream_start\n"); 152 | stream_start(); 153 | result = RESULT_ok; 154 | break; 155 | 156 | case VAR_setpoint: 157 | printf("VAR_setpoint\n"); 158 | memcpy(&request_response_buf[1], &setpoint, sizeof(float)); 159 | result = RESULT_ok; 160 | break; 161 | case VAR_kP: 162 | printf("VAR_kP\n"); 163 | memcpy(&request_response_buf[1], &kP, sizeof(float)); 164 | result = RESULT_ok; 165 | break; 166 | case VAR_kI: 167 | printf("VAR_kI\n"); 168 | memcpy(&request_response_buf[1], &kI, sizeof(float)); 169 | result = RESULT_ok; 170 | break; 171 | case VAR_kD: 172 | printf("VAR_kD\n"); 173 | memcpy(&request_response_buf[1], &kD, sizeof(float)); 174 | result = RESULT_ok; 175 | break; 176 | case VAR_err_I: 177 | printf("VAR_err_I\n"); 178 | memcpy(&request_response_buf[1], &err_I, sizeof(float)); 179 | result = RESULT_ok; 180 | break; 181 | case VAR_err_P_limits: 182 | printf("VAR_err_P_limits\n"); 183 | memcpy(&request_response_buf[1], err_P_limits, 2*sizeof(float)); 184 | result = RESULT_ok; 185 | break; 186 | case VAR_err_I_limits: 187 | printf("VAR_err_I_limits\n"); 188 | memcpy(&request_response_buf[1], err_I_limits, 2*sizeof(float)); 189 | result = RESULT_ok; 190 | break; 191 | 192 | case CMD_save_to_eeprom: 193 | printf("CMD_save_to_eeprom\n"); 194 | result = RESULT_ok; 195 | break; 196 | 197 | default: 198 | printf("Unknown request\n"); 199 | result = RESULT_error; 200 | break; 201 | } 202 | } 203 | 204 | else { 205 | printf("write: "); 206 | 207 | switch (request.var_cmd) { 208 | case VAR_setpoint: 209 | printf("VAR_setpoint\n"); 210 | memcpy(&setpoint, &request_response_buf[1], sizeof(float)); 211 | result = RESULT_ok; 212 | break; 213 | case VAR_kP: 214 | printf("VAR_kP\n"); 215 | memcpy(&kP, &request_response_buf[1], sizeof(float)); 216 | result = RESULT_ok; 217 | break; 218 | case VAR_kI: 219 | printf("VAR_kI\n"); 220 | memcpy(&kI, &request_response_buf[1], sizeof(float)); 221 | result = RESULT_ok; 222 | break; 223 | case VAR_kD: 224 | printf("VAR_kD\n"); 225 | memcpy(&kD, &request_response_buf[1], sizeof(float)); 226 | result = RESULT_ok; 227 | break; 228 | case VAR_err_I: 229 | printf("VAR_err_I\n"); 230 | if (*(float *)&request_response_buf[1] == 0.0f) { 231 | err_I = 0.0f; 232 | result = RESULT_ok; 233 | } 234 | else { 235 | result = RESULT_error; 236 | } 237 | break; 238 | case VAR_err_P_limits: 239 | printf("VAR_err_P_limits\n"); 240 | memcpy(err_P_limits, &request_response_buf[1], 2*sizeof(float)); 241 | result = RESULT_ok; 242 | break; 243 | case VAR_err_I_limits: 244 | printf("VAR_err_I_limits\n"); 245 | memcpy(err_I_limits, &request_response_buf[1], 2*sizeof(float)); 246 | result = RESULT_ok; 247 | break; 248 | 249 | default: 250 | printf("Unknown request\n"); 251 | result = RESULT_error; 252 | break; 253 | } 254 | 255 | memset(&request_response_buf[1], 0, 2*sizeof(float)); 256 | } 257 | 258 | request.result = result; 259 | memcpy(request_response_buf, &request, sizeof(char)); 260 | // response.result = result; 261 | // memcpy(response_buf, &response, sizeof(char)); 262 | 263 | return result; 264 | } 265 | -------------------------------------------------------------------------------- /pid-controller-gui/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | settings.py - way to settings managing, both internal and graphical 3 | 4 | 5 | Settings 6 | dict subclass with the additional interface to QSettings for handling both run-time and non-volatile settings 7 | 8 | SettingsWindow 9 | GUI to let a user edit the settings 10 | """ 11 | 12 | import copy 13 | import json 14 | import ipaddress 15 | 16 | from PyQt5.QtCore import QSettings 17 | from PyQt5.QtGui import QIcon, QIntValidator 18 | from PyQt5.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout, QGridLayout, QGroupBox, QRadioButton, QLabel, QStyle, \ 19 | QPushButton, QLineEdit, QSpinBox, QButtonGroup 20 | 21 | # local imports 22 | import util 23 | import miscgraphics 24 | 25 | 26 | 27 | class Settings(dict): 28 | """ 29 | Easier way to manage different parameters. Briefly speaking it combines conventional dictionary with the additional 30 | interface to QSettings via 'persistentStorage' property 31 | """ 32 | 33 | def __init__(self, defaults: str='../defaultSettings.json'): 34 | """ 35 | Settings constructor. Automatically retrieving settings from the persistent storage if they are present 36 | 37 | :param defaults: path to default settings JSON file 38 | """ 39 | 40 | super(Settings, self).__init__() 41 | 42 | with open(defaults, mode='r') as defaultSettingsJSONFile: 43 | self.defaults = json.load(defaultSettingsJSONFile) 44 | 45 | self.persistentStorage = QSettings() 46 | self._retrieve() 47 | 48 | 49 | def _retrieve(self) -> None: 50 | """ 51 | Determines whether settings are present in the system storage and based on that decides what to choose 52 | 53 | :return: None 54 | """ 55 | 56 | # It seems like QT or OS stores additional parameters unwanted for us (at least on macOS) so we isolate our 57 | # storage using 'app' group 58 | self.persistentStorage.beginGroup('app') 59 | 60 | if not self.persistentStorage.contains('settings'): 61 | print("No settings, clear all, use default...") 62 | self.persistentStorage.endGroup() 63 | self.persistentStorage.clear() 64 | 65 | # update() is the inherited method of the dictionary to update its items. We assume that 'defaults' will not 66 | # change but for a confidence we make a copy 67 | self.update(copy.deepcopy(self.defaults)) 68 | # self.save(self.defaults) 69 | 70 | else: 71 | print("Restore from NV-storage") 72 | self.update(self.persistentStorage.value('settings', type=dict)) 73 | self.persistentStorage.endGroup() 74 | 75 | 76 | def save(self, settings: dict) -> None: 77 | """ 78 | Push settings from the given dictionary to the persistent storage 79 | 80 | :param settings: dictionary containing settings. Any subclass including this can be passed as well 81 | :return: None 82 | """ 83 | 84 | print("Saving settings...") 85 | self.persistentStorage.beginGroup('app') 86 | self.persistentStorage.setValue('settings', copy.deepcopy(settings)) 87 | self.persistentStorage.endGroup() 88 | 89 | 90 | def __deepcopy__(self, memodict={}) -> dict: 91 | """ 92 | As this class contains additional properties such as QSettings that we don't want to be copied we need to 93 | override __deepcopy__ method 94 | 95 | :param memodict: see deepcopy docs, not used in our case 96 | :return: copied dictionary 97 | 98 | """ 99 | 100 | # inner instruction makes temporary shallow copy of the dictionary (without custom options) and outer is 101 | # performing an actual deep-copying 102 | return copy.deepcopy(dict(self)) 103 | 104 | 105 | 106 | class SettingsWindow(QWidget): 107 | """ 108 | Window to edit some parameters of network connection, visual appearance and so on. Actual saving is performing 109 | during the close event 110 | """ 111 | 112 | def __init__(self, app, parent=None): 113 | """ 114 | SettingsWindow constructor 115 | 116 | :param app: parent MainApplication instance 117 | :param parent: [optional] parent class 118 | """ 119 | 120 | super(SettingsWindow, self).__init__(parent) 121 | 122 | self.app = app 123 | 124 | self.setWindowTitle('Settings') 125 | self.setWindowIcon(QIcon(util.resource_path('../img/settings.png'))) 126 | 127 | # 128 | # Network section 129 | # 130 | networkGroupBox = QGroupBox("Controller connection") 131 | networkVBox = QVBoxLayout() 132 | networkGroupBox.setLayout(networkVBox) 133 | 134 | networkHBox1 = QHBoxLayout() 135 | self.ipLineEdit = QLineEdit() 136 | self.ipLineEdit.setInputMask("000.000.000.000;_") 137 | self.portLineEdit = QLineEdit() 138 | self.portLineEdit.setValidator(QIntValidator(0, 65535)) 139 | networkHBox1.addWidget(QLabel("IP address:")) 140 | networkHBox1.addWidget(self.ipLineEdit) 141 | networkHBox1.addWidget(QLabel("UDP port:")) 142 | networkHBox1.addWidget(self.portLineEdit) 143 | 144 | networkHBox2 = QHBoxLayout() 145 | self.connCheckIntervalSpinBox = QSpinBox() 146 | self.connCheckIntervalSpinBox.setSuffix(" ms") 147 | self.connCheckIntervalSpinBox.setMinimum(10) 148 | self.connCheckIntervalSpinBox.setMaximum(1e9) 149 | self.connCheckIntervalSpinBox.setSingleStep(1000) 150 | networkHBox2.addWidget(QLabel("Checks interval:")) 151 | networkHBox2.addWidget(self.connCheckIntervalSpinBox) 152 | 153 | networkVBox.addLayout(networkHBox1) 154 | networkVBox.addLayout(networkHBox2) 155 | 156 | # 157 | # Appearance section 158 | # 159 | themeGroupBox = QGroupBox('Theme') 160 | themeHBox = QHBoxLayout() 161 | themeGroupBox.setLayout(themeHBox) 162 | 163 | self.themeLightRadioButton = QRadioButton('Light') 164 | self.themeDarkRadioButton = QRadioButton('Dark') 165 | themeButtonGroup = QButtonGroup(themeGroupBox) 166 | themeButtonGroup.addButton(self.themeLightRadioButton) 167 | themeButtonGroup.addButton(self.themeDarkRadioButton) 168 | 169 | themeHBox.addWidget(self.themeLightRadioButton) 170 | themeHBox.addWidget(self.themeDarkRadioButton) 171 | 172 | # 173 | # Graphs section 174 | # 175 | graphsGroupBox = QGroupBox('Graphics') 176 | graphsVBox = QVBoxLayout() 177 | graphsGroupBox.setLayout(graphsVBox) 178 | 179 | graphsHBox1 = QHBoxLayout() 180 | self.graphsUpdateIntervalSpinBox = QSpinBox() 181 | self.graphsUpdateIntervalSpinBox.setSuffix(' ms') 182 | self.graphsUpdateIntervalSpinBox.setMinimum(1) 183 | self.graphsUpdateIntervalSpinBox.setMaximum(1e9) 184 | self.graphsUpdateIntervalSpinBox.setSingleStep(10) 185 | graphsHBox1.addWidget(QLabel("Update interval:")) 186 | graphsHBox1.addWidget(self.graphsUpdateIntervalSpinBox) 187 | 188 | graphsHBox2 = QHBoxLayout() 189 | self.graphsNumberOfPointsSpinBox = QSpinBox() 190 | self.graphsNumberOfPointsSpinBox.setMinimum(3) 191 | self.graphsNumberOfPointsSpinBox.setMaximum(1e5) 192 | self.graphsNumberOfPointsSpinBox.setSingleStep(50) 193 | graphsHBox2.addWidget(QLabel("Number of points:")) 194 | graphsHBox2.addWidget(self.graphsNumberOfPointsSpinBox) 195 | 196 | graphsVBox.addLayout(graphsHBox1) 197 | graphsVBox.addLayout(graphsHBox2) 198 | 199 | 200 | # reset to defaults 201 | resetSettingsButton = QPushButton(QIcon(self.style().standardIcon(QStyle.SP_DialogCancelButton)), 202 | "Reset to defaults") 203 | resetSettingsButton.clicked.connect(self.resetSettings) 204 | 205 | # get all values for the first time 206 | self.updateDisplayingValues() 207 | 208 | # widget layout - grid 209 | grid = QGridLayout() 210 | self.setLayout(grid) 211 | grid.addWidget(themeGroupBox) 212 | grid.addWidget(networkGroupBox) 213 | grid.addWidget(graphsGroupBox) 214 | grid.addWidget(resetSettingsButton) 215 | 216 | 217 | def updateDisplayingValues(self) -> None: 218 | """ 219 | Using global app settings class property to set all widgets to its values 220 | 221 | :return: None 222 | """ 223 | 224 | self.ipLineEdit.setText(self.app.settings['network']['ip']) 225 | self.portLineEdit.setText(str(self.app.settings['network']['port'])) 226 | self.connCheckIntervalSpinBox.setValue(self.app.settings['network']['checkInterval']) 227 | if self.app.settings['appearance']['theme'] == 'light': 228 | self.themeLightRadioButton.setChecked(True) 229 | else: 230 | self.themeDarkRadioButton.setChecked(True) 231 | self.graphsUpdateIntervalSpinBox.setValue(self.app.settings['graphs']['updateInterval']) 232 | self.graphsNumberOfPointsSpinBox.setValue(self.app.settings['graphs']['numberOfPoints']) 233 | 234 | 235 | def show(self): 236 | """ 237 | Overridden method to refresh all values on each window show 238 | 239 | :return: None 240 | """ 241 | 242 | self.settingsAtStart = copy.deepcopy(self.app.settings) 243 | self.updateDisplayingValues() 244 | 245 | super(SettingsWindow, self).show() 246 | 247 | 248 | def closeEvent(self, event): 249 | """ 250 | Save settings on each window close. Before that we can edit parameters as we want to 251 | 252 | :param event: QT event 253 | :return: None 254 | """ 255 | 256 | self.saveSettings() 257 | super(SettingsWindow, self).closeEvent(event) 258 | 259 | 260 | def saveSettings(self) -> None: 261 | """ 262 | Parse, check and save the settings into the persistent storage associated with a self.app.settings instance 263 | 264 | :return: None 265 | """ 266 | 267 | errors = [] 268 | 269 | try: 270 | ipaddress.ip_address(self.ipLineEdit.text()) 271 | self.app.settings['network']['ip'] = self.ipLineEdit.text() 272 | except ValueError: 273 | errors.append('IP address') 274 | 275 | try: 276 | self.app.settings['network']['port'] = int(self.portLineEdit.text()) 277 | except ValueError: 278 | errors.append('UDP port') 279 | 280 | self.app.settings['network']['checkInterval'] = self.connCheckIntervalSpinBox.value() 281 | if self.themeLightRadioButton.isChecked(): 282 | self.app.settings['appearance']['theme'] = 'light' 283 | else: 284 | self.app.settings['appearance']['theme'] = 'dark' 285 | self.app.settings['graphs']['updateInterval'] = int(self.graphsUpdateIntervalSpinBox.value()) 286 | self.app.settings['graphs']['numberOfPoints'] = int(self.graphsNumberOfPointsSpinBox.value()) 287 | 288 | 289 | if self.app.settings == self.settingsAtStart: 290 | print("settings are same as at start") 291 | return 292 | elif self.app.settings == self.app.settings.defaults: 293 | print("settings are same as default") 294 | self.resetSettings() 295 | miscgraphics.MessageWindow("Settings have been reset to their defaults. " 296 | "Please restart the application to take effects", status='Info') 297 | return 298 | else: 299 | self.app.settings.save(self.app.settings) 300 | if errors: 301 | miscgraphics.MessageWindow("There were errors during these parameters saving:\n\n\t" + 302 | "\n\t".join(errors) + "\n\nPlease check input data", status='Error') 303 | else: 304 | miscgraphics.MessageWindow("Parameters are successfully saved. Please restart the application to take " 305 | "effects", status='Info') 306 | 307 | 308 | def resetSettings(self) -> None: 309 | """ 310 | Recover settings to their default values 311 | 312 | :return: None 313 | """ 314 | 315 | self.app.settings.persistentStorage.clear() 316 | self.app.settings.update(copy.deepcopy(self.app.settings.defaults)) 317 | self.updateDisplayingValues() 318 | -------------------------------------------------------------------------------- /pid-controller-gui/graphs.py: -------------------------------------------------------------------------------- 1 | """ 2 | graphs.py - PyQtGraph fast widget to display live plots 3 | 4 | 5 | STREAM_PIPE_OVERFLOW_NUM_POINTS_THRESHOLD 6 | number of stream points (vector with multiple values) representing difference between numbers of incoming and 7 | plotted points that we consider an overflow 8 | 9 | STREAM_PIPE_OVERFLOW_CHECK_TIME_PERIOD_MS 10 | overflow checking timer period in milliseconds 11 | 12 | STREAM_PIPE_OVERFLOW_WARNING_SIGN_DURATION 13 | time for which an overflow warning sign will be displayed 14 | 15 | 16 | CustomGraphicsLayoutWidget 17 | PyQtGraph fast widget to display live plots 18 | """ 19 | 20 | import multiprocessing.connection 21 | 22 | import numpy as np 23 | 24 | import pyqtgraph 25 | 26 | from PyQt5.QtCore import QTimer 27 | from PyQt5.QtWidgets import QVBoxLayout 28 | 29 | # local imports 30 | import remotecontroller 31 | 32 | 33 | 34 | STREAM_PIPE_OVERFLOW_NUM_POINTS_THRESHOLD = 50 35 | STREAM_PIPE_OVERFLOW_CHECK_TIME_PERIOD_MS = 10000 36 | STREAM_PIPE_OVERFLOW_WARNING_SIGN_DURATION = 5000 37 | 38 | 39 | 40 | class CustomGraphicsLayoutWidget(pyqtgraph.GraphicsLayoutWidget): 41 | """ 42 | Plots widget with animation support. This class allows to place plots only. To be able to add some other pyqtgraph 43 | elements you can use pyqtgraph.LayoutWidget as a base instead: 44 | 45 | class CustomLayoutWidget(pyqtgraph.LayoutWidget): 46 | 47 | def __init__(self): 48 | super(CustomLayoutWidget, self).__init__() 49 | 50 | self.timeAxes = np.linspace(-nPoints*interval, 0, nPoints) 51 | 52 | self.procVarGraph = pyqtgraph.PlotWidget(y=np.zeros([self.nPoints])) 53 | self.procVarGraph.plotItem.setRange(yRange=procVarRange) 54 | self.addWidget(self.procVarGraph) 55 | self.nextRow() 56 | self.averLabel = pyqtgraph.ValueLabel(averageTime=nPoints*interval) 57 | self.addWidget(self.averLabel) 58 | self.nextRow() 59 | self.contOutGraph = pyqtgraph.PlotWidget(y=np.zeros([self.nPoints])) 60 | self.contOutGraph.plotItem.setRange(yRange=contOutRange) 61 | self.addWidget(self.contOutGraph) 62 | 63 | self.updateTimer = QTimer() 64 | self.updateTimer.timeout.connect(self.update_graphs) 65 | if start: 66 | self.start_live_graphs() 67 | 68 | ... 69 | 70 | """ 71 | 72 | def __init__( 73 | self, names: tuple=("Process Variable", "Controller Output"), numPoints: int=200, interval: int=17, 74 | ranges: tuple=((-2.0, 2.0), (-2.0, 2.0)), units: tuple=('Monkeys', 'Parrots'), 75 | controlPipe: multiprocessing.connection.Connection=None, 76 | streamPipeRX: multiprocessing.connection.Connection=None, 77 | theme: str='dark', 78 | ): 79 | """ 80 | Graphs' constructor. Lengths of tuple arguments should be equal and each item in them should respectively match 81 | others 82 | 83 | :param names: tuple of strings with graphs' names 84 | :param numPoints: number of points in each graph 85 | :param interval: time in ms to force the plot refresh 86 | :param ranges: tuple of tuples with (min,max) values of each plot respectively 87 | :param units: tuple of strings representing measurement unit of each plot 88 | :param controlPipe: multiprocessing.Connection instance to communicate with a stream source 89 | :param streamPipeRX: multiprocessing.Connection instance from where new points should arrive 90 | :param theme: string representing visual appearance of the widget ('light' or 'dark') 91 | """ 92 | 93 | # lengths of tuple arguments should be equal 94 | assert len(names) == len(ranges) == len(units) 95 | 96 | # need to set a theme before any other pyqtgraph operations 97 | if theme != 'dark': 98 | pyqtgraph.setConfigOption('background', 'w') 99 | pyqtgraph.setConfigOption('foreground', 'k') 100 | 101 | super(CustomGraphicsLayoutWidget, self).__init__() 102 | 103 | 104 | self.nPoints = numPoints 105 | self.pointsCnt = 0 106 | self.lastPoint = np.zeros(len(names)) 107 | self.interval = interval 108 | 109 | self.names = list(names) # for usage outside the class 110 | self.ranges = list(ranges) 111 | 112 | # X (time) axis is "starting" at the right border (current time) and goes to the past to the left (negative 113 | # time). It remains the same for an entire Graphs lifetime 114 | self.timeAxes = np.linspace(-numPoints * interval, 0, numPoints) 115 | 116 | 117 | if controlPipe is not None and streamPipeRX is not None: 118 | self._isOfflineMode = False 119 | 120 | self.controlPipe = controlPipe 121 | 122 | self.overflowCheckTimer = QTimer() 123 | self.overflowCheckTimer.timeout.connect(self._overflowCheck) 124 | 125 | self.streamPipeRX = streamPipeRX 126 | else: 127 | self._isOfflineMode = True 128 | 129 | self._isRun = False 130 | 131 | 132 | self.graphs = [] 133 | for name, range in zip(names, ranges): 134 | if name == names[-1]: 135 | graph = self.addPlot(y=np.zeros(numPoints), labels={'right': name, 'bottom': "Time, ms"}, pen='r') 136 | else: 137 | graph = self.addPlot(y=np.zeros(numPoints), labels={'right': name}, pen='r') 138 | graph.setRange(yRange=range) 139 | graph.hideButtons() 140 | graph.hideAxis('left') 141 | graph.showGrid(x=True, y=True, alpha=0.2) 142 | self.graphs.append(graph) 143 | self.nextRow() 144 | 145 | # label widget accumulating incoming values and calculating an average from last 'averageTime' seconds 146 | self.averageLabels = [] 147 | for name, unit in zip(names, units): 148 | averageLabel = pyqtgraph.ValueLabel(siPrefix=True, suffix=unit, averageTime=numPoints * interval * 0.001) 149 | averageLabel.setToolTip(f"Average {name} value of last {averageLabel.averageTime:.2f}s") 150 | self.averageLabels.append(averageLabel) 151 | 152 | 153 | # data receiving and plots redrawing timer 154 | self.updateTimer = QTimer() 155 | self.updateTimer.timeout.connect(self._update) 156 | 157 | # notify a user about an overflow by a red circle appearing in an upper-left corner of the plot canvas 158 | self._warningSign = None 159 | self.warningSignRemoveTimer = QTimer() 160 | self.warningSignRemoveTimer.setSingleShot(True) 161 | self.warningSignRemoveTimer.setInterval(STREAM_PIPE_OVERFLOW_WARNING_SIGN_DURATION) 162 | self.warningSignRemoveTimer.timeout.connect(self._removeWarningSign) 163 | 164 | 165 | @property 166 | def isRun(self) -> bool: 167 | """bool property getter""" 168 | return self._isRun 169 | 170 | 171 | def _addWarningSign(self) -> None: 172 | """ 173 | Notify a user about an overflow by a red circle appearing in an upper-left corner of the plot canvas 174 | 175 | :return: None 176 | """ 177 | 178 | self._warningSign = self.graphs[0].plot(y=[self.ranges[0][1]*0.75], x=[-self.nPoints*self.interval*0.95], 179 | symbol='o', symbolSize=24, symbolPen='r', symbolBrush='r') 180 | 181 | def _removeWarningSign(self) -> None: 182 | """ 183 | Notify a user about an overflow by a red circle appearing in an upper-left corner of the plot canvas (callback 184 | for warningSignRemoveTimer.timeout slot) 185 | 186 | :return: None 187 | """ 188 | 189 | self.graphs[0].removeItem(self._warningSign) 190 | 191 | 192 | def _overflowCheck(self) -> None: 193 | """ 194 | Procedure to check the stream pipe overflow. When a rate of incoming stream socket packets is faster than an 195 | update time period of this graphs (i.e. pipe' readings) an internal buffer of the pipe entity will grow. We 196 | responsible for the detection of such situations because quite soon after this the entire connection tends to 197 | be an unresponsive 198 | 199 | :return: None 200 | """ 201 | 202 | # request to read a points (messages) counter 203 | self.controlPipe.send(remotecontroller.InputThreadCommand.MSG_CNT_GET) 204 | if self.controlPipe.poll(timeout=0.1): # wait for it ... 205 | input_thread_points_cnt = self.controlPipe.recv() # ... and read it 206 | 207 | print(f'sock: {input_thread_points_cnt}, plot: {self.pointsCnt}') 208 | 209 | # compare the local points counter with gotten one (overflow condition) 210 | if input_thread_points_cnt - self.pointsCnt > STREAM_PIPE_OVERFLOW_NUM_POINTS_THRESHOLD: 211 | self.stop() # stop incoming stream and flush the pipe 212 | self._addWarningSign() # notify a user 213 | self.warningSignRemoveTimer.start() 214 | self.start() # restart the stream 215 | 216 | 217 | def start(self) -> None: 218 | """ 219 | Prepare and start a live plotting 220 | 221 | :return: None 222 | """ 223 | 224 | # reset data cause it has changed during the pause time 225 | for graph in self.graphs: 226 | graph.curves[0].setData(self.timeAxes, np.zeros(self.nPoints)) 227 | 228 | self.updateTimer.start(self.interval) 229 | 230 | if not self._isOfflineMode: 231 | self.overflowCheckTimer.start(STREAM_PIPE_OVERFLOW_CHECK_TIME_PERIOD_MS) 232 | self.controlPipe.send(remotecontroller.InputThreadCommand.STREAM_ACCEPT) # send command to allow stream 233 | 234 | self._isRun = True 235 | 236 | 237 | def stop(self) -> None: 238 | """ 239 | Stop a live plotting and do finish routines 240 | 241 | :return: None 242 | """ 243 | 244 | self.updateTimer.stop() 245 | 246 | if not self._isOfflineMode: 247 | self.overflowCheckTimer.stop() 248 | self.controlPipe.send(remotecontroller.InputThreadCommand.STREAM_REJECT) 249 | self.controlPipe.send(remotecontroller.InputThreadCommand.MSG_CNT_RST) # reset remote counter 250 | 251 | # flush the stream pipe 252 | while True: 253 | if self.streamPipeRX.poll(): 254 | self.streamPipeRX.recv() 255 | else: 256 | break 257 | 258 | self.pointsCnt = 0 # reset local counter 259 | self._isRun = False 260 | 261 | 262 | def toggle(self) -> None: 263 | """ 264 | Toggle live plotting 265 | 266 | :return: None 267 | """ 268 | 269 | if self._isRun: 270 | self.stop() 271 | else: 272 | self.start() 273 | 274 | 275 | def _update(self) -> None: 276 | """ 277 | Routine to get a new data and plot it (i.e. redraw graphs) 278 | 279 | :return: None 280 | """ 281 | 282 | # use fake (random) numbers in offline mode 283 | if self._isOfflineMode: 284 | self.lastPoint = -0.5 + np.random.random(len(self.lastPoint)) 285 | self.pointsCnt += 1 286 | else: 287 | try: 288 | if self.streamPipeRX.poll(): 289 | point = self.streamPipeRX.recv() 290 | self.lastPoint = point 291 | self.pointsCnt += 1 292 | except OSError: # may occur during an exit mess 293 | pass 294 | 295 | # shift points array on 1 position to free up the place for a new point 296 | for point, graph, averageLabel in zip(self.lastPoint, self.graphs, self.averageLabels): 297 | data = np.roll(graph.curves[0].getData()[1], -1) 298 | data[-1] = point 299 | graph.curves[0].setData(self.timeAxes, data) 300 | averageLabel.setValue(point) 301 | 302 | 303 | 304 | if __name__ == '__main__': 305 | """ 306 | Use this block for testing purposes (run the module as a standalone script) 307 | """ 308 | 309 | from PyQt5.QtWidgets import QWidget, QApplication 310 | import sys 311 | 312 | app = QApplication(sys.argv) 313 | window = QWidget() 314 | 315 | graphs = CustomGraphicsLayoutWidget() 316 | graphs.start() 317 | 318 | layout = QVBoxLayout(window) 319 | layout.addWidget(graphs) 320 | for label in graphs.averageLabels: 321 | layout.addWidget(label) 322 | 323 | window.show() 324 | sys.exit(app.exec_()) 325 | -------------------------------------------------------------------------------- /pid-controller-gui/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | main.py - Main script 3 | 4 | 5 | MainApplication 6 | customized QApplication which encapsulates settings, controller remote connection 7 | 8 | MainWindow 9 | QMainWindow subclass for tools and status bars, some actions and signals/slots 10 | 11 | CentralWidget 12 | remaining UI elements such as PID values GroupBox'es, live graphs 13 | """ 14 | 15 | import multiprocessing 16 | import sys 17 | 18 | from PyQt5.QtCore import Qt, QCoreApplication, QTimer, pyqtSlot, pyqtSignal 19 | from PyQt5.QtWidgets import QApplication, QWidget, QMainWindow, QGridLayout, QHBoxLayout, QLabel, QAction 20 | from PyQt5.QtGui import QIcon 21 | 22 | import qdarkstyle 23 | 24 | # local imports 25 | import util 26 | import remotecontroller 27 | import miscgraphics 28 | import graphs 29 | import settings 30 | import errorssettings 31 | import about 32 | 33 | 34 | 35 | 36 | class CentralWidget(QWidget): 37 | """ 38 | CentralWidget holds ValueGroupBox'es corresponding to PID setpoint and coefficients, live graphs and averaging 39 | labels 40 | """ 41 | 42 | def __init__(self, app: QApplication, parent=None): 43 | """ 44 | CentralWidget constructor 45 | 46 | :param app: parent MainApplication 47 | :param parent: [optional] parent class 48 | """ 49 | 50 | super(CentralWidget, self).__init__(parent) 51 | 52 | self.app = app 53 | 54 | grid = QGridLayout() 55 | self.setLayout(grid) 56 | 57 | 58 | self.contValGroupBoxes = [ 59 | miscgraphics.ValueGroupBox('setpoint', float_fmt=app.settings['pid']['valueFormat'], conn=app.conn), 60 | miscgraphics.ValueGroupBox('kP', float_fmt=app.settings['pid']['valueFormat'], conn=app.conn), 61 | miscgraphics.ValueGroupBox('kI', float_fmt=app.settings['pid']['valueFormat'], conn=app.conn), 62 | miscgraphics.ValueGroupBox('kD', float_fmt=app.settings['pid']['valueFormat'], conn=app.conn) 63 | ] 64 | 65 | for groupBox, yPosition in zip(self.contValGroupBoxes, [0,3,6,9]): 66 | grid.addWidget(groupBox, yPosition, 0, 3, 2) 67 | 68 | 69 | self.graphs = graphs.CustomGraphicsLayoutWidget( 70 | names=(app.settings['pid']['processVariable']['name'], 71 | app.settings['pid']['controllerOutput']['name']), 72 | numPoints=app.settings['graphs']['numberOfPoints'], 73 | interval=app.settings['graphs']['updateInterval'], 74 | ranges=((app.settings['pid']['processVariable']['limits']['min'], 75 | app.settings['pid']['processVariable']['limits']['max']), 76 | (app.settings['pid']['controllerOutput']['limits']['min'], 77 | app.settings['pid']['controllerOutput']['limits']['max'])), 78 | units=(app.settings['pid']['processVariable']['unit'], 79 | app.settings['pid']['controllerOutput']['unit']), 80 | controlPipe=None if app.isOfflineMode else app.conn.input_thread_control_pipe_main, 81 | streamPipeRX=None if app.isOfflineMode else app.conn.stream.pipe_rx, 82 | theme=app.settings['appearance']['theme'], 83 | ) 84 | 85 | for averageLabel, name, yPosition in zip(self.graphs.averageLabels, self.graphs.names, [12,13]): 86 | hBox = QHBoxLayout() 87 | hBox.addWidget(QLabel(name)) 88 | hBox.addWidget(averageLabel, alignment=Qt.AlignLeft) 89 | grid.addLayout(hBox, yPosition, 0, 1, 2) 90 | 91 | grid.addWidget(self.graphs, 0, 2, 14, 6) 92 | 93 | 94 | def updateDisplayingValues(self) -> None: 95 | """ 96 | Retrieve all controller parameters and update corresponding GUI elements. Useful to apply after connection's 97 | breaks. This does not affect values saved during the app launch (RemoteController.save_current_values()) 98 | 99 | :return: None 100 | """ 101 | 102 | for groupBox in self.contValGroupBoxes: 103 | groupBox.refreshVal() 104 | 105 | self.app.mainWindow.errorsSettingsWindow.updateDisplayingValues('err_P_limits', 'err_I_limits') 106 | 107 | 108 | 109 | 110 | class MainWindow(QMainWindow): 111 | """ 112 | MainWindow contains of toolbar, status bar, menu. All other items are placed on a CentralWidget 113 | """ 114 | 115 | def __init__(self, app: QApplication, parent=None): 116 | """ 117 | MainWindow constructor 118 | 119 | :param app: parent MainApplication 120 | :param parent: [optional] parent class 121 | """ 122 | 123 | super(MainWindow, self).__init__(parent) 124 | 125 | self.app = app 126 | 127 | self.setWindowTitle(QCoreApplication.applicationName()) 128 | self.setWindowIcon(QIcon(util.resource_path('../img/icon.png'))) 129 | 130 | 131 | # 132 | # App Toolbar section 133 | # 134 | exitAction = QAction(QIcon(util.resource_path('../img/exit.png')), 'Exit', self) 135 | exitAction.setShortcut('Ctrl+Q') 136 | exitAction.setStatusTip("[Ctrl+Q] Exit application") 137 | exitAction.triggered.connect(self.app.quit) 138 | 139 | aboutAction = QAction(QIcon(util.resource_path('../img/info.png')), 'About', self) # see about.py 140 | aboutAction.setShortcut('Ctrl+I') 141 | aboutAction.setStatusTip("[Ctrl+I] Application Info & About") 142 | self.aboutWindow = about.AboutWindow() 143 | aboutAction.triggered.connect(self.aboutWindow.show) 144 | 145 | settingsAction = QAction(QIcon(util.resource_path('../img/settings.png')), 'Settings', self) # see settings.py 146 | settingsAction.setShortcut('Ctrl+P') 147 | settingsAction.setStatusTip("[Ctrl+P] Application Settings") 148 | self.settingsWindow = settings.SettingsWindow(app=app) 149 | settingsAction.triggered.connect(self.settingsWindow.show) 150 | 151 | appToolbar = self.addToolBar('app') # internal name 152 | appToolbar.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) 153 | appToolbar.addAction(exitAction) 154 | appToolbar.addAction(aboutAction) 155 | appToolbar.addAction(settingsAction) 156 | 157 | # 158 | # Controller Toolbar section 159 | # 160 | # see errorssettings.py 161 | errorsSettingsAction = QAction(QIcon(util.resource_path('../img/set_errors.png')), 'Errors limits', self) 162 | errorsSettingsAction.setShortcut('E') 163 | errorsSettingsAction.setStatusTip("[E] Set values of errors limits") 164 | self.errorsSettingsWindow = errorssettings.ErrorsSettingsWindow(app=app) 165 | errorsSettingsAction.triggered.connect(self.errorsSettingsWindow.show) 166 | 167 | restoreValuesAction = QAction(QIcon(util.resource_path('../img/restore.png')), 'Restore controller', self) 168 | restoreValuesAction.setShortcut('R') 169 | restoreValuesAction.setStatusTip("[R] Restore all controller parameters to values at the program start time") 170 | restoreValuesAction.triggered.connect(self.restoreContValues) 171 | 172 | saveToEEPROMAction = QAction(QIcon(util.resource_path('../img/eeprom.png')), 'Save to EEPROM', self) 173 | saveToEEPROMAction.setShortcut('S') 174 | saveToEEPROMAction.setStatusTip("[S] Save current controller configuration to EEPROM") 175 | saveToEEPROMAction.triggered.connect(self.saveToEEPROM) 176 | 177 | contToolbar = self.addToolBar('controller') # internal name 178 | contToolbar.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) 179 | contToolbar.addAction(errorsSettingsAction) 180 | contToolbar.addAction(restoreValuesAction) 181 | contToolbar.addAction(saveToEEPROMAction) 182 | 183 | # 184 | # Graphs Toolbar section 185 | # 186 | playpauseAction = QAction(QIcon(util.resource_path('../img/play_pause.png')), 'Play/Pause', self) 187 | playpauseAction.setShortcut('P') 188 | playpauseAction.setStatusTip("[P] Play/pause graphs") 189 | playpauseAction.triggered.connect(self.playpauseGraphs) 190 | 191 | graphsToolbar = self.addToolBar('graphs') # internal name 192 | graphsToolbar.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) 193 | graphsToolbar.addAction(playpauseAction) 194 | self.playpauseButton = graphsToolbar.widgetForAction(playpauseAction) 195 | self.playpauseButton.setCheckable(True) 196 | self.playpauseButton.setChecked(True) 197 | self.graphsWereRun = False 198 | 199 | 200 | mainMenu = self.menuBar().addMenu('&Menu') 201 | mainMenu.addAction(aboutAction) 202 | mainMenu.addAction(settingsAction) 203 | mainMenu.addAction(exitAction) 204 | 205 | 206 | if self.app.isOfflineMode: 207 | self.statusBar().addWidget(QLabel("Offline mode")) 208 | 209 | self.centralWidget = CentralWidget(app=app) 210 | self.setCentralWidget(self.centralWidget) 211 | 212 | self.statusBar().show() # can be not visible in online mode otherwise 213 | 214 | 215 | def playpauseGraphs(self) -> None: 216 | """ 217 | Smartly toggles the state of live graphs 218 | 219 | :return: None 220 | """ 221 | 222 | if self.centralWidget.graphs.isRun: 223 | self.playpauseButton.setChecked(True) 224 | else: 225 | self.playpauseButton.setChecked(False) 226 | self.app.conn.stream.toggle() 227 | self.centralWidget.graphs.toggle() 228 | 229 | 230 | def restoreContValues(self) -> None: 231 | """ 232 | Write PID parameters stored at program start to the controller 233 | 234 | :return: None 235 | """ 236 | 237 | date = self.app.conn.restore_values(self.app.conn.snapshots[0]) # currently save and use only one snapshot 238 | print(f"Snapshot from {date} is restored") 239 | self.centralWidget.updateDisplayingValues() 240 | 241 | 242 | def saveToEEPROM(self) -> None: 243 | """ 244 | Initiate writing of current parameters to the controller's EEPROM 245 | 246 | :return: None 247 | """ 248 | 249 | if self.app.conn.save_to_eeprom() == remotecontroller.result['ok']: 250 | miscgraphics.MessageWindow("Successfully saved", status='Info') 251 | self.centralWidget.updateDisplayingValues() 252 | else: 253 | miscgraphics.MessageWindow("Saving failed!", status='Error') 254 | 255 | 256 | def hideEvent(self, *args, **kwargs) -> None: 257 | """ 258 | Detect window hide event to perform necessary actions. Modern OSes put minimized DE applications into some kind 259 | of 'sleep' mode to optimize its own performance and energy consumption. This leads to, for example, QT timers to 260 | be slow down and so on. And it may lead to some violations in a work flow such as connection breaks and stream 261 | overflows. So it better to 'freeze' any network and graphical activity during such period and then smoothly 262 | resume it when needed. The user doesn't stare at the application, anyway :) 263 | 264 | Note: this doesn't include simple moving a window to the background (e.g. overlapping by another window) 265 | 266 | :param args: positional arguments passing directly to the base-class function 267 | :param kwargs: keyword arguments passing directly to the base-class function 268 | :return: None 269 | """ 270 | 271 | # 1. stop the connection check timer 272 | if not self.app.isOfflineMode: 273 | self.app.connCheckTimer.stop() 274 | 275 | # 2. stop live plots (it stops both the stream and graphs) 276 | if self.centralWidget.graphs.isRun: 277 | self.playpauseGraphs() 278 | self.graphsWereRun = True 279 | else: 280 | self.graphsWereRun = False 281 | 282 | # 3. in the end block the listening section of the input thread (to prevent pipes overflows) 283 | self.app.conn.pause() 284 | 285 | # finally, call the base class' method 286 | super(MainWindow, self).hideEvent(*args, **kwargs) 287 | 288 | 289 | def showEvent(self, *args, **kwargs) -> None: 290 | """ 291 | Detect window show event to perform necessary actions. Modern OSes put minimized DE applications into some kind 292 | of 'sleep' mode to optimize its own performance and energy consumption. This leads to, for example, QT timers to 293 | be slow down and so on. And it may lead to some violations in a work flow such as connection breaks and stream 294 | overflows. So it better to 'freeze' any network and graphical activity during such period and then smoothly 295 | resume it when needed. The user doesn't stare at the application, anyway :) 296 | 297 | Note: this doesn't include simple moving a window to the background (e.g. overlapping by another window) 298 | 299 | :param args: positional arguments passing directly to the base-class function 300 | :param kwargs: keyword arguments passing directly to the base-class function 301 | :return: None 302 | """ 303 | 304 | # 1. resume the listening section of the input thread first 305 | self.app.conn.resume() 306 | 307 | # 2. start the connection check timer 308 | if not self.app.isOfflineMode: 309 | self.app.connCheckTimer.start(self.app.settings['network']['checkInterval']) 310 | 311 | # 3. re-run graphs if they were in run 312 | if self.graphsWereRun: 313 | self.playpauseGraphs() 314 | 315 | # finally, call the base class' method 316 | super(MainWindow, self).showEvent(*args, **kwargs) 317 | 318 | 319 | def closeEvent(self, event) -> None: 320 | """ 321 | Catch the window close event - interpret it the same as an application termination 322 | 323 | :param event: QT event 324 | :return: None 325 | """ 326 | 327 | self.hide() 328 | self.app.quit() 329 | 330 | 331 | 332 | 333 | class MainApplication(QApplication): 334 | """ 335 | Customized QApplication - entry point of the whole program 336 | """ 337 | 338 | 339 | connLostSignal = pyqtSignal() # must be part of the class definition and cannot be dynamically added after 340 | 341 | 342 | def __init__(self, argv: list): 343 | """ 344 | MainApplication constructor 345 | 346 | :param argv: the list of command line arguments passed to a Python script 347 | """ 348 | 349 | super(MainApplication, self).__init__(argv) 350 | 351 | 352 | # settings [customized] dictionary 353 | self.settings = settings.Settings(defaults=util.resource_path('../defaultSettings.json')) 354 | 355 | if self.settings['appearance']['theme'] == 'dark': 356 | # TODO: warns itself as a deprecated method though no suitable alternative has been suggested 357 | self.setStyleSheet(qdarkstyle.load_stylesheet_pyqt5()) 358 | 359 | 360 | # Create a handler function for connection breaks (for example, when a break is occur during the read of some 361 | # coefficient from the controller). We need to setup it early here to proper connection initialization process 362 | self.connLostSignal.connect(self.connLostHandler) 363 | # show this when the connection is broken 364 | self.connLostStatusBarLabel = QLabel("Connection was lost. Trying to reconnect...") 365 | # check connection timer, set it when we will know status of the connection 366 | self.connCheckTimer = QTimer() 367 | 368 | self.isOfflineMode = False 369 | 370 | self.conn = remotecontroller.RemoteController( 371 | self.settings['network']['ip'], 372 | self.settings['network']['port'], 373 | conn_lost_signal=self.connLostSignal 374 | ) 375 | 376 | # RemoteController' self-check determines the state of the connection. Such app state determined during the 377 | # startup process will remain during all following activities (i.e. app enters the demo mode) and can be changed 378 | # only after the restart 379 | if self.conn.is_offline_mode: 380 | self.isOfflineMode = True 381 | print("Offline mode") 382 | miscgraphics.MessageWindow("No connection to the remote controller. App goes to the Offline (demo) mode. " 383 | "All values are random. To try to reconnect please restart the app", status='Warning') 384 | 385 | else: 386 | # If connection is present (so no demo mode is needed) then create the timer for connection checking. It 387 | # will start on MainWindow' show 388 | self.connCheckTimer.timeout.connect(self.connCheckTimerHandler) 389 | 390 | self.conn.save_current_values() 391 | 392 | 393 | # We can create the MainWindow only after instantiating the RemoteController because it is used for obtaining 394 | # values and setting parameters 395 | self.mainWindow = MainWindow(app=self) 396 | self.mainWindow.show() 397 | 398 | 399 | def quit(self) -> None: 400 | """ 401 | QApplication quit method 402 | 403 | :return: None 404 | """ 405 | 406 | self.connLostSignal.disconnect(self.connLostHandler) 407 | self.connCheckTimer.stop() 408 | 409 | self.conn.close() 410 | 411 | super(MainApplication, self).quit() 412 | 413 | 414 | def connCheckTimerHandler(self) -> None: 415 | """ 416 | Procedure to invoke for connection checking 417 | 418 | :return: None 419 | """ 420 | 421 | print("Check connection") 422 | 423 | if self.conn.check_connection() == remotecontroller.result['error']: 424 | self.connLostHandler() 425 | else: 426 | # prevent of multiple calls of these instructions by using this flag 427 | if self.isOfflineMode: 428 | self.isOfflineMode = False 429 | print('Reconnected') 430 | self.mainWindow.centralWidget.updateDisplayingValues() 431 | self.mainWindow.statusBar().removeWidget(self.connLostStatusBarLabel) 432 | self.mainWindow.statusBar().showMessage('Reconnected') 433 | 434 | 435 | @pyqtSlot() 436 | def connLostHandler(self) -> None: 437 | """ 438 | Slot corresponding to MainApplication.connLostSignal (typically emitted from RemoteController on unsuccessful 439 | read/write operations). Can also be called directly to handle connection break case 440 | 441 | :return: None 442 | """ 443 | 444 | if not self.isOfflineMode: 445 | self.isOfflineMode = True 446 | print("Connection lost") 447 | try: 448 | if self.mainWindow.centralWidget.graphs.isRun: 449 | self.mainWindow.playpauseGraphs() 450 | self.mainWindow.statusBar().addWidget(self.connLostStatusBarLabel) 451 | miscgraphics.MessageWindow("Connection was lost. The app goes to the Offline mode and will be trying " 452 | "to reconnect", status='Warning') 453 | 454 | # This exception is met when the RemoteController.check_connection() test has been passed but following 455 | # read/write operations cannot be successfully performed due to small corresponding timeouts. Then, connLost 456 | # signal is emitting. Since the RemoteController initialization has not been complete MainWindow certainly 457 | # has not been instantiated yet and so we will catch this exception. We can simply ignore such case and just 458 | # quietly mark the connection as offline without any notifications toward the user but we do 459 | except AttributeError: 460 | print("Too small timeout") 461 | miscgraphics.MessageWindow("It seems like the connection is present but specified read/write timeouts " 462 | "is too small. Consider to fix your network or edit timeouts to match your " 463 | f"timings (see '{ remotecontroller.__name__}.py' module).", status='Warning') 464 | 465 | 466 | 467 | 468 | if __name__ == '__main__': 469 | """ 470 | Main entry point 471 | """ 472 | 473 | multiprocessing.freeze_support() # Windows support 474 | 475 | QCoreApplication.setOrganizationName("Andrey Chufyrev") 476 | QCoreApplication.setApplicationName("PID controller GUI") 477 | 478 | application = MainApplication(sys.argv) 479 | 480 | sys.exit(application.exec_()) 481 | -------------------------------------------------------------------------------- /pid-controller-gui/remotecontroller.py: -------------------------------------------------------------------------------- 1 | """ 2 | remotecontroller.py - standalone interface to the remote PID controller with zero external dependencies 3 | 4 | 5 | const REMOTECONTROLLER_MSG_SIZE 6 | message size (in bytes) to and from the remote controller (same for commands, values, stream messages) 7 | const FLOAT_SIZE 8 | float type representation size (in bytes) 9 | const THREAD_INPUT_HANDLER_SLEEP_TIME 10 | sleep time between incoming messages processing (in seconds) 11 | const CHECK_CONNECTION_TIMEOUT_FIRST_CHECK 12 | timeout for the first check only (in seconds) 13 | const CHECK_CONNECTION_TIMEOUT_DEFAULT 14 | timeout for all following checks (in seconds) 15 | const READ_WRITE_TIMEOUT_SYNCHRONOUS 16 | though input listening thread is running asynchronously we retrieve and send non-stream data in a synchronous manner 17 | 18 | 19 | class _Response 20 | class _Request 21 | C-type bitfields representing RemoteController response and request respectively. Make parsing and construction 22 | easier 23 | 24 | class _ResponseByte 25 | C-type union allows to represent the byte both as a value and as a bitfield specified earlier 26 | 27 | dict opcode 28 | dict var_cmd 29 | dict result 30 | int stream_prefix 31 | instruction set constants in form of dictionary 32 | 33 | dict opcode_swapped 34 | dict var_cmd_swapped 35 | dict result_swapped 36 | value-key swapped dictionaries for parsing and other tasks 37 | 38 | function _make_request 39 | function _parse_response 40 | core functions to construct the request and parse the response respectively (additional checks are performed in 41 | respective RemoteController methods) 42 | 43 | class Stream 44 | class representing the stream from the RemoteController (e.g. plot data) 45 | 46 | enum InputThreadCommand 47 | commands to control the input listening thread 48 | 49 | function _thread_input_handler 50 | function to run as multiprocessing.Process target that receives all incoming data from the given socket and cast to 51 | respective listeners from the main thread via pipes 52 | 53 | dict snapshot_template 54 | PID values snapshot dictionary with attached datetime (template) 55 | 56 | exception _BaseExceptions 57 | exception RequestInvalidOperationException 58 | exception ResponseException 59 | exception ResponseVarCmdMismatchException 60 | exception ResponseOperationMismatchException 61 | exception RequestKeyException 62 | module's exceptions to raise in case of error 63 | 64 | class RemoteController 65 | class combining defined earlier instruments in a convenient high-level interface 66 | """ 67 | 68 | import copy 69 | import datetime 70 | import enum 71 | import sys 72 | import socket 73 | import struct 74 | import ctypes 75 | import time 76 | import multiprocessing 77 | import select 78 | import random 79 | 80 | 81 | 82 | # 83 | # buffers sizes in bytes 84 | # 85 | REMOTECONTROLLER_MSG_SIZE = 9 86 | FLOAT_SIZE = 4 87 | 88 | # 89 | # timeouts in seconds 90 | # 91 | THREAD_INPUT_HANDLER_SLEEP_TIME = 0.005 92 | 93 | CHECK_CONNECTION_TIMEOUT_FIRST_CHECK = 2.0 94 | CHECK_CONNECTION_TIMEOUT_DEFAULT = 1.0 95 | 96 | READ_WRITE_TIMEOUT_SYNCHRONOUS = 1.0 97 | 98 | 99 | 100 | class _Response(ctypes.Structure): 101 | """Bitfield structure for easy parsing of responses from the remote controller""" 102 | _fields_ = [ 103 | ('stream', ctypes.c_uint8, 2), # LSB 104 | ('result', ctypes.c_uint8, 1), 105 | ('var_cmd', ctypes.c_uint8, 4), 106 | ('opcode', ctypes.c_uint8, 1) # MSB 107 | ] 108 | 109 | class _ResponseByte(ctypes.Union): 110 | """Union allows to represent a byte as a bitfield""" 111 | _anonymous_ = ("bit",) 112 | _fields_ = [ 113 | ("bit", _Response), 114 | ("asByte", ctypes.c_uint8) 115 | ] 116 | 117 | class _Request(ctypes.Structure): 118 | """Bitfield to easy construct the request byte""" 119 | _fields_ = [ 120 | ('_reserved', ctypes.c_uint8, 3), # LSB 121 | ('var_cmd', ctypes.c_uint8, 4), 122 | ('opcode', ctypes.c_uint8, 1) # MSB 123 | ] 124 | 125 | 126 | opcode = { 127 | 'read': 0, 128 | 'write': 1 129 | } 130 | opcode_swapped = {value: key for key, value in opcode.items()} 131 | 132 | _VAR_CMD_STREAM = 65535 133 | var_cmd = { 134 | # variables 135 | 'setpoint': 0b0100, 136 | 137 | 'kP': 0b0101, 138 | 'kI': 0b0110, 139 | 'kD': 0b0111, 140 | 141 | 'err_I': 0b1000, 142 | 143 | 'err_P_limits': 0b1001, 144 | 'err_I_limits': 0b1010, 145 | 146 | # commands - send them only in 'read' mode 147 | 'stream_start': 0b0001, 148 | 'stream_stop': 0b0000, 149 | 150 | 'save_to_eeprom': 0b1011, 151 | 152 | # stream 153 | 'stream': _VAR_CMD_STREAM 154 | } 155 | var_cmd_swapped = {value: key for key, value in var_cmd.items()} 156 | 157 | # Use the same dictionary to parse controller' responses and as return value of functions so use it from other modules 158 | # for comparisons as well 159 | result = { 160 | 'ok': 0, 161 | 'error': 1 162 | } 163 | result_swapped = {value: key for key, value in result.items()} 164 | 165 | stream_prefix = 0b00000001 # every stream message should be prefaced with such byte 166 | 167 | 168 | 169 | def _make_request(operation: str, variable_command: str, *values) -> bytearray: 170 | """ 171 | Prepare the request bytearray for sending to the controller using given operation, variable/command and (optional) 172 | values (for writing only) 173 | 174 | :param operation: string representing operation ('read' or 'write') 175 | :param variable_command: string representing variable or command 176 | :param values: (optional) numbers to write 177 | :return: request bytearray 178 | """ 179 | 180 | request = _Request() 181 | request.opcode = opcode[operation] 182 | request.var_cmd = var_cmd[variable_command] 183 | 184 | request_buf = bytes(request) 185 | for val in values: 186 | request_buf += struct.pack('f', float(val)) 187 | 188 | return request_buf 189 | 190 | 191 | def _parse_response(response_buf: bytearray) -> dict: 192 | """ 193 | Parse the buffer received from the controller to extract opcode (as a string), status (as a string), 194 | variable/command (as a string) and supplied values (if present) 195 | 196 | :param response_buf: bytearray received over the network 197 | :return: dict(str, str, str, list) 198 | """ 199 | 200 | response_byte = _ResponseByte() 201 | response_byte.asByte = response_buf[0] 202 | 203 | if response_byte.stream: 204 | response_dict = { 205 | 'opcode': opcode['read'], 206 | 'var_cmd': var_cmd['stream'], 207 | 'result': result['ok'] 208 | } 209 | else: 210 | response_dict = { 211 | 'opcode': opcode_swapped[response_byte.opcode], 212 | 'var_cmd': var_cmd_swapped[response_byte.var_cmd], 213 | 'result': result_swapped[response_byte.result] 214 | } 215 | 216 | response_dict['values'] = list(struct.unpack('ff', response_buf[1:2*FLOAT_SIZE + 1])) 217 | 218 | return response_dict 219 | 220 | 221 | 222 | class Stream: 223 | """Class representing the stream from the RemoteController (e.g. plot data)""" 224 | 225 | class RemoteController: 226 | """Mock for function annotations""" 227 | pass 228 | 229 | def __init__(self, connection: RemoteController=None): 230 | """ 231 | Initialize the Stream instance inside a given RemoteController. It does not opening the stream itself. Use 232 | pipe_rx and pipe_tx counterparts in outer code to assign source and sink of a stream pipe 233 | 234 | :param connection: RemoteController instance to bind with 235 | """ 236 | self.connection = connection 237 | self.pipe_rx, self.pipe_tx = multiprocessing.Pipe(duplex=False) 238 | self._msg_counter = 0 239 | self._is_run = False 240 | 241 | def is_run(self): 242 | return self._is_run 243 | 244 | def start(self): 245 | self.connection.read('stream_start') 246 | self._is_run = True 247 | 248 | def stop(self): 249 | self.connection.read('stream_stop') 250 | self._is_run = False 251 | 252 | def toggle(self): 253 | if self._is_run: 254 | self.stop() 255 | else: 256 | self.start() 257 | 258 | def close(self): 259 | self.stop() 260 | self.pipe_rx.close() 261 | self.pipe_tx.close() 262 | 263 | 264 | 265 | @enum.unique 266 | class InputThreadCommand(enum.Enum): 267 | """ 268 | Simple instruction set to control the input thread 269 | 270 | Notes 271 | Unfortunately we cannot subclass Pipe because this is a function returning 2 Connection objects to add some useful 272 | properties and methods. 273 | Ideally, it would be great to have built-in counter and accept/reject flag in Stream class. There will be no need in 274 | such instruction set as all operations will be performing through this meta-object. We can check for overflow "on 275 | the spot" too, Graph class would be simpler. After all, general architecture would be nicer. The problem is to 276 | somehow [gracefully] share or pass the Stream object (or its part) to the thread 277 | """ 278 | 279 | MSG_CNT_GET = enum.auto() 280 | MSG_CNT_RST = enum.auto() 281 | 282 | STREAM_ACCEPT = enum.auto() 283 | STREAM_REJECT = enum.auto() 284 | 285 | INPUT_ACCEPT = enum.auto() 286 | INPUT_REJECT = enum.auto() 287 | 288 | EXIT = enum.auto() 289 | 290 | 291 | def _thread_input_handler( 292 | sock: socket.socket, 293 | control_pipe: multiprocessing.Pipe, 294 | var_cmd_pipe_tx: multiprocessing.Pipe, 295 | stream_pipe_tx: multiprocessing.Pipe 296 | ) -> None: 297 | 298 | """ 299 | Routine is intended to be running in the background as a thread and listening to all incoming messages. Maximum 300 | receive time is determined by THREAD_INPUT_HANDLER_SLEEP_TIME variable. The function then performs a basic parsing 301 | to determine a type of the message and route it to the corresponding pipe. 302 | No other thread should listen to the given socket at the same time. Use 'stream_accept' flag to block the execution. 303 | Listening to pipes threads are responsible for overflow detection and correction. Use 'control_pipe' to send/receive 304 | service messages and control thread execution. 305 | Thread is normally terminated by SIGTERM signal 306 | 307 | :param sock: socket instance to listen 308 | :param control_pipe: send/receive service messages over this 309 | :param var_cmd_pipe_tx: transmission part of the pipe for delivering messages like 'setpoint' and 'err_I_limits' 310 | :param stream_pipe_tx: transmission part of the pipe for delivering streaming values (e.g. for plotting). Take care 311 | to not overflow it! 312 | :return: None 313 | """ 314 | 315 | input_accept = True 316 | 317 | stream_accept = True 318 | stream_msg_cnt = 0 319 | 320 | while True: 321 | if input_accept: 322 | 323 | # poll a socket for available data and return immediately (last argument is a timeout) 324 | available = select.select([sock], [], [], 0) 325 | if available[0] == [sock]: 326 | try: 327 | payload = sock.recv(REMOTECONTROLLER_MSG_SIZE) 328 | except ConnectionResetError: # meet on Windows 329 | sys.exit() 330 | 331 | response = _parse_response(payload) 332 | if response['var_cmd'] == var_cmd['stream']: 333 | if stream_accept: 334 | stream_pipe_tx.send(response['values']) 335 | stream_msg_cnt += 1 336 | else: 337 | var_cmd_pipe_tx.send(response) 338 | 339 | # check whether there are any service messages (non-blocking mode) 340 | if control_pipe.poll(): 341 | command = control_pipe.recv() 342 | if command == InputThreadCommand.MSG_CNT_GET: 343 | control_pipe.send(stream_msg_cnt) 344 | elif command == InputThreadCommand.MSG_CNT_RST: 345 | stream_msg_cnt = 0 346 | elif command == InputThreadCommand.STREAM_REJECT: 347 | stream_accept = False 348 | elif command == InputThreadCommand.STREAM_ACCEPT: 349 | stream_accept = True 350 | elif command == InputThreadCommand.INPUT_REJECT: 351 | input_accept = False 352 | elif command == InputThreadCommand.INPUT_ACCEPT: 353 | input_accept = True 354 | elif command == InputThreadCommand.EXIT: 355 | sys.exit() 356 | 357 | # sleep all remaining time and repeat 358 | time.sleep(THREAD_INPUT_HANDLER_SLEEP_TIME) 359 | 360 | 361 | 362 | # use this standardized dictionary to fill snapshots 363 | snapshot_template = { 364 | 'date': 'datetime.datetime.now()', 365 | 366 | 'setpoint': 0.0, 367 | 'kP': 0.0, 368 | 'kI': 0.0, 369 | 'kD': 0.0, 370 | 'err_P_limits': [-1.0, 1.0], 371 | 'err_I_limits': [-1.0, 1.0] 372 | } 373 | 374 | 375 | 376 | class _BaseException(Exception): 377 | """Basis exception - 'operation' is used by all sub-exceptions""" 378 | 379 | def __init__(self, operation): 380 | super(_BaseException, self).__init__() 381 | self.operation = operation 382 | 383 | 384 | class RequestInvalidOperationException(_BaseException): 385 | 386 | def __str__(self): 387 | return f"Invalid operation '{self.operation}'" 388 | 389 | 390 | class RequestKeyException(_BaseException): 391 | 392 | def __init__(self, operation, key): 393 | super(RequestKeyException, self).__init__(operation) 394 | self.key = key 395 | 396 | def __str__(self): 397 | return f"Invalid key '{self.key}' for operation '{self.operation}'" 398 | 399 | 400 | class ResponseException(_BaseException): 401 | 402 | def __init__(self, operation, thing, values): 403 | super(ResponseException, self).__init__(operation) 404 | self.thing = thing 405 | self.values = values 406 | 407 | def __str__(self): 408 | return f"RemoteController has responded with error code for operation '{self.operation}' on '{self.thing}', " \ 409 | "supplied values: " + str(self.values) 410 | 411 | 412 | class ResponseVarCmdMismatchException(_BaseException): 413 | 414 | def __init__(self, operation, got, expected, values): 415 | super(ResponseVarCmdMismatchException, self).__init__(operation) 416 | self.got = got 417 | self.expected = expected 418 | self.values = values 419 | 420 | def __str__(self): 421 | return f"operation '{self.operation}': expected '{self.expected}', got '{self.got}', supplied values: " + \ 422 | str(self.values) 423 | 424 | 425 | class ResponseOperationMismatchException(_BaseException): 426 | 427 | def __init__(self, op_got, op_expected, thing, values): 428 | super(ResponseOperationMismatchException, self).__init__(op_expected) 429 | self.op_got = op_got 430 | self.thing = thing 431 | self.values = values 432 | 433 | def __str__(self): 434 | return f"'{self.thing}': requested '{self.operation}', got '{self.op_got}', supplied values: " + \ 435 | str(self.values) 436 | 437 | 438 | 439 | class RemoteController: 440 | """ 441 | Straightforward interface to the remote PID controller. Can operate both in 'online' (real connection is present) 442 | and 'offline' (replacing real values by fake random data) mode 443 | """ 444 | 445 | def __init__(self, ip_addr: str, udp_port: int, conn_lost_signal=None): 446 | """ 447 | Initialization of the RemoteController class 448 | 449 | Notes 450 | We can use Queue here instead of the Pipe. It seems to be more suited for this application due to its a little 451 | bit nicer waiting interface. Pipe is faster though but the variable/command stream is not so heavy-loaded also 452 | 453 | self.var_cmd_queue = multiprocessing.Queue() 454 | 455 | For example, RemoteController.read(what) function in this case will looks like: 456 | 457 | try: 458 | self.sock.sendto(request, self.cont_ip_port) 459 | response = self.var_cmd_queue.get(timeout=READ_WRITE_TIMEOUT_SYNCHRONOUS) 460 | except (queue.Empty, OSError): 461 | self.conn_lost.signal.emit() 462 | self._parse_response(what) 463 | else: 464 | self._parse_response(what, response=response) 465 | 466 | At the same time in the input thread: 467 | 468 | var_cmd_queue.put(response) 469 | 470 | :param ip_addr: string representing IP-address of the controller' network interface 471 | :param udp_port: integer representing UDP port of the controller' network interface 472 | :param conn_lost_signal: [optional] PyQt signal to emit when the connection is lost during the read/write 473 | operations. Otherwise the disconnect could only be revealed by an explicit call to check_connection() method 474 | """ 475 | 476 | self.snapshots = [] # currently only one snapshot is created and used 477 | 478 | self._is_offline_mode = False 479 | self.cont_ip_port = (ip_addr, udp_port) 480 | 481 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 482 | self.sock.settimeout(0) # explicitly set the non-blocking mode 483 | 484 | self.input_thread_control_pipe_main,\ 485 | self.input_thread_control_pipe_thread = multiprocessing.Pipe(duplex=True) 486 | 487 | self.var_cmd_pipe_rx,\ 488 | self.var_cmd_pipe_tx = multiprocessing.Pipe(duplex=False) 489 | 490 | self.stream = Stream(connection=self) 491 | 492 | self.input_thread = multiprocessing.Process( 493 | target=_thread_input_handler, 494 | args=( 495 | self.sock, 496 | self.input_thread_control_pipe_thread, 497 | self.var_cmd_pipe_tx, 498 | self.stream.pipe_tx 499 | ) 500 | ) 501 | self.input_thread.start() 502 | 503 | self.conn_lost_signal = conn_lost_signal 504 | 505 | # use recently started thread to check an actual connection and if it is not present close all the related stuff 506 | if self.check_connection(timeout=CHECK_CONNECTION_TIMEOUT_FIRST_CHECK) == result['error']: 507 | self._is_offline_mode = True 508 | self.close() 509 | else: 510 | self.stream.stop() # explicitly stop the stream in case it somehow was active 511 | 512 | 513 | @property 514 | def is_offline_mode(self): 515 | """getter of the read-only property""" 516 | return self._is_offline_mode 517 | 518 | 519 | @staticmethod 520 | def _parse_response(operation: str, what: str, response: dict=None): 521 | """ 522 | Additional wrapper around the _parse_response() function performing more deep inspection of what we got from 523 | the controller and what we should return to the caller in accordance to parsed data. Raises some exceptions 524 | in case of detected errors 525 | 526 | :param operation: string representing an operation ('read' or 'write') 527 | :param what: string representing expected RemoteController' variable or command 528 | :param response: response dictionary (_parse_response() output) 529 | :return: int or [int, int] in accordance with the requested data 530 | """ 531 | 532 | # online mode - parse the response 533 | if response is not None: 534 | 535 | if response['result'] == 'error': 536 | raise ResponseException(response['opcode'], response['var_cmd'], response['values']) 537 | if response['opcode'] != operation: 538 | raise ResponseOperationMismatchException(response['opcode'], operation, response['var_cmd'], 539 | response['values']) 540 | if response['var_cmd'] != what: 541 | raise ResponseVarCmdMismatchException(response['opcode'], response['var_cmd'], what, response['values']) 542 | 543 | if response['opcode'] == 'read': 544 | if response['var_cmd'] in ['setpoint', 'kP', 'kI', 'kD', 'err_I']: 545 | return response['values'][0] 546 | elif response['var_cmd'] in ['err_P_limits', 'err_I_limits']: 547 | return response['values'] 548 | elif response['var_cmd'] in ['save_to_eeprom', 'stream_start', 'stream_stop']: 549 | return result[response['result']] # 'ok' 550 | else: 551 | return result[response['result']] # 'ok' 552 | 553 | # offline mode - provide fake (random) data 554 | else: 555 | 556 | if what in ['setpoint', 'kP', 'kI', 'kD', 'err_I']: 557 | return random.random() 558 | elif what in ['err_P_limits', 'err_I_limits']: 559 | return random.random(), random.random() 560 | elif what in ['save_to_eeprom', 'stream_start', 'stream_stop']: 561 | return result['error'] 562 | 563 | 564 | @staticmethod 565 | def _make_request(operation: str, what: str, *values) -> bytearray: 566 | """ 567 | Additional wrapper around the _make_request() function that checks some parameters. Raises some exceptions in 568 | case of detected errors 569 | 570 | :param operation: string representing an operation ('read' or 'write') 571 | :param what: string representing RemoteController' variable or command 572 | :param values: (optional) supplied values (for 'write' operation) 573 | :return: bytearray request 574 | """ 575 | 576 | if operation not in opcode.keys(): 577 | raise RequestInvalidOperationException(operation) 578 | 579 | # check for valid key 580 | if what not in var_cmd.keys(): 581 | raise RequestKeyException(operation, what) 582 | 583 | # for reading all keys are allowed so we check only writing 584 | if operation == 'write': 585 | if what in ['stream_start', 'stream_stop', 'save_to_eeprom']: 586 | raise RequestKeyException(operation, what) 587 | elif what == 'err_I' and values[0] != 0.0: 588 | raise ValueError("'err_I' allows only reading and reset (writing 0.0), got " + str(values)) 589 | 590 | return _make_request(operation, what, *values) 591 | 592 | 593 | def read(self, what: str) -> int: 594 | """ 595 | Read a variable from the controller. Synchronous function, waits for the reply from the controller via the 596 | 'var_cmd_pipe' (waiting timeout is READ_WRITE_TIMEOUT_SYNCHRONOUS) 597 | 598 | :param what: string representing the variable to be read 599 | :return: result['error'] or result['ok'] (int) 600 | """ 601 | 602 | if not self._is_offline_mode: 603 | 604 | request = self._make_request('read', what) 605 | 606 | self.sock.sendto(request, self.cont_ip_port) 607 | if self.var_cmd_pipe_rx.poll(timeout=READ_WRITE_TIMEOUT_SYNCHRONOUS): 608 | response = self.var_cmd_pipe_rx.recv() 609 | else: 610 | self._is_offline_mode = True 611 | if self.conn_lost_signal is not None: 612 | self.conn_lost_signal.emit() 613 | return self._parse_response('read', what) 614 | return self._parse_response('read', what, response=response) 615 | 616 | else: 617 | return self._parse_response('read', what) 618 | 619 | 620 | def write(self, what: str, *values) -> int: 621 | """ 622 | Write a variable to the controller. Synchronous function, waits for the reply from the controller via the 623 | 'var_cmd_pipe' (waiting timeout is READ_WRITE_TIMEOUT_SYNCHRONOUS) 624 | 625 | :param what: string representing the variable to be written 626 | :param values: (optional) numbers supplied with a request 627 | :return: result['error'] or result['ok'] (int) 628 | """ 629 | 630 | if not self._is_offline_mode: 631 | 632 | request = self._make_request('write', what, *values) 633 | 634 | self.sock.sendto(request, self.cont_ip_port) 635 | if self.var_cmd_pipe_rx.poll(timeout=READ_WRITE_TIMEOUT_SYNCHRONOUS): 636 | response = self.var_cmd_pipe_rx.recv() 637 | else: 638 | self._is_offline_mode = True 639 | if self.conn_lost_signal is not None: 640 | self.conn_lost_signal.emit() 641 | return result['error'] 642 | return self._parse_response('write', what, response) 643 | 644 | else: 645 | return result['ok'] 646 | 647 | 648 | def reset_i_err(self) -> int: 649 | """ 650 | Resets an accumulated integral error of the PID algorithm 651 | 652 | :return: result['error'] or result['ok'] (int) 653 | """ 654 | 655 | return self.write('err_I', 0.0) 656 | 657 | 658 | def save_current_values(self) -> None: 659 | """ 660 | Saves current PID parameters in the snapshot dictionary, supplies it with a current date and store the result 661 | in the 'snapshots' list (instance attribute) 662 | 663 | :return: None 664 | """ 665 | 666 | snapshot = copy.deepcopy(snapshot_template) 667 | for key in snapshot.keys(): 668 | if key != 'date': 669 | snapshot[key] = self.read(key) 670 | snapshot['date'] = datetime.datetime.now() 671 | self.snapshots.append(snapshot) 672 | 673 | 674 | def restore_values(self, snapshot: dict) -> datetime.datetime: 675 | """ 676 | Gets PID values from the given snapshot dictionary and writes them into the controller. This does not writes 677 | values to the EEPROM 678 | 679 | :param snapshot: special dictionary representing a single snapshot 680 | :return: datetime.datetime object of the restored snapshot 681 | """ 682 | 683 | for key, value in snapshot.items(): 684 | if key != 'date': # snapshot has an accessory 'date' key 685 | if isinstance(value, list): 686 | self.write(key, *value) 687 | else: 688 | self.write(key, value) 689 | 690 | return snapshot['date'] 691 | 692 | 693 | def save_to_eeprom(self) -> int: 694 | """ 695 | Saves current PID-related values to controller's EEPROM 696 | 697 | :return: result['error'] or result['ok'] (int) 698 | """ 699 | 700 | return self.read('save_to_eeprom') 701 | 702 | 703 | def check_connection(self, timeout=CHECK_CONNECTION_TIMEOUT_DEFAULT) -> int: 704 | """ 705 | Check the connection. The function sends the request to read a 'setpoint' and waits for the response from the 706 | input listening thread. Therefore a usage is possible only in 'online' mode 707 | 708 | :param timeout: timeout (default is CHECK_CONNECTION_TIMEOUT_DEFAULT) 709 | :return: result['error'] or result['ok'] (int) 710 | """ 711 | 712 | request = _make_request('read', 'setpoint') # use setpoint as a test request 713 | 714 | try: 715 | self.sock.sendto(request, self.cont_ip_port) 716 | except OSError: # probably PC has no network 717 | self._is_offline_mode = True 718 | return result['error'] 719 | 720 | if self.var_cmd_pipe_rx.poll(timeout=timeout): 721 | self.var_cmd_pipe_rx.recv() # receive the message to keep the pipe clean 722 | else: 723 | self._is_offline_mode = True 724 | return result['error'] 725 | 726 | self._is_offline_mode = False 727 | return result['ok'] 728 | 729 | 730 | def pause(self) -> None: 731 | """ 732 | Stop listening to any incoming messages 733 | 734 | :return: None 735 | """ 736 | 737 | if not self._is_offline_mode: 738 | self.input_thread_control_pipe_main.send(InputThreadCommand.INPUT_REJECT) 739 | 740 | 741 | def resume(self) -> None: 742 | """ 743 | Resume listening to any incoming messages 744 | 745 | :return: None 746 | """ 747 | 748 | if not self._is_offline_mode: 749 | self.input_thread_control_pipe_main.send(InputThreadCommand.INPUT_ACCEPT) 750 | 751 | 752 | def close(self) -> None: 753 | """ 754 | "Close" the entire connection in a sense of the "online" communication: socket, input thread, pipes etc. 755 | RemoteController though will still be able to provide fake (random) data to simulate the behavior of a real 756 | connection 757 | 758 | :return: None 759 | """ 760 | 761 | if not self._is_offline_mode: 762 | self.stream.close() 763 | 764 | self.var_cmd_pipe_rx.close() 765 | self.var_cmd_pipe_tx.close() 766 | 767 | if self.input_thread.is_alive(): 768 | self.input_thread_control_pipe_main.send(InputThreadCommand.EXIT) 769 | 770 | self.input_thread_control_pipe_main.close() 771 | self.input_thread_control_pipe_thread.close() 772 | 773 | self.sock.close() 774 | 775 | 776 | 777 | if __name__ == '__main__': 778 | """ 779 | Use this block for testing purposes (run the module as a standalone script) 780 | """ 781 | 782 | conn = RemoteController('127.0.0.1', 1200) 783 | print('offline mode:', conn.is_offline_mode) 784 | 785 | print('setpoint:', conn.read('setpoint')) 786 | 787 | print('some stream values:') 788 | conn.stream.start() 789 | for i in range(50): 790 | if conn.stream.pipe_rx.poll(): 791 | point = conn.stream.pipe_rx.recv() 792 | print(point) 793 | else: 794 | time.sleep(0.005) 795 | 796 | conn.close() 797 | --------------------------------------------------------------------------------