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