├── .github ├── renovate.json └── workflows │ └── python-package.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── MANIFEST.in ├── README.md ├── clickplc ├── __init__.py ├── driver.py ├── mock.py ├── tests │ ├── __init__.py │ ├── bad_tags.csv │ ├── plc_tags.csv │ └── test_driver.py └── util.py ├── ruff.toml ├── setup.cfg └── setup.py /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "timezone": "America/Chicago", 7 | "pre-commit": { 8 | "enabled": true 9 | }, 10 | "packageRules": [ 11 | { 12 | "matchManagers": ["github-actions"], 13 | "automerge": true 14 | }, 15 | { 16 | "matchPackagePatterns": ["mypy", "pre-commit", "ruff"], 17 | "automerge": true 18 | }, 19 | { 20 | "matchPackagePatterns": ["ruff"], 21 | "groupName": "ruff", 22 | "schedule": [ 23 | "on the first day of the month" 24 | ] 25 | }, 26 | { 27 | "matchPackagePatterns": ["pymodbus"], 28 | "rangeStrategy": "widen" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: clickplc 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | test: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 20 | pymodbus-version: ["2.5.3", "3.0.2", "3.1.3", "3.2.2", "3.3.1", "3.4.1", "3.5.4", "3.6.8"] 21 | exclude: 22 | - python-version: "3.10" 23 | pymodbus-version: "2.5.3" 24 | - python-version: "3.11" 25 | pymodbus-version: "2.5.3" 26 | - python-version: "3.12" 27 | pymodbus-version: "2.5.3" 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | - name: Set up Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v5 33 | with: 34 | allow-prereleases: true 35 | python-version: ${{ matrix.python-version }} 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | python -m pip install 'pymodbus==${{ matrix.pymodbus-version }}' 40 | python -m pip install '.[test]' 41 | - name: Lint with ruff 42 | run: | 43 | ruff check . 44 | - name: Check types with mypy 45 | run: | 46 | mypy clickplc 47 | - name: Pytest 48 | run: | 49 | pytest 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .DS_Store 3 | *.pyc 4 | build 5 | dist 6 | *.egg-info 7 | .vscode 8 | .coverage 9 | -------------------------------------------------------------------------------- /.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/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | - id: check-json 12 | - repo: https://github.com/charliermarsh/ruff-pre-commit 13 | rev: v0.5.0 14 | hooks: 15 | - id: ruff 16 | args: [--fix, --exit-non-zero-on-fix] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | clickplc 2 | ======== 3 | 4 | Python ≥3.8 driver and command-line tool for [Koyo Ethernet ClickPLCs](https://www.automationdirect.com/adc/Overview/Catalog/Programmable_Controllers/CLICK_Series_PLCs_(Stackable_Micro_Brick)). 5 | 6 |

7 | 8 |

9 | 10 | Installation 11 | ============ 12 | 13 | ``` 14 | pip install clickplc 15 | ``` 16 | 17 | Usage 18 | ===== 19 | 20 | ### Command Line 21 | 22 | ``` 23 | $ clickplc the-plc-ip-address 24 | ``` 25 | 26 | This will print all the X, Y, DS, and DF registers to stdout as JSON. You can pipe 27 | this as needed. However, you'll likely want the python functionality below. 28 | 29 | ### Python 30 | 31 | This uses Python ≥3.5's async/await syntax to asynchronously communicate with 32 | a ClickPLC. For example: 33 | 34 | ```python 35 | import asyncio 36 | from clickplc import ClickPLC 37 | 38 | async def get(): 39 | async with ClickPLC('the-plc-ip-address') as plc: 40 | print(await plc.get('df1-df500')) 41 | 42 | asyncio.run(get()) 43 | ``` 44 | 45 | The entire API is `get` and `set`, and takes a range of inputs: 46 | 47 | ```python 48 | >>> await plc.get('df1') 49 | 0.0 50 | >>> await plc.get('df1-df20') 51 | {'df1': 0.0, 'df2': 0.0, ..., 'df20': 0.0} 52 | >>> await plc.get('y101-y316') 53 | {'y101': False, 'y102': False, ..., 'y316': False} 54 | 55 | >>> await plc.set('df1', 0.0) # Sets DF1 to 0.0 56 | >>> await plc.set('df1', [0.0, 0.0, 0.0]) # Sets DF1-DF3 to 0.0. 57 | >>> await plc.set('y101', True) # Sets Y101 to true 58 | ``` 59 | 60 | Currently, the following datatypes are supported: 61 | 62 | | | | | 63 | |---|---|---| 64 | | x | bool | Input point | 65 | | y | bool | Output point | 66 | | c | bool | (C)ontrol relay | 67 | | t | bool | (T)imer | 68 | | ct | bool | (C)oun(t)er | 69 | | ds | int16 | (D)ata register, (s)ingle signed int | 70 | | dd | int32 | (D)ata register, (d)double signed int | 71 | | df | float | (D)ata register, (f)loating point | 72 | | td | int16 | (T)ime (d)elay register | 73 | | ctd | int32 | (C)oun(t)er Current Values, (d)ouble int | 74 | | sd | int16 | (S)ystem (D)ata register | 75 | 76 | ### Tags / Nicknames 77 | 78 | Recent ClickPLC software provides the ability to export a "tags file", which 79 | contains all variables with user-assigned nicknames. The tags file can be used 80 | with this driver to improve code readability. (Who really wants to think about 81 | modbus addresses and register/coil types?) 82 | 83 | To export a tags file, open the ClickPLC software, go to the Address Picker, 84 | select "Display MODBUS address", and export the file. 85 | 86 | Once you have this file, simply pass the file path to the driver. You can now 87 | `set` variables by name and `get` all named variables by default. 88 | 89 | ```python 90 | async with ClickPLC('the-plc-ip-address', 'path-to-tags.csv') as plc: 91 | await plc.set('my-nickname', True) # Set variable by nickname 92 | print(await plc.get()) # Get all named variables in tags file 93 | ``` 94 | 95 | Additionally, the tags file can be used with the commandline tool to provide more informative output: 96 | ``` 97 | $ clickplc the-plc-ip-address tags-filepath 98 | ``` 99 | -------------------------------------------------------------------------------- /clickplc/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | A Python driver for Koyo ClickPLC ethernet units. 3 | 4 | Distributed under the GNU General Public License v2 5 | Copyright (C) 2019 NuMat Technologies 6 | """ 7 | from clickplc.driver import ClickPLC 8 | 9 | 10 | def command_line(args=None): 11 | """Command-line tool for ClickPLC communication.""" 12 | import argparse 13 | import asyncio 14 | import json 15 | 16 | parser = argparse.ArgumentParser(description="Control a ClickPLC from " 17 | "the command line") 18 | parser.add_argument('address', help="The IP address of the ClickPLC") 19 | parser.add_argument('tags_file', nargs="?", 20 | help="Optional: Path to a tags file for this PLC") 21 | args = parser.parse_args(args) 22 | 23 | async def get(): 24 | async with ClickPLC(args.address, args.tags_file) as plc: 25 | if args.tags_file is not None: 26 | d = await plc.get() 27 | else: 28 | d = await plc.get('x001-x816') 29 | d.update(await plc.get('y001-y816')) 30 | d.update(await plc.get('c1-c100')) 31 | d.update(await plc.get('df1-df100')) 32 | d.update(await plc.get('ds1-ds100')) 33 | d.update(await plc.get('ctd1-ctd250')) 34 | print(json.dumps(d, indent=4)) 35 | 36 | loop = asyncio.new_event_loop() 37 | loop.run_until_complete(get()) 38 | 39 | 40 | if __name__ == '__main__': 41 | command_line() 42 | -------------------------------------------------------------------------------- /clickplc/driver.py: -------------------------------------------------------------------------------- 1 | """ 2 | A Python driver for Koyo ClickPLC ethernet units. 3 | 4 | Distributed under the GNU General Public License v2 5 | Copyright (C) 2024 Alex Ruddick 6 | Copyright (C) 2020 NuMat Technologies 7 | """ 8 | from __future__ import annotations 9 | 10 | import copy 11 | import csv 12 | import pydoc 13 | from collections import defaultdict 14 | from string import digits 15 | from typing import Any, ClassVar 16 | 17 | from pymodbus.constants import Endian 18 | from pymodbus.payload import BinaryPayloadBuilder, BinaryPayloadDecoder 19 | 20 | from clickplc.util import AsyncioModbusClient 21 | 22 | 23 | class ClickPLC(AsyncioModbusClient): 24 | """Ethernet driver for the Koyo ClickPLC. 25 | 26 | This interface handles the quirks of both Modbus TCP/IP and the ClickPLC, 27 | abstracting corner cases and providing a simple asynchronous interface. 28 | """ 29 | 30 | data_types: ClassVar[dict] = { 31 | 'x': 'bool', # Input point 32 | 'y': 'bool', # Output point 33 | 'c': 'bool', # (C)ontrol relay 34 | 't': 'bool', # (T)imer 35 | 'ct': 'bool', # (C)oun(t)er 36 | 'ds': 'int16', # (D)ata register (s)ingle 37 | 'dd': 'int32', # (D)ata register, (d)ouble 38 | 'df': 'float', # (D)ata register (f)loating point 39 | 'td': 'int16', # (T)imer register 40 | 'ctd': 'int32', # (C)oun(t)er Current values, (d)ouble 41 | 'sd': 'int16', # (S)ystem (D)ata register, single 42 | } 43 | 44 | def __init__(self, address, tag_filepath='', timeout=1): 45 | """Initialize PLC connection and data structure. 46 | 47 | Args: 48 | address: The PLC IP address or DNS name 49 | tag_filepath: Path to the PLC tags file 50 | timeout (optional): Timeout when communicating with PLC. Default 1s. 51 | 52 | """ 53 | super().__init__(address, timeout) 54 | self.tags = self._load_tags(tag_filepath) 55 | self.active_addresses = self._get_address_ranges(self.tags) 56 | 57 | def get_tags(self) -> dict: 58 | """Return all tags and associated configuration information. 59 | 60 | Use this data for debugging or to provide more detailed 61 | information on user interfaces. 62 | 63 | Returns: 64 | A dictionary containing information associated with each tag name. 65 | 66 | """ 67 | return copy.deepcopy(self.tags) 68 | 69 | async def get(self, address: str | None = None) -> dict: 70 | """Get variables from the ClickPLC. 71 | 72 | Args: 73 | address: ClickPLC address(es) to get. Specify a range with a 74 | hyphen, e.g. 'DF1-DF40' 75 | 76 | If driver is loaded with a tags file this can be called without an 77 | address to return all nicknamed addresses in the tags file 78 | >>> plc.get() 79 | {'P-101': 0.0, 'P-102': 0.0 ..., T-101:0.0} 80 | 81 | Otherwise one or more internal variable can be requested 82 | >>> plc.get('df1') 83 | 0.0 84 | >>> plc.get('df1-df20') 85 | {'df1': 0.0, 'df2': 0.0, ..., 'df20': 0.0} 86 | >>> plc.get('y101-y316') 87 | {'y101': False, 'y102': False, ..., 'y316': False} 88 | 89 | This uses the ClickPLC's internal variable notation, which can be 90 | found in the Address Picker of the ClickPLC software. 91 | """ 92 | if address is None: 93 | if not self.tags: 94 | raise ValueError('An address must be supplied to get if tags were not ' 95 | 'provided when driver initialized') 96 | results = {} 97 | for category, _address in self.active_addresses.items(): 98 | results.update(await getattr(self, '_get_' + category) 99 | (_address['min'], _address['max'])) 100 | return {tag_name: results[tag_info['id'].lower()] 101 | for tag_name, tag_info in self.tags.items()} 102 | 103 | if '-' in address: 104 | start, end = address.split('-') 105 | else: 106 | start, end = address, None 107 | i = next(i for i, s in enumerate(start) if s.isdigit()) 108 | category, start_index = start[:i].lower(), int(start[i:]) 109 | end_index = None if end is None else int(end[i:]) 110 | 111 | if end_index is not None and end_index < start_index: 112 | raise ValueError("End address must be greater than start address.") 113 | if category not in self.data_types: 114 | raise ValueError(f"{category} currently unsupported.") 115 | if end is not None and end[:i].lower() != category: 116 | raise ValueError("Inter-category ranges are unsupported.") 117 | return await getattr(self, '_get_' + category)(start_index, end_index) 118 | 119 | async def set(self, address: str, data): 120 | """Set values on the ClickPLC. 121 | 122 | Args: 123 | address: ClickPLC address to set. If `data` is a list, it will set 124 | this and subsequent addresses. 125 | data: A value or list of values to set. 126 | 127 | >>> plc.set('df1', 0.0) # Sets DF1 to 0.0 128 | >>> plc.set('df1', [0.0, 0.0, 0.0]) # Sets DF1-DF3 to 0.0. 129 | >>> plc.set('myTagNickname', True) # Sets address named myTagNickname to true 130 | 131 | This uses the ClickPLC's internal variable notation, which can be 132 | found in the Address Picker of the ClickPLC software. If a tags file 133 | was loaded at driver initialization, nicknames can be used instead. 134 | """ 135 | if address in self.tags: 136 | address = self.tags[address]['id'] 137 | 138 | if not isinstance(data, list): 139 | data = [data] 140 | 141 | i = next(i for i, s in enumerate(address) if s.isdigit()) 142 | category, index = address[:i].lower(), int(address[i:]) 143 | if category not in self.data_types: 144 | raise ValueError(f"{category} currently unsupported.") 145 | data_type = self.data_types[category].rstrip(digits) 146 | for datum in data: 147 | if type(datum) == int and data_type == 'float': # noqa: E721 148 | datum = float(datum) 149 | if type(datum) != pydoc.locate(data_type): 150 | raise ValueError(f"Expected {address} as a {data_type}.") 151 | return await getattr(self, '_set_' + category)(index, data) 152 | 153 | async def _get_x(self, start: int, end: int) -> dict: 154 | """Read X addresses. Called by `get`. 155 | 156 | X entries start at 0 (1 in the Click software's 1-indexed 157 | notation). This function also handles some of the quirks of the unit. 158 | 159 | First, the modbus addresses aren't sequential. Instead, the pattern is: 160 | X001 0 161 | [...] 162 | X016 15 163 | X101 32 164 | [...] 165 | The X addressing only goes up to *16, then jumps 16 coils to get to 166 | the next hundred. Rather than the overhead of multiple requests, this 167 | is handled by reading all the data and throwing away unowned addresses. 168 | 169 | Second, the response always returns a full byte of data. If you request 170 | a number of addresses not divisible by 8, it will have extra data. The 171 | extra data here is discarded before returning. 172 | """ 173 | if start % 100 == 0 or start % 100 > 16: 174 | raise ValueError('X start address must be *01-*16.') 175 | if start < 1 or start > 816: 176 | raise ValueError('X start address must be in [001, 816].') 177 | 178 | start_coil = 32 * (start // 100) + start % 100 - 1 179 | if end is None: 180 | count = 1 181 | else: 182 | if end % 100 == 0 or end % 100 > 16: 183 | raise ValueError('X end address must be *01-*16.') 184 | if end < 1 or end > 816: 185 | raise ValueError('X end address must be in [001, 816].') 186 | end_coil = 32 * (end // 100) + end % 100 - 1 187 | count = end_coil - start_coil + 1 188 | 189 | coils = await self.read_coils(start_coil, count) 190 | if count == 1: 191 | return coils.bits[0] 192 | output = {} 193 | current = start 194 | for bit in coils.bits: 195 | if current > end: 196 | break 197 | elif current % 100 <= 16: 198 | output[f'x{current:03}'] = bit 199 | elif current % 100 == 32: 200 | current += 100 - 32 201 | current += 1 202 | return output 203 | 204 | async def _get_y(self, start: int, end: int) -> dict: 205 | """Read Y addresses. Called by `get`. 206 | 207 | Y entries start at 8192 (8193 in the Click software's 1-indexed 208 | notation). This function also handles some of the quirks of the unit. 209 | 210 | First, the modbus addresses aren't sequential. Instead, the pattern is: 211 | Y001 8192 212 | [...] 213 | Y016 8208 214 | Y101 8224 215 | [...] 216 | The Y addressing only goes up to *16, then jumps 16 coils to get to 217 | the next hundred. Rather than the overhead of multiple requests, this 218 | is handled by reading all the data and throwing away unowned addresses. 219 | 220 | Second, the response always returns a full byte of data. If you request 221 | a number of addresses not divisible by 8, it will have extra data. The 222 | extra data here is discarded before returning. 223 | """ 224 | if start % 100 == 0 or start % 100 > 16: 225 | raise ValueError('Y start address must be *01-*16.') 226 | if start < 1 or start > 816: 227 | raise ValueError('Y start address must be in [001, 816].') 228 | 229 | start_coil = 8192 + 32 * (start // 100) + start % 100 - 1 230 | if end is None: 231 | count = 1 232 | else: 233 | if end % 100 == 0 or end % 100 > 16: 234 | raise ValueError('Y end address must be *01-*16.') 235 | if end < 1 or end > 816: 236 | raise ValueError('Y end address must be in [001, 816].') 237 | end_coil = 8192 + 32 * (end // 100) + end % 100 - 1 238 | count = end_coil - start_coil + 1 239 | 240 | coils = await self.read_coils(start_coil, count) 241 | if count == 1: 242 | return coils.bits[0] 243 | output = {} 244 | current = start 245 | for bit in coils.bits: 246 | if current > end: 247 | break 248 | elif current % 100 <= 16: 249 | output[f'y{current:03}'] = bit 250 | elif current % 100 == 32: 251 | current += 100 - 32 252 | current += 1 253 | return output 254 | 255 | async def _get_c(self, start: int, end: int) -> dict | bool: 256 | """Read C addresses. Called by `get`. 257 | 258 | C entries start at 16384 (16385 in the Click software's 1-indexed 259 | notation). This continues for 2000 bits, ending at 18383. 260 | 261 | The response always returns a full byte of data. If you request 262 | a number of addresses not divisible by 8, it will have extra data. The 263 | extra data here is discarded before returning. 264 | """ 265 | if start < 1 or start > 2000: 266 | raise ValueError('C start address must be 1-2000.') 267 | 268 | start_coil = 16384 + start - 1 269 | if end is None: 270 | count = 1 271 | else: 272 | if end <= start or end > 2000: 273 | raise ValueError('C end address must be >start and <=2000.') 274 | end_coil = 16384 + end - 1 275 | count = end_coil - start_coil + 1 276 | 277 | coils = await self.read_coils(start_coil, count) 278 | if count == 1: 279 | return coils.bits[0] 280 | return {f'c{(start + i)}': bit for i, bit in enumerate(coils.bits) if i < count} 281 | 282 | async def _get_t(self, start: int, end: int) -> dict | bool: 283 | """Read T addresses. 284 | 285 | T entries start at 45056 (45057 in the Click software's 1-indexed 286 | notation). This continues for 500 bits, ending at 45555. 287 | 288 | The response always returns a full byte of data. If you request 289 | a number of addresses not divisible by 8, it will have extra data. The 290 | extra data here is discarded before returning. 291 | """ 292 | if start < 1 or start > 500: 293 | raise ValueError('T start address must be 1-500.') 294 | 295 | start_coil = 45057 + start - 1 296 | if end is None: 297 | count = 1 298 | else: 299 | if end <= start or end > 500: 300 | raise ValueError('T end address must be >start and <=500.') 301 | end_coil = 14555 + end - 1 302 | count = end_coil - start_coil + 1 303 | 304 | coils = await self.read_coils(start_coil, count) 305 | if count == 1: 306 | return coils.bits[0] 307 | return {f't{(start + i)}': bit for i, bit in enumerate(coils.bits) if i < count} 308 | 309 | async def _get_ct(self, start: int, end: int) -> dict | bool: 310 | """Read CT addresses. 311 | 312 | CT entries start at 49152 (49153 in the Click software's 1-indexed 313 | notation). This continues for 250 bits, ending at 49402. 314 | 315 | The response always returns a full byte of data. If you request 316 | a number of addresses not divisible by 8, it will have extra data. The 317 | extra data here is discarded before returning. 318 | """ 319 | if start < 1 or start > 250: 320 | raise ValueError('CT start address must be 1-250.') 321 | 322 | start_coil = 49152 + start - 1 323 | if end is None: 324 | count = 1 325 | else: 326 | if end <= start or end > 250: 327 | raise ValueError('CT end address must be >start and <=250.') 328 | end_coil = 49401 + end - 1 329 | count = end_coil - start_coil + 1 330 | 331 | coils = await self.read_coils(start_coil, count) 332 | if count == 1: 333 | return coils.bits[0] 334 | return {f'ct{(start + i)}': bit for i, bit in enumerate(coils.bits) if i < count} 335 | 336 | async def _get_ds(self, start: int, end: int) -> dict | int: 337 | """Read DS registers. Called by `get`. 338 | 339 | DS entries start at Modbus address 0 (1 in the Click software's 340 | 1-indexed notation). Each DS entry takes 16 bits. 341 | """ 342 | if start < 1 or start > 4500: 343 | raise ValueError('DS must be in [1, 4500]') 344 | if end is not None and (end < 1 or end > 4500): 345 | raise ValueError('DS end must be in [1, 4500]') 346 | 347 | address = 0 + start - 1 348 | count = 1 if end is None else (end - start + 1) 349 | registers = await self.read_registers(address, count) 350 | bigendian = Endian.BIG if self.pymodbus35plus else Endian.Big # type:ignore[attr-defined] 351 | lilendian = Endian.LITTLE if self.pymodbus35plus else Endian.Little # type:ignore 352 | decoder = BinaryPayloadDecoder.fromRegisters(registers, 353 | byteorder=bigendian, 354 | wordorder=lilendian) 355 | if end is None: 356 | return decoder.decode_16bit_int() 357 | return {f'ds{n}': decoder.decode_16bit_int() for n in range(start, end + 1)} 358 | 359 | async def _get_dd(self, start: int, end: int) -> dict | int: 360 | """Read DD registers. 361 | 362 | DD entries start at Modbus address 16384 (16385 in the Click software's 363 | 1-indexed notation). Each DS entry takes 32 bits. 364 | """ 365 | if start < 1 or start > 1000: 366 | raise ValueError('DD must be in [1, 1000]') 367 | if end is not None and (end < 1 or end > 1000): 368 | raise ValueError('DD end must be in [1, 1000]') 369 | 370 | address = 16384 + 2 * (start - 1) # 32-bit 371 | count = 2 if end is None else 2 * (end - start + 1) 372 | registers = await self.read_registers(address, count) 373 | bigendian = Endian.BIG if self.pymodbus35plus else Endian.Big # type:ignore[attr-defined] 374 | lilendian = Endian.LITTLE if self.pymodbus35plus else Endian.Little # type:ignore 375 | decoder = BinaryPayloadDecoder.fromRegisters(registers, 376 | byteorder=bigendian, 377 | wordorder=lilendian) 378 | if end is None: 379 | return decoder.decode_32bit_int() 380 | return {f'dd{n}': decoder.decode_32bit_int() for n in range(start, end + 1)} 381 | 382 | async def _get_df(self, start: int, end: int) -> dict | float: 383 | """Read DF registers. Called by `get`. 384 | 385 | DF entries start at Modbus address 28672 (28673 in the Click software's 386 | 1-indexed notation). Each DF entry takes 32 bits, or 2 16-bit 387 | registers. 388 | """ 389 | if start < 1 or start > 500: 390 | raise ValueError('DF must be in [1, 500]') 391 | if end is not None and (end < 1 or end > 500): 392 | raise ValueError('DF end must be in [1, 500]') 393 | 394 | address = 28672 + 2 * (start - 1) 395 | count = 2 * (1 if end is None else (end - start + 1)) 396 | registers = await self.read_registers(address, count) 397 | bigendian = Endian.BIG if self.pymodbus35plus else Endian.Big # type:ignore[attr-defined] 398 | lilendian = Endian.LITTLE if self.pymodbus35plus else Endian.Little # type:ignore 399 | decoder = BinaryPayloadDecoder.fromRegisters(registers, 400 | byteorder=bigendian, 401 | wordorder=lilendian) 402 | if end is None: 403 | return decoder.decode_32bit_float() 404 | return {f'df{n}': decoder.decode_32bit_float() for n in range(start, end + 1)} 405 | 406 | async def _get_td(self, start: int, end: int) -> dict: 407 | """Read TD registers. Called by `get`. 408 | 409 | TD entries start at Modbus address 45056 (45057 in the Click software's 410 | 1-indexed notation). Each TD entry takes 16 bits. 411 | """ 412 | if start < 1 or start > 500: 413 | raise ValueError('TD must be in [1, 500]') 414 | if end is not None and (end < 1 or end > 500): 415 | raise ValueError('TD end must be in [1, 500]') 416 | 417 | address = 45056 + (start - 1) 418 | count = 1 if end is None else (end - start + 1) 419 | registers = await self.read_registers(address, count) 420 | bigendian = Endian.BIG if self.pymodbus35plus else Endian.Big # type:ignore[attr-defined] 421 | lilendian = Endian.LITTLE if self.pymodbus35plus else Endian.Little # type:ignore 422 | decoder = BinaryPayloadDecoder.fromRegisters(registers, 423 | byteorder=bigendian, 424 | wordorder=lilendian) 425 | if end is None: 426 | return decoder.decode_16bit_int() 427 | return {f'td{n}': decoder.decode_16bit_int() for n in range(start, end + 1)} 428 | 429 | async def _get_ctd(self, start: int, end: int) -> dict: 430 | """Read CTD registers. Called by `get`. 431 | 432 | CTD entries start at Modbus address 449152 (449153 in the Click software's 433 | 1-indexed notation). Each CTD entry takes 32 bits, which is 2 16bit registers. 434 | """ 435 | if start < 1 or start > 250: 436 | raise ValueError('CTD must be in [1, 250]') 437 | if end is not None and (end < 1 or end > 250): 438 | raise ValueError('CTD end must be in [1, 250]') 439 | 440 | address = 49152 + 2 * (start - 1) # 32-bit 441 | count = 1 if end is None else (end - start + 1) 442 | registers = await self.read_registers(address, count * 2) 443 | bigendian = Endian.BIG if self.pymodbus35plus else Endian.Big # type:ignore[attr-defined] 444 | lilendian = Endian.LITTLE if self.pymodbus35plus else Endian.Little # type:ignore 445 | decoder = BinaryPayloadDecoder.fromRegisters(registers, 446 | byteorder=bigendian, 447 | wordorder=lilendian) 448 | if end is None: 449 | return decoder.decode_32bit_int() 450 | return {f'ctd{n}': decoder.decode_32bit_int() for n in range(start, end + 1)} 451 | 452 | async def _get_sd(self, start: int, end: int) -> dict | int: 453 | """Read SD registers. Called by `get`. 454 | 455 | SD entries start at Modbus address 361440 (361441 in the Click software's 456 | 1-indexed notation). Each SD entry takes 16 bits. 457 | """ 458 | if start < 1 or start > 4500: 459 | raise ValueError('SD must be in [1, 4500]') 460 | if end is not None and (end < 1 or end > 4500): 461 | raise ValueError('SD end must be in [1, 4500]') 462 | 463 | address = 61440 + start - 1 464 | count = 1 if end is None else (end - start + 1) 465 | registers = await self.read_registers(address, count) 466 | bigendian = Endian.BIG if self.pymodbus35plus else Endian.Big # type:ignore[attr-defined] 467 | lilendian = Endian.LITTLE if self.pymodbus35plus else Endian.Little # type:ignore 468 | decoder = BinaryPayloadDecoder.fromRegisters(registers, 469 | byteorder=bigendian, 470 | wordorder=lilendian) 471 | if end is None: 472 | return decoder.decode_16bit_int() 473 | return {f'sd{n}': decoder.decode_16bit_int() for n in range(start, end + 1)} 474 | 475 | async def _set_y(self, start: int, data: list[bool] | bool): 476 | """Set Y addresses. Called by `set`. 477 | 478 | For more information on the quirks of Y coils, read the `_get_y` 479 | docstring. 480 | """ 481 | if start % 100 == 0 or start % 100 > 16: 482 | raise ValueError('Y start address must be *01-*16.') 483 | if start < 1 or start > 816: 484 | raise ValueError('Y start address must be in [001, 816].') 485 | coil = 8192 + 32 * (start // 100) + start % 100 - 1 486 | 487 | if isinstance(data, list): 488 | if len(data) > 16 * (9 - start // 100) - start % 100 + 1: 489 | raise ValueError('Data list longer than available addresses.') 490 | payload = [] 491 | if (start % 100) + len(data) > 16: 492 | i = 17 - (start % 100) 493 | payload += data[:i] + [False] * 16 494 | data = data[i:] 495 | while len(data) > 16: 496 | payload += data[:16] + [False] * 16 497 | data = data[16:] 498 | payload += data 499 | await self.write_coils(coil, payload) 500 | else: 501 | await self.write_coil(coil, data) 502 | 503 | async def _set_c(self, start: int, data: list[bool] | bool): 504 | """Set C addresses. Called by `set`. 505 | 506 | For more information on the quirks of C coils, read the `_get_c` 507 | docstring. 508 | """ 509 | if start < 1 or start > 2000: 510 | raise ValueError('C start address must be 1-2000.') 511 | coil = 16384 + start - 1 512 | 513 | if isinstance(data, list): 514 | if len(data) > (2000 - start + 1): 515 | raise ValueError('Data list longer than available addresses.') 516 | await self.write_coils(coil, data) 517 | else: 518 | await self.write_coil(coil, data) 519 | 520 | async def _set_df(self, start: int, data: list[float] | float): 521 | """Set DF registers. Called by `set`. 522 | 523 | The ClickPLC is little endian, but on registers ("words") instead 524 | of bytes. As an example, take a random floating point number: 525 | Input: 0.1 526 | Hex: 3dcc cccd (IEEE-754 float32) 527 | Click: -1.076056E8 528 | Hex: cccd 3dcc 529 | To fix, we need to flip the registers. Implemented below in `pack`. 530 | """ 531 | if start < 1 or start > 500: 532 | raise ValueError('DF must be in [1, 500]') 533 | address = 28672 + 2 * (start - 1) 534 | 535 | bigendian = Endian.BIG if self.pymodbus35plus else Endian.Big # type:ignore[attr-defined] 536 | lilendian = Endian.LITTLE if self.pymodbus35plus else Endian.Little # type:ignore 537 | 538 | def _pack(values: list[float]): 539 | builder = BinaryPayloadBuilder(byteorder=bigendian, 540 | wordorder=lilendian) 541 | for value in values: 542 | builder.add_32bit_float(float(value)) 543 | return builder.build() 544 | 545 | if isinstance(data, list): 546 | if len(data) > 500 - start + 1: 547 | raise ValueError('Data list longer than available addresses.') 548 | payload = _pack(data) 549 | await self.write_registers(address, payload, skip_encode=True) 550 | else: 551 | await self.write_register(address, _pack([data]), skip_encode=True) 552 | 553 | async def _set_ds(self, start: int, data: list[int] | int): 554 | """Set DS registers. Called by `set`. 555 | 556 | See _get_ds for more information. 557 | """ 558 | if start < 1 or start > 4500: 559 | raise ValueError('DS must be in [1, 4500]') 560 | address = (start - 1) 561 | 562 | bigendian = Endian.BIG if self.pymodbus35plus else Endian.Big # type:ignore[attr-defined] 563 | lilendian = Endian.LITTLE if self.pymodbus35plus else Endian.Little # type:ignore 564 | 565 | def _pack(values: list[int]): 566 | builder = BinaryPayloadBuilder(byteorder=bigendian, 567 | wordorder=lilendian) 568 | for value in values: 569 | builder.add_16bit_int(int(value)) 570 | return builder.build() 571 | 572 | if isinstance(data, list): 573 | if len(data) > 4500 - start + 1: 574 | raise ValueError('Data list longer than available addresses.') 575 | payload = _pack(data) 576 | await self.write_registers(address, payload, skip_encode=True) 577 | else: 578 | await self.write_register(address, _pack([data]), skip_encode=True) 579 | 580 | async def _set_dd(self, start: int, data: list[int] | int): 581 | """Set DD registers. Called by `set`. 582 | 583 | See _get_dd for more information. 584 | """ 585 | if start < 1 or start > 1000: 586 | raise ValueError('DD must be in [1, 1000]') 587 | address = 16384 + 2 * (start - 1) 588 | 589 | bigendian = Endian.BIG if self.pymodbus35plus else Endian.Big # type:ignore[attr-defined] 590 | lilendian = Endian.LITTLE if self.pymodbus35plus else Endian.Little # type:ignore 591 | 592 | def _pack(values: list[int]): 593 | builder = BinaryPayloadBuilder(byteorder=bigendian, 594 | wordorder=lilendian) 595 | for value in values: 596 | builder.add_32bit_int(int(value)) 597 | return builder.build() 598 | 599 | if isinstance(data, list): 600 | if len(data) > 1000 - start + 1: 601 | raise ValueError('Data list longer than available addresses.') 602 | payload = _pack(data) 603 | await self.write_registers(address, payload, skip_encode=True) 604 | else: 605 | await self.write_register(address, _pack([data]), skip_encode=True) 606 | 607 | async def _set_td(self, start: int, data: list[int] | int): 608 | """Set TD registers. Called by `set`. 609 | 610 | See _get_td for more information. 611 | """ 612 | if start < 1 or start > 500: 613 | raise ValueError('TD must be in [1, 500]') 614 | address = 45056 + (start - 1) 615 | 616 | bigendian = Endian.BIG if self.pymodbus35plus else Endian.Big # type:ignore[attr-defined] 617 | lilendian = Endian.LITTLE if self.pymodbus35plus else Endian.Little # type:ignore 618 | 619 | def _pack(values: list[int]): 620 | builder = BinaryPayloadBuilder(byteorder=bigendian, 621 | wordorder=lilendian) 622 | for value in values: 623 | builder.add_16bit_int(int(value)) 624 | return builder.build() 625 | 626 | if isinstance(data, list): 627 | if len(data) > 500 - start + 1: 628 | raise ValueError('Data list longer than available addresses.') 629 | payload = _pack(data) 630 | await self.write_registers(address, payload, skip_encode=True) 631 | else: 632 | await self.write_register(address, _pack([data]), skip_encode=True) 633 | 634 | def _load_tags(self, tag_filepath: str) -> dict: 635 | """Load tags from file path. 636 | 637 | This tag file is optional but is needed to identify the appropriate variable names, 638 | and modbus addresses for tags in use on the PLC. 639 | 640 | """ 641 | if not tag_filepath: 642 | return {} 643 | with open(tag_filepath) as csv_file: 644 | csv_data = csv_file.read().splitlines() 645 | csv_data[0] = csv_data[0].lstrip('## ') 646 | parsed: dict[str, dict[str, Any]] = { 647 | row['Nickname']: { 648 | 'address': { 649 | 'start': int(row['Modbus Address']), 650 | }, 651 | 'id': row['Address'], 652 | 'comment': row['Address Comment'], 653 | 'type': self.data_types.get( 654 | row['Address'].rstrip(digits).lower() 655 | ), 656 | } 657 | for row in csv.DictReader(csv_data) 658 | if row['Nickname'] and not row['Nickname'].startswith("_") 659 | } 660 | for data in parsed.values(): 661 | if not data['comment']: 662 | del data['comment'] 663 | if not data['type']: 664 | raise TypeError( 665 | f"{data['id']} is an unsupported data type. Open a " 666 | "github issue at numat/clickplc to get it added." 667 | ) 668 | sorted_tags = {k: parsed[k] for k in 669 | sorted(parsed, key=lambda k: parsed[k]['address']['start'])} 670 | return sorted_tags 671 | 672 | @staticmethod 673 | def _get_address_ranges(tags: dict) -> dict[str, dict]: 674 | """Determine range of addresses required. 675 | 676 | Parse the loaded tags to determine the range of addresses that must be 677 | queried to return all values 678 | """ 679 | address_dict: dict = defaultdict(lambda: {'min': 1, 'max': 1}) 680 | for tag_info in tags.values(): 681 | i = next(i for i, s in enumerate(tag_info['id']) if s.isdigit()) 682 | category, index = tag_info['id'][:i].lower(), int(tag_info['id'][i:]) 683 | address_dict[category]['min'] = min(address_dict[category]['min'], index) 684 | address_dict[category]['max'] = max(address_dict[category]['max'], index) 685 | return address_dict 686 | -------------------------------------------------------------------------------- /clickplc/mock.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python mock driver for AutomationDirect (formerly Koyo) ClickPLCs. 3 | 4 | Uses local storage instead of remote communications. 5 | 6 | Distributed under the GNU General Public License v2 7 | Copyright (C) 2021 NuMat Technologies 8 | """ 9 | from collections import defaultdict 10 | from unittest.mock import MagicMock 11 | 12 | from pymodbus.bit_read_message import ReadCoilsResponse, ReadDiscreteInputsResponse 13 | from pymodbus.bit_write_message import WriteMultipleCoilsResponse, WriteSingleCoilResponse 14 | from pymodbus.register_read_message import ReadHoldingRegistersResponse 15 | from pymodbus.register_write_message import WriteMultipleRegistersResponse 16 | 17 | from clickplc.driver import ClickPLC as realClickPLC 18 | 19 | 20 | class AsyncClientMock(MagicMock): 21 | """Magic mock that works with async methods.""" 22 | 23 | async def __call__(self, *args, **kwargs): 24 | """Convert regular mocks into into an async coroutine.""" 25 | return super().__call__(*args, **kwargs) 26 | 27 | def stop(self) -> None: 28 | """Close the connection (2.5.3).""" 29 | ... 30 | 31 | class ClickPLC(realClickPLC): 32 | """A version of the driver replacing remote communication with local storage for testing.""" 33 | 34 | def __init__(self, address, tag_filepath='', timeout=1): 35 | self.tags = self._load_tags(tag_filepath) 36 | self.active_addresses = self._get_address_ranges(self.tags) 37 | self.client = AsyncClientMock() 38 | self._coils = defaultdict(bool) 39 | self._discrete_inputs = defaultdict(bool) 40 | self._registers = defaultdict(bytes) 41 | self._detect_pymodbus_version() 42 | if self.pymodbus33plus: 43 | self.client.close = lambda: None 44 | 45 | async def _request(self, method, *args, **kwargs): 46 | if method == 'read_coils': 47 | address, count = args 48 | return ReadCoilsResponse([self._coils[address + i] for i in range(count)]) 49 | if method == 'read_discrete_inputs': 50 | address, count = args 51 | return ReadDiscreteInputsResponse([self._discrete_inputs[address + i] 52 | for i in range(count)]) 53 | elif method == 'read_holding_registers': 54 | address, count = args 55 | return ReadHoldingRegistersResponse([int.from_bytes(self._registers[address + i], 56 | byteorder='big') 57 | for i in range(count)]) 58 | elif method == 'write_coil': 59 | address, data = args 60 | self._coils[address] = data 61 | return WriteSingleCoilResponse(address, data) 62 | elif method == 'write_coils': 63 | address, data = args 64 | for i, d in enumerate(data): 65 | self._coils[address + i] = d 66 | return WriteMultipleCoilsResponse(address, data) 67 | elif method == 'write_registers': 68 | address, data = args 69 | for i, d in enumerate(data): 70 | self._registers[address + i] = d 71 | return WriteMultipleRegistersResponse(address, data) 72 | return NotImplementedError(f'Unrecognised method: {method}') 73 | -------------------------------------------------------------------------------- /clickplc/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/numat/clickplc/6a6916b6cd67f4d6982e99a22ab72456ba348a6c/clickplc/tests/__init__.py -------------------------------------------------------------------------------- /clickplc/tests/bad_tags.csv: -------------------------------------------------------------------------------- 1 | Address,Data Type,Modbus Address,Function Code,Nickname,Initial Value,Retentive,Address Comment 2 | Y301,BIT,8289,"FC=01,05,15","P_101",0,No,"" 3 | FOO1,BIT,16385,"FC=01,05,15","P_101_auto",0,No,"" 4 | -------------------------------------------------------------------------------- /clickplc/tests/plc_tags.csv: -------------------------------------------------------------------------------- 1 | Address,Data Type,Modbus Address,Function Code,Nickname,Initial Value,Retentive,Address Comment 2 | X101,BIT,100033,"FC=02","_IO1_Module_Error",0,No,"On when module is not functioning" 3 | X102,BIT,100034,"FC=02","_IO1_Module_Config",0,No,"On when module is initializing" 4 | X103,BIT,100035,"FC=02","_IO1_CH1_Burnout",0,No,"On when CH1 senses burnout or open circuit" 5 | X104,BIT,100036,"FC=02","_IO1_CH1_Under_Range",0,No,"On when CH1 receives under range input" 6 | X105,BIT,100037,"FC=02","_IO1_CH1_Over_Range",0,No,"On when CH1 receives over range input" 7 | X106,BIT,100038,"FC=02","_IO1_CH2_Burnout",0,No,"On when CH2 senses burnout or open circuit" 8 | X107,BIT,100039,"FC=02","_IO1_CH2_Under_Range",0,No,"On when CH2 receives under range input" 9 | X108,BIT,100040,"FC=02","_IO1_CH2_Over_Range",0,No,"On when CH2 receives over range input" 10 | X109,BIT,100041,"FC=02","_IO1_CH3_Burnout",0,No,"On when CH3 senses burnout or open circuit" 11 | X110,BIT,100042,"FC=02","_IO1_CH3_Under_Range",0,No,"On when CH3 receives under range input" 12 | X111,BIT,100043,"FC=02","_IO1_CH3_Over_Range",0,No,"On when CH3 receives over range input" 13 | X112,BIT,100044,"FC=02","_IO1_CH4_Burnout",0,No,"On when CH4 senses burnout or open circuit" 14 | X113,BIT,100045,"FC=02","_IO1_CH4_Under_Range",0,No,"On when CH4 receives under range input" 15 | X114,BIT,100046,"FC=02","_IO1_CH4_Over_Range",0,No,"On when CH4 receives over range input" 16 | X201,BIT,100065,"FC=02","_IO2_Module_Error",0,No,"On when module is not functioning" 17 | X202,BIT,100066,"FC=02","_IO2_Missing_24V",0,No,"On when missing external 24VDC input" 18 | Y301,BIT,8289,"FC=01,05,15","P_101",0,No,"" 19 | Y302,BIT,8290,"FC=01,05,15","P_103",0,No,"" 20 | C1,BIT,16385,"FC=01,05,15","P_101_auto",0,No,"" 21 | C2,BIT,16386,"FC=01,05,15","P_102_auto",0,No,"" 22 | C10,BIT,16394,"FC=01,05,15","VAH_101_OK",0,No,"" 23 | C11,BIT,16395,"FC=01,05,15","VAHH_101_OK",0,No,"" 24 | C12,BIT,16396,"FC=01,05,15","IO2_Module_OK",0,No,"" 25 | C13,BIT,16397,"FC=01,05,15","IO2_24V_OK",0,No,"" 26 | SD1,FLOAT,361441,"FC=03,06,16","PLC_Error_Code",0,Yes,"" 27 | DS100,INT,400100,"FC=03,06,16","TIC101_PID_ErrorCode",0,Yes,"PID Error Code" 28 | DF1,FLOAT,428673,"FC=03,06,16","TI_101",0,Yes,"" 29 | DF5,FLOAT,428681,"FC=03,06,16","LI_102",0,Yes,"" 30 | DF6,FLOAT,428683,"FC=03,06,16","LI_101",0,Yes,"" 31 | DF7,FLOAT,428685,"FC=03,06,16","VI_101",0,Yes,"" 32 | CTD1,INT2,449153,"FC=03,06,16","timer",0,Yes,"" 33 | -------------------------------------------------------------------------------- /clickplc/tests/test_driver.py: -------------------------------------------------------------------------------- 1 | """Test the driver correctly parses a tags file and responds with correct data.""" 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from clickplc import command_line 7 | from clickplc.mock import ClickPLC 8 | 9 | ADDRESS = 'fakeip' 10 | # from clickplc.driver import ClickPLC 11 | # ADDRESS = '172.16.0.168' 12 | 13 | 14 | @pytest.fixture(scope='session') 15 | async def plc_driver(): 16 | """Confirm the driver correctly initializes without a tags file.""" 17 | async with ClickPLC(ADDRESS) as c: 18 | yield c 19 | 20 | @pytest.fixture 21 | def expected_tags(): 22 | """Return the tags defined in the tags file.""" 23 | return { 24 | 'IO2_24V_OK': {'address': {'start': 16397}, 'id': 'C13', 'type': 'bool'}, 25 | 'IO2_Module_OK': {'address': {'start': 16396}, 'id': 'C12', 'type': 'bool'}, 26 | 'LI_101': {'address': {'start': 428683}, 'id': 'DF6', 'type': 'float'}, 27 | 'LI_102': {'address': {'start': 428681}, 'id': 'DF5', 'type': 'float'}, 28 | 'P_101': {'address': {'start': 8289}, 'id': 'Y301', 'type': 'bool'}, 29 | 'P_101_auto': {'address': {'start': 16385}, 'id': 'C1', 'type': 'bool'}, 30 | 'P_102_auto': {'address': {'start': 16386}, 'id': 'C2', 'type': 'bool'}, 31 | 'P_103': {'address': {'start': 8290}, 'id': 'Y302', 'type': 'bool'}, 32 | 'TIC101_PID_ErrorCode': {'address': {'start': 400100}, 33 | 'comment': 'PID Error Code', 34 | 'id': 'DS100', 35 | 'type': 'int16'}, 36 | 'TI_101': {'address': {'start': 428673}, 'id': 'DF1', 'type': 'float'}, 37 | 'VAHH_101_OK': {'address': {'start': 16395}, 'id': 'C11', 'type': 'bool'}, 38 | 'VAH_101_OK': {'address': {'start': 16394}, 'id': 'C10', 'type': 'bool'}, 39 | 'VI_101': {'address': {'start': 428685}, 'id': 'DF7', 'type': 'float'}, 40 | 'PLC_Error_Code': {'address': {'start': 361441}, 'id': 'SD1', 'type': 'int16'}, 41 | 'timer': {'address': {'start': 449153}, 'id': 'CTD1', 'type': 'int32'}, 42 | } 43 | 44 | @mock.patch('clickplc.ClickPLC', ClickPLC) 45 | def test_driver_cli(capsys): 46 | """Confirm the commandline interface works without a tags file.""" 47 | command_line([ADDRESS]) 48 | captured = capsys.readouterr() 49 | assert 'x816' in captured.out 50 | assert 'c100' in captured.out 51 | assert 'df100' in captured.out 52 | 53 | @mock.patch('clickplc.ClickPLC', ClickPLC) 54 | def test_driver_cli_tags(capsys): 55 | """Confirm the commandline interface works with a tags file.""" 56 | command_line([ADDRESS, 'clickplc/tests/plc_tags.csv']) 57 | captured = capsys.readouterr() 58 | assert 'P_101' in captured.out 59 | assert 'VAHH_101_OK' in captured.out 60 | assert 'TI_101' in captured.out 61 | with pytest.raises(SystemExit): 62 | command_line([ADDRESS, 'tags', 'bogus']) 63 | 64 | @pytest.mark.asyncio(scope='session') 65 | async def test_unsupported_tags(): 66 | """Confirm the driver detects an improper tags file.""" 67 | with pytest.raises(TypeError, match='unsupported data type'): 68 | ClickPLC(ADDRESS, 'clickplc/tests/bad_tags.csv') 69 | 70 | @pytest.mark.asyncio(scope='session') 71 | async def test_tagged_driver(expected_tags): 72 | """Test a roundtrip with the driver using a tags file.""" 73 | async with ClickPLC(ADDRESS, 'clickplc/tests/plc_tags.csv') as tagged_driver: 74 | await tagged_driver.set('VAH_101_OK', True) 75 | state = await tagged_driver.get() 76 | assert state.get('VAH_101_OK') 77 | assert expected_tags == tagged_driver.get_tags() 78 | 79 | @pytest.mark.asyncio(scope='session') 80 | async def test_y_roundtrip(plc_driver): 81 | """Confirm y (output bools) are read back correctly after being set.""" 82 | await plc_driver.set('y1', [False, True, False, True]) 83 | expected = {'y001': False, 'y002': True, 'y003': False, 'y004': True} 84 | assert expected == await plc_driver.get('y1-y4') 85 | await plc_driver.set('y816', True) 86 | assert await plc_driver.get('y816') is True 87 | 88 | @pytest.mark.asyncio(scope='session') 89 | async def test_c_roundtrip(plc_driver): 90 | """Confirm c bools are read back correctly after being set.""" 91 | await plc_driver.set('c2', True) 92 | await plc_driver.set('c3', [False, True]) 93 | expected = {'c1': False, 'c2': True, 'c3': False, 'c4': True, 'c5': False} 94 | assert expected == await plc_driver.get('c1-c5') 95 | await plc_driver.set('c2000', True) 96 | assert await plc_driver.get('c2000') is True 97 | 98 | @pytest.mark.asyncio(scope='session') 99 | async def test_ds_roundtrip(plc_driver): 100 | """Confirm ds ints are read back correctly after being set.""" 101 | await plc_driver.set('ds1', 1) 102 | await plc_driver.set('ds3', [-32768, 32767]) 103 | expected = {'ds1': 1, 'ds2': 0, 'ds3': -32768, 'ds4': 32767, 'ds5': 0} 104 | assert expected == await plc_driver.get('ds1-ds5') 105 | await plc_driver.set('ds4500', 4500) 106 | assert await plc_driver.get('ds4500') == 4500 107 | 108 | @pytest.mark.asyncio(scope='session') 109 | async def test_df_roundtrip(plc_driver): 110 | """Confirm df floats are read back correctly after being set.""" 111 | await plc_driver.set('df1', 0.0) 112 | await plc_driver.set('df2', [2.0, 3.0, 4.0, 0.0]) 113 | expected = {'df1': 0.0, 'df2': 2.0, 'df3': 3.0, 'df4': 4.0, 'df5': 0.0} 114 | assert expected == await plc_driver.get('df1-df5') 115 | await plc_driver.set('df500', 1.0) 116 | assert await plc_driver.get('df500') == 1.0 117 | 118 | @pytest.mark.asyncio(scope='session') 119 | async def test_td_roundtrip(plc_driver): 120 | """Confirm td ints are read back correctly after being set.""" 121 | await plc_driver.set('td1', 1) 122 | await plc_driver.set('td2', [2, -32768, 32767, 0]) 123 | expected = {'td1': 1, 'td2': 2, 'td3': -32768, 'td4': 32767, 'td5': 0} 124 | assert expected == await plc_driver.get('td1-td5') 125 | await plc_driver.set('td500', 500) 126 | assert await plc_driver.get('td500') == 500 127 | 128 | @pytest.mark.asyncio(scope='session') 129 | async def test_dd_roundtrip(plc_driver): 130 | """Confirm dd double ints are read back correctly after being set.""" 131 | await plc_driver.set('dd1', 1) 132 | await plc_driver.set('dd3', [-2**31, 2**31 - 1]) 133 | expected = {'dd1': 1, 'dd2': 0, 'dd3': -2**31, 'dd4': 2**31 - 1, 'dd5': 0} 134 | assert expected == await plc_driver.get('dd1-dd5') 135 | await plc_driver.set('dd1000', 1000) 136 | assert await plc_driver.get('dd1000') == 1000 137 | 138 | @pytest.mark.asyncio(scope='session') 139 | async def test_get_error_handling(plc_driver): 140 | """Confirm the driver gives an error on invalid get() calls.""" 141 | with pytest.raises(ValueError, match='An address must be supplied'): 142 | await plc_driver.get() 143 | with pytest.raises(ValueError, match='End address must be greater than start address'): 144 | await plc_driver.get('c3-c1') 145 | with pytest.raises(ValueError, match='foo currently unsupported'): 146 | await plc_driver.get('foo1') 147 | with pytest.raises(ValueError, match='Inter-category ranges are unsupported'): 148 | await plc_driver.get('c1-x3') 149 | 150 | @pytest.mark.asyncio(scope='session') 151 | async def test_set_error_handling(plc_driver): 152 | """Confirm the driver gives an error on invalid set() calls.""" 153 | with pytest.raises(ValueError, match='foo currently unsupported'): 154 | await plc_driver.set('foo1', 1) 155 | 156 | @pytest.mark.asyncio(scope='session') 157 | @pytest.mark.parametrize('prefix', ['x', 'y']) 158 | async def test_get_xy_error_handling(plc_driver, prefix): 159 | """Ensure errors are handled for invalid get requests of x and y registers.""" 160 | with pytest.raises(ValueError, match=r'address must be \*01-\*16.'): 161 | await plc_driver.get(f'{prefix}17') 162 | with pytest.raises(ValueError, match=r'address must be in \[001, 816\].'): 163 | await plc_driver.get(f'{prefix}1001') 164 | with pytest.raises(ValueError, match=r'address must be \*01-\*16.'): 165 | await plc_driver.get(f'{prefix}1-{prefix}17') 166 | with pytest.raises(ValueError, match=r'address must be in \[001, 816\].'): 167 | await plc_driver.get(f'{prefix}1-{prefix}1001') 168 | 169 | @pytest.mark.asyncio(scope='session') 170 | async def test_set_y_error_handling(plc_driver): 171 | """Ensure errors are handled for invalid set requests of y registers.""" 172 | with pytest.raises(ValueError, match=r'address must be \*01-\*16.'): 173 | await plc_driver.set('y17', True) 174 | with pytest.raises(ValueError, match=r'address must be in \[001, 816\].'): 175 | await plc_driver.set('y1001', True) 176 | with pytest.raises(ValueError, match=r'Data list longer than available addresses.'): 177 | await plc_driver.set('y816', [True, True]) 178 | 179 | @pytest.mark.asyncio(scope='session') 180 | async def test_c_error_handling(plc_driver): 181 | """Ensure errors are handled for invalid requests of c registers.""" 182 | with pytest.raises(ValueError, match=r'C start address must be 1-2000.'): 183 | await plc_driver.get('c2001') 184 | with pytest.raises(ValueError, match=r'C end address must be >start and <=2000.'): 185 | await plc_driver.get('c1-c2001') 186 | with pytest.raises(ValueError, match=r'C start address must be 1-2000.'): 187 | await plc_driver.set('c2001', True) 188 | with pytest.raises(ValueError, match=r'Data list longer than available addresses.'): 189 | await plc_driver.set('c2000', [True, True]) 190 | 191 | @pytest.mark.asyncio(scope='session') 192 | async def test_t_error_handling(plc_driver): 193 | """Ensure errors are handled for invalid requests of t registers.""" 194 | with pytest.raises(ValueError, match=r'T start address must be 1-500.'): 195 | await plc_driver.get('t501') 196 | with pytest.raises(ValueError, match=r'T end address must be >start and <=500.'): 197 | await plc_driver.get('t1-t501') 198 | 199 | @pytest.mark.asyncio(scope='session') 200 | async def test_ct_error_handling(plc_driver): 201 | """Ensure errors are handled for invalid requests of ct registers.""" 202 | with pytest.raises(ValueError, match=r'CT start address must be 1-250.'): 203 | await plc_driver.get('ct251') 204 | with pytest.raises(ValueError, match=r'CT end address must be >start and <=250.'): 205 | await plc_driver.get('ct1-ct251') 206 | 207 | 208 | @pytest.mark.asyncio(scope='session') 209 | async def test_df_error_handling(plc_driver): 210 | """Ensure errors are handled for invalid requests of df registers.""" 211 | with pytest.raises(ValueError, match=r'DF must be in \[1, 500\]'): 212 | await plc_driver.get('df501') 213 | with pytest.raises(ValueError, match=r'DF end must be in \[1, 500\]'): 214 | await plc_driver.get('df1-df501') 215 | with pytest.raises(ValueError, match=r'DF must be in \[1, 500\]'): 216 | await plc_driver.set('df501', 1.0) 217 | with pytest.raises(ValueError, match=r'Data list longer than available addresses.'): 218 | await plc_driver.set('df500', [1.0, 2.0]) 219 | 220 | @pytest.mark.asyncio(scope='session') 221 | async def test_ds_error_handling(plc_driver): 222 | """Ensure errors are handled for invalid requests of ds registers.""" 223 | with pytest.raises(ValueError, match=r'DS must be in \[1, 4500\]'): 224 | await plc_driver.get('ds4501') 225 | with pytest.raises(ValueError, match=r'DS end must be in \[1, 4500\]'): 226 | await plc_driver.get('ds1-ds4501') 227 | with pytest.raises(ValueError, match=r'DS must be in \[1, 4500\]'): 228 | await plc_driver.set('ds4501', 1) 229 | with pytest.raises(ValueError, match=r'Data list longer than available addresses.'): 230 | await plc_driver.set('ds4500', [1, 2]) 231 | 232 | @pytest.mark.asyncio(scope='session') 233 | async def test_dd_error_handling(plc_driver): 234 | """Ensure errors are handled for invalid requests of dd registers.""" 235 | with pytest.raises(ValueError, match=r'DD must be in \[1, 1000\]'): 236 | await plc_driver.get('dd1001') 237 | with pytest.raises(ValueError, match=r'DD end must be in \[1, 1000\]'): 238 | await plc_driver.get('dd1-dd1001') 239 | with pytest.raises(ValueError, match=r'DD must be in \[1, 1000\]'): 240 | await plc_driver.set('dd1001', 1) 241 | with pytest.raises(ValueError, match=r'Data list longer than available addresses.'): 242 | await plc_driver.set('dd1000', [1, 2]) 243 | 244 | @pytest.mark.asyncio(scope='session') 245 | async def test_td_error_handling(plc_driver): 246 | """Ensure errors are handled for invalid requests of td registers.""" 247 | with pytest.raises(ValueError, match=r'TD must be in \[1, 500\]'): 248 | await plc_driver.get('td501') 249 | with pytest.raises(ValueError, match=r'TD end must be in \[1, 500\]'): 250 | await plc_driver.get('td1-td501') 251 | 252 | @pytest.mark.asyncio(scope='session') 253 | async def test_ctd_error_handling(plc_driver): 254 | """Ensure errors are handled for invalid requests of ctd registers.""" 255 | with pytest.raises(ValueError, match=r'CTD must be in \[1, 250\]'): 256 | await plc_driver.get('ctd251') 257 | with pytest.raises(ValueError, match=r'CTD end must be in \[1, 250\]'): 258 | await plc_driver.get('ctd1-ctd251') 259 | 260 | @pytest.mark.asyncio(scope='session') 261 | @pytest.mark.parametrize('prefix', ['y', 'c']) 262 | async def test_bool_typechecking(plc_driver, prefix): 263 | """Ensure errors are handled for set() requests that should be bools.""" 264 | with pytest.raises(ValueError, match='Expected .+ as a bool'): 265 | await plc_driver.set(f'{prefix}1', 1) 266 | with pytest.raises(ValueError, match='Expected .+ as a bool'): 267 | await plc_driver.set(f'{prefix}1', [1.0, 1]) 268 | 269 | @pytest.mark.asyncio(scope='session') 270 | async def test_df_typechecking(plc_driver): 271 | """Ensure errors are handled for set() requests that should be floats.""" 272 | await plc_driver.set('df1', 1) 273 | with pytest.raises(ValueError, match='Expected .+ as a float'): 274 | await plc_driver.set('df1', True) 275 | with pytest.raises(ValueError, match='Expected .+ as a float'): 276 | await plc_driver.set('df1', [True, True]) 277 | 278 | @pytest.mark.asyncio(scope='session') 279 | @pytest.mark.parametrize('prefix', ['ds', 'dd']) 280 | async def test_ds_dd_typechecking(plc_driver, prefix): 281 | """Ensure errors are handled for set() requests that should be ints.""" 282 | with pytest.raises(ValueError, match='Expected .+ as a int'): 283 | await plc_driver.set(f'{prefix}1', 1.0) 284 | with pytest.raises(ValueError, match='Expected .+ as a int'): 285 | await plc_driver.set(f'{prefix}1', True) 286 | with pytest.raises(ValueError, match='Expected .+ as a int'): 287 | await plc_driver.set(f'{prefix}1', [True, True]) 288 | -------------------------------------------------------------------------------- /clickplc/util.py: -------------------------------------------------------------------------------- 1 | """Base functionality for modbus communication. 2 | 3 | Distributed under the GNU General Public License v2 4 | Copyright (C) 2022 NuMat Technologies 5 | """ 6 | from __future__ import annotations 7 | 8 | import asyncio 9 | 10 | try: 11 | from pymodbus.client import AsyncModbusTcpClient # 3.x 12 | except ImportError: # 2.4.x - 2.5.x 13 | from pymodbus.client.asynchronous.async_io import ( # type: ignore 14 | ReconnectingAsyncioModbusTcpClient, 15 | ) 16 | import pymodbus.exceptions 17 | 18 | 19 | class AsyncioModbusClient: 20 | """A generic asyncio client. 21 | 22 | This expands upon the pymodbus AsyncModbusTcpClient by 23 | including standard timeouts, async context manager, and queued requests. 24 | """ 25 | 26 | def __init__(self, address, timeout=1): 27 | """Set up communication parameters.""" 28 | self.ip = address 29 | self.timeout = timeout 30 | self._detect_pymodbus_version() 31 | if self.pymodbus30plus: 32 | self.client = AsyncModbusTcpClient(address, timeout=timeout) 33 | else: # 2.x 34 | self.client = ReconnectingAsyncioModbusTcpClient() 35 | self.lock = asyncio.Lock() 36 | self.connectTask = asyncio.create_task(self._connect()) 37 | 38 | async def __aenter__(self): 39 | """Asynchronously connect with the context manager.""" 40 | return self 41 | 42 | async def __aexit__(self, *args) -> None: 43 | """Provide exit to the context manager.""" 44 | await self._close() 45 | 46 | def _detect_pymodbus_version(self) -> None: 47 | """Detect various pymodbus versions.""" 48 | self.pymodbus30plus = int(pymodbus.__version__[0]) == 3 49 | self.pymodbus32plus = self.pymodbus30plus and int(pymodbus.__version__[2]) >= 2 50 | self.pymodbus33plus = self.pymodbus30plus and int(pymodbus.__version__[2]) >= 3 51 | self.pymodbus35plus = self.pymodbus30plus and int(pymodbus.__version__[2]) >= 5 52 | 53 | async def _connect(self) -> None: 54 | """Start asynchronous reconnect loop.""" 55 | try: 56 | if self.pymodbus30plus: 57 | await asyncio.wait_for(self.client.connect(), timeout=self.timeout) # 3.x 58 | else: # 2.4.x - 2.5.x 59 | await self.client.start(self.ip) # type: ignore 60 | except Exception: 61 | raise OSError(f"Could not connect to '{self.ip}'.") 62 | 63 | async def read_coils(self, address: int, count): 64 | """Read modbus output coils (0 address prefix).""" 65 | return await self._request('read_coils', address, count) 66 | 67 | async def read_registers(self, address: int, count): 68 | """Read modbus registers. 69 | 70 | The Modbus protocol doesn't allow responses longer than 250 bytes 71 | (ie. 125 registers, 62 DF addresses), which this function manages by 72 | chunking larger requests. 73 | """ 74 | registers = [] 75 | while count > 124: 76 | r = await self._request('read_holding_registers', address, 124) 77 | registers += r.registers 78 | address, count = address + 124, count - 124 79 | r = await self._request('read_holding_registers', address, count) 80 | registers += r.registers 81 | return registers 82 | 83 | async def write_coil(self, address: int, value): 84 | """Write modbus coils.""" 85 | await self._request('write_coil', address, value) 86 | 87 | async def write_coils(self, address: int, values): 88 | """Write modbus coils.""" 89 | await self._request('write_coils', address, values) 90 | 91 | async def write_register(self, address: int, value, skip_encode=False): 92 | """Write a modbus register.""" 93 | await self._request('write_register', address, value, skip_encode=skip_encode) 94 | 95 | async def write_registers(self, address: int, values, skip_encode=False): 96 | """Write modbus registers. 97 | 98 | The Modbus protocol doesn't allow requests longer than 250 bytes 99 | (ie. 125 registers, 62 DF addresses), which this function manages by 100 | chunking larger requests. 101 | """ 102 | while len(values) > 62: 103 | await self._request('write_registers', 104 | address, values, skip_encode=skip_encode) 105 | address, values = address + 124, values[62:] 106 | await self._request('write_registers', 107 | address, values, skip_encode=skip_encode) 108 | 109 | async def _request(self, method, *args, **kwargs): 110 | """Send a request to the device and awaits a response. 111 | 112 | This mainly ensures that requests are sent serially, as the Modbus 113 | protocol does not allow simultaneous requests (it'll ignore any 114 | request sent while it's processing something). The driver handles this 115 | by assuming there is only one client instance. If other clients 116 | exist, other logic will have to be added to either prevent or manage 117 | race conditions. 118 | """ 119 | await self.connectTask 120 | async with self.lock: 121 | try: 122 | if self.pymodbus32plus: 123 | future = getattr(self.client, method) 124 | else: 125 | future = getattr(self.client.protocol, method) # type: ignore 126 | return await future(*args, **kwargs) 127 | except (asyncio.TimeoutError, pymodbus.exceptions.ConnectionException): 128 | raise TimeoutError("Not connected to PLC.") 129 | 130 | async def _close(self): 131 | """Close the TCP connection.""" 132 | if self.pymodbus33plus: 133 | self.client.close() # 3.3.x 134 | elif self.pymodbus30plus: 135 | await self.client.close() # type: ignore # 3.0.x - 3.2.x 136 | else: # 2.4.x - 2.5.x 137 | self.client.stop() # type: ignore 138 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | exclude = ["venv*"] 2 | line-length = 99 3 | lint.ignore = [ 4 | "PLR2004", 5 | "D104", 6 | "D107", 7 | "C901", 8 | "PT023", # Use `@pytest.mark.asyncio()` over `@pytest.mark.asyncio` 9 | "PT001", # Use `@pytest.fixture()` over `@pytest.fixture` 10 | ] 11 | lint.select = [ 12 | "C", # complexity 13 | "D", # docstrings 14 | "E", # pycodestyle errors 15 | "F", # pyflakes 16 | "I", # isort 17 | "UP", # pyupgrade 18 | "PT", # flake8-pytest 19 | "RUF", # ruff base config 20 | "SIM", # flake-simplify 21 | "W", # pycodestyle warnings 22 | "YTT", # flake8-2020 23 | # "ARG", # flake8-unused args 24 | # "B" # bandit 25 | ] 26 | [lint.pydocstyle] 27 | convention = "pep257" 28 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [mypy] 2 | check_untyped_defs = True 3 | [mypy-pymodbus.*] 4 | ignore_missing_imports = True 5 | 6 | [tool:pytest] 7 | addopts = --cov=clickplc 8 | asyncio_mode = auto 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Python driver for AutomationDirect (formerly Koyo) Ethernet ClickPLCs.""" 2 | 3 | from setuptools import setup 4 | 5 | with open('README.md') as in_file: 6 | long_description = in_file.read() 7 | 8 | setup( 9 | name='clickplc', 10 | version='0.9.0', 11 | description="Python driver for Koyo Ethernet ClickPLCs.", 12 | long_description=long_description, 13 | long_description_content_type='text/markdown', 14 | url='https://github.com/alexrudd2/clickplc/', 15 | author='Patrick Fuller', 16 | author_email='pat@numat-tech.com', 17 | maintainer='Alex Ruddick', 18 | maintainer_email='alex@ruddick.tech', 19 | packages=['clickplc'], 20 | entry_points={ 21 | 'console_scripts': [('clickplc = clickplc:command_line')] 22 | }, 23 | install_requires=[ 24 | 'pymodbus>=2.4.0; python_version == "3.8"', 25 | 'pymodbus>=2.4.0; python_version == "3.9"', 26 | 'pymodbus>=3.0.2,<3.7.0; python_version >= "3.10"', 27 | ], 28 | extras_require={ 29 | 'test': [ 30 | 'pytest', 31 | 'pytest-asyncio>=0.23.7,<=0.23.9', 32 | 'pytest-cov', 33 | 'pytest-xdist', 34 | 'mypy==1.14.1', 35 | 'ruff==0.5.0', 36 | ], 37 | }, 38 | license='GPLv2', 39 | classifiers=[ 40 | 'License :: OSI Approved :: GNU General Public License v2 (GPLv2)', 41 | 'Development Status :: 4 - Beta', 42 | 'Natural Language :: English', 43 | 'Programming Language :: Python', 44 | 'Programming Language :: Python :: 3', 45 | 'Programming Language :: Python :: 3.8', 46 | 'Programming Language :: Python :: 3.9', 47 | 'Programming Language :: Python :: 3.10', 48 | 'Programming Language :: Python :: 3.11', 49 | 'Programming Language :: Python :: 3.12', 50 | 'Topic :: Scientific/Engineering :: Human Machine Interfaces' 51 | ] 52 | ) 53 | --------------------------------------------------------------------------------