├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── CONTRIBUTE.md ├── LICENSE.txt ├── README.md ├── pyproject.toml ├── resources ├── icons.qrc └── icons │ ├── SerialTool.ico │ ├── SerialTool.png │ ├── down-arrow.png │ ├── external-link.png │ ├── icon-delete.png │ ├── icon-save-raw.png │ ├── icon-save.png │ ├── refresh.png │ └── settings.png ├── screenshots ├── blankConfiguration.png ├── buttonsExplanation.png ├── communicationDialog.png ├── dataAndSeqExplanation.png ├── exampleConfiguration.png └── recenlyUsedConfigurations.png ├── scripts ├── prepare_venv.bat └── pyqt5_ui_to_py.bat ├── serialTool.code-workspace ├── src └── serial_tool │ ├── __init__.py │ ├── __main__.py │ ├── app.py │ ├── cfg_hdlr.py │ ├── cmd_args.py │ ├── communication.py │ ├── defines │ ├── __init__.py │ ├── base.py │ ├── cfg_defs.py │ ├── colors.py │ └── ui_defs.py │ ├── gui │ ├── __init__.py │ ├── gui.py │ ├── icons_rc.py │ └── serialSetupDialog.py │ ├── models.py │ ├── paths.py │ ├── serial_hdlr.py │ ├── setup_dialog.py │ └── validators.py ├── tests ├── test_cmd_args.py ├── test_setup_dialog.py ├── test_validators.py └── todo │ ├── _test_async_serial.py │ ├── _test_serial.py │ └── _test_unittest.py └── ui ├── gui.ui └── serialSetupDialog.ui /.gitignore: -------------------------------------------------------------------------------- 1 | #other 2 | build/ 3 | log/ 4 | Serial Tool LOG/ 5 | .pytest* 6 | venv_*/ 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | env/ 19 | build/ 20 | develop-eggs/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | dist/ 29 | var/ 30 | wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | .hypothesis/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # dotenv 90 | .env 91 | 92 | # virtualenv 93 | .venv 94 | venv/ 95 | ENV/ 96 | 97 | # Spyder project settings 98 | .spyderproject 99 | .spyproject 100 | 101 | # Rope project settings 102 | .ropeproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/psf/black 5 | rev: 22.12.0 6 | hooks: 7 | - id: black 8 | - repo: https://github.com/pre-commit/mirrors-mypy 9 | rev: v0.991 10 | hooks: 11 | - id: mypy 12 | - repo: https://github.com/pre-commit/pre-commit-hooks 13 | rev: v3.2.0 14 | hooks: 15 | - id: check-toml 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug app.py", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${workspaceFolder}/src/serial_tool/app.py", 12 | "console": "integratedTerminal", 13 | "args": [ 14 | "--load-mru-cfg" 15 | ] 16 | }, 17 | { 18 | "name": "Current file", 19 | "type": "python", 20 | "request": "launch", 21 | "cwd": "${fileDirname}", 22 | "program": "${file}", 23 | "console": "integratedTerminal" 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestEnabled": true, 3 | "python.formatting.provider": "black", 4 | "python.linting.enabled": true, 5 | "python.linting.pylintEnabled": true, 6 | "python.linting.lintOnSave": true, 7 | "python.analysis.diagnosticMode": "openFilesOnly", 8 | "python.analysis.typeCheckingMode": "basic", 9 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Open QtDesigner", 8 | "type": "process", 9 | "command": "C:/Python3.7_64x/Lib/site-packages/pyqt5_tools/Qt/bin/designer.exe", 10 | "args": [ 11 | "${workspaceFolder}/src/gui/ui/gui.ui", 12 | "${workspaceFolder}/src/gui/ui/serialSetupDialog.ui" 13 | ], 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "ui -> py", 18 | "type": "process", 19 | "command": "pyqt5_ui_to_py.bat", 20 | "options": { 21 | "cwd": "${workspaceFolder}/scripts" 22 | }, 23 | "problemMatcher": [] 24 | }, 25 | { 26 | "label": "Generate .exe", 27 | "type": "process", 28 | "command": "python", 29 | "args": [ 30 | "${workspaceFolder}/src/to_exe.py" 31 | ], 32 | "options": { 33 | "cwd": "${workspaceFolder}/src" 34 | }, 35 | "problemMatcher": [] 36 | }, 37 | { 38 | "label": "Run Serial Tool.exe (build)", 39 | "type": "process", 40 | "command": "${workspaceFolder}/build/SerialTool.exe", 41 | "problemMatcher": [] 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog: 2 | 3 | **v3.1.1 (3.9.2023):** 4 | - fix: RX data not displayed. 5 | - fix: On-close event handler not called. 6 | - improv: Add support for setting RX/TX timeout 7 | Note: on-close event blocked by issue on low level library `aioserial`: https://github.com/johannchangpro/aioserial.py/issues/21 8 | 9 | **v3.0.1 (9.8.2023):** 10 | - fix: Add missing *\_\_main\_\_.py* file, making `$python -m serial_tool` working properly 11 | 12 | **v3.0 (nov-dec 2022):** 13 | - REWRITE: v3 14 | - package: now installable via `pip` and `pipx` 15 | - PEP8 compliance (snake_case) 16 | - type hinting 17 | - static analysis 18 | - more separation of logic 19 | - more object/files responsibility separation 20 | 21 | **v2.4 (21.12.2019):** 22 | - improv: Removed verbose display mode (color-separated), implemented '\n' on RX data with timeout 23 | 24 | **v2.3 (8.12.2019):** 25 | - fix: serial read with asyncio (low CPU usage) 26 | - improv: added "\n" control on RX data 27 | - improv: configuration loading with better backward compatibility 28 | 29 | **v2.2 (16.11.2019):** 30 | - fix: log window scroll issue on text selection 31 | 32 | **v2.1 (16.11.2019):** 33 | - fix: RT/TX checkbox, log message exception handler 34 | - improv: note font more readable (now black) 35 | 36 | **v2 (3.11.2019):** 37 | - REWRITE: v2.0 38 | - MVC architecture (appropriate use of PyQt signals and slots) 39 | - data/sequence validator 40 | - RX background thread 41 | - log window settings, export capabilities 42 | 43 | **v1.5 (14.10.2019):** 44 | - improv: restructured repository 45 | - fix: new configuration load 46 | 47 | **v1.4 (10.10.2019):** 48 | - Python 3.7 49 | - added string output representation 50 | - improv: output representation stored to config file 51 | - added VS Code workspace files 52 | - improv: changed default save/load config path 53 | 54 | **v1.3 (3.11.2018):** 55 | - updated icons, minor GUI updates 56 | - added utility scripts and .bat files 57 | - added screenshots 58 | 59 | 60 | **v1.2 (7.2.2018):** 61 | - port to Python v3.6 and PyQt5 62 | - improv: updated hex/ascii log write-out 63 | - improv: added cx_freeze distribution (see sourceforge) 64 | - fix: buttons blackout 65 | - improv: icon 66 | 67 | **v1.1 (25.6.2017):** 68 | - fix: minor bug fixes and code formatting 69 | - fix: updated serial methods (read, write, in_waiting) 70 | - improv: py2exe distribution added 71 | 72 | **v1.0 - initial release (24.4.2017)** 73 | - python v2.7, pyqt4 -------------------------------------------------------------------------------- /CONTRIBUTE.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | ## Setup Development Environment 4 | 1. Ensure Python 3.11 is available on your machine. 5 | 2. Create local checkout of this repository, for example in a folder named */serial_tool*. 6 | 3. Navigate to this folder. 7 | 4. Prepare virtual environment with python 3.11: `python -m venv venv` 8 | 5. Activate environment. 9 | 6. Update `pip`: `python -m pip install -U pip`. 10 | 7. Install package in editable mode, while supplying `[dev]` argument: 11 | ``` 12 | $ python -m pip install -e .[dev] 13 | ``` 14 | 8. Install `pre-commit` hook: 15 | ``` 16 | $ pre-commit install 17 | ``` 18 | 19 | **Notes:** 20 | 1. All settings are available in *pyproject.toml*. Avoid adding any other cfg files if possible. 21 | 2. For new functionalities, tests are mandatory. Current state without test is a painful legacy. 22 | 3. Pylint is disabled in pre-commit, as there is just to many warnings. However, inspect issues before commiting anything. 23 | 24 | ## Scripts 25 | ... are available in *./scripts* directory. Windows users only, but the commands should be easily ported to any platform. 26 | 27 | ## VS Code workspace 28 | VS Code workspace is available with already configured pytest/black/pylint actions. 29 | 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | {one line to give the program's name and a brief idea of what it does.} 635 | Copyright (C) {year} {name of author} 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | {project} Copyright (C) {year} {fullname} 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serial Tool 2 | Serial Tool is a utility for developing, debugging and validating serial communication with PC. 3 | Great for data verification, custom protocols for embedded systems and other simple projects that include serial 4 | communication such as UART or RS232 (with appropriate hardware, like USB to UART converter and common FTDI chips). 5 | 6 | Original project: [https://damogranlabs.com/2022/12/serial-tool-v3/](https://damogranlabs.com/2022/12/serial-tool-v3/) 7 | 8 | ![Example configuration](screenshots/exampleConfiguration.png) 9 | ## Features 10 | * View/rx/tx data types: integers, HEX numbers, ASCII characters, strings. 11 | * Data/sequence field verification on the fly. 12 | * User notes for each data channel. 13 | * Sequence generator: create multiple blocks of (data channel, delay, repeat number) sequence. 14 | * Asynchronous read of any received data. 15 | * Log window display customization. 16 | * Log window/raw data export capability. 17 | * Save/load current settings to a configuration file. 18 | 19 | # Installation And Usage 20 | Use isolated virtual environment for these commands. If you are not sure what this is, see [here](https://docs.python.org/3/library/venv.html#:~:text=A%20virtual%20environment%20is%20created,the%20virtual%20environment%20are%20available.). 21 | 22 | Install: 23 | ``` 24 | $ python -m pip install serial_tool@git+https://github.com/damogranlabs/serial-tool 25 | ``` 26 | Run: 27 | ``` 28 | $ serial_tool 29 | ``` 30 | 31 | Alternatively, run via `-m`: 32 | ``` 33 | $ python -m serial_tool 34 | ``` 35 | 36 | ... or with `pipx` 37 | ``` 38 | $ pipx install git+https://github.com/damogranlabs/serial-tool 39 | $ serial_tool 40 | ``` 41 | 42 | ### Usage FAQ: 43 | 1. Explore options with `-h` command line switch. 44 | 2. `serial_tool` vs `serial_tool_cmd`? 45 | `serial_tool_cmd` is the same as `serial_tool`, but it prints std out/err to console. 46 | 47 | # Screenshots 48 | New, default blank configuration: 49 | ![Blank (initial) configuration](screenshots/blankConfiguration.png) 50 | Example configuration and explanation of data/sequence field validator: 51 | ![Data and sequence validator](screenshots/dataAndSeqExplanation.png) 52 | Serial port settings, which are also a part of configuration file settings: 53 | ![Serial port settings dialog](screenshots/communicationDialog.png) 54 | Log/save/export window settings: 55 | ![Log buttons explanation](screenshots/buttonsExplanation.png) 56 | Configurations can be stored and recalled: 57 | ![List of recently used configurations](screenshots/recenlyUsedConfigurations.png) 58 | 59 | 60 | Want to contribute? See [CONTRIBUTE.md](https://github.com/damogranlabs/serial-tool/blob/master/CONTRIBUTE.md). 61 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "serial_tool" 3 | version = "3.1.1" 4 | description = "Serial Tool is a utility for developing, debugging and validating serial communication with PC." 5 | readme = "README.md" 6 | license = { file = "LICENSE.txt" } 7 | keywords = ["serial tool", "UART", "serial", "RS232"] 8 | authors = [ 9 | { name = "Domen Jurkovic", email = "domen.jurkovic@damogranlabs.com" }, 10 | ] 11 | requires-python = ">=3.11" 12 | dependencies = ["PyQt5", "aioserial", "pyserial"] 13 | classifiers = [ 14 | "License :: OSI Approved :: MIT License", 15 | "Intended Audience :: Developers", 16 | "Development Status :: 2 - Pre-Alpha", 17 | "Intended Audience :: Science/Research", 18 | "Topic :: Communications", 19 | "Topic :: Terminals :: Serial", 20 | "Natural Language :: English", 21 | "Programming Language :: Python :: 3.11", 22 | ] 23 | 24 | [project.urls] 25 | repository = "https://github.com/damogranlabs/serial-tool" 26 | homepage = "https://damogranlabs.com/2019/11/serial-tool-v2" 27 | 28 | [project.gui-scripts] 29 | # gui window without console and terminal showing log 30 | serial_tool = "serial_tool.app:main" 31 | 32 | [project.scripts] 33 | # console script - prints logs to the terminal 34 | serial_tool_cmd = "serial_tool.app:main" 35 | 36 | [project.optional-dependencies] 37 | dev = [ 38 | "pre-commit", 39 | "PyQt5Designer", 40 | "pyqt5-stubs", 41 | "pytest", 42 | "mypy", 43 | "black", 44 | "pylint", 45 | "pylint-pytest", 46 | ] 47 | test = ["pytest"] 48 | analyze = ["pytest", "mypy", "black", "pylint", "pylint-pytest"] 49 | 50 | [tool.pytest.ini_options] 51 | addopts = "-ra -q --color=auto --tb=short --durations=5 -v" 52 | testpaths = ["tests"] 53 | 54 | [tool.black] 55 | line-length = 120 56 | include = '\.pyi?$' 57 | extend-exclude = ''' 58 | /( 59 | | src/serial_tool/gui 60 | | tests/todo 61 | )/ 62 | ''' 63 | 64 | [tool.pylint] 65 | max-line-length = 120 66 | load-plugins = ["pylint_pytest"] 67 | disable = """ 68 | logging-fstring-interpolation, 69 | missing-function-docstring, 70 | missing-class-docstring, 71 | missing-module-docstring, 72 | line-too-long, 73 | too-many-lines, 74 | too-few-public-methods, 75 | invalid-name, 76 | broad-except 77 | """ 78 | extension-pkg-allow-list = ["PyQt5"] 79 | ignore-paths = ['src/serial_tool/gui', 'tests/todo'] 80 | good-names = ["f", "ui"] 81 | 82 | 83 | [tool.mypy] 84 | files = ["src", "tests"] 85 | exclude = ['src/serial_tool/gui', 'tests/todo'] 86 | ignore_missing_imports = true 87 | -------------------------------------------------------------------------------- /resources/icons.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/down-arrow.png 4 | icons/external-link.png 5 | icons/icon-delete.png 6 | icons/icon-save.png 7 | icons/icon-save-raw.png 8 | icons/refresh.png 9 | icons/SerialTool.ico 10 | icons/SerialTool.png 11 | icons/settings.png 12 | 13 | 14 | -------------------------------------------------------------------------------- /resources/icons/SerialTool.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/resources/icons/SerialTool.ico -------------------------------------------------------------------------------- /resources/icons/SerialTool.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/resources/icons/SerialTool.png -------------------------------------------------------------------------------- /resources/icons/down-arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/resources/icons/down-arrow.png -------------------------------------------------------------------------------- /resources/icons/external-link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/resources/icons/external-link.png -------------------------------------------------------------------------------- /resources/icons/icon-delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/resources/icons/icon-delete.png -------------------------------------------------------------------------------- /resources/icons/icon-save-raw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/resources/icons/icon-save-raw.png -------------------------------------------------------------------------------- /resources/icons/icon-save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/resources/icons/icon-save.png -------------------------------------------------------------------------------- /resources/icons/refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/resources/icons/refresh.png -------------------------------------------------------------------------------- /resources/icons/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/resources/icons/settings.png -------------------------------------------------------------------------------- /screenshots/blankConfiguration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/screenshots/blankConfiguration.png -------------------------------------------------------------------------------- /screenshots/buttonsExplanation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/screenshots/buttonsExplanation.png -------------------------------------------------------------------------------- /screenshots/communicationDialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/screenshots/communicationDialog.png -------------------------------------------------------------------------------- /screenshots/dataAndSeqExplanation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/screenshots/dataAndSeqExplanation.png -------------------------------------------------------------------------------- /screenshots/exampleConfiguration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/screenshots/exampleConfiguration.png -------------------------------------------------------------------------------- /screenshots/recenlyUsedConfigurations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/screenshots/recenlyUsedConfigurations.png -------------------------------------------------------------------------------- /scripts/prepare_venv.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | SET PY_EXE_PATH="C:\Python311\python.exe" 4 | 5 | SET VENV_NAME=venv_py311 6 | 7 | cd .. 8 | 9 | %PY_EXE_PATH% -m venv %VENV_NAME% 10 | 11 | CALL "%VENV_NAME%/Scripts/activate.bat" 12 | 13 | python -m pip install -U pip 14 | python -m pip install -U -r requirements_dev.txt 15 | python -m pip install -e . 16 | pause -------------------------------------------------------------------------------- /scripts/pyqt5_ui_to_py.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | echo Changing cd to a project root dir... 3 | cd .. 4 | 5 | echo Activating venv... 6 | CALL "venv_py311/Scripts/activate" 7 | 8 | echo Generating GUI objects... 9 | SET SRC_DIR=./ui/ 10 | SET DST_DIR=./src/serial_tool/gui/ 11 | pyuic5 --import-from=serial_tool.gui -o %DST_DIR%gui.py %SRC_DIR%gui.ui 12 | pyuic5 --import-from=serial_tool.gui -o %DST_DIR%serialSetupDialog.py %SRC_DIR%serialSetupDialog.ui 13 | 14 | echo Generating resources... 15 | pyrcc5 -o %DST_DIR%icons_rc.py ./resources/icons.qrc 16 | 17 | echo Done. 18 | pause -------------------------------------------------------------------------------- /serialTool.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "files.exclude": { 9 | "**/.git": true, 10 | "**/.svn": true, 11 | "**/.hg": true, 12 | "**/CVS": true, 13 | "**/.DS_Store": true, 14 | "**/__pycache__": true, 15 | } 16 | }, 17 | } -------------------------------------------------------------------------------- /src/serial_tool/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | __version__ = importlib.metadata.version(__package__ or __name__) 4 | -------------------------------------------------------------------------------- /src/serial_tool/__main__.py: -------------------------------------------------------------------------------- 1 | import serial_tool.app 2 | 3 | if __name__ == "__main__": 4 | serial_tool.app.main() 5 | -------------------------------------------------------------------------------- /src/serial_tool/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import partial 3 | import os 4 | import sys 5 | import time 6 | import traceback 7 | import webbrowser 8 | from typing import List, Optional, Tuple 9 | 10 | from serial import serialutil 11 | from PyQt5 import QtCore 12 | from PyQt5 import QtGui 13 | from PyQt5 import QtWidgets 14 | 15 | import serial_tool 16 | from serial_tool.defines import base 17 | from serial_tool.defines import colors 18 | from serial_tool.defines import ui_defs 19 | from serial_tool import cmd_args 20 | from serial_tool import models 21 | from serial_tool import cfg_hdlr 22 | from serial_tool import serial_hdlr 23 | from serial_tool import communication 24 | from serial_tool import setup_dialog 25 | from serial_tool import paths 26 | from serial_tool import validators 27 | 28 | # pyuic generated GUI files 29 | from serial_tool.gui.gui import Ui_root 30 | 31 | 32 | class Gui(QtWidgets.QMainWindow): 33 | sig_write = QtCore.pyqtSignal(str, str) 34 | sig_warning = QtCore.pyqtSignal(str, str) 35 | sig_error = QtCore.pyqtSignal(str, str) 36 | 37 | def __init__(self, args: cmd_args.SerialToolArgs) -> None: 38 | """Main Serial Tool application window.""" 39 | QtWidgets.QMainWindow.__init__(self) 40 | 41 | self.args = args 42 | 43 | self.ui = Ui_root() 44 | self.ui.setupUi(self) 45 | self._set_taskbar_icon() 46 | 47 | # set up exception handler 48 | sys.excepthook = self._app_exc_handler 49 | 50 | # create lists of all similar items 51 | self.ui_data_fields: Tuple[QtWidgets.QLineEdit, ...] = ( 52 | self.ui.TI_data1, 53 | self.ui.TI_data2, 54 | self.ui.TI_data3, 55 | self.ui.TI_data4, 56 | self.ui.TI_data5, 57 | self.ui.TI_data6, 58 | self.ui.TI_data7, 59 | self.ui.TI_data8, 60 | ) 61 | self.ui_data_send_buttons: Tuple[QtWidgets.QPushButton, ...] = ( 62 | self.ui.PB_send1, 63 | self.ui.PB_send2, 64 | self.ui.PB_send3, 65 | self.ui.PB_send4, 66 | self.ui.PB_send5, 67 | self.ui.PB_send6, 68 | self.ui.PB_send7, 69 | self.ui.PB_send8, 70 | ) 71 | 72 | self.ui_note_fields: Tuple[QtWidgets.QLineEdit, ...] = ( 73 | self.ui.TI_note1, 74 | self.ui.TI_note2, 75 | self.ui.TI_note3, 76 | self.ui.TI_note4, 77 | self.ui.TI_note5, 78 | self.ui.TI_note6, 79 | self.ui.TI_note7, 80 | self.ui.TI_note8, 81 | ) 82 | 83 | self.ui_seq_fields: Tuple[QtWidgets.QLineEdit, ...] = ( 84 | self.ui.TI_sequence1, 85 | self.ui.TI_sequence2, 86 | self.ui.TI_sequence3, 87 | ) 88 | self.ui_seq_send_buttons: Tuple[QtWidgets.QPushButton, ...] = ( 89 | self.ui.PB_sendSequence1, 90 | self.ui.PB_sendSequence2, 91 | self.ui.PB_sendSequence3, 92 | ) 93 | self._seq_threads: List[Optional[QtCore.QThread]] = [ 94 | None 95 | ] * ui_defs.NUM_OF_SEQ_CHANNELS # threads of sequence handlers 96 | self._seq_tx_workers: List[Optional[communication.TxDataSequenceHdlr]] = [ 97 | None 98 | ] * ui_defs.NUM_OF_SEQ_CHANNELS # actual sequence handlers 99 | 100 | self.ui.RB_GROUP_outputRepresentation.setId( 101 | self.ui.RB_outputRepresentationString, models.OutputRepresentation.STRING 102 | ) 103 | self.ui.RB_GROUP_outputRepresentation.setId( 104 | self.ui.RB_outputRepresentationIntList, models.OutputRepresentation.INT_LIST 105 | ) 106 | self.ui.RB_GROUP_outputRepresentation.setId( 107 | self.ui.RB_outputRepresentationHexList, models.OutputRepresentation.HEX_LIST 108 | ) 109 | self.ui.RB_GROUP_outputRepresentation.setId( 110 | self.ui.RB_outputRepresentationAsciiList, models.OutputRepresentation.ASCII_LIST 111 | ) 112 | 113 | self._signals = models.SharedSignalsContainer(self.sig_write, self.sig_warning, self.sig_error) 114 | 115 | # prepare data and port handlers 116 | self.data_cache = models.RuntimeDataCache() 117 | self.ser_port = serial_hdlr.SerialPort(self.data_cache.serial_settings) 118 | self.port_hdlr = communication.PortHdlr(self.data_cache.serial_settings, self.ser_port) 119 | 120 | # RX display data newline internal logic 121 | # timestamp of a last RX data event 122 | self._last_rx_event_timestamp = time.time() 123 | # if true, log window is currently displaying RX data (to be used with '\n on RX data') 124 | self._display_rx_data = False 125 | 126 | self.cfg_hdlr = cfg_hdlr.ConfigurationHdlr(self.data_cache, self._signals) 127 | 128 | # init app and gui 129 | self.connect_signals_to_slots() 130 | self.connect_update_signals_to_slots() 131 | self.connect_app_signals_to_slots() 132 | 133 | self.init_gui() 134 | 135 | self.raise_() 136 | 137 | def _set_taskbar_icon(self) -> None: 138 | # windows specific: https://stackoverflow.com/a/27872625/9200430 139 | if os.name == "nt": 140 | import ctypes 141 | 142 | app_id = "damogranlabs.serialtool" 143 | ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) 144 | 145 | def connect_signals_to_slots(self) -> None: 146 | # save/load dialog 147 | self.ui.PB_fileMenu_newConfiguration.triggered.connect(self.on_file_create_new_cfg) 148 | self.ui.PB_fileMenu_saveConfiguration.triggered.connect(self.on_file_save_cfg) 149 | self.ui.PB_fileMenu_loadConfiguration.triggered.connect(self.on_file_load_cfg) 150 | 151 | # help menu 152 | self.ui.PB_helpMenu_about.triggered.connect(self.on_help_about) 153 | self.ui.PB_helpMenu_docs.triggered.connect(self.on_help_docs) 154 | self.ui.PB_helpMenu_openLogFile.triggered.connect(self.on_open_log) 155 | 156 | # SERIAL PORT setup 157 | self.ui.PB_serialSetup.clicked.connect(self.set_serial_settings_with_dialog) 158 | self.ui.PB_refreshCommPortsList.clicked.connect(self.refresh_ports_list) 159 | self.ui.PB_commPortCtrl.clicked.connect(self.on_port_hdlr_button) 160 | 161 | # data note data send and button fields 162 | for idx, data_field in enumerate(self.ui_data_fields): 163 | data_field.textChanged.connect(partial(self.on_data_field_change, idx)) 164 | 165 | for idx, data_send_pb in enumerate(self.ui_data_send_buttons): 166 | data_send_pb.clicked.connect(partial(self.on_send_data_button, idx)) 167 | 168 | for idx, note_field in enumerate(self.ui_note_fields): 169 | note_field.textChanged.connect(partial(self.on_note_field_change, idx)) 170 | 171 | # sequence fields 172 | for idx, seq_field in enumerate(self.ui_seq_fields): 173 | seq_field.textChanged.connect(partial(self.on_seq_field_change, idx)) 174 | 175 | for idx, seq_send_pb in enumerate(self.ui_seq_send_buttons): 176 | seq_send_pb.clicked.connect(partial(self.on_send_stop_seq_button, idx)) 177 | 178 | # log 179 | self.ui.PB_clearLog.clicked.connect(self.clear_log_window) 180 | self.ui.PB_exportLog.clicked.connect(self.save_log_window) 181 | self.ui.PB_exportRxTxData.clicked.connect(self.save_rx_tx_data) 182 | self.ui.CB_rxToLog.clicked.connect(self.on_rx_display_mode_change) 183 | self.ui.CB_txToLog.clicked.connect(self.on_tx_display_mode_change) 184 | self.ui.RB_GROUP_outputRepresentation.buttonClicked.connect(self.on_out_representation_mode_change) 185 | self.ui.CB_rxNewLine.clicked.connect(self.on_rx_new_line_change) 186 | self.ui.SB_rxTimeoutMs.valueChanged.connect(self.on_rx_new_line_timeout_change) 187 | 188 | def connect_app_signals_to_slots(self) -> None: 189 | self.sig_write.connect(self.log_text) 190 | self.sig_warning.connect(self.log_text) 191 | self.sig_error.connect(self.log_text) 192 | 193 | self.port_hdlr.sig_connection_successful.connect(self.on_connect_event) 194 | self.port_hdlr.sig_connection_closed.connect(self.on_disconnect_event) 195 | self.port_hdlr.sig_data_received.connect(self.on_data_received_event) 196 | 197 | def connect_update_signals_to_slots(self) -> None: 198 | self.data_cache.sig_serial_settings_update.connect(self.on_serial_settings_update) 199 | self.data_cache.sig_data_field_update.connect(self.on_data_field_update) 200 | self.data_cache.sig_note_field_update.connect(self.on_note_field_update) 201 | self.data_cache.sig_seq_field_update.connect(self.on_seq_field_update) 202 | self.data_cache.sig_rx_display_update.connect(self.on_rx_display_mode_update) 203 | self.data_cache.sig_tx_display_update.connect(self.on_tx_display_mode_update) 204 | self.data_cache.sig_out_representation_update.connect(self.on_out_representation_mode_update) 205 | self.data_cache.sig_new_line_on_rx_update.connect(self.on_rx_new_line_update) 206 | 207 | def init_gui(self) -> None: 208 | """Init GUI and emit signals to update/check fields""" 209 | 210 | # update current window name with version string 211 | self.set_main_window_name() 212 | 213 | self._set_mru_cfg_paths() 214 | 215 | # serial port settings 216 | baudrate_validator = QtGui.QIntValidator(0, serialutil.SerialBase.BAUDRATES[-1]) 217 | self.ui.DD_baudrate.setValidator(baudrate_validator) 218 | self.ui.DD_baudrate.addItems([str(baudrate) for baudrate in serialutil.SerialBase.BAUDRATES]) 219 | baudrate_idx = self.ui.DD_baudrate.findText(str(base.DEFAULT_BAUDRATE)) 220 | self.ui.DD_baudrate.setCurrentIndex(baudrate_idx) 221 | 222 | if self.args.load_mru_cfg: 223 | mru_path = paths.get_most_recently_used_cfg_file() 224 | if mru_path is not None: 225 | self.cfg_hdlr.load_cfg(mru_path) 226 | else: 227 | self.log_text( 228 | "`--load-cfg` argument is set, but there is no most recently used configuration path available.", 229 | colors.LOG_WARNING, 230 | ) 231 | else: 232 | self.cfg_hdlr.set_default_cfg() 233 | 234 | self.clear_log_window() 235 | 236 | logging.info("GUI initialized.") 237 | 238 | def _set_mru_cfg_paths(self) -> None: 239 | """Set most recently used configurations to "File menu > Recently used configurations" list""" 240 | self.ui.PB_fileMenu_recentlyUsedConfigurations.clear() 241 | 242 | files = paths.get_recently_used_cfgs(ui_defs.NUM_OF_MAX_RECENTLY_USED_CFG_GUI) 243 | for file_path in files: 244 | name = os.path.basename(file_path) 245 | 246 | action = QtWidgets.QAction(name, self) 247 | action.triggered.connect(partial(self.on_file_load_cfg, file_path)) 248 | self.ui.PB_fileMenu_recentlyUsedConfigurations.addAction(action) 249 | 250 | def set_main_window_name(self, name: Optional[str] = None) -> None: 251 | """Set additional name to the main application GUI window.""" 252 | main_name = f"{ui_defs.APP_NAME} v{serial_tool.__version__}" 253 | if name: 254 | main_name += f" - {name}" 255 | 256 | self.setWindowTitle(main_name) 257 | 258 | def get_selected_port(self) -> str: 259 | """Return name of currently selected serial port from a drop down menu.""" 260 | return self.ui.DD_commPortSelector.currentText() 261 | 262 | def get_selected_baudrate(self) -> str: 263 | """Return selected/set baudrate from a drop down menu.""" 264 | return self.ui.DD_baudrate.currentText() 265 | 266 | def set_data_button_state(self, ch_idx: int, is_enabled: bool) -> None: 267 | """Set chosen data data channel push button state to enabled/disabled.""" 268 | self.ui_data_send_buttons[ch_idx].setEnabled(is_enabled) 269 | 270 | def set_data_buttons_state(self, is_enabled: bool) -> None: 271 | """Set all data send push button state to enabled/disabled.""" 272 | for button in self.ui_data_send_buttons: 273 | button.setEnabled(is_enabled) 274 | 275 | def set_new_button_state(self, ch_idx: int, is_enabled: bool) -> None: 276 | """Set chosen sequence data channel push button state to enabled/disabled.""" 277 | self.ui_seq_send_buttons[ch_idx].setEnabled(is_enabled) 278 | 279 | def set_new_buttons_state(self, is_enabled: bool) -> None: 280 | """Set all sequence send push button state to enabled/disabled.""" 281 | for button in self.ui_seq_send_buttons: 282 | button.setEnabled(is_enabled) 283 | 284 | def stop_all_seq_tx_threads(self) -> None: 285 | """Stop all sequence threads, ignoring all exceptions.""" 286 | for idx, seq_worker in enumerate(self._seq_tx_workers): 287 | try: 288 | if seq_worker is not None: 289 | seq_worker.sig_seq_stop_request.emit() 290 | except Exception as err: 291 | logging.error(f"Unable to stop sequence {idx+1} thread.\n{err}") 292 | 293 | def colorize_text_field(self, field: QtWidgets.QLineEdit, status: models.TextFieldStatus) -> None: 294 | """Colorize given text input field with pre-defined scheme (see status parameter).""" 295 | color = models.TextFieldStatus.get_color(status) 296 | field.setStyleSheet(f"{ui_defs.DEFAULT_FONT_STYLE} background-color: {color}") 297 | 298 | def set_connection_buttons_state(self, is_enabled: bool) -> None: 299 | """ 300 | Set comm port connection status button state (text and color). 301 | 302 | Args: 303 | is_enabled: 304 | - if True: text = CONNECTED, color = green 305 | - if False: text = not connected, color = red 306 | """ 307 | if is_enabled: 308 | self.ui.PB_commPortCtrl.setText(ui_defs.COMM_PORT_CONNECTED_TEXT) 309 | self.ui.PB_commPortCtrl.setStyleSheet( 310 | f"{ui_defs.DEFAULT_FONT_STYLE} background-color: {colors.COMM_PORT_CONNECTED}" 311 | ) 312 | else: 313 | self.ui.PB_commPortCtrl.setText(ui_defs.COMM_PORT_NOT_CONNECTED_TEXT) 314 | self.ui.PB_commPortCtrl.setStyleSheet( 315 | f"{ui_defs.DEFAULT_FONT_STYLE} background-color: {colors.COMM_PORT_NOT_CONNECTED}" 316 | ) 317 | 318 | def get_rx_new_line_timeout_msec(self) -> int: 319 | """Return value from RX new line spinbox timeout setting.""" 320 | return int(self.ui.SB_rxTimeoutMs.value() // 1e3) # (to ms conversion) 321 | 322 | @QtCore.pyqtSlot(str, str) 323 | def log_text( 324 | self, msg: str, color: str = colors.LOG_NORMAL, append_new_line: bool = True, ensure_new_line: bool = True 325 | ) -> None: 326 | """ 327 | Write to log window with a given color. 328 | 329 | Args: 330 | msg: message to write to log window. 331 | color: color of displayed text (hex format). 332 | append_new_line: if True, new line terminator is appended to a message 333 | ensure_new_line: if True, additional cursor position check is implemented 334 | so given msg is really displayed in new line. 335 | """ 336 | self._display_rx_data = False 337 | 338 | if append_new_line: 339 | msg = f"{msg}\n" 340 | 341 | if ensure_new_line: 342 | if self.ui.TE_log.textCursor().position() != 0: 343 | msg = f"\n{msg}" 344 | 345 | # if autoscroll is not in use, set previous location. 346 | current_vertical_scrollbar_pos = self.ui.TE_log.verticalScrollBar().value() 347 | # always insert at the end of the log window 348 | self.ui.TE_log.moveCursor(QtGui.QTextCursor.End) 349 | 350 | self.ui.TE_log.setTextColor(QtGui.QColor(color)) 351 | self.ui.TE_log.insertPlainText(msg) 352 | self.ui.TE_log.setTextColor(QtGui.QColor(colors.LOG_NORMAL)) 353 | 354 | if self.ui.PB_autoScroll.isChecked(): 355 | self.ui.TE_log.moveCursor(QtGui.QTextCursor.End) 356 | else: 357 | self.ui.TE_log.verticalScrollBar().setValue(current_vertical_scrollbar_pos) 358 | 359 | logging.debug(f"[LOG_WINDOW]: {msg.strip()}") 360 | 361 | def log_html(self, msg: str) -> None: 362 | """ 363 | Write HTML content to log window with a given color. 364 | NOTE: override autoscroll checkbox setting - always display message. 365 | 366 | Args: 367 | msg: html formatted message to write to log window. 368 | """ 369 | msg = f"{msg}
" 370 | self.ui.TE_log.moveCursor(QtGui.QTextCursor.End) 371 | self.ui.TE_log.insertHtml(msg) 372 | 373 | if self.ui.PB_autoScroll.isChecked(): 374 | self.ui.TE_log.ensureCursorVisible() 375 | 376 | logging.debug(f"writeHtmlToLogWindow: {msg}") 377 | 378 | ################################################################################################ 379 | # Menu bar slots 380 | ################################################################################################ 381 | @QtCore.pyqtSlot() 382 | def on_file_create_new_cfg(self) -> None: 383 | """ 384 | Create new blank configuration and discard any current settings. 385 | User is previously asked for confirmation. 386 | """ 387 | if self.confirm_action_dialog("Warning!", "Create new configuration?\nThis will discard any changes!"): 388 | self.data_cache.cfg_file_path = None 389 | self.cfg_hdlr.set_default_cfg() 390 | 391 | self.log_text("New default configuration created.", colors.LOG_GRAY) 392 | 393 | self.set_main_window_name() 394 | else: 395 | logging.debug("New configuration request canceled.") 396 | 397 | @QtCore.pyqtSlot() 398 | def on_file_save_cfg(self) -> None: 399 | """ 400 | Save current configuration to a file. File path is selected with default os GUI pop-up. 401 | """ 402 | if self.data_cache.cfg_file_path is None: 403 | cfg_file_path = os.path.join(paths.get_default_log_dir(), base.DEFAULT_CFG_FILE_NAME) 404 | else: 405 | cfg_file_path = self.data_cache.cfg_file_path 406 | 407 | path = self.ask_for_save_file_path("Save configuration...", cfg_file_path, base.CFG_FILE_EXT_FILTER) 408 | if path is None: 409 | logging.debug("Save configuration request canceled.") 410 | else: 411 | self.data_cache.cfg_file_path = path 412 | self.cfg_hdlr.save_cfg(path) 413 | 414 | paths.add_cfg_to_recently_used_cfgs(path) 415 | self._set_mru_cfg_paths() 416 | 417 | self.log_text(f"Configuration saved: {path}", colors.LOG_GRAY) 418 | self.set_main_window_name(path) 419 | 420 | @QtCore.pyqtSlot() 421 | def on_file_load_cfg(self, path: Optional[str] = None) -> None: 422 | """ 423 | Load configuration from a file and discard any current settings. 424 | User is previously asked for confirmation. 425 | 426 | Args: 427 | path: if None, user is asked to entry file path. 428 | """ 429 | refresh_menu = False 430 | 431 | if path is None: 432 | if self.data_cache.cfg_file_path is None: 433 | cfg_dir = paths.get_default_log_dir() 434 | else: 435 | cfg_dir = os.path.dirname(self.data_cache.cfg_file_path) 436 | 437 | if self.confirm_action_dialog("Warning!", "Loading new configuration?\nThis will discard any changes!"): 438 | path = self.ask_for_open_file_path("Load configuration...", cfg_dir, base.CFG_FILE_EXT_FILTER) 439 | if path is not None: 440 | self.data_cache.cfg_file_path = path 441 | self.cfg_hdlr.load_cfg(path) 442 | refresh_menu = True 443 | else: 444 | logging.debug("Load configuration request canceled.") 445 | else: 446 | path = os.path.normpath(path) 447 | self.cfg_hdlr.load_cfg(path) 448 | self.data_cache.cfg_file_path = path 449 | refresh_menu = True 450 | 451 | if refresh_menu: 452 | assert path is not None 453 | paths.add_cfg_to_recently_used_cfgs(path) 454 | self._set_mru_cfg_paths() 455 | 456 | self.log_text(f"Configuration loaded: {path}", colors.LOG_GRAY) 457 | self.set_main_window_name(path) 458 | 459 | @QtCore.pyqtSlot() 460 | def on_help_about(self) -> None: 461 | """Print current version and info links to log window.""" 462 | lines = [] 463 | lines.append(f"
************ Serial Tool v{serial_tool.__version__} ************") 464 | # add extra new line 465 | lines.append(f'Domen Jurkovic @ Damogran Labs
') 466 | lines.append(f'Repository: {base.LINK_REPOSITORY}') 467 | lines.append(f'Docs: {base.LINK_DOCS}') 468 | lines.append(f'Homepage: {base.LINK_HOMEPAGE}') 469 | 470 | self.log_html("
".join(lines)) 471 | 472 | @QtCore.pyqtSlot() 473 | def on_help_docs(self) -> None: 474 | """Open Github README page in a web browser.""" 475 | webbrowser.open(base.LINK_DOCS, new=2) # new=2 new tab 476 | 477 | logging.debug("Online docs opened.") 478 | 479 | @QtCore.pyqtSlot() 480 | def on_open_log(self) -> None: 481 | """Open Serial Tool log file.""" 482 | path = paths.get_log_file_path() 483 | 484 | webbrowser.open(f"file://{path}", new=2) 485 | 486 | ################################################################################################ 487 | # serial settings slots 488 | ################################################################################################ 489 | @QtCore.pyqtSlot() 490 | def set_serial_settings_with_dialog(self) -> None: 491 | """Open serial settings dialog and set new port settings.""" 492 | dialog = setup_dialog.SerialSetupDialog(self.data_cache.serial_settings) 493 | dialog.setWindowModality(QtCore.Qt.ApplicationModal) 494 | dialog.display() 495 | dialog.exec_() 496 | 497 | if dialog.must_apply_settings(): 498 | self.data_cache.serial_settings = dialog.get_settings() 499 | 500 | self.refresh_ports_list() 501 | 502 | self.log_text(f"New serial settings applied: {self.data_cache.serial_settings}", colors.LOG_GRAY) 503 | else: 504 | logging.debug("New serial settings request canceled.") 505 | 506 | @QtCore.pyqtSlot() 507 | def on_serial_settings_update(self) -> None: 508 | """Load new serial settings dialog values from data model.""" 509 | self.refresh_ports_list() # also de-init 510 | 511 | if self.data_cache.serial_settings.port is not None: 512 | port = self.ui.DD_commPortSelector.findText(self.data_cache.serial_settings.port) 513 | if port == -1: 514 | self.log_text( 515 | f"No {self.data_cache.serial_settings.port} serial port currently available.", 516 | colors.LOG_WARNING, 517 | ) 518 | else: 519 | self.ui.DD_commPortSelector.setCurrentIndex(port) 520 | 521 | baudrate = self.ui.DD_baudrate.findText(str(self.data_cache.serial_settings.baudrate)) 522 | if baudrate == -1: 523 | self.log_text( 524 | f"No {self.data_cache.serial_settings.baudrate} baudrate available, manually added.", 525 | colors.LOG_WARNING, 526 | ) 527 | self.ui.DD_baudrate.addItem(str(self.data_cache.serial_settings.baudrate)) 528 | self.ui.DD_baudrate.setCurrentText(str(self.data_cache.serial_settings.baudrate)) 529 | else: 530 | self.ui.DD_baudrate.setCurrentIndex(baudrate) 531 | 532 | logging.debug("New serial settings applied.") 533 | 534 | @QtCore.pyqtSlot() 535 | def refresh_ports_list(self) -> None: 536 | """Refresh list of available serial port list. Will close current port.""" 537 | logging.debug("Serial port list refresh request.") 538 | 539 | self.port_hdlr.deinit_port() 540 | 541 | ports = serial_hdlr.SerialPort().get_available_ports() 542 | self.ui.DD_commPortSelector.clear() 543 | self.ui.DD_commPortSelector.addItems(list(sorted(ports, reverse=True))) 544 | 545 | @QtCore.pyqtSlot() 546 | def on_port_hdlr_button(self) -> None: 547 | """Connect/disconnect from a port with serial settings.""" 548 | if self.ui.PB_commPortCtrl.text() == ui_defs.COMM_PORT_CONNECTED_TEXT: 549 | # currently connected, stop all sequences and disconnect 550 | self.stop_all_seq_tx_threads() # might be a problem with unfinished, blockin sequences 551 | 552 | self.port_hdlr.deinit_port() 553 | self.log_text("Disconnect request.", colors.LOG_GRAY) 554 | else: 555 | # currently disconnected, connect 556 | serial_port = self.get_selected_port() 557 | if not serial_port: 558 | raise RuntimeError("No available port to init serial communication.") 559 | self.data_cache.serial_settings.port = serial_port 560 | 561 | baudrate = self.get_selected_baudrate() 562 | if not baudrate: 563 | raise RuntimeError("Set baudrate of serial port.") 564 | self.data_cache.serial_settings.baudrate = int(baudrate) 565 | 566 | self.port_hdlr.serial_settings = self.data_cache.serial_settings 567 | self.port_hdlr.init_port_and_rx_thread() 568 | 569 | self.log_text("Connect request.", colors.LOG_GRAY) 570 | 571 | ################################################################################################ 572 | # application generated events 573 | ################################################################################################ 574 | @QtCore.pyqtSlot() 575 | def on_connect_event(self) -> None: 576 | """This function is called once connection to port is successfully created.""" 577 | self.set_connection_buttons_state(True) 578 | self.ui.DD_commPortSelector.setEnabled(False) 579 | self.ui.DD_baudrate.setEnabled(False) 580 | 581 | for idx, _ in enumerate(self.ui_data_fields): 582 | result_ch = self._parse_data_field(idx) 583 | if result_ch.status == models.TextFieldStatus.OK: 584 | self.set_data_button_state(idx, True) 585 | else: 586 | self.set_data_button_state(idx, False) 587 | 588 | for idx, _ in enumerate(self.ui_seq_fields): 589 | result_seq = self._parse_seq_data_field(idx) 590 | if result_seq.status == models.TextFieldStatus.OK: 591 | for block in result_seq.data: 592 | if self.data_cache.parsed_data_fields[block.ch_idx] is None: 593 | self.set_new_button_state(idx, False) 594 | break 595 | else: 596 | self.set_new_button_state(idx, True) 597 | else: 598 | self.set_new_button_state(idx, False) 599 | 600 | logging.debug("\tEvent: connect") 601 | 602 | @QtCore.pyqtSlot() 603 | def on_disconnect_event(self) -> None: 604 | """This function is called once connection to port is closed.""" 605 | self.set_connection_buttons_state(False) 606 | self.ui.DD_commPortSelector.setEnabled(True) 607 | self.ui.DD_baudrate.setEnabled(True) 608 | 609 | self.set_data_buttons_state(False) 610 | self.set_new_buttons_state(False) 611 | 612 | logging.debug("\tEvent: disconnect") 613 | 614 | @QtCore.pyqtSlot(list) 615 | def on_data_received_event(self, data: List[int]) -> None: 616 | """This function is called once data is received on a serial port.""" 617 | data_str = self._convert_data(data, self.data_cache.output_data_representation, ui_defs.RX_DATA_SEPARATOR) 618 | 619 | self.data_cache.all_rx_tx_data.append(f"{ui_defs.EXPORT_RX_TAG}{data}") 620 | if self.data_cache.display_rx_data: 621 | msg = f"{data_str}" 622 | if self.data_cache.new_line_on_rx: 623 | # insert \n on RX data, after specified timeout 624 | if self._display_rx_data: 625 | # we are in the middle of displaying RX data, check timestamp delta 626 | if (time.time() - self._last_rx_event_timestamp) > self.get_rx_new_line_timeout_msec(): 627 | msg = f"\n{data_str}" 628 | # else: # not enough time has passed, just add data without new line 629 | # else: # some RX data or other message was displayed in log window since last RX data display 630 | # else: # no RX on new line is needed, just add RX data 631 | self.log_text(msg, colors.LOG_RX_DATA, False, False) 632 | self._display_rx_data = True 633 | 634 | self._last_rx_event_timestamp = time.time() 635 | 636 | logging.debug(f"\tEvent: data received: {data_str}") 637 | 638 | @QtCore.pyqtSlot(int) 639 | def on_seq_finish_event(self, seq_idx: int) -> None: 640 | """This function is called once sequence sending thread is finished.""" 641 | self.ui_seq_send_buttons[seq_idx].setText(ui_defs.SEQ_BUTTON_IDLE_TEXT) 642 | self.ui_seq_send_buttons[seq_idx].setStyleSheet(f"{ui_defs.DEFAULT_FONT_STYLE} background-color: None") 643 | self._seq_threads[seq_idx] = None 644 | 645 | logging.debug(f"\tEvent: sequence {seq_idx + 1} finished") 646 | 647 | @QtCore.pyqtSlot(int, int) 648 | def on_seq_send_event(self, seq_idx: int, ch_idx: int) -> None: 649 | """This function is called once data is send from send sequence thread.""" 650 | data = self.data_cache.parsed_data_fields[ch_idx] 651 | assert data is not None 652 | data_str = self._convert_data(data, self.data_cache.output_data_representation, ui_defs.TX_DATA_SEPARATOR) 653 | 654 | self.data_cache.all_rx_tx_data.append(f"{ui_defs.SEQ_TAG}{seq_idx+1}_CH{ch_idx+1}{ui_defs.EXPORT_TX_TAG}{data}") 655 | if self.data_cache.display_tx_data: 656 | msg = f"{ui_defs.SEQ_TAG}{seq_idx+1}_CH{ch_idx+1}: {data_str}" 657 | 658 | self.log_text(msg, colors.LOG_TX_DATA) 659 | 660 | logging.debug(f"\tEvent: sequence {seq_idx + 1}, data channel {ch_idx + 1} send request") 661 | 662 | @QtCore.pyqtSlot(int) 663 | def stop_seq_request_event(self, ch_idx: int) -> None: 664 | """Display "stop" request sequence action.""" 665 | worker = self._seq_tx_workers[ch_idx] 666 | assert worker is not None 667 | worker.sig_seq_stop_request.emit() 668 | 669 | logging.debug(f"\tEvent: sequence {ch_idx + 1} stop request") 670 | 671 | @QtCore.pyqtSlot() 672 | def closeEvent(self, event: QtGui.QCloseEvent) -> None: 673 | self.port_hdlr.sig_deinit_request.emit() 674 | 675 | event.accept() 676 | self.close() 677 | 678 | ################################################################################################ 679 | # data/sequence fields/buttons slots 680 | ################################################################################################ 681 | @QtCore.pyqtSlot(int) 682 | def on_data_field_update(self, ch_idx: int) -> None: 683 | """Actions to take place once data field is updated (for example, from load configuration).""" 684 | self.ui_data_fields[ch_idx].setText(self.data_cache.data_fields[ch_idx]) 685 | self.on_data_field_change(ch_idx) 686 | 687 | @QtCore.pyqtSlot(int) 688 | def on_note_field_update(self, note_idx: int) -> None: 689 | """Actions to take place once note field is updated (for example, from load configuration).""" 690 | self.ui_note_fields[note_idx].setText(self.data_cache.note_fields[note_idx]) 691 | 692 | @QtCore.pyqtSlot(int) 693 | def on_seq_field_update(self, seq_idx: int) -> None: 694 | """Actions to take place once sequence field is updated (for example, from load configuration).""" 695 | self.ui_seq_fields[seq_idx].setText(self.data_cache.seq_fields[seq_idx]) 696 | self.on_seq_field_change(seq_idx) 697 | 698 | @QtCore.pyqtSlot(int) 699 | def on_data_field_change(self, ch_idx: int) -> None: 700 | """Actions to take place once any data field is changed.""" 701 | self.data_cache.data_fields[ch_idx] = self.ui_data_fields[ch_idx].text() 702 | 703 | result = self._parse_data_field(ch_idx) 704 | self.colorize_text_field(self.ui_data_fields[ch_idx], result.status) 705 | 706 | if result.status == models.TextFieldStatus.OK: 707 | self.data_cache.parsed_data_fields[ch_idx] = result.data 708 | if self.port_hdlr.is_connected(): 709 | self.set_data_button_state(ch_idx, True) 710 | else: 711 | self.set_data_button_state(ch_idx, False) 712 | else: # False or None (empty field) 713 | self.data_cache.parsed_data_fields[ch_idx] = None 714 | self.set_data_button_state(ch_idx, False) 715 | 716 | # update sequence fields - sequence fields depends on data fields. 717 | for idx, _ in enumerate(self.ui_seq_fields): 718 | self.on_seq_field_change(idx) 719 | 720 | @QtCore.pyqtSlot(int) 721 | def on_note_field_change(self, note_idx: int) -> None: 722 | """Actions to take place once any of note field is changed.""" 723 | text = self.ui_note_fields[note_idx].text() 724 | self.data_cache.note_fields[note_idx] = text.strip() 725 | 726 | @QtCore.pyqtSlot(int) 727 | def on_seq_field_change(self, seq_idx: int) -> None: 728 | """ 729 | Actions to take place once any sequence field is changed. 730 | TODO: colorize sequence RED if any of selected data channels is not valid 731 | """ 732 | self.data_cache.seq_fields[seq_idx] = self.ui_seq_fields[seq_idx].text() 733 | 734 | result = self._parse_seq_data_field(seq_idx) 735 | self.colorize_text_field(self.ui_seq_fields[seq_idx], result.status) 736 | 737 | if result.status == models.TextFieldStatus.OK: 738 | self.data_cache.parsed_seq_fields[seq_idx] = result.data 739 | # check if seq button can be enabled (seq field is properly formatted. 740 | # Are all data fields properly formatted? 741 | for block in result.data: 742 | if self.data_cache.parsed_data_fields[block.ch_idx] is None: 743 | self.set_new_button_state(seq_idx, False) 744 | break 745 | else: 746 | if self.port_hdlr.is_connected(): 747 | self.set_new_button_state(seq_idx, True) 748 | else: 749 | self.set_new_button_state(seq_idx, False) 750 | else: # False or None (empty field) 751 | self.data_cache.parsed_seq_fields[seq_idx] = None 752 | self.set_new_button_state(seq_idx, False) 753 | 754 | @QtCore.pyqtSlot(int) 755 | def on_send_data_button(self, ch_idx: int) -> None: 756 | """Send data on a selected data channel.""" 757 | data = self.data_cache.parsed_data_fields[ch_idx] 758 | assert data is not None 759 | data_str = self._convert_data(data, self.data_cache.output_data_representation, ui_defs.TX_DATA_SEPARATOR) 760 | 761 | self.data_cache.all_rx_tx_data.append(f"CH{ch_idx}{ui_defs.EXPORT_TX_TAG}{data}") 762 | if self.data_cache.display_tx_data: 763 | self.log_text(data_str, colors.LOG_TX_DATA) 764 | 765 | self.port_hdlr.sig_write.emit(data) 766 | 767 | @QtCore.pyqtSlot(int) 768 | def on_send_stop_seq_button(self, seq_idx: int) -> None: 769 | """Start sending data sequence.""" 770 | if self.ui_seq_send_buttons[seq_idx].text() == ui_defs.SEQ_BUTTON_IDLE_TEXT: 771 | self.ui_seq_send_buttons[seq_idx].setText(ui_defs.SEQ_BUTTON_STOP_TEXT) 772 | self.ui_seq_send_buttons[seq_idx].setStyleSheet( 773 | f"{ui_defs.DEFAULT_FONT_STYLE} background-color: {colors.SEQ_ACTIVE}" 774 | ) 775 | 776 | seq_data = self.data_cache.parsed_seq_fields[seq_idx] 777 | assert seq_data is not None 778 | 779 | thread = QtCore.QThread(self) 780 | worker = communication.TxDataSequenceHdlr( 781 | self.port_hdlr.ser_port, 782 | seq_idx, 783 | self.data_cache.parsed_data_fields, 784 | seq_data, 785 | ) 786 | worker.sig_seq_tx_finished.connect(self.on_seq_finish_event) 787 | worker.sig_data_send_event.connect(self.on_seq_send_event) 788 | 789 | worker.moveToThread(thread) 790 | thread.started.connect(worker.run) 791 | 792 | self._seq_threads[seq_idx] = thread 793 | self._seq_tx_workers[seq_idx] = worker 794 | 795 | thread.start() 796 | else: 797 | tx_seq_worker = self._seq_tx_workers[seq_idx] 798 | assert tx_seq_worker is not None 799 | tx_seq_worker.sig_seq_stop_request.emit() 800 | 801 | self.log_text(f"Sequence {seq_idx+1} stop request!", colors.LOG_WARNING) 802 | 803 | ################################################################################################ 804 | # log settings slots 805 | ################################################################################################ 806 | @QtCore.pyqtSlot() 807 | def clear_log_window(self) -> None: 808 | self.data_cache.all_rx_tx_data = [] 809 | self.ui.TE_log.clear() 810 | 811 | @QtCore.pyqtSlot() 812 | def save_log_window(self) -> None: 813 | """ 814 | Save (export) content of a current log window to a file. 815 | Pick destination with default OS pop-up window. 816 | """ 817 | default_path = os.path.join(paths.get_default_log_dir(), base.DEFAULT_LOG_EXPORT_FILENAME) 818 | path = self.ask_for_save_file_path("Save log window content...", default_path, base.LOG_EXPORT_FILE_EXT_FILTER) 819 | if path is not None: 820 | with open(path, "w+", encoding="utf-8") as f: 821 | lines = self.ui.TE_log.toPlainText() 822 | f.writelines(lines) 823 | 824 | self.log_text(f"Log window content saved to: {path}", colors.LOG_GRAY) 825 | else: 826 | logging.debug("Save log window content request canceled.") 827 | 828 | @QtCore.pyqtSlot() 829 | def save_rx_tx_data(self) -> None: 830 | """ 831 | Save (export) content of all RX/TX data to a file. 832 | Pick destination with default OS pop-up window. 833 | """ 834 | default_path = os.path.join(paths.get_default_log_dir(), base.DEFAULT_DATA_EXPORT_FILENAME) 835 | path = self.ask_for_save_file_path("Save raw RX/TX data...", default_path, base.DATA_EXPORT_FILE_EXT_FILTER) 836 | if path is not None: 837 | with open(path, "w+", encoding="utf-8") as f: 838 | for data in self.data_cache.all_rx_tx_data: 839 | f.write(data + "\n") 840 | 841 | self.data_cache.all_rx_tx_data = [] 842 | self.log_text(f"RX/TX data exported: {path}", colors.LOG_GRAY) 843 | else: 844 | logging.debug("RX/TX data export request canceled.") 845 | 846 | @QtCore.pyqtSlot() 847 | def on_rx_display_mode_update(self) -> None: 848 | """ 849 | Action to take place once RX-to-log checkbox setting is altered (for example, on load configuration). 850 | """ 851 | self.ui.CB_rxToLog.setChecked(self.data_cache.display_rx_data) 852 | 853 | @QtCore.pyqtSlot() 854 | def on_rx_display_mode_change(self) -> None: 855 | """Get RX-to-log checkbox settings from GUI.""" 856 | self.data_cache.display_rx_data = self.ui.CB_rxToLog.isChecked() 857 | 858 | @QtCore.pyqtSlot() 859 | def on_tx_display_mode_update(self) -> None: 860 | """ 861 | Action to take place once TX-to-log checkbox setting is altered (for example, on load configuration). 862 | """ 863 | self.ui.CB_txToLog.setChecked(self.data_cache.display_tx_data) 864 | 865 | @QtCore.pyqtSlot() 866 | def on_tx_display_mode_change(self) -> None: 867 | """Get TX-to-log checkbox settings from GUI.""" 868 | self.data_cache.display_tx_data = self.ui.CB_txToLog.isChecked() 869 | 870 | @QtCore.pyqtSlot() 871 | def on_out_representation_mode_update(self) -> None: 872 | """ 873 | Action to take place once outputDataRepresentation setting is altered (for example, on load configuration). 874 | """ 875 | self.ui.RB_GROUP_outputRepresentation.button(self.data_cache.output_data_representation).click() 876 | 877 | @QtCore.pyqtSlot() 878 | def on_out_representation_mode_change(self) -> None: 879 | """Get output representation type from GUI selection.""" 880 | self.data_cache.output_data_representation = models.OutputRepresentation( 881 | self.ui.RB_GROUP_outputRepresentation.checkedId() 882 | ) 883 | 884 | @QtCore.pyqtSlot() 885 | def on_rx_new_line_update(self) -> None: 886 | """Action to take place once RX new line setting is altered (for example, on load configuration).""" 887 | self.ui.CB_rxNewLine.setChecked(self.data_cache.new_line_on_rx) 888 | if self.data_cache.new_line_on_rx: 889 | self.ui.SB_rxTimeoutMs.setEnabled(True) 890 | else: 891 | self.ui.SB_rxTimeoutMs.setEnabled(False) 892 | 893 | @QtCore.pyqtSlot() 894 | def on_rx_new_line_change(self) -> None: 895 | """Get RX new line settings of log RX/TX data.""" 896 | self.data_cache.new_line_on_rx = self.ui.CB_rxNewLine.isChecked() 897 | if self.data_cache.new_line_on_rx: 898 | self.ui.SB_rxTimeoutMs.setEnabled(True) 899 | else: 900 | self.ui.SB_rxTimeoutMs.setEnabled(False) 901 | 902 | @QtCore.pyqtSlot() 903 | def on_rx_new_line_timeout_update(self) -> None: 904 | """Action to take place once RX new line timeout setting is altered (for example, on load configuration).""" 905 | self.ui.SB_rxTimeoutMs.setValue(self.data_cache.new_line_on_rx_timeout_msec) 906 | 907 | @QtCore.pyqtSlot() 908 | def on_rx_new_line_timeout_change(self) -> None: 909 | """Get RX new line settings of log RX/TX data.""" 910 | self.data_cache.new_line_on_rx_timeout_msec = self.ui.SB_rxTimeoutMs.value() 911 | 912 | ################################################################################################ 913 | # utility functions 914 | ################################################################################################ 915 | def _parse_data_field(self, ch_idx: int) -> models.ChannelTextFieldParserResult: 916 | """Get string from a data field and return parsed data""" 917 | assert 0 <= ch_idx < ui_defs.NUM_OF_DATA_CHANNELS 918 | 919 | text = self.ui_data_fields[ch_idx].text() 920 | return validators.parse_channel_data(text) 921 | 922 | def _parse_seq_data_field(self, seq_idx: int) -> models.SequenceTextFieldParserResult: 923 | """Get data from a sequence field and return parsed data""" 924 | assert 0 <= seq_idx < ui_defs.NUM_OF_SEQ_CHANNELS 925 | seq = self.ui_seq_fields[seq_idx] 926 | text = seq.text() 927 | 928 | return validators.parse_seq_data(text) 929 | 930 | def _convert_data(self, data: List[int], new_format: models.OutputRepresentation, separator: str) -> str: 931 | """Convert chosen data to a string with selected format.""" 932 | if new_format == models.OutputRepresentation.STRING: 933 | # Convert list of integers to a string, without data separator. 934 | output_data = "".join([chr(num) for num in data]) 935 | elif new_format == models.OutputRepresentation.INT_LIST: 936 | # Convert list of integers to a string of integer values. 937 | int_data = [str(num) for num in data] 938 | output_data = separator.join(int_data) + separator 939 | elif new_format == models.OutputRepresentation.HEX_LIST: 940 | # Convert list of integers to a string of hex values. 941 | # format always as 0x** (two fields for data value) 942 | hex_data = [f"0x{num:02x}" for num in data] 943 | output_data = separator.join(hex_data) + separator 944 | else: 945 | ascii_data = [f"'{chr(num)}'" for num in data] 946 | output_data = separator.join(ascii_data) + separator 947 | 948 | return output_data.strip() 949 | 950 | def ask_for_save_file_path( 951 | self, name: str, dir_path: Optional[str] = None, filter_ext: str = "*.txt" 952 | ) -> Optional[str]: 953 | """ 954 | Get path where file should be saved with default os GUI pop-up. Returns None on cancel or exit. 955 | See ask_for_file_path() for parameters. 956 | """ 957 | return self.ask_for_file_path(name, True, dir_path, filter_ext) 958 | 959 | def ask_for_open_file_path( 960 | self, name: str, dir_path: Optional[str] = None, filter_ext: str = "*.txt" 961 | ) -> Optional[str]: 962 | """ 963 | Get path where file should be saved with default os GUI pop-up. Returns None on cancel or exit. 964 | See ask_for_file_path() for parameters. 965 | """ 966 | return self.ask_for_file_path(name, False, dir_path, filter_ext) 967 | 968 | def ask_for_file_path( 969 | self, name: str, mode: bool = True, dir_path: Optional[str] = None, filter_ext: Optional[str] = None 970 | ) -> Optional[str]: 971 | """ 972 | Get path where file should be saved with default os GUI pop-up. Returns None on cancel or exit. 973 | 974 | Args: 975 | name: name of pop-up gui window. 976 | mode: if True, dialog for selecting save file is created. Otherwise, dialog to open file is created. 977 | dir_path: path to a folder/file where dialog should be open. 978 | paths.get_default_log_dir() is used by default. 979 | filter_ext: file extension filter (can be merged list: '"*.txt, "*.json", "*.log"') 980 | """ 981 | if dir_path is None: 982 | dir_path = paths.get_default_log_dir() 983 | else: 984 | dir_path = os.path.normpath(dir_path) 985 | 986 | if filter_ext is None: 987 | filter_ext = "" 988 | 989 | if mode: 990 | name, _ = QtWidgets.QFileDialog.getSaveFileName(self, name, dir_path, filter_ext) 991 | else: 992 | name, _ = QtWidgets.QFileDialog.getOpenFileName(self, name, dir_path, filter_ext) 993 | 994 | if name == "": 995 | return None 996 | 997 | return os.path.normpath(name) 998 | 999 | def confirm_action_dialog( 1000 | self, 1001 | name: str, 1002 | question: str, 1003 | icon_type: Optional[QtWidgets.QMessageBox.Icon] = QtWidgets.QMessageBox.Warning, 1004 | ) -> bool: 1005 | """ 1006 | Pop-up system dialog with OK|Cancel options. 1007 | Return True if user respond with click to OK button. Else, return False. 1008 | """ 1009 | dialog = QtWidgets.QMessageBox() 1010 | 1011 | window_icon = QtGui.QIcon() 1012 | window_icon.addPixmap(QtGui.QPixmap(":/icons/icons/SerialTool.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 1013 | dialog.setWindowIcon(window_icon) 1014 | 1015 | if icon_type is not None: 1016 | dialog.setIcon(icon_type) 1017 | 1018 | dialog.setStandardButtons(QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel) 1019 | dialog.setDefaultButton(QtWidgets.QMessageBox.Ok) 1020 | dialog.setEscapeButton(QtWidgets.QMessageBox.Cancel) 1021 | 1022 | dialog.setText(question) 1023 | dialog.setWindowTitle(name) 1024 | 1025 | retval = dialog.exec_() 1026 | 1027 | return retval == QtWidgets.QMessageBox.Ok 1028 | 1029 | def _app_exc_handler(self, exc_type, exc_value, traceback_obj) -> None: 1030 | """Global function to catch unhandled exceptions.""" 1031 | msg = "\n" + " *" * 20 1032 | msg += "\nUnhandled unexpected exception occurred! Program will try to continue with execution." 1033 | msg += f"\n\tExc. type: {exc_type}" 1034 | msg += f"\n\tExc. value: {exc_value}" 1035 | msg += f"\n\tExc. traceback: {traceback.format_tb(traceback_obj)}" 1036 | msg += "\n\n" 1037 | logging.error(f"{msg}") 1038 | 1039 | try: 1040 | self.sig_error.emit(msg, colors.LOG_ERROR) 1041 | except Exception as err: 1042 | # at least, log to file if log over signal fails 1043 | logging.error(f"Error emitting `error signal` from system exception handler:\n{err}") 1044 | 1045 | try: 1046 | self.stop_all_seq_tx_threads() 1047 | self.port_hdlr.deinit_port() 1048 | except Exception as err: 1049 | logging.error(f"Error in final exception handler:\n{err}") 1050 | self.closeEvent() 1051 | 1052 | 1053 | def init_logger(level: int = logging.DEBUG) -> None: 1054 | log_dir = paths.get_default_log_dir() 1055 | os.makedirs(log_dir, exist_ok=True) 1056 | file_path = os.path.join(log_dir, base.LOG_FILENAME) 1057 | 1058 | fmt = logging.Formatter(base.LOG_FORMAT, datefmt=base.LOG_DATETIME_FORMAT) 1059 | 1060 | logger = logging.getLogger() 1061 | logger.setLevel(level) 1062 | 1063 | std_hdlr = logging.StreamHandler() 1064 | std_hdlr.setLevel(level) 1065 | std_hdlr.setFormatter(fmt) 1066 | logger.addHandler(std_hdlr) 1067 | 1068 | file_hdlr = logging.FileHandler(file_path, encoding="utf-8") 1069 | file_hdlr.setFormatter(fmt) 1070 | file_hdlr.setLevel(level) 1071 | logger.addHandler(file_hdlr) 1072 | 1073 | asyncio_logger = logging.getLogger("asyncio") 1074 | asyncio_logger.setLevel(logging.WARNING) 1075 | 1076 | logging.info(f"Logger initialized: {file_path}") 1077 | 1078 | 1079 | def main() -> None: 1080 | args = cmd_args.SerialToolArgs.parse() 1081 | init_logger(args.log_level) 1082 | 1083 | app = QtWidgets.QApplication(sys.argv) 1084 | gui = Gui(args) 1085 | gui.show() 1086 | 1087 | ret = app.exec_() 1088 | 1089 | sys.exit(ret) 1090 | 1091 | 1092 | if __name__ == "__main__": 1093 | main() 1094 | -------------------------------------------------------------------------------- /src/serial_tool/cfg_hdlr.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import json 4 | from typing import Any, Dict 5 | 6 | from serial_tool.defines import base 7 | from serial_tool.defines import cfg_defs 8 | from serial_tool.defines import ui_defs 9 | from serial_tool.defines import colors 10 | from serial_tool import models 11 | from serial_tool import serial_hdlr 12 | 13 | TYP_IO_DATA = Dict[str, Any] 14 | 15 | 16 | class ConfigurationHdlr: 17 | def __init__(self, data_cache: models.RuntimeDataCache, signals: models.SharedSignalsContainer) -> None: 18 | """Handler of user config data (channels, notes, sequences) and save/load actions""" 19 | self.data_cache = data_cache 20 | self.signals = signals 21 | 22 | def save_cfg(self, path: str) -> None: 23 | """Overwrite data with current settings in a json format.""" 24 | data: TYP_IO_DATA = {} 25 | data[cfg_defs.KEY_FILE_VER] = cfg_defs.CFG_FORMAT_VERSION 26 | 27 | ser_cfg_data: TYP_IO_DATA = {} 28 | ser_cfg_data[cfg_defs.KEY_SER_PORT] = self.data_cache.serial_settings.port 29 | ser_cfg_data[cfg_defs.KEY_SER_BAUDRATE] = self.data_cache.serial_settings.baudrate 30 | ser_cfg_data[cfg_defs.KEY_SER_DATASIZE] = self.data_cache.serial_settings.data_size 31 | ser_cfg_data[cfg_defs.KEY_SER_STOPBITS] = self.data_cache.serial_settings.stop_bits 32 | ser_cfg_data[cfg_defs.KEY_SER_PARITY] = self.data_cache.serial_settings.parity 33 | ser_cfg_data[cfg_defs.KEY_SER_SWFLOWCTRL] = self.data_cache.serial_settings.sw_flow_ctrl 34 | ser_cfg_data[cfg_defs.KEY_SER_HWFLOWCTRL] = self.data_cache.serial_settings.hw_flow_ctrl 35 | ser_cfg_data[cfg_defs.KEY_SER_RX_TIMEOUT_MS] = self.data_cache.serial_settings.rx_timeout_ms 36 | ser_cfg_data[cfg_defs.KEY_SER_TX_TIMEOUT_MS] = self.data_cache.serial_settings.tx_timeout_ms 37 | data[cfg_defs.KEY_SER_CFG] = ser_cfg_data 38 | 39 | data[cfg_defs.KEY_GUI_DATA_FIELDS] = {} 40 | for idx, field in enumerate(self.data_cache.data_fields): 41 | data[cfg_defs.KEY_GUI_DATA_FIELDS][idx] = field 42 | data[cfg_defs.KEY_GUI_NOTE_FIELDS] = {} 43 | for idx, field in enumerate(self.data_cache.note_fields): 44 | data[cfg_defs.KEY_GUI_NOTE_FIELDS][idx] = field 45 | data[cfg_defs.KEY_GUI_SEQ_FIELDS] = {} 46 | for idx, field in enumerate(self.data_cache.seq_fields): 47 | data[cfg_defs.KEY_GUI_SEQ_FIELDS][idx] = field 48 | 49 | data[cfg_defs.KEY_GUI_RX_LOG] = self.data_cache.display_rx_data 50 | data[cfg_defs.KEY_GUI_TX_LOG] = self.data_cache.display_tx_data 51 | data[cfg_defs.KEY_GUI_OUT_REPRESENTATION] = self.data_cache.output_data_representation 52 | data[cfg_defs.KEY_GUI_RX_NEWLINE] = self.data_cache.new_line_on_rx 53 | data[cfg_defs.KEY_GUI_RX_NEWLINE_TIMEOUT] = self.data_cache.new_line_on_rx_timeout_msec 54 | 55 | with open(path, "w+", encoding="utf-8") as f: 56 | json.dump(data, f, indent=4) 57 | 58 | def load_cfg(self, path: str) -> None: 59 | """Read (load) given json file and set new configuration.""" 60 | with open(path, "r", encoding="utf-8") as f: 61 | data = json.load(f) 62 | 63 | if cfg_defs.KEY_FILE_VER not in data: 64 | msg = f"Missing `{cfg_defs.KEY_FILE_VER}` key in configuration file. Possibly unknown data format." 65 | self.signals.warning.emit(msg, colors.LOG_WARNING) 66 | logging.warning(f"Trying to load configuration file with missing `{cfg_defs.KEY_FILE_VER}` field...") 67 | elif data[cfg_defs.KEY_FILE_VER] != cfg_defs.CFG_FORMAT_VERSION: 68 | msg = "Configuration file format has changed - expect invalid/missing configuration data." 69 | msg += ( 70 | f"\nCurrent version: {cfg_defs.CFG_FORMAT_VERSION}, config file version: {data[cfg_defs.KEY_FILE_VER]}" 71 | ) 72 | self.signals.warning.emit(msg, colors.LOG_WARNING) 73 | logging.warning("Trying to load configuration file with different format version...") 74 | 75 | try: 76 | settings = serial_hdlr.SerialCommSettings() 77 | ser_cfg_data = data[cfg_defs.KEY_SER_CFG] 78 | settings.port = ser_cfg_data[cfg_defs.KEY_SER_PORT] 79 | cfg_baudrate = ser_cfg_data[cfg_defs.KEY_SER_BAUDRATE] 80 | if cfg_baudrate is None: 81 | settings.baudrate = base.DEFAULT_BAUDRATE 82 | else: 83 | settings.baudrate = cfg_baudrate 84 | settings.data_size = ser_cfg_data[cfg_defs.KEY_SER_DATASIZE] 85 | settings.stop_bits = ser_cfg_data[cfg_defs.KEY_SER_STOPBITS] 86 | settings.parity = ser_cfg_data[cfg_defs.KEY_SER_PARITY] 87 | settings.sw_flow_ctrl = ser_cfg_data[cfg_defs.KEY_SER_SWFLOWCTRL] 88 | settings.hw_flow_ctrl = ser_cfg_data[cfg_defs.KEY_SER_HWFLOWCTRL] 89 | settings.rx_timeout_ms = ser_cfg_data[cfg_defs.KEY_SER_RX_TIMEOUT_MS] 90 | settings.tx_timeout_ms = ser_cfg_data[cfg_defs.KEY_SER_TX_TIMEOUT_MS] 91 | self.data_cache.set_serial_settings(settings) 92 | except KeyError as err: 93 | msg = f"Unable to set serial settings from a configuration file: {err}" 94 | self.signals.error.emit(msg, colors.LOG_ERROR) 95 | 96 | try: 97 | for idx, field in data[cfg_defs.KEY_GUI_DATA_FIELDS].items(): 98 | self.data_cache.set_data_field(int(idx), field) 99 | 100 | for idx, field in data[cfg_defs.KEY_GUI_NOTE_FIELDS].items(): 101 | self.data_cache.set_note_field(int(idx), field) 102 | 103 | for idx, field in data[cfg_defs.KEY_GUI_SEQ_FIELDS].items(): 104 | self.data_cache.set_seq_field(int(idx), field) 105 | except KeyError as err: 106 | msg = f"Unable to set data/note/sequence settings from a configuration file: {err}" 107 | self.signals.error.emit(msg, colors.LOG_ERROR) 108 | 109 | try: 110 | self.data_cache.set_rx_display_ode(data[cfg_defs.KEY_GUI_RX_LOG]) 111 | self.data_cache.set_tx_display_mode(data[cfg_defs.KEY_GUI_TX_LOG]) 112 | self.data_cache.set_output_representation_mode(data[cfg_defs.KEY_GUI_OUT_REPRESENTATION]) 113 | self.data_cache.set_new_line_on_rx_mode(data[cfg_defs.KEY_GUI_RX_NEWLINE]) 114 | self.data_cache.set_new_line_on_rx_timeout(data[cfg_defs.KEY_GUI_RX_NEWLINE_TIMEOUT]) 115 | except KeyError as err: 116 | msg = f"Unable to set log settings from a configuration file: {err}" 117 | self.signals.error.emit(msg, colors.LOG_ERROR) 118 | 119 | def set_default_cfg(self) -> None: 120 | """ 121 | Set instance of data model with default values. 122 | Will emit signals to update GUI. 123 | """ 124 | self.data_cache.set_serial_settings(self.data_cache.serial_settings) 125 | for idx in range(ui_defs.NUM_OF_DATA_CHANNELS): 126 | self.data_cache.set_data_field(idx, "") 127 | self.data_cache.set_note_field(idx, "") 128 | 129 | for idx in range(ui_defs.NUM_OF_SEQ_CHANNELS): 130 | self.data_cache.set_seq_field(idx, "") 131 | 132 | self.data_cache.set_rx_display_ode(True) 133 | self.data_cache.set_tx_display_mode(True) 134 | self.data_cache.set_output_representation_mode(models.OutputRepresentation.STRING) 135 | self.data_cache.set_new_line_on_rx_mode(False) 136 | -------------------------------------------------------------------------------- /src/serial_tool/cmd_args.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | 4 | 5 | class SerialToolArgs: 6 | def __init__(self, log_level=logging.DEBUG, load_mru_cfg: bool = False): 7 | self.log_level = log_level 8 | self.load_mru_cfg = load_mru_cfg 9 | 10 | @staticmethod 11 | def parse() -> "SerialToolArgs": 12 | parser = argparse.ArgumentParser() 13 | 14 | parser.add_argument( 15 | "--log-level", type=str, default="DEBUG", required=False, help="Optionally set logging level." 16 | ) 17 | parser.add_argument( 18 | "--load-mru-cfg", 19 | action="store_true", 20 | required=False, 21 | help="If present, most recently used configuration is loaded on startup, if available.", 22 | ) 23 | 24 | args = parser.parse_args() 25 | 26 | levels = logging.getLevelNamesMapping() 27 | if args.log_level not in levels: 28 | raise ValueError(f"`{args.log_level}` is not a valid log level. Must be any of: {levels.keys()}") 29 | return SerialToolArgs(levels[args.log_level], args.load_mru_cfg) 30 | -------------------------------------------------------------------------------- /src/serial_tool/communication.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import time 4 | import threading 5 | import traceback 6 | from typing import List, Optional 7 | 8 | from PyQt5 import QtCore 9 | 10 | from serial_tool import models 11 | from serial_tool import serial_hdlr 12 | 13 | 14 | class _RxDataHdlr(QtCore.QObject): 15 | sig_rx_not_empty = QtCore.pyqtSignal() 16 | 17 | def __init__(self, port_hdlr: serial_hdlr.SerialPort) -> None: 18 | """ 19 | This class initialize thread that read available data with asyncio read and store receive data in a list. 20 | On data readout, sig_rx_not_empty signal is emitted to notify parent that new data is available. 21 | """ 22 | super().__init__() 23 | 24 | self._port_hdlr: serial_hdlr.SerialPort = port_hdlr 25 | 26 | self.rx_data: List[int] = [] 27 | self._rx_data_lock = threading.Lock() 28 | self._rx_not_empty_notified = False 29 | 30 | self._rx_thread_stop_flag = False 31 | 32 | self._async_read_byte_task: Optional[asyncio.Task] = None 33 | 34 | def run(self) -> None: 35 | """Wait and receive data in async mode. It is run as a thread.""" 36 | try: 37 | self._port_hdlr.is_connected(True) 38 | 39 | with self._rx_data_lock: 40 | self.rx_data.clear() 41 | 42 | self._rx_thread_stop_flag = False 43 | while not self._rx_thread_stop_flag: 44 | try: 45 | byte = asyncio.run(self._async_read_data()) # asynchronously receive 1 byte 46 | if self._rx_thread_stop_flag: 47 | return 48 | if byte == b"": 49 | continue # nothing received 50 | 51 | # receive data available, read all 52 | rx_data = self._port_hdlr.read_data() 53 | with self._rx_data_lock: 54 | self.rx_data.append(ord(byte)) # first received byte (async) 55 | self.rx_data.extend(rx_data) # other data 56 | 57 | if not self._rx_not_empty_notified: 58 | self._rx_not_empty_notified = True # prevent notifying multiple times for new data 59 | self.sig_rx_not_empty.emit() 60 | except asyncio.CancelledError: 61 | # Asyncio task cancel request by user. 62 | assert self._async_read_byte_task is not None 63 | 64 | except Exception as err: 65 | logging.error(f"inner exc:\n{err}\n{traceback.format_exc()}") 66 | raise Exception(f"Exception caught in receive thread read_data() function:\n{err}") from err 67 | 68 | except Exception as err: 69 | logging.error(f"Exception in data receiving thread:\n{err}") 70 | raise 71 | 72 | def request_stop(self) -> None: 73 | """Request to stop RX thread. On exit, thread might still be running.""" 74 | self._rx_thread_stop_flag = True 75 | 76 | if self._async_read_byte_task: 77 | self._async_read_byte_task.cancel() 78 | 79 | self._port_hdlr._port.cancel_read() 80 | 81 | def get_rx_data(self) -> List[int]: 82 | """Return all currently received data as a copy.""" 83 | with self._rx_data_lock: 84 | rx_data = self.rx_data.copy() 85 | self.rx_data.clear() 86 | 87 | self._rx_not_empty_notified = False # data is read, new "notify" callback can be generated on next data 88 | return rx_data 89 | 90 | async def _async_read_data(self) -> bytes: 91 | """ 92 | Asynchronously read data from a serial port and return one byte. 93 | Might be an empty byte (b''), which indicates no new received data. 94 | Raise exception on error. 95 | """ 96 | self._async_read_byte_task = asyncio.create_task(self._port_hdlr._port.read_async()) 97 | 98 | return await self._async_read_byte_task 99 | 100 | 101 | class TxDataSequenceHdlr(QtCore.QObject): 102 | sig_data_send_event = QtCore.pyqtSignal(int, int) 103 | sig_seq_tx_finished = QtCore.pyqtSignal(int) 104 | sig_seq_stop_request = QtCore.pyqtSignal() 105 | 106 | def __init__( 107 | self, 108 | port_hdlr: serial_hdlr.SerialPort, 109 | seq_idx: int, 110 | parsed_data_fields: List[Optional[List[int]]], 111 | parsed_seq_data: List[models.SequenceInfo], 112 | ) -> None: 113 | """ 114 | This class initialize thread that sends specified sequence over given serial port. 115 | 116 | Args: 117 | port_hdlr: Initialized serial port handler. 118 | seq_idx: Index of sequence field that needs to be transmitted. 119 | parsed_data_fields: list of parsed data fields. 120 | parsed_seq_data: parsed sequence field info. 121 | """ 122 | super().__init__() 123 | 124 | self._port_hdlr = port_hdlr 125 | self.seq_idx = seq_idx 126 | self.parsed_data_fields = parsed_data_fields 127 | self.parsed_seq_data = parsed_seq_data 128 | 129 | self._stop_seq_request = False 130 | 131 | self.sig_seq_stop_request.connect(self.on_stop_seq_request) 132 | 133 | @QtCore.pyqtSlot() 134 | def on_stop_seq_request(self) -> None: 135 | self._stop_seq_request = True 136 | 137 | def run(self) -> None: 138 | """Execute transmission of sequence data. It is run as a thread.""" 139 | self._port_hdlr.is_connected(True) 140 | 141 | try: 142 | while not self._stop_seq_request: 143 | for seq_info in self.parsed_seq_data: 144 | if self._stop_seq_request: 145 | break 146 | 147 | data = self.parsed_data_fields[seq_info.ch_idx] 148 | assert data is not None 149 | 150 | for _ in range(seq_info.repeat): 151 | if self._stop_seq_request: 152 | break 153 | 154 | self._port_hdlr.write_data(data) 155 | self.sig_data_send_event.emit(self.seq_idx, seq_info.ch_idx) 156 | 157 | if self._stop_seq_request: 158 | break 159 | time.sleep(seq_info.delay_msec / 1000) 160 | break 161 | except Exception as err: 162 | logging.error(f"Exception while transmitting sequence {self.seq_idx+1}:\n{err}") 163 | raise 164 | 165 | finally: 166 | self.sig_seq_tx_finished.emit(self.seq_idx) 167 | 168 | 169 | class PortHdlr(QtCore.QObject): 170 | sig_init_request = QtCore.pyqtSignal() 171 | sig_deinit_request = QtCore.pyqtSignal() 172 | 173 | sig_write = QtCore.pyqtSignal(list) 174 | sig_data_received = QtCore.pyqtSignal(list) 175 | 176 | sig_connection_successful = QtCore.pyqtSignal() 177 | sig_connection_closed = QtCore.pyqtSignal() 178 | 179 | def __init__(self, serial_settings: serial_hdlr.SerialCommSettings, ser_port: serial_hdlr.SerialPort) -> None: 180 | """Main wrapper around low level serial port handler. This class also holds instance of RX threads.""" 181 | super().__init__() 182 | self.serial_settings = serial_settings 183 | self.ser_port = ser_port 184 | 185 | self._rx_data_hdlr: Optional[_RxDataHdlr] = None 186 | self._rx_watcher_thread: Optional[QtCore.QThread] = None 187 | 188 | self.connect_signals_to_slots() 189 | 190 | def connect_signals_to_slots(self) -> None: 191 | self.sig_init_request.connect(self.init_port_and_rx_thread) 192 | self.sig_deinit_request.connect(self.deinit_port) 193 | 194 | self.sig_write.connect(self.write_data) 195 | 196 | def is_connected(self) -> bool: 197 | return self.ser_port.is_connected() 198 | 199 | def init_port_and_rx_thread(self) -> None: 200 | if self.ser_port.init(self.serial_settings): 201 | self._rx_data_hdlr = _RxDataHdlr(self.ser_port) 202 | self._rx_data_hdlr.sig_rx_not_empty.connect(self.get_rx_data) 203 | 204 | self._rx_watcher_thread = QtCore.QThread() 205 | self._rx_data_hdlr.moveToThread(self._rx_watcher_thread) 206 | self._rx_watcher_thread.started.connect(self._rx_data_hdlr.run) 207 | self._rx_watcher_thread.start() 208 | 209 | self.sig_connection_successful.emit() 210 | else: 211 | self.sig_connection_closed.emit() 212 | 213 | def deinit_port(self) -> None: 214 | if self._rx_watcher_thread is not None: 215 | if self._rx_data_hdlr is not None: 216 | self._rx_data_hdlr.request_stop() 217 | self._wait_until_rx_thread_is_finished() 218 | 219 | self._rx_watcher_thread.quit() 220 | self._rx_watcher_thread.wait() 221 | 222 | self._rx_watcher_thread = None 223 | self._rx_data_hdlr = None 224 | 225 | self.ser_port.close_port() 226 | 227 | self.sig_connection_closed.emit() 228 | 229 | def _wait_until_rx_thread_is_finished(self, timeout_ms: int = 5000) -> bool: 230 | """Return True if thread is finished once this function exits, False otherwise.""" 231 | if not self._rx_watcher_thread: 232 | return True 233 | 234 | end_time = time.perf_counter() + timeout_ms / 1000 235 | while time.perf_counter() < end_time: 236 | if self._rx_watcher_thread.isFinished(): 237 | return True 238 | 239 | return False 240 | 241 | def write_data(self, data: List[int]) -> None: 242 | self.ser_port.write_data(data) 243 | 244 | def get_rx_data(self) -> None: 245 | assert self._rx_data_hdlr is not None 246 | 247 | data = self._rx_data_hdlr.get_rx_data() 248 | self.sig_data_received.emit(data) 249 | -------------------------------------------------------------------------------- /src/serial_tool/defines/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/src/serial_tool/defines/__init__.py -------------------------------------------------------------------------------- /src/serial_tool/defines/base.py: -------------------------------------------------------------------------------- 1 | LOG_FORMAT = "%(asctime)s.%(msecs)03d %(levelname)+8s: %(message)s" 2 | LOG_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" 3 | 4 | # links 5 | LINK_DAMGORANLABS = "http://damogranlabs.com/" 6 | LINK_HOMEPAGE = "https://damogranlabs.com/2017/05/serial-tool/" 7 | LINK_REPOSITORY = "https://github.com/damogranlabs/serial-tool/" 8 | LINK_DOCS = "https://github.com/damogranlabs/serial-tool/blob/master/README.md" 9 | 10 | # default serial settings 11 | DEFAULT_BAUDRATE = 115200 12 | SERIAL_RX_TIMEOUT_MS = 1000 13 | SERIAL_TX_TIMEOUT_MS = 300 14 | 15 | # extensions 16 | LOG_EXPORT_FILE_EXT_FILTER = "*.log" 17 | DATA_EXPORT_FILE_EXT_FILTER = "*.log" 18 | CFG_FILE_EXT_FILTER = "*.json" 19 | 20 | # default paths and file names 21 | APPDATA_DIR_NAME = "SerialTool" 22 | LOG_FILENAME = "SerialTool.log" 23 | DEFAULT_LOG_EXPORT_FILENAME = "logWindow.log" 24 | DEFAULT_DATA_EXPORT_FILENAME = "rxTxData.log" 25 | DEFAULT_CFG_FILE_NAME = "SerialToolCfg.json" 26 | RECENTLY_USED_CFG_FILE_NAME = "_recentlyUsedConfigurations.txt" 27 | -------------------------------------------------------------------------------- /src/serial_tool/defines/cfg_defs.py: -------------------------------------------------------------------------------- 1 | CFG_FORMAT_VERSION = 2.0 # configuration file format version (not main software version) 2 | 3 | # Configuration data JSON keys 4 | # TODO: switch to pydantic models one day 5 | KEY_FILE_VER = "version" 6 | 7 | KEY_SER_CFG = "serialSettings" 8 | KEY_SER_PORT = "port" 9 | KEY_SER_BAUDRATE = "baudrate" 10 | KEY_SER_DATASIZE = "dataSize" 11 | KEY_SER_STOPBITS = "stopbits" 12 | KEY_SER_PARITY = "parity" 13 | KEY_SER_SWFLOWCTRL = "swFlowControl" 14 | KEY_SER_HWFLOWCTRL = "hwFlowControl" 15 | KEY_SER_RX_TIMEOUT_MS = "readTimeoutMs" 16 | KEY_SER_TX_TIMEOUT_MS = "writeTimeoutMs" 17 | 18 | KEY_GUI_DATA_FIELDS = "dataFields" 19 | KEY_GUI_NOTE_FIELDS = "noteFields" 20 | KEY_GUI_SEQ_FIELDS = "sequenceFields" 21 | KEY_GUI_RX_LOG = "rxToLog" 22 | KEY_GUI_TX_LOG = "txToLog" 23 | KEY_GUI_OUT_REPRESENTATION = "outputDataRepresentation" 24 | KEY_GUI_RX_NEWLINE = "newLineOnRxData" 25 | KEY_GUI_RX_NEWLINE_TIMEOUT = "newLineOnRxTimeout" 26 | -------------------------------------------------------------------------------- /src/serial_tool/defines/colors.py: -------------------------------------------------------------------------------- 1 | RED = "#d45353" 2 | GREEN = "#9dff96" 3 | ORANGE = "#f5881b" 4 | 5 | DAMOGRANLABS_BLUE = "#4f7fd7" 6 | DAMOGRANLABS_GRAY = "#646464" 7 | 8 | COMM_PORT_CONNECTED = GREEN 9 | COMM_PORT_NOT_CONNECTED = RED 10 | SEQ_ACTIVE = ORANGE 11 | 12 | INPUT_NONE = "None" # qt style 13 | INPUT_VALID = GREEN 14 | INPUT_ERROR = RED 15 | 16 | LOG_NORMAL = "#000000" 17 | LOG_WARNING = "#ff8000" # orange 18 | LOG_ERROR = "#ff0000" 19 | LOG_GRAY = DAMOGRANLABS_GRAY 20 | 21 | LOG_RX_DATA = DAMOGRANLABS_GRAY 22 | LOG_TX_DATA = DAMOGRANLABS_BLUE 23 | -------------------------------------------------------------------------------- /src/serial_tool/defines/ui_defs.py: -------------------------------------------------------------------------------- 1 | APP_NAME = "Serial Tool" 2 | 3 | # general GUI stuff 4 | DEFAULT_FONT_STYLE = "font-size: 10pt;" 5 | NUM_OF_MAX_RECENTLY_USED_CFG_GUI = 8 6 | 7 | DEFAULT_RX_NEWLINE_TIMEOUT_MS = 10 8 | 9 | NUM_OF_DATA_CHANNELS = 8 10 | NUM_OF_SEQ_CHANNELS = 3 11 | 12 | # button strings 13 | COMM_PORT_CONNECTED_TEXT = "CONNECTED" 14 | COMM_PORT_NOT_CONNECTED_TEXT = "not connected" 15 | 16 | SEQ_BUTTON_IDLE_TEXT = "SEND SEQUENCE" 17 | SEQ_BUTTON_STOP_TEXT = "STOP SEQUENCE" 18 | 19 | # log/window tags and separation strings 20 | SEQ_TAG = "SEQ" 21 | 22 | RX_DATA_SEPARATOR = "; " 23 | TX_DATA_SEPARATOR = "; " 24 | 25 | DATA_BYTES_SEPARATOR = ";" 26 | 27 | SEQ_BLOCK_SEPARATOR = ";" 28 | SEQ_BLOCK_DATA_SEPARATOR = "," 29 | SEQ_BLOCK_START_CHAR = "(" 30 | SEQ_BLOCK_END_CHAR = ")" 31 | 32 | EXPORT_RX_TAG = " <-- " # added spaces at the beginning, to align with tx channel syntax (example: CH0) 33 | EXPORT_TX_TAG = "--> " 34 | -------------------------------------------------------------------------------- /src/serial_tool/gui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/damogranlabs/serial-tool/e70394a16b9a6e90e3260740c0d5b6823dfd1e75/src/serial_tool/gui/__init__.py -------------------------------------------------------------------------------- /src/serial_tool/gui/serialSetupDialog.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file './ui/serialSetupDialog.ui' 4 | # 5 | # Created by: PyQt5 UI code generator 5.15.9 6 | # 7 | # WARNING: Any manual changes made to this file will be lost when pyuic5 is 8 | # run again. Do not edit this file unless you know what you are doing. 9 | 10 | 11 | from PyQt5 import QtCore, QtGui, QtWidgets 12 | 13 | 14 | class Ui_SerialSetupDialog(object): 15 | def setupUi(self, SerialSetupDialog): 16 | SerialSetupDialog.setObjectName("SerialSetupDialog") 17 | SerialSetupDialog.resize(507, 184) 18 | sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Minimum) 19 | sizePolicy.setHorizontalStretch(0) 20 | sizePolicy.setVerticalStretch(0) 21 | sizePolicy.setHeightForWidth(SerialSetupDialog.sizePolicy().hasHeightForWidth()) 22 | SerialSetupDialog.setSizePolicy(sizePolicy) 23 | icon = QtGui.QIcon() 24 | icon.addPixmap(QtGui.QPixmap(":/icons/icons/SerialTool.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off) 25 | SerialSetupDialog.setWindowIcon(icon) 26 | self.verticalLayout = QtWidgets.QVBoxLayout(SerialSetupDialog) 27 | self.verticalLayout.setObjectName("verticalLayout") 28 | self.gridLayout = QtWidgets.QGridLayout() 29 | self.gridLayout.setObjectName("gridLayout") 30 | self.stop_bits = QtWidgets.QVBoxLayout() 31 | self.stop_bits.setObjectName("stop_bits") 32 | self.label_3 = QtWidgets.QLabel(SerialSetupDialog) 33 | self.label_3.setObjectName("label_3") 34 | self.stop_bits.addWidget(self.label_3) 35 | self.RB_stopBits_one = QtWidgets.QRadioButton(SerialSetupDialog) 36 | self.RB_stopBits_one.setChecked(True) 37 | self.RB_stopBits_one.setObjectName("RB_stopBits_one") 38 | self.RB_stopBitsGroup = QtWidgets.QButtonGroup(SerialSetupDialog) 39 | self.RB_stopBitsGroup.setObjectName("RB_stopBitsGroup") 40 | self.RB_stopBitsGroup.addButton(self.RB_stopBits_one) 41 | self.stop_bits.addWidget(self.RB_stopBits_one) 42 | self.RB_stopBits_two = QtWidgets.QRadioButton(SerialSetupDialog) 43 | self.RB_stopBits_two.setObjectName("RB_stopBits_two") 44 | self.RB_stopBitsGroup.addButton(self.RB_stopBits_two) 45 | self.stop_bits.addWidget(self.RB_stopBits_two) 46 | spacerItem = QtWidgets.QSpacerItem(20, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) 47 | self.stop_bits.addItem(spacerItem) 48 | self.gridLayout.addLayout(self.stop_bits, 0, 6, 1, 1) 49 | self.line = QtWidgets.QFrame(SerialSetupDialog) 50 | self.line.setFrameShape(QtWidgets.QFrame.VLine) 51 | self.line.setFrameShadow(QtWidgets.QFrame.Sunken) 52 | self.line.setObjectName("line") 53 | self.gridLayout.addWidget(self.line, 0, 1, 1, 1) 54 | self.parity = QtWidgets.QVBoxLayout() 55 | self.parity.setObjectName("parity") 56 | self.label_2 = QtWidgets.QLabel(SerialSetupDialog) 57 | self.label_2.setObjectName("label_2") 58 | self.parity.addWidget(self.label_2) 59 | self.RB_parity_none = QtWidgets.QRadioButton(SerialSetupDialog) 60 | self.RB_parity_none.setChecked(True) 61 | self.RB_parity_none.setObjectName("RB_parity_none") 62 | self.RB_parityGroup = QtWidgets.QButtonGroup(SerialSetupDialog) 63 | self.RB_parityGroup.setObjectName("RB_parityGroup") 64 | self.RB_parityGroup.addButton(self.RB_parity_none) 65 | self.parity.addWidget(self.RB_parity_none) 66 | self.RB_parity_even = QtWidgets.QRadioButton(SerialSetupDialog) 67 | self.RB_parity_even.setObjectName("RB_parity_even") 68 | self.RB_parityGroup.addButton(self.RB_parity_even) 69 | self.parity.addWidget(self.RB_parity_even) 70 | self.RB_parity_odd = QtWidgets.QRadioButton(SerialSetupDialog) 71 | self.RB_parity_odd.setObjectName("RB_parity_odd") 72 | self.RB_parityGroup.addButton(self.RB_parity_odd) 73 | self.parity.addWidget(self.RB_parity_odd) 74 | spacerItem1 = QtWidgets.QSpacerItem(20, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) 75 | self.parity.addItem(spacerItem1) 76 | self.gridLayout.addLayout(self.parity, 0, 4, 1, 1) 77 | self.line_4 = QtWidgets.QFrame(SerialSetupDialog) 78 | self.line_4.setFrameShape(QtWidgets.QFrame.VLine) 79 | self.line_4.setFrameShadow(QtWidgets.QFrame.Sunken) 80 | self.line_4.setObjectName("line_4") 81 | self.gridLayout.addWidget(self.line_4, 0, 5, 1, 1) 82 | self.bytesize = QtWidgets.QVBoxLayout() 83 | self.bytesize.setObjectName("bytesize") 84 | self.label = QtWidgets.QLabel(SerialSetupDialog) 85 | self.label.setObjectName("label") 86 | self.bytesize.addWidget(self.label) 87 | self.RB_dataSize_eight = QtWidgets.QRadioButton(SerialSetupDialog) 88 | self.RB_dataSize_eight.setChecked(True) 89 | self.RB_dataSize_eight.setObjectName("RB_dataSize_eight") 90 | self.RB_dataSizeGroup = QtWidgets.QButtonGroup(SerialSetupDialog) 91 | self.RB_dataSizeGroup.setObjectName("RB_dataSizeGroup") 92 | self.RB_dataSizeGroup.addButton(self.RB_dataSize_eight) 93 | self.bytesize.addWidget(self.RB_dataSize_eight) 94 | self.RB_dataSize_seven = QtWidgets.QRadioButton(SerialSetupDialog) 95 | self.RB_dataSize_seven.setObjectName("RB_dataSize_seven") 96 | self.RB_dataSizeGroup.addButton(self.RB_dataSize_seven) 97 | self.bytesize.addWidget(self.RB_dataSize_seven) 98 | self.RB_dataSize_six = QtWidgets.QRadioButton(SerialSetupDialog) 99 | self.RB_dataSize_six.setObjectName("RB_dataSize_six") 100 | self.RB_dataSizeGroup.addButton(self.RB_dataSize_six) 101 | self.bytesize.addWidget(self.RB_dataSize_six) 102 | self.RB_dataSize_five = QtWidgets.QRadioButton(SerialSetupDialog) 103 | self.RB_dataSize_five.setObjectName("RB_dataSize_five") 104 | self.RB_dataSizeGroup.addButton(self.RB_dataSize_five) 105 | self.bytesize.addWidget(self.RB_dataSize_five) 106 | spacerItem2 = QtWidgets.QSpacerItem(0, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) 107 | self.bytesize.addItem(spacerItem2) 108 | self.gridLayout.addLayout(self.bytesize, 0, 2, 1, 1) 109 | self.line_3 = QtWidgets.QFrame(SerialSetupDialog) 110 | self.line_3.setFrameShape(QtWidgets.QFrame.VLine) 111 | self.line_3.setFrameShadow(QtWidgets.QFrame.Sunken) 112 | self.line_3.setObjectName("line_3") 113 | self.gridLayout.addWidget(self.line_3, 0, 3, 1, 1) 114 | self.flow_control = QtWidgets.QVBoxLayout() 115 | self.flow_control.setObjectName("flow_control") 116 | self.label_4 = QtWidgets.QLabel(SerialSetupDialog) 117 | self.label_4.setObjectName("label_4") 118 | self.flow_control.addWidget(self.label_4) 119 | self.CB_swFlowCtrl = QtWidgets.QCheckBox(SerialSetupDialog) 120 | self.CB_swFlowCtrl.setObjectName("CB_swFlowCtrl") 121 | self.flow_control.addWidget(self.CB_swFlowCtrl) 122 | self.line_2 = QtWidgets.QFrame(SerialSetupDialog) 123 | self.line_2.setFrameShape(QtWidgets.QFrame.HLine) 124 | self.line_2.setFrameShadow(QtWidgets.QFrame.Sunken) 125 | self.line_2.setObjectName("line_2") 126 | self.flow_control.addWidget(self.line_2) 127 | self.label_5 = QtWidgets.QLabel(SerialSetupDialog) 128 | self.label_5.setObjectName("label_5") 129 | self.flow_control.addWidget(self.label_5) 130 | self.CB_hwFlowCtrl = QtWidgets.QCheckBox(SerialSetupDialog) 131 | self.CB_hwFlowCtrl.setObjectName("CB_hwFlowCtrl") 132 | self.flow_control.addWidget(self.CB_hwFlowCtrl) 133 | spacerItem3 = QtWidgets.QSpacerItem(20, 0, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) 134 | self.flow_control.addItem(spacerItem3) 135 | self.gridLayout.addLayout(self.flow_control, 0, 0, 1, 1) 136 | self.timeouts = QtWidgets.QVBoxLayout() 137 | self.timeouts.setObjectName("timeouts") 138 | self.label_6 = QtWidgets.QLabel(SerialSetupDialog) 139 | self.label_6.setObjectName("label_6") 140 | self.timeouts.addWidget(self.label_6) 141 | self.gridLayout_2 = QtWidgets.QGridLayout() 142 | self.gridLayout_2.setObjectName("gridLayout_2") 143 | self.label_9 = QtWidgets.QLabel(SerialSetupDialog) 144 | self.label_9.setObjectName("label_9") 145 | self.gridLayout_2.addWidget(self.label_9, 0, 0, 1, 1) 146 | self.SB_readTimeout = QtWidgets.QSpinBox(SerialSetupDialog) 147 | sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) 148 | sizePolicy.setHorizontalStretch(0) 149 | sizePolicy.setVerticalStretch(0) 150 | sizePolicy.setHeightForWidth(self.SB_readTimeout.sizePolicy().hasHeightForWidth()) 151 | self.SB_readTimeout.setSizePolicy(sizePolicy) 152 | self.SB_readTimeout.setMaximum(100000) 153 | self.SB_readTimeout.setSingleStep(10) 154 | self.SB_readTimeout.setStepType(QtWidgets.QAbstractSpinBox.DefaultStepType) 155 | self.SB_readTimeout.setProperty("value", 1000) 156 | self.SB_readTimeout.setObjectName("SB_readTimeout") 157 | self.gridLayout_2.addWidget(self.SB_readTimeout, 0, 1, 1, 1) 158 | self.label_7 = QtWidgets.QLabel(SerialSetupDialog) 159 | self.label_7.setObjectName("label_7") 160 | self.gridLayout_2.addWidget(self.label_7, 1, 0, 1, 1) 161 | self.SB_writeTimeout = QtWidgets.QSpinBox(SerialSetupDialog) 162 | sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) 163 | sizePolicy.setHorizontalStretch(0) 164 | sizePolicy.setVerticalStretch(0) 165 | sizePolicy.setHeightForWidth(self.SB_writeTimeout.sizePolicy().hasHeightForWidth()) 166 | self.SB_writeTimeout.setSizePolicy(sizePolicy) 167 | self.SB_writeTimeout.setMaximum(100000) 168 | self.SB_writeTimeout.setSingleStep(10) 169 | self.SB_writeTimeout.setStepType(QtWidgets.QAbstractSpinBox.DefaultStepType) 170 | self.SB_writeTimeout.setProperty("value", 300) 171 | self.SB_writeTimeout.setObjectName("SB_writeTimeout") 172 | self.gridLayout_2.addWidget(self.SB_writeTimeout, 1, 1, 1, 1) 173 | self.timeouts.addLayout(self.gridLayout_2) 174 | spacerItem4 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) 175 | self.timeouts.addItem(spacerItem4) 176 | self.gridLayout.addLayout(self.timeouts, 0, 8, 1, 1) 177 | self.line_6 = QtWidgets.QFrame(SerialSetupDialog) 178 | self.line_6.setFrameShape(QtWidgets.QFrame.VLine) 179 | self.line_6.setFrameShadow(QtWidgets.QFrame.Sunken) 180 | self.line_6.setObjectName("line_6") 181 | self.gridLayout.addWidget(self.line_6, 0, 7, 1, 1) 182 | self.verticalLayout.addLayout(self.gridLayout) 183 | self.horizontalLayout = QtWidgets.QHBoxLayout() 184 | self.horizontalLayout.setObjectName("horizontalLayout") 185 | spacerItem5 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) 186 | self.horizontalLayout.addItem(spacerItem5) 187 | self.PB_OK = QtWidgets.QPushButton(SerialSetupDialog) 188 | self.PB_OK.setObjectName("PB_OK") 189 | self.horizontalLayout.addWidget(self.PB_OK) 190 | self.PB_cancel = QtWidgets.QPushButton(SerialSetupDialog) 191 | self.PB_cancel.setObjectName("PB_cancel") 192 | self.horizontalLayout.addWidget(self.PB_cancel) 193 | self.verticalLayout.addLayout(self.horizontalLayout) 194 | 195 | self.retranslateUi(SerialSetupDialog) 196 | QtCore.QMetaObject.connectSlotsByName(SerialSetupDialog) 197 | 198 | def retranslateUi(self, SerialSetupDialog): 199 | _translate = QtCore.QCoreApplication.translate 200 | SerialSetupDialog.setWindowTitle(_translate("SerialSetupDialog", "Serial Setup Dialog")) 201 | self.label_3.setText(_translate("SerialSetupDialog", "Stop bits:")) 202 | self.RB_stopBits_one.setText(_translate("SerialSetupDialog", "1")) 203 | self.RB_stopBits_two.setText(_translate("SerialSetupDialog", "2")) 204 | self.label_2.setText(_translate("SerialSetupDialog", "Parity:")) 205 | self.RB_parity_none.setText(_translate("SerialSetupDialog", "NONE")) 206 | self.RB_parity_even.setText(_translate("SerialSetupDialog", "EVEN")) 207 | self.RB_parity_odd.setText(_translate("SerialSetupDialog", "ODD")) 208 | self.label.setText(_translate("SerialSetupDialog", "Data size:")) 209 | self.RB_dataSize_eight.setText(_translate("SerialSetupDialog", "8")) 210 | self.RB_dataSize_seven.setText(_translate("SerialSetupDialog", "7")) 211 | self.RB_dataSize_six.setText(_translate("SerialSetupDialog", "6")) 212 | self.RB_dataSize_five.setText(_translate("SerialSetupDialog", "5")) 213 | self.label_4.setText(_translate("SerialSetupDialog", "Software flow control:")) 214 | self.CB_swFlowCtrl.setText(_translate("SerialSetupDialog", "XON/XOFF")) 215 | self.label_5.setText(_translate("SerialSetupDialog", "Hardware flow control:")) 216 | self.CB_hwFlowCtrl.setText(_translate("SerialSetupDialog", "RTS/CTS")) 217 | self.label_6.setText(_translate("SerialSetupDialog", "Timeouts [ms]:")) 218 | self.label_9.setText(_translate("SerialSetupDialog", "Read:")) 219 | self.label_7.setText(_translate("SerialSetupDialog", "Write:")) 220 | self.PB_OK.setText(_translate("SerialSetupDialog", "OK")) 221 | self.PB_cancel.setText(_translate("SerialSetupDialog", "Cancel")) 222 | 223 | 224 | from serial_tool.gui import icons_rc 225 | -------------------------------------------------------------------------------- /src/serial_tool/models.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from typing import Generic, List, Optional, TypeVar 3 | 4 | from PyQt5 import QtCore 5 | 6 | from serial_tool.defines import colors 7 | from serial_tool.defines import ui_defs 8 | from serial_tool import serial_hdlr 9 | 10 | 11 | class OutputRepresentation(enum.IntEnum): 12 | STRING = 0 13 | INT_LIST = 1 14 | HEX_LIST = 2 15 | ASCII_LIST = 3 16 | 17 | 18 | class SequenceInfo: 19 | def __init__(self, ch_idx: int, delay_msec: int = 0, repeat: int = 1): 20 | """Container of parsed block of sequence data 21 | 22 | Args: 23 | ch_idx: index of data channel field (starting with zero). 24 | delay_msec: delay after this channel data is sent in milliseconds. 25 | repeat: number of times this channel is sent with given data and delay. 26 | """ 27 | self.ch_idx: int = ch_idx 28 | self.delay_msec: int = delay_msec 29 | self.repeat: int = repeat 30 | 31 | def __str__(self): 32 | return ( 33 | f"{ui_defs.SEQ_BLOCK_START_CHAR}{self.ch_idx}{ui_defs.SEQ_BLOCK_DATA_SEPARATOR} " 34 | f"{self.delay_msec}{ui_defs.SEQ_BLOCK_DATA_SEPARATOR} " 35 | f"{self.repeat}{ui_defs.SEQ_BLOCK_END_CHAR}" 36 | ) 37 | 38 | 39 | class TextFieldStatus(enum.Enum): 40 | OK = "valid" 41 | BAD = "invalid" 42 | EMPTY = "no content" 43 | 44 | @staticmethod 45 | def get_color(status: "TextFieldStatus") -> str: 46 | if status == TextFieldStatus.OK: 47 | return colors.INPUT_VALID 48 | if status == TextFieldStatus.BAD: 49 | return colors.INPUT_ERROR 50 | if status == TextFieldStatus.EMPTY: 51 | return colors.INPUT_NONE 52 | 53 | raise ValueError(f"Unable to determine color for status: {status}") 54 | 55 | 56 | T_DATA_TYPE = TypeVar("T_DATA_TYPE") 57 | 58 | 59 | class _TextFieldParserResult(Generic[T_DATA_TYPE]): 60 | def __init__(self, status: TextFieldStatus, msg: str = "", data: Optional[T_DATA_TYPE] = None) -> None: 61 | self.status = status 62 | self.msg = msg 63 | 64 | self._data = data 65 | 66 | @property 67 | def data(self) -> T_DATA_TYPE: 68 | assert self._data is not None 69 | 70 | return self._data 71 | 72 | 73 | class ChannelTextFieldParserResult(_TextFieldParserResult[List[int]]): 74 | def __init__(self, status: TextFieldStatus, msg: str = "", data: Optional[List[int]] = None) -> None: 75 | super().__init__(status, msg, data) 76 | 77 | 78 | class SequenceTextFieldParserResult(_TextFieldParserResult[List[SequenceInfo]]): 79 | def __init__(self, status: TextFieldStatus, msg: str = "", data: Optional[List[SequenceInfo]] = None) -> None: 80 | super().__init__(status, msg, data) 81 | 82 | 83 | class SharedSignalsContainer: 84 | def __init__( 85 | self, write: QtCore.pyqtBoundSignal, warning: QtCore.pyqtBoundSignal, error: QtCore.pyqtBoundSignal 86 | ) -> None: 87 | self.write = write 88 | self.warning = warning 89 | self.error = error 90 | 91 | 92 | class RuntimeDataCache(QtCore.QObject): 93 | sig_serial_settings_update = QtCore.pyqtSignal() 94 | sig_data_field_update = QtCore.pyqtSignal(int) 95 | sig_note_field_update = QtCore.pyqtSignal(int) 96 | sig_seq_field_update = QtCore.pyqtSignal(int) 97 | sig_rx_display_update = QtCore.pyqtSignal() 98 | sig_tx_display_update = QtCore.pyqtSignal() 99 | sig_out_representation_update = QtCore.pyqtSignal() 100 | sig_new_line_on_rx_update = QtCore.pyqtSignal() 101 | sig_new_line_on_rx_timeout_update = QtCore.pyqtSignal() 102 | 103 | def __init__(self) -> None: 104 | """Main shared data object.""" 105 | super().__init__() 106 | 107 | self.serial_settings = serial_hdlr.SerialCommSettings() 108 | 109 | self.cfg_file_path: Optional[str] = None 110 | 111 | self.data_fields: List[str] = [""] * ui_defs.NUM_OF_DATA_CHANNELS 112 | self.parsed_data_fields: List[Optional[List[int]]] = [None] * ui_defs.NUM_OF_DATA_CHANNELS 113 | self.note_fields: List[str] = [""] * ui_defs.NUM_OF_DATA_CHANNELS 114 | 115 | self.seq_fields: List[str] = [""] * ui_defs.NUM_OF_SEQ_CHANNELS 116 | self.parsed_seq_fields: List[Optional[List[SequenceInfo]]] = [None] * ui_defs.NUM_OF_SEQ_CHANNELS 117 | 118 | self.all_rx_tx_data: List[str] = [] 119 | 120 | self.output_data_representation = OutputRepresentation.STRING 121 | self.display_rx_data = True 122 | self.display_tx_data = True 123 | self.new_line_on_rx = False 124 | self.new_line_on_rx_timeout_msec: int = ui_defs.DEFAULT_RX_NEWLINE_TIMEOUT_MS 125 | 126 | def set_serial_settings(self, settings: serial_hdlr.SerialCommSettings) -> None: 127 | """Update serial settings and emit a signal at the end.""" 128 | self.serial_settings = settings 129 | self.sig_serial_settings_update.emit() 130 | 131 | def set_data_field(self, channel: int, data: str) -> None: 132 | """Update data field and emit a signal at the end.""" 133 | self.data_fields[channel] = data 134 | self.sig_data_field_update.emit(channel) 135 | 136 | def set_note_field(self, channel: int, data: str) -> None: 137 | """Update note field and emit a signal at the end.""" 138 | self.note_fields[channel] = data 139 | self.sig_note_field_update.emit(channel) 140 | 141 | def set_seq_field(self, channel: int, data: str) -> None: 142 | """Update sequence field and emit a signal at the end.""" 143 | self.seq_fields[channel] = data 144 | self.sig_seq_field_update.emit(channel) 145 | 146 | def set_rx_display_ode(self, is_enabled: bool) -> None: 147 | """Update RX log visibility field and emit a signal at the end.""" 148 | self.display_rx_data = is_enabled 149 | self.sig_rx_display_update.emit() 150 | 151 | def set_tx_display_mode(self, is_enabled: bool) -> None: 152 | """Update TX log visibility field and emit a signal at the end.""" 153 | self.display_tx_data = is_enabled 154 | self.sig_tx_display_update.emit() 155 | 156 | def set_output_representation_mode(self, mode: OutputRepresentation) -> None: 157 | """Update output representation field and emit a signal at the end.""" 158 | self.output_data_representation = mode 159 | self.sig_out_representation_update.emit() 160 | 161 | def set_new_line_on_rx_mode(self, is_enabled: bool) -> None: 162 | """Update RX new line field and emit a signal at the end.""" 163 | self.new_line_on_rx = is_enabled 164 | self.sig_new_line_on_rx_update.emit() 165 | 166 | def set_new_line_on_rx_timeout(self, timeout_msec: int) -> None: 167 | """Update RX new line timeout field (timeout after \n is appended to next RX data) 168 | and emit a signal at the end.""" 169 | self.new_line_on_rx_timeout_msec = timeout_msec 170 | self.sig_new_line_on_rx_timeout_update.emit() 171 | -------------------------------------------------------------------------------- /src/serial_tool/paths.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from functools import lru_cache 4 | from typing import List, Optional 5 | 6 | from serial_tool.defines import base 7 | from serial_tool.defines import ui_defs 8 | 9 | 10 | @lru_cache 11 | def get_default_log_dir() -> str: 12 | """Return path to a default Serial Tool log directory in Appdata.""" 13 | return os.path.join(os.environ["APPDATA"], base.APPDATA_DIR_NAME) 14 | 15 | 16 | @lru_cache 17 | def get_log_file_path() -> str: 18 | """Return path to a default Serial Tool log file.""" 19 | return os.path.join(get_default_log_dir(), base.LOG_FILENAME) 20 | 21 | 22 | @lru_cache 23 | def get_recently_used_cfg_cache_file() -> str: 24 | """Return path to a file where recently used cfgs are stored (log folder).""" 25 | return os.path.join(get_default_log_dir(), base.RECENTLY_USED_CFG_FILE_NAME) 26 | 27 | 28 | def get_most_recently_used_cfg_file() -> Optional[str]: 29 | """ 30 | Get the most recently used configuration. 31 | Return None if file does not exist or it is empty. 32 | """ 33 | file_paths = get_recently_used_cfgs(1) 34 | if file_paths: 35 | return file_paths[0] 36 | 37 | return None 38 | 39 | 40 | def add_cfg_to_recently_used_cfgs(file_path: str) -> None: 41 | """ 42 | Add entry (insert at position 0, first line) given configuration file path 43 | to a list of recently used configurations. 44 | """ 45 | 46 | def _write_fresh(path: str, data: str) -> None: 47 | with open(path, "w", encoding="utf-8") as f: 48 | f.write(data) 49 | 50 | file_path = os.path.abspath(file_path) 51 | 52 | write_fresh = False 53 | ruc_file_path = get_recently_used_cfg_cache_file() 54 | if os.path.exists(ruc_file_path): 55 | try: 56 | with open(ruc_file_path, "r+", encoding="utf-8") as f: 57 | lines = f.readlines() 58 | 59 | lines.insert(0, f"{file_path}\n") 60 | lines = list(dict.fromkeys(lines)) # remove duplicates 61 | lines = lines[: ui_defs.NUM_OF_MAX_RECENTLY_USED_CFG_GUI] # shorten number of entries 62 | 63 | f.seek(0) # strange \x00 appeared without this 64 | f.truncate(0) 65 | f.writelines(lines) 66 | except PermissionError as err: 67 | logging.warning(f"Error while reading/writing/parsing recently used cfgs file:\n{err}") 68 | write_fresh = True 69 | else: 70 | write_fresh = True 71 | 72 | if write_fresh: 73 | try: 74 | logging.info(f"Writing new recently used cfgs file: {ruc_file_path}") 75 | _write_fresh(ruc_file_path, f"{file_path}\n") 76 | except PermissionError as err: 77 | logging.warning(f"Unable to create new recently used cfgs file. No further attempts.\n{err}") 78 | 79 | 80 | def get_recently_used_cfgs(number: int) -> List[str]: 81 | """ 82 | Get a list of last 'number' of valid entries (existing files) of recently used configurations. 83 | 84 | Args: 85 | number: number of max recently used configuration file paths to return. 86 | """ 87 | ruc_file_path = get_recently_used_cfg_cache_file() 88 | 89 | file_paths: List[str] = [] 90 | if os.path.exists(ruc_file_path): 91 | try: 92 | with open(ruc_file_path, "r", encoding="utf-8") as f: 93 | lines = f.readlines() 94 | 95 | file_paths = [] 96 | for line in lines: 97 | line = line.strip() 98 | if os.path.exists(line): 99 | file_paths.append(line) 100 | 101 | return file_paths[:number] 102 | except PermissionError as err: 103 | logging.warning(f"Unable to get most recently used configurations from file: {ruc_file_path}\n{err}") 104 | 105 | return file_paths 106 | -------------------------------------------------------------------------------- /src/serial_tool/serial_hdlr.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import aioserial 4 | import serial 5 | from serial.tools.list_ports import comports 6 | from serial.serialutil import SerialException 7 | 8 | from serial_tool.defines import base 9 | 10 | # import debugpy 11 | 12 | 13 | class SerialCommSettings: 14 | def __init__(self) -> None: 15 | self.port: Optional[str] = None 16 | self.baudrate: int = base.DEFAULT_BAUDRATE 17 | self.data_size: int = serial.EIGHTBITS # serial.SerialBase.BYTESIZES 18 | self.stop_bits: int = serial.STOPBITS_ONE # serialutil.SerialBase.STOPBITS 19 | self.parity: str = serial.PARITY_NONE # serialutil.SerialBase.PARITIES 20 | self.sw_flow_ctrl: bool = False # XON/XOFF 21 | self.hw_flow_ctrl: bool = False # RTS/CTS 22 | self.rx_timeout_ms: int = base.SERIAL_RX_TIMEOUT_MS 23 | self.tx_timeout_ms: int = base.SERIAL_TX_TIMEOUT_MS 24 | 25 | def __str__(self) -> str: 26 | """Return a human readable string of all arguments.""" 27 | settings = f"Data size: {self.data_size}, " 28 | settings += f"Stop bits: {self.stop_bits}, " 29 | settings += f"Parity: {serial.PARITY_NAMES[self.parity]}, " 30 | settings += f"HW Flow Ctrl: {self.hw_flow_ctrl}, " 31 | settings += f"SW Flow Ctrl: {self.sw_flow_ctrl}, " 32 | settings += f"RX timeout: {self.rx_timeout_ms} ms, " 33 | settings += f"TX timeout: {self.tx_timeout_ms} ms" 34 | 35 | if self.port is not None: 36 | settings = f"{self.port} @ {self.baudrate}, {settings}" 37 | 38 | return settings 39 | 40 | 41 | class ParityAsNumbers: 42 | NONE = 0 # serial.PARITY_NONE 43 | EVEN = 1 # serial.PARITY_EVEN 44 | ODD = 2 # serial.PARITY_ODD 45 | 46 | 47 | def parity_as_int(parity: str) -> int: 48 | if parity == serial.PARITY_NONE: 49 | return ParityAsNumbers.NONE 50 | if parity == serial.PARITY_EVEN: 51 | return ParityAsNumbers.EVEN 52 | if parity == serial.PARITY_ODD: 53 | return ParityAsNumbers.ODD 54 | 55 | raise ValueError(f"Unable to convert parity string ({parity}) to a matching number.") 56 | 57 | 58 | def parity_as_str(parity: int) -> str: 59 | if parity == ParityAsNumbers.NONE: 60 | return serial.PARITY_NONE 61 | if parity == ParityAsNumbers.EVEN: 62 | return serial.PARITY_EVEN 63 | if parity == ParityAsNumbers.ODD: 64 | return serial.PARITY_ODD 65 | 66 | raise ValueError(f"Unable to convert parity string ({parity}) to a matching number.") 67 | 68 | 69 | class SerialPort: 70 | def __init__(self, serial_settings: Optional[SerialCommSettings] = None) -> None: 71 | """ 72 | Non-threaded serial port communication class. 73 | Holds all needed functions to init, read and write to/from serial port. 74 | """ 75 | self._port = aioserial.AioSerial() 76 | if serial_settings is None: 77 | serial_settings = SerialCommSettings() 78 | self.settings = serial_settings 79 | 80 | def get_available_ports(self) -> List[str]: 81 | """Get a list of all available 'COMx' or '/dev/ttyX' serial ports.""" 82 | return [port.name for port in comports()] 83 | 84 | def init(self, settings: SerialCommSettings, raise_exc: bool = True) -> bool: 85 | """Initialize serial port with a given settings and return True on success, False otherwise.""" 86 | self.close_port() 87 | 88 | try: 89 | self._port = aioserial.AioSerial( 90 | port=settings.port, 91 | baudrate=settings.baudrate, 92 | bytesize=settings.data_size, 93 | parity=settings.parity, 94 | stopbits=settings.stop_bits, 95 | xonxoff=settings.sw_flow_ctrl, 96 | rtscts=settings.hw_flow_ctrl, 97 | dsrdtr=False, # disable hardware (DSR/DTR) flow control 98 | timeout=settings.rx_timeout_ms / 1000, 99 | write_timeout=settings.tx_timeout_ms / 1000, 100 | ) 101 | 102 | self.settings = settings 103 | 104 | return True 105 | 106 | except SerialException as err: 107 | if raise_exc: 108 | raise RuntimeError(f"Unable to init serial port with following settings: {settings}") from err 109 | 110 | return False 111 | 112 | def is_connected(self, raise_exc: bool = False) -> bool: 113 | """Return True if connection to serial port is established, False otherwise.""" 114 | status = self._port.is_open 115 | if status: 116 | return True 117 | if raise_exc: 118 | raise RuntimeError("Unable to open serial port.") 119 | 120 | return False 121 | 122 | def close_port(self, raise_exc: bool = False) -> None: 123 | """Close port (if open). Optionally raise exception if port is not closed successfully.""" 124 | if self._port.is_open: 125 | self._port.close() 126 | 127 | if raise_exc and self._port.is_open: 128 | raise RuntimeError("Unable to close serial port!") 129 | 130 | def is_data_available(self) -> bool: 131 | """Return True if there is any data in RX buffer, False otherwise.""" 132 | return self._port.in_waiting > 0 133 | 134 | def flush_read_buff(self) -> None: 135 | """Try to flush RX buffed (if port is open).""" 136 | self.is_connected(True) 137 | self._port.reset_input_buffer() 138 | 139 | def flush_write_buff(self) -> None: 140 | """Try to flush TX buffer (if port is open).""" 141 | self.is_connected(True) 142 | self._port.reset_input_buffer() 143 | 144 | def write_data(self, data: List[int], raise_exc: bool = True) -> int: 145 | """Write data to port, where each list item is an integer (0 - 255).""" 146 | num = self._port.write(data) 147 | if num == len(data): 148 | return num 149 | if raise_exc: 150 | raise Exception(f"Serial port write data list unsuccessful. {num} bytes sent instead of {len(data)}.") 151 | 152 | return num 153 | 154 | def read_data(self) -> List[int]: 155 | """Read data from a serial port and return a list of received data (unsigned integers 0 - 255).""" 156 | return list(self._port.read(self._port.in_waiting)) 157 | -------------------------------------------------------------------------------- /src/serial_tool/setup_dialog.py: -------------------------------------------------------------------------------- 1 | """ 2 | Serial setup dialog window handler. 3 | """ 4 | from functools import partial 5 | from typing import Optional 6 | 7 | import serial 8 | from PyQt5 import QtCore, QtWidgets 9 | 10 | from serial_tool.gui.serialSetupDialog import Ui_SerialSetupDialog 11 | 12 | from serial_tool import serial_hdlr 13 | 14 | 15 | class SerialSetupDialog(QtWidgets.QDialog): 16 | def __init__(self, settings: Optional[serial_hdlr.SerialCommSettings] = None) -> None: 17 | QtWidgets.QDialog.__init__(self) 18 | self.ui = Ui_SerialSetupDialog() 19 | self.ui.setupUi(self) 20 | 21 | if settings is None: 22 | settings = serial_hdlr.SerialCommSettings() 23 | self.settings = settings 24 | 25 | self.apply_settings_on_close = False 26 | 27 | self._connect_signals_to_slots() 28 | 29 | self._set_ui_values(self.settings) 30 | 31 | def _connect_signals_to_slots(self) -> None: 32 | # round button data size group 33 | self.ui.RB_dataSizeGroup.setId(self.ui.RB_dataSize_eight, serial.EIGHTBITS) 34 | self.ui.RB_dataSizeGroup.setId(self.ui.RB_dataSize_seven, serial.SEVENBITS) 35 | self.ui.RB_dataSizeGroup.setId(self.ui.RB_dataSize_six, serial.SIXBITS) 36 | self.ui.RB_dataSizeGroup.setId(self.ui.RB_dataSize_five, serial.FIVEBITS) 37 | 38 | # round button parity group 39 | self.ui.RB_parityGroup.setId(self.ui.RB_parity_none, serial_hdlr.parity_as_int(serial.PARITY_NONE)) 40 | self.ui.RB_parityGroup.setId(self.ui.RB_parity_even, serial_hdlr.parity_as_int(serial.PARITY_EVEN)) 41 | self.ui.RB_parityGroup.setId(self.ui.RB_parity_odd, serial_hdlr.parity_as_int(serial.PARITY_ODD)) 42 | 43 | # round button stop bits group 44 | self.ui.RB_stopBitsGroup.setId(self.ui.RB_stopBits_one, serial.STOPBITS_ONE) 45 | self.ui.RB_stopBitsGroup.setId(self.ui.RB_stopBits_two, serial.STOPBITS_TWO) 46 | 47 | # OK/cancel buttons 48 | self.ui.PB_OK.clicked.connect(partial(self.on_exit, True)) 49 | self.ui.PB_cancel.clicked.connect(partial(self.on_exit, False)) 50 | 51 | def display(self) -> None: 52 | """Show dialog and raise it above parent widget.""" 53 | self.show() 54 | self.raise_() 55 | 56 | @QtCore.pyqtSlot(bool) 57 | def on_exit(self, save_if_ok: bool) -> None: 58 | """ 59 | On OK, store dialog settings to settings. 60 | On Cancel or close, don't do nothing. 61 | On Exit, close the dialog. 62 | """ 63 | if save_if_ok: 64 | self._store_ui_settings() 65 | self.apply_settings_on_close = True 66 | 67 | self.close() 68 | 69 | def _store_ui_settings(self) -> None: 70 | """Save current setup dialog values from GUI fields.""" 71 | self.settings.hw_flow_ctrl = self.ui.CB_hwFlowCtrl.isChecked() 72 | self.settings.sw_flow_ctrl = self.ui.CB_swFlowCtrl.isChecked() 73 | 74 | self.settings.data_size = self.ui.RB_dataSizeGroup.checkedId() 75 | self.settings.stop_bits = self.ui.RB_stopBitsGroup.checkedId() 76 | self.settings.parity = serial_hdlr.parity_as_str(self.ui.RB_parityGroup.checkedId()) 77 | 78 | self.settings.rx_timeout_ms = self.ui.SB_readTimeout.value() 79 | self.settings.tx_timeout_ms = self.ui.SB_writeTimeout.value() 80 | 81 | def get_settings(self) -> serial_hdlr.SerialCommSettings: 82 | return self.settings 83 | 84 | def must_apply_settings(self) -> bool: 85 | """ 86 | Return True if dialog values must be applied, False otherwise. 87 | Only make sense to call this function once dialog is closed. 88 | """ 89 | return self.apply_settings_on_close 90 | 91 | def _set_ui_values(self, settings: serial_hdlr.SerialCommSettings) -> None: 92 | """Set dialog values from a given settings values""" 93 | self.ui.CB_hwFlowCtrl.setChecked(settings.sw_flow_ctrl) 94 | self.ui.CB_swFlowCtrl.setChecked(settings.hw_flow_ctrl) 95 | 96 | round_button = self.ui.RB_dataSizeGroup.button(settings.data_size) 97 | round_button.click() 98 | 99 | parity_as_num = serial_hdlr.parity_as_int(settings.parity) 100 | round_button = self.ui.RB_parityGroup.button(parity_as_num) 101 | round_button.click() 102 | 103 | round_button = self.ui.RB_stopBitsGroup.button(settings.stop_bits) 104 | round_button.click() 105 | 106 | self.ui.SB_readTimeout.setValue(settings.rx_timeout_ms) 107 | self.ui.SB_writeTimeout.setValue(settings.tx_timeout_ms) 108 | 109 | self._store_ui_settings() 110 | -------------------------------------------------------------------------------- /src/serial_tool/validators.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import List 3 | 4 | from serial_tool.defines import ui_defs 5 | from serial_tool import models 6 | 7 | 8 | def parse_channel_data(text: str) -> models.ChannelTextFieldParserResult: 9 | text = text.strip() 10 | if text == "": 11 | return models.ChannelTextFieldParserResult(models.TextFieldStatus.EMPTY) 12 | 13 | data = [] 14 | 15 | try: 16 | parts = text.strip(ui_defs.DATA_BYTES_SEPARATOR).split(ui_defs.DATA_BYTES_SEPARATOR) 17 | for part in parts: 18 | part = part.strip() 19 | 20 | # handle HEX numbers (can be one or more bytes) 21 | if part.lower().startswith("0x"): 22 | part = part.lower()[2:] 23 | if not part: # `0x` and nothing after 24 | return models.ChannelTextFieldParserResult( 25 | models.TextFieldStatus.BAD, f"HEX data format detected, but no value specified: {text}" 26 | ) 27 | if len(part) % 2: 28 | part = "0" + part 29 | hex_numbers = list(bytearray.fromhex(part)) 30 | data.extend(hex_numbers) 31 | continue 32 | 33 | # character (enclosed in "") 34 | if part.startswith('"') and part.endswith('"'): 35 | part = part.strip('"') 36 | for char in part: 37 | int_value = ord(char) 38 | _check_number_in_range(int_value) 39 | data.append(int_value) 40 | continue 41 | 42 | # number 43 | if part.isdigit() or part.startswith("-"): 44 | int_value = int(part) 45 | _check_number_in_range(int_value) 46 | # if negative number, create two's complement 47 | if int_value < 0: 48 | byta_value = int_value.to_bytes(1, byteorder=sys.byteorder, signed=True) 49 | uint = int.from_bytes(byta_value, byteorder=sys.byteorder, signed=False) 50 | else: 51 | uint = int_value 52 | data.append(uint) 53 | continue 54 | 55 | return models.ChannelTextFieldParserResult( 56 | models.TextFieldStatus.BAD, f"Channel data format/values not valid: {text}" 57 | ) 58 | 59 | except ValueError as err: 60 | return models.ChannelTextFieldParserResult( 61 | models.TextFieldStatus.BAD, f"Unable to parse given channel data: {text}\n{err}" 62 | ) 63 | 64 | return models.ChannelTextFieldParserResult(models.TextFieldStatus.OK, data=data) 65 | 66 | 67 | def _check_number_in_range(num: int) -> None: 68 | """ 69 | Raise ValueError if given number is not within possible values of 1 byte 70 | - Number is signed char (as int8_t): -128 <= number <= +127 71 | - Number is unsigned char (as uint8_t): 0 <= number <= 255 72 | False otherwise. 73 | """ 74 | if not -128 <= num <= 255: 75 | raise ValueError(f"Number {num} is not within allowed range (-128 ... 255){'.'}") 76 | 77 | 78 | def parse_seq_data(text: str) -> models.SequenceTextFieldParserResult: 79 | """Parse sequence data string.""" 80 | text = text.strip() 81 | 82 | if text == "": 83 | return models.SequenceTextFieldParserResult(models.TextFieldStatus.EMPTY) 84 | 85 | parsed_blocks_data: List[models.SequenceInfo] = [] 86 | blocks = text.strip(ui_defs.SEQ_BLOCK_SEPARATOR).split(ui_defs.SEQ_BLOCK_SEPARATOR) 87 | for block in blocks: 88 | block = block.strip() 89 | 90 | try: 91 | if not (block.startswith(ui_defs.SEQ_BLOCK_START_CHAR) and block.endswith(ui_defs.SEQ_BLOCK_END_CHAR)): 92 | return models.SequenceTextFieldParserResult( 93 | models.TextFieldStatus.BAD, 94 | f"Invalid format, expecting '{ui_defs.SEQ_BLOCK_START_CHAR}' and '{ui_defs.SEQ_BLOCK_END_CHAR}' " 95 | f"separators in block: {block}", 96 | ) 97 | 98 | block = block.strip(ui_defs.SEQ_BLOCK_START_CHAR).strip(ui_defs.SEQ_BLOCK_END_CHAR) 99 | data = block.split(ui_defs.SEQ_BLOCK_DATA_SEPARATOR) 100 | data = [d for d in data if d.strip() != ""] 101 | # repeat number is not mandatory 102 | if len(data) not in [2, 3]: 103 | return models.SequenceTextFieldParserResult( 104 | models.TextFieldStatus.BAD, 105 | f"Invalid format, expecting two or three fields: channel index, delay[, repeat]. " 106 | f"Block: {block}", 107 | ) 108 | 109 | ch_idx = int(data[0].strip()) 110 | # user must enter a number as seen in GUI, starts with 1 111 | if not 1 <= ch_idx <= ui_defs.NUM_OF_DATA_CHANNELS: 112 | return models.SequenceTextFieldParserResult( 113 | models.TextFieldStatus.BAD, 114 | f"Invalid data channel index in sequence: {ch_idx}, block: {block}", 115 | ) 116 | 117 | ch_idx = ch_idx - 1 118 | delay_msec = int(data[1].strip()) 119 | if delay_msec < 0: 120 | return models.SequenceTextFieldParserResult( 121 | models.TextFieldStatus.BAD, 122 | f"Invalid delay, must be a positive number: {delay_msec}, block: {block}", 123 | ) 124 | 125 | seq_data = models.SequenceInfo(ch_idx, delay_msec) 126 | if len(data) == 3: # repeat is specified 127 | repeat_num = int(data[2].strip()) 128 | if repeat_num < 1: 129 | return models.SequenceTextFieldParserResult( 130 | models.TextFieldStatus.BAD, 131 | f"Invalid 'repeat' number, must be a positive number: {repeat_num}, block: {block}", 132 | ) 133 | seq_data.repeat = repeat_num 134 | 135 | parsed_blocks_data.append(seq_data) 136 | continue 137 | 138 | except ValueError as err: 139 | return models.SequenceTextFieldParserResult( 140 | models.TextFieldStatus.BAD, f"Unable to parse given field as sequence data: {text}\n{err}" 141 | ) 142 | 143 | return models.SequenceTextFieldParserResult(models.TextFieldStatus.OK, data=parsed_blocks_data) 144 | -------------------------------------------------------------------------------- /tests/test_cmd_args.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from typing import List 4 | import pytest 5 | 6 | from serial_tool import cmd_args 7 | 8 | 9 | class TempCmdArgs: 10 | def __init__(self, args: List[str]) -> None: 11 | self.args = args 12 | self.sysargs = sys.argv.copy() 13 | 14 | def __enter__(self): 15 | sys.argv = [self.sysargs[0]] + self.args 16 | 17 | def __exit__(self, exc_type, exc_val, exc_tb): 18 | sys.argv = [self.sysargs[0]] + self.args 19 | 20 | 21 | def test_cmd_args(): 22 | with TempCmdArgs([]): 23 | args = cmd_args.SerialToolArgs.parse() 24 | assert args.log_level == logging.DEBUG 25 | assert args.load_mru_cfg is False 26 | 27 | with TempCmdArgs(["--load-mru-cfg"]): 28 | args = cmd_args.SerialToolArgs.parse() 29 | assert args.log_level == logging.DEBUG 30 | assert args.load_mru_cfg is True 31 | 32 | with TempCmdArgs(["--log-level=ERROR"]): 33 | args = cmd_args.SerialToolArgs.parse() 34 | assert args.log_level == logging.ERROR 35 | assert args.load_mru_cfg is False 36 | 37 | with TempCmdArgs(["--load-mru-cfg", "--log-level=ERROR"]): 38 | args = cmd_args.SerialToolArgs.parse() 39 | assert args.log_level == logging.ERROR 40 | assert args.load_mru_cfg is True 41 | 42 | 43 | def test_cmd_args_invalid(): 44 | with pytest.raises(ValueError): 45 | with TempCmdArgs(["--log-level=WHATEVER"]): 46 | cmd_args.SerialToolArgs.parse() 47 | 48 | with pytest.raises(SystemExit): 49 | with TempCmdArgs(["--invalid"]): 50 | cmd_args.SerialToolArgs.parse() 51 | -------------------------------------------------------------------------------- /tests/test_setup_dialog.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import serial 4 | from PyQt5 import QtWidgets 5 | 6 | from serial_tool import serial_hdlr 7 | from serial_tool import setup_dialog 8 | 9 | 10 | def manual_test_widget() -> None: 11 | app = QtWidgets.QApplication(sys.argv) 12 | 13 | init_settings = serial_hdlr.SerialCommSettings() 14 | init_settings.sw_flow_ctrl = True 15 | init_settings.stop_bits = serial.STOPBITS_TWO 16 | init_settings.data_size = serial.SIXBITS 17 | 18 | dialog = setup_dialog.SerialSetupDialog(init_settings) 19 | dialog.display() 20 | 21 | app.exec_() 22 | 23 | 24 | if __name__ == "__main__": 25 | manual_test_widget() 26 | -------------------------------------------------------------------------------- /tests/test_validators.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | import pytest 3 | 4 | from serial_tool import models 5 | from serial_tool import validators 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "data_in,data_out", 10 | [ 11 | ("0", [0]), 12 | ("1; -128; -127; 255; 0", [1, 128, 129, 255, 0]), # note two's complement 13 | ("0x0; 0x1; 0X2; 0x123456789", [0, 1, 2] + list(bytearray.fromhex("0123456789"))), 14 | ('"a"; "A"; "ABCD"', [ord("a"), ord("A"), ord("A"), ord("B"), ord("C"), ord("D")]), 15 | # special cases 16 | ('1; 0x2; "aBc"', [1, 2, ord("a"), ord("B"), ord("c")]), # mixed values 17 | (' 1 ; 0x2 ; "aBc"; ', [1, 2, ord("a"), ord("B"), ord("c")]), # spaces, extra end char 18 | ], 19 | ) 20 | def test_parse_channel_data_valid(data_in: str, data_out: List[int]) -> None: 21 | result = validators.parse_channel_data(data_in) 22 | assert result.status == models.TextFieldStatus.OK, result.msg 23 | 24 | assert len(result.data) == len(data_out) 25 | assert result.data == data_out 26 | 27 | 28 | @pytest.mark.parametrize( 29 | "data_in, msg", 30 | [ 31 | ("1 2", "No separator"), 32 | ("1; 2 3; 4", "No separator #2"), 33 | ("1, 2, 3", "Invalid separator"), 34 | ("1; !}", "Invalid character"), 35 | ("-255", "Out of range"), 36 | ("256", "Out of range"), 37 | ("0x", "No actual number"), 38 | ], 39 | ) 40 | def test_parse_channel_data_invalid_format(data_in: str, msg: str) -> None: 41 | result = validators.parse_channel_data(data_in) 42 | assert result.status == models.TextFieldStatus.BAD, f"Expected fail: {msg}\n{result.msg}" 43 | 44 | 45 | @pytest.mark.parametrize( 46 | "data_in,data_out", 47 | [ 48 | ("(1, 2)", [models.SequenceInfo(0, 2)]), 49 | ("(2, 3, 4)", [models.SequenceInfo(1, 3, 4)]), 50 | ("(1, 2); (2, 3, 4)", [models.SequenceInfo(0, 2), models.SequenceInfo(1, 3, 4)]), 51 | # special cases 52 | ("( 1 , 2 ); ", [models.SequenceInfo(0, 2)]), # allow extra spaces end char 53 | ], 54 | ) 55 | def test_parse_seq_data_valid(data_in: str, data_out: List[models.SequenceInfo]) -> None: 56 | result = validators.parse_seq_data(data_in) 57 | assert result.status == models.TextFieldStatus.OK, result.msg 58 | 59 | assert len(result.data) == len(data_out) 60 | for idx, data in enumerate(data_out): 61 | result_data = result.data[idx] 62 | assert result_data.ch_idx == data.ch_idx 63 | assert result_data.delay_msec == data.delay_msec 64 | assert result_data.repeat == data.repeat 65 | 66 | 67 | @pytest.mark.parametrize( 68 | "data_in, msg", 69 | [ 70 | ("1,2", "No start/end char"), 71 | ("{1, 2,}", "Invalid start/end char"), 72 | ("()", "No minimum required data"), 73 | ("(1)", "No minimum required data (delay)"), 74 | ("(, )", "No minimum required data (channel idx)"), 75 | ("(1, 2, 3, 4)", "To much data"), 76 | ("(1, 2), (1, 2)", "Invalid block separator character"), 77 | ("(1,a)", "'a' is not an int"), 78 | ('(1,"a")', "'a' is not an int"), 79 | ("(1,'a')", "'a' is not an int"), 80 | ], 81 | ) 82 | def test_parse_seq_data_invalid_format(data_in: str, msg: str) -> None: 83 | result = validators.parse_seq_data(data_in) 84 | assert result.status == models.TextFieldStatus.BAD, f"Expected fail: {msg}\n{result.msg}" 85 | -------------------------------------------------------------------------------- /tests/todo/_test_async_serial.py: -------------------------------------------------------------------------------- 1 | import aioserial 2 | import asyncio 3 | 4 | 5 | async def read_and_print(aioserial_instance: aioserial.AioSerial): 6 | while True: 7 | number_of_byte_like_data_written: int = await aioserial_instance.write_async(b"Some data") 8 | raw_data: bytes = await aioserial_instance.read_async() 9 | print(raw_data.decode(errors="ignore"), end="", flush=True) 10 | 11 | 12 | serialInterface = aioserial.AioSerial(port="COM5") 13 | asyncio.run(read_and_print(serialInterface)) 14 | -------------------------------------------------------------------------------- /tests/todo/_test_serial.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import unittest 4 | import serComm 5 | 6 | 7 | class SerialCommunication(unittest.TestCase): 8 | def __init__(self): 9 | self.serialSettings = serComm.SerialCommSettings() 10 | self.serialSettings.port = "COM5" 11 | self.serialSettings.baudrate = 115200 12 | 13 | def test_simpleTest(self): 14 | portHandler = serComm.SerialPortHandler() 15 | if portHandler.initPort(self.serialSettings, raiseException=False): 16 | print(f"Connected to port with following settings: {self.serialSettings}") 17 | 18 | for i in range(5): 19 | txData = "asdasdasd" 20 | sent = portHandler.writeData(txData) 21 | print(f"Bytes sent: {sent}") 22 | 23 | time.sleep(0.25) 24 | 25 | rxData = portHandler.readData() 26 | rxDataStr = "".join(rxData) 27 | print(f"Bytes received: {rxDataStr}") 28 | 29 | if rxDataStr != txData: 30 | print(f"!!! RX and TX Data is not the same:\n\tTX: {txData}\n\tRX: {rxDataStr}") 31 | # self.assertFalse(False, f"!!! RX and TX Data is not the same:\n\tTX: {txData}\n\tRX: {rxDataStr}") 32 | 33 | portHandler.closePort() 34 | return 35 | print(f"!!! Connection to port unsucesfull. Settings: {self.serialSettings}") 36 | 37 | """ 38 | def test_threadedTest(self): 39 | import random 40 | 41 | portHandler = serComm.SerialPortHandlerThreaded() 42 | portHandler.initPort(self.serialSettings) 43 | portHandler.startReceivingData() 44 | print(f"Connected and receiving data from port with following settings: {self.serialSettings}") 45 | 46 | txData = '.' 47 | endTime = time.time() + 10 48 | lastPrintTime = 0 49 | nextDataSendTime = random.uniform(0.2, 1.5) 50 | while time.time() < endTime: 51 | if time.time() > (lastPrintTime + 1): 52 | lastPrintTime = time.time() 53 | print(f"All RX data: {portHandler.getAllReceivedData()}") 54 | 55 | if time.time() > nextDataSendTime: 56 | portHandler.writeData(txData) 57 | nextDataSendTime = time.time() + random.uniform(0.0, 1.5) 58 | 59 | portHandler.stopReceivingData() 60 | portHandler.closePort() 61 | """ 62 | -------------------------------------------------------------------------------- /tests/todo/_test_unittest.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | 4 | class TestSum(unittest.TestCase): 5 | def test_sum(self): 6 | self.assertEqual(sum([1, 2, 3]), 6, "Should be 6") 7 | 8 | def test_sum_tuple(self): 9 | self.assertEqual(sum((1, 2, 2)), 6, "Should be 6") 10 | -------------------------------------------------------------------------------- /ui/serialSetupDialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | SerialSetupDialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 507 10 | 184 11 | 12 | 13 | 14 | 15 | 0 16 | 0 17 | 18 | 19 | 20 | Serial Setup Dialog 21 | 22 | 23 | 24 | :/icons/icons/SerialTool.png:/icons/icons/SerialTool.png 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | Stop bits: 35 | 36 | 37 | 38 | 39 | 40 | 41 | 1 42 | 43 | 44 | true 45 | 46 | 47 | RB_stopBitsGroup 48 | 49 | 50 | 51 | 52 | 53 | 54 | 2 55 | 56 | 57 | RB_stopBitsGroup 58 | 59 | 60 | 61 | 62 | 63 | 64 | Qt::Vertical 65 | 66 | 67 | 68 | 20 69 | 0 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | Qt::Vertical 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | Parity: 89 | 90 | 91 | 92 | 93 | 94 | 95 | NONE 96 | 97 | 98 | true 99 | 100 | 101 | RB_parityGroup 102 | 103 | 104 | 105 | 106 | 107 | 108 | EVEN 109 | 110 | 111 | RB_parityGroup 112 | 113 | 114 | 115 | 116 | 117 | 118 | ODD 119 | 120 | 121 | RB_parityGroup 122 | 123 | 124 | 125 | 126 | 127 | 128 | Qt::Vertical 129 | 130 | 131 | 132 | 20 133 | 0 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | Qt::Vertical 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | Data size: 153 | 154 | 155 | 156 | 157 | 158 | 159 | 8 160 | 161 | 162 | true 163 | 164 | 165 | RB_dataSizeGroup 166 | 167 | 168 | 169 | 170 | 171 | 172 | 7 173 | 174 | 175 | RB_dataSizeGroup 176 | 177 | 178 | 179 | 180 | 181 | 182 | 6 183 | 184 | 185 | RB_dataSizeGroup 186 | 187 | 188 | 189 | 190 | 191 | 192 | 5 193 | 194 | 195 | RB_dataSizeGroup 196 | 197 | 198 | 199 | 200 | 201 | 202 | Qt::Vertical 203 | 204 | 205 | 206 | 0 207 | 0 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | Qt::Vertical 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | Software flow control: 227 | 228 | 229 | 230 | 231 | 232 | 233 | XON/XOFF 234 | 235 | 236 | 237 | 238 | 239 | 240 | Qt::Horizontal 241 | 242 | 243 | 244 | 245 | 246 | 247 | Hardware flow control: 248 | 249 | 250 | 251 | 252 | 253 | 254 | RTS/CTS 255 | 256 | 257 | 258 | 259 | 260 | 261 | Qt::Vertical 262 | 263 | 264 | 265 | 20 266 | 0 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | Timeouts [ms]: 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | Read: 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 0 296 | 0 297 | 298 | 299 | 300 | 100000 301 | 302 | 303 | 10 304 | 305 | 306 | QAbstractSpinBox::DefaultStepType 307 | 308 | 309 | 1000 310 | 311 | 312 | 313 | 314 | 315 | 316 | Write: 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 0 325 | 0 326 | 327 | 328 | 329 | 100000 330 | 331 | 332 | 10 333 | 334 | 335 | QAbstractSpinBox::DefaultStepType 336 | 337 | 338 | 300 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | Qt::Vertical 348 | 349 | 350 | 351 | 20 352 | 40 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | Qt::Vertical 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | Qt::Horizontal 374 | 375 | 376 | 377 | 40 378 | 20 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | OK 387 | 388 | 389 | 390 | 391 | 392 | 393 | Cancel 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | --------------------------------------------------------------------------------