├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── _static │ ├── cip_msg_plc_name_msg_cfg.png │ └── cip_msg_plc_name_resp.png ├── api_reference │ ├── cip_driver.rst │ ├── data_types.rst │ ├── index.rst │ ├── logix_driver.rst │ └── slc_driver.rst ├── cip_reference.rst ├── conf.py ├── contributing.rst ├── examples │ ├── basic_rw_examples.rst │ ├── generic_messaging_examples.rst │ ├── index.rst │ └── tag_examples.rst ├── getting_started.rst ├── index.rst ├── make.bat ├── releases.rst ├── requirements.txt └── usage │ ├── cipdriver.rst │ ├── index.rst │ ├── logixdriver.rst │ └── slcdriver.rst ├── examples ├── __init__.py ├── basic_reads.py ├── basic_writes.py ├── generic_messaging.py ├── lgx_gui_test.py ├── tags.py └── upload_eds.py ├── pycomm3 ├── __init__.py ├── _version.py ├── cip │ ├── __init__.py │ ├── data_types.py │ ├── object_library.py │ ├── pccc.py │ ├── services.py │ └── status_info.py ├── cip_driver.py ├── const.py ├── custom_types.py ├── exceptions.py ├── logger.py ├── logix_driver.py ├── map.py ├── packets │ ├── __init__.py │ ├── base.py │ ├── cip.py │ ├── ethernetip.py │ ├── logix.py │ └── util.py ├── py.typed ├── slc_driver.py ├── socket_.py ├── tag.py └── util.py ├── setup.py ├── tests ├── __init__.py ├── offline │ ├── __init__.py │ ├── all_tags.json │ ├── controller_tags.json │ ├── test_cip_driver.py │ ├── test_logix_driver.py │ ├── test_slc.py │ ├── test_socket_.py │ ├── test_tag.py │ ├── test_types.py │ └── test_util.py ├── online │ ├── __init__.py │ ├── conftest.py │ ├── test_demo_plc.py │ ├── test_reads.py │ └── test_writes.py └── pycomm3.L5X └── tox.ini /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] - " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Pre-checks** 11 | - [ ] running latest version 12 | - [ ] enabled logging 13 | - [ ] consulted the [docs](https://pycomm3.readthedocs.io/en/latest/) 14 | 15 | **Description** 16 | _A clear and concise description of what the bug is._ 17 | 18 | **Target PLC** 19 | Model: [e.g. 1756-L83E] 20 | Firmware Revision: [e.g. 32] 21 | Other Devices in CIP Path: [e.g. 1756-EN2T v11.002] 22 | 23 | **Code Sample** 24 | _Minimal reproduceable code sample_ 25 | ```python 26 | # code goes here 27 | ``` 28 | 29 | **Additional context** 30 | - _if reading/writing a tag, describe it's type/structure/etc_ 31 | - _attach any relevant L5X or data files_ 32 | - _paste/attach logging output_ 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE] - " 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Type of Feature Request** 11 | - [ ] missing feature 12 | - [ ] feature available in another library: _please specify_ 13 | - [ ] change to API 14 | - [ ] enhancing a current feature 15 | - [ ] removal of a broken/unsupported/etc feature 16 | - [ ] other: _please specify_ 17 | 18 | **Feature Description** 19 | _A clear and concise summary of what the feature is and why it is desired_ 20 | _If feature breaks current functionality, explain why the new functionality is better_ 21 | 22 | **Possible Solution** 23 | _A clear and concise description of how this feature would work and/or be implemented_ 24 | _Include code samples or references_ 25 | 26 | **Additional context** 27 | _Add any other context or screenshots about the feature request here._ 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | *.sw[a-z] 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | 45 | # Translations 46 | *.mo 47 | *.pot 48 | 49 | # Django stuff: 50 | *.log 51 | 52 | # Sphinx documentation 53 | docs/_build/ 54 | 55 | # PyBuilder 56 | target/ 57 | 58 | # pycharm editor 59 | .idea/ 60 | 61 | #Eclipse 62 | .project 63 | .settings/ 64 | .pydevproject 65 | 66 | 67 | # Logix Files, besides main project 68 | *.BAK000.acd 69 | *.Recovery 70 | *.Wrk 71 | *.Sem 72 | 73 | /dev_test.py 74 | /test.py 75 | tests/**/.hypothesis/ 76 | /.venv/ 77 | /venv*/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing to pycomm3 2 | 3 | This document aims to provide a brief guide on how to contribute to `pycomm3`. 4 | 5 | ### Who can contribute? 6 | 7 | Anyone! Contributions from any user are welcome. Contributions aren't limited to changing code. 8 | Filing bug reports, asking questions, adding examples or documentation are all ways to contribute. 9 | New users may find it helpful to start with improving documentation, type hinting, or tests. 10 | 11 | ## Asking a question 12 | 13 | Questions can be submitted as either an issue or a discussion post. A general question not directly related to the code 14 | or one that may be beneficial to other users would be most appropriate in the discussions area. One that is about a 15 | specific feature or could turn into a feature request or bug report would be more appropriate as an issue. If submitting 16 | a question as an issue, please use the _question_ template. 17 | 18 | ## Submitting an Issue 19 | 20 | No code is perfect, `pycomm3` is no different and user submitted issues aid in improving the quality of this library. 21 | Before submitting an issue, check to see if someone has already submitted one before so we can avoid duplicate issues. 22 | 23 | ### Bug Reports 24 | 25 | To submit a bug report, please create an issue using the _Bug Report_ template. Please include as much information as 26 | possible relating to the bug. The more detailed the bug report, the easier and faster it will be to resolve. 27 | Some details to include: 28 | - The version of `pycomm3` (easily found with the `pip show pycomm3` command) 29 | - Model/Firmware/etc if the issue is related to a specific device or firmware version 30 | - Logs (see the [documentation](https://pycomm3.dev/getting_started.html#logging) to configure) 31 | - A helper method is provided to simplify logging configs, including logging to a file 32 | - Using the `LOG_VERBOSE` level is the most helpful 33 | - Sample code that will reproduce the bug 34 | 35 | ### Feature Requests 36 | 37 | For feature requests or enhancements, please create an issue using the _Feature Request_ template. New features could be 38 | things like: 39 | - A missing feature from a similar library 40 | - e.g. Library _X_ has a feature _Y_, would it be possible to add _Y_ functionality to `pycomm3`? 41 | - Change or modification to the API 42 | - If it's a breaking change be sure to include why the new functionality is better than the current 43 | - Enhancing a current feature 44 | - Removing an old/broken/unsupported feature 45 | 46 | 47 | ## Submitting Changes 48 | 49 | Submitting code or documentation changes is another way to contribute. All contributions should be made in the form of 50 | a pull request. You should fork this repository and clone it to your machine. All work is done in the `develop` branch 51 | first before merging to `master`. All pull requests should target the `develop` branch. This is because some of the 52 | tests are specific to a demo PLC. Once changes are completed in `develop` and all tests are passing, `develop` will 53 | be merged into `master` and a new release created and available on PyPI. 54 | 55 | Some requirements for code changes to be accepted include: 56 | 57 | - code should be _pythonic_ and follow PEP8, PEP20, and other Python best-practices or common conventions 58 | - public methods should have docstrings which will be included in the documentation 59 | - comments and docstrings should explain _why_ and _how_ the code works, not merely _what_ it is doing 60 | - type hinting should be used as much as possible, all public methods need to have hints 61 | - new functionality should have tests 62 | - run the _user_ tests and verify there are no issues 63 | - avoid 3rd party dependencies, code should only require the Python standard library 64 | - avoid breaking changes, unless adequately justified 65 | - do not update the library version 66 | 67 | Some suggested contributions include: 68 | - type hinting 69 | - all public methods are type hinted, but many internal methods are missing them 70 | - tests 71 | - new tests are always welcome, particularly offline tests or any methods missing tests 72 | - examples 73 | - example scripts showing how to use this library or any of it's features 74 | - you may include just the example script if you're not comfortable with also updating the docs to include it 75 | 76 | 77 | ### New Feature or an Example? 78 | 79 | It can be tough to decide whether functionality should be added to the library or shown as an example. New features 80 | should apply to generally to almost all devices for a driver or implement new functionality that cannot be done externally. 81 | If submitting an example, please include name/username/email/etc in a comment/docstring if you wish to be credited. 82 | 83 | Here are a couple examples of changes and why they were added either as a feature or example: 84 | 85 | **[Feature] Add support for writing structures with a dictionary for the value:** 86 | - Cannot be done without modifying internal methods 87 | - New functionality not yet implemented 88 | - Improves user experience 89 | - user can read a struct, change one value, and write it back without changing the data structure 90 | 91 | **[Example] Add support for reading/writing Powerflex drive parameters:** 92 | - Implemented using the `generic_message` method 93 | - Does not apply to a wide arrange of device types 94 | - Not a PLC, so doesn't fit in the Logix or SLC drivers 95 | - Too specific for the CIPDriver, but not enough to create a new driver 96 | 97 | Some questions to ask yourself when deciding between a feature or an example: 98 | - Is this new functionality or a new use of current functionality? _Former may be a feature, latter could be an example_ 99 | - Can this be done using already available features? _Yes, then maybe an example_ 100 | - Does this apply to a wide arrange of devices? _Yes, then maybe a feature_ 101 | - Will this require internal changes to existing functionality? _Yes, then maybe a feature_ 102 | - Is this useful? _Either should be useful_ 103 | 104 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Ian Ottoway 4 | Copyright (c) 2014 Agostino Ruscito 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | pycomm3 3 | ======= 4 | 5 | .. <> 6 | 7 | .. image:: https://img.shields.io/pypi/v/pycomm3.svg?style=for-the-badge 8 | :target: https://pypi.python.org/pypi/pycomm3 9 | :alt: PyPI Version 10 | 11 | .. image:: https://img.shields.io/pypi/l/pycomm3.svg?style=for-the-badge 12 | :target: https://pypi.python.org/pypi/pycomm3 13 | :alt: License 14 | 15 | .. image:: https://img.shields.io/pypi/pyversions/pycomm3.svg?style=for-the-badge 16 | :target: https://pypi.python.org/pypi/pycomm3 17 | :alt: Python Versions 18 | 19 | | 20 | 21 | .. image:: https://img.shields.io/pypi/dm/pycomm3?style=social 22 | :target: https://pypi.python.org/pypi/pycomm3 23 | :alt: Downloads 24 | 25 | .. image:: https://img.shields.io/github/watchers/ottowayi/pycomm3?style=social 26 | :target: https://github.com/ottowayi/pycomm3 27 | :alt: Watchers 28 | 29 | .. image:: https://img.shields.io/github/stars/ottowayi/pycomm3?style=social 30 | :target: https://github.com/ottowayi/pycomm3 31 | :alt: Stars 32 | 33 | .. image:: https://img.shields.io/github/forks/ottowayi/pycomm3?style=social 34 | :target: https://github.com/ottowayi/pycomm3 35 | :alt: Forks 36 | 37 | | 38 | 39 | .. image:: https://readthedocs.org/projects/pycomm3/badge/?version=latest&style=for-the-badge 40 | :target: https://pycomm3.readthedocs.io/en/latest/ 41 | :alt: Read the Docs 42 | 43 | .. image:: https://img.shields.io/badge/gitmoji-%20%F0%9F%98%9C%20%F0%9F%98%8D-FFDD67.svg?style=for-the-badge 44 | :target: https://gitmoji.dev 45 | :alt: Gitmoji 46 | 47 | 48 | Introduction 49 | ============ 50 | 51 | ``pycomm3`` started as a Python 3 fork of `pycomm`_, which is a Python 2 library for 52 | communicating with Allen-Bradley PLCs using Ethernet/IP. The initial Python 3 port was done 53 | in this `fork`_ and was used as the base for ``pycomm3``. Since then, the library has been 54 | almost entirely rewritten and the API is no longer compatible with ``pycomm``. Without the 55 | hard work done by the original ``pycomm`` developers, ``pycomm3`` would not exist. This 56 | library seeks to expand upon their great work. 57 | 58 | 59 | .. _pycomm: https://github.com/ruscito/pycomm 60 | 61 | .. _fork: https://github.com/bpaterni/pycomm/tree/pycomm3 62 | 63 | 64 | Drivers 65 | ======= 66 | 67 | ``pycomm3`` includes 3 drivers: 68 | 69 | - `CIPDriver`_ 70 | This driver is the base driver for the library, it handles common CIP services used 71 | by the other drivers. Things like opening/closing a connection, register/unregister sessions, 72 | forward open/close services, device discovery, and generic messaging. It can be used to connect to 73 | any Ethernet/IP device, like: drives, switches, meters, and other non-PLC devices. 74 | 75 | - `LogixDriver`_ 76 | This driver supports services specific to ControlLogix, CompactLogix, and Micro800 PLCs. 77 | Services like reading/writing tags, uploading the tag list, and getting/setting the PLC time. 78 | 79 | - `SLCDriver`_ 80 | This driver supports basic reading/writing data files in a SLC500 or MicroLogix PLCs. It is 81 | a port of the ``SlcDriver`` from ``pycomm`` with minimal changes to make the API similar to the 82 | other drivers. Currently this driver is considered legacy and it's development will be on 83 | a limited basis. 84 | 85 | .. _CIPDriver: https://docs.pycomm3.dev/en/latest/usage/cipdriver.html 86 | 87 | .. _LogixDriver: https://docs.pycomm3.dev/en/latest/usage/logixdriver.html 88 | 89 | .. _SLCDriver: https://docs.pycomm3.dev/en/latest/usage/slcdriver.html 90 | 91 | Disclaimer 92 | ========== 93 | 94 | PLCs can be used to control heavy or dangerous equipment, this library is provided "as is" and makes no guarantees on 95 | its reliability in a production environment. This library makes no promises in the completeness or correctness of the 96 | protocol implementations and should not be solely relied upon for critical systems. The development for this library 97 | is aimed at providing quick and convenient access for reading/writing data inside Allen-Bradley PLCs. 98 | 99 | 100 | Setup 101 | ===== 102 | 103 | The package can be installed from `PyPI`_ using ``pip``: ``pip install pycomm3`` or ``python -m pip install pycomm3``. 104 | 105 | .. _PyPI: https://pypi.org/project/pycomm3/ 106 | 107 | Optionally, you may configure logging using the Python standard `logging`_ library. A convenience method is provided 108 | to help configure basic logging, see the `Logging Section`_ in the docs for more information. 109 | 110 | .. _logging: https://docs.python.org/3/library/logging.html 111 | 112 | .. _Logging Section: https://docs.pycomm3.dev/en/latest/getting_started.html#logging 113 | 114 | 115 | Python and OS Support 116 | ===================== 117 | 118 | ``pycomm3`` is a Python 3-only library and is supported on Python versions from 3.6.1 up to 3.10. 119 | There should be no OS-specific requirements and should be able to run on any OS that Python is supported on. 120 | Development and testing is done primarily on Windows 10. If you encounter an OS-related problem, please open an issue 121 | in the `GitHub repository`_ and it will be investigated. 122 | 123 | .. attention:: 124 | 125 | Python 3.6.0 is not supported due to ``NamedTuple`` not supporting 126 | `default values and methods `_ until 3.6.1 127 | 128 | .. _GitHub repository: https://github.com/ottowayi/pycomm3 129 | 130 | .. <> 131 | 132 | Documentation 133 | ============= 134 | 135 | This README covers a basic overview of the library, full documentation can be found on 136 | `Read the Docs`_ or by visiting `https://pycomm3.dev `_. 137 | 138 | .. _Read the Docs: https://pycomm3.readthedocs.io/en/latest/ 139 | 140 | Contributions 141 | ============= 142 | 143 | If you'd like to contribute or are having an issue, please read the `Contributing`_ guidelines. 144 | 145 | .. _Contributing: CONTRIBUTING.md 146 | 147 | 148 | Highlighted Features 149 | ==================== 150 | 151 | - ``generic_message`` for extra functionality not directly implemented 152 | - working similar to the MSG instruction in Logix, arguments similar to the MESSAGE properties 153 | - See the examples section for things like getting/setting drive parameters, IP configuration, or uploading an EDS file 154 | - used internally to implement some of the other methods (get/set_plc_time, forward open/close, etc) 155 | - simplified data types 156 | - allows use of standard Python types by abstracting CIP implementation details away from the user 157 | - strings use normal Python ``str`` objects, does not require handling of the ``LEN`` and ``DATA`` attributes separately 158 | - custom string types are also identified automatically and not limited to just the builtin one 159 | - BOOL arrays use normal Python ``bool`` objects, does not require complicated bit shifting of the DWORD value 160 | - powerful type system to allow types to represent any CIP object and handle encoding/decoding the object 161 | 162 | LogixDriver 163 | ----------- 164 | 165 | - simple API, only 1 ``read`` method and 1 ``write`` method for tags. 166 | - does not require using different methods for different data types 167 | - requires the tag name only, no other information required from the user 168 | - automatically manages request/response size to pack as many requests into a single packet 169 | - automatically handles fragmented requests for large tags that can't fit in a single packet 170 | - both support full structure reading/writing (UDTs, AOIs, etc) 171 | - for ``read`` the ``Tag.value`` will be a ``dict`` of ``{attribute: value}`` 172 | - for ``write`` the value should be a dict of ``{attribute: value}`` , nesting as needed 173 | - does not do partial writes, the value must match the complete structure 174 | - not recommended for builtin type (TIMER, CONTROL, COUNTER, etc) 175 | - both require no attributes to have an External Access of None 176 | - uploads the tag list and data type definitions from the PLC 177 | - no requirement for user to determine tags available (like from an L5X export) 178 | - definitions are required for ``read``/``write`` methods 179 | - automatically enables/disables different features based on the target PLC 180 | - Extended Forward Open (EN2T or newer and v20+) 181 | - Symbol Instance Addressing (Logix v21+) 182 | - detection of Micro800 and disables unsupported features (CIP Path, Ex. Forward Open, Instance Addressing, etc) 183 | 184 | LogixDriver Overview 185 | ==================== 186 | 187 | Creating a driver is simple, only a ``path`` argument is required. The ``path`` can be the IP address, IP and slot, 188 | or a full CIP route, refer to the documentation for more details. The example below shows how to create a simple 189 | driver and print some of the information collected about the device. 190 | 191 | :: 192 | 193 | from pycomm3 import LogixDriver 194 | 195 | with LogixDriver('10.20.30.100/1') as plc: 196 | print(plc) 197 | # OUTPUT: 198 | # Program Name: PLCA, Device: 1756-L83E/B, Revision: 28.13 199 | 200 | print(plc.info) 201 | # OUTPUT: 202 | # {'vendor': 'Rockwell Automation/Allen-Bradley', 'product_type': 'Programmable Logic Controller', 203 | # 'product_code': 166, 'version_major': 28, 'version_minor': 13, 'revision': '28.13', 'serial': 'FFFFFFFF', 204 | # 'device_type': '1756-L83E/B', 'keyswitch': 'REMOTE RUN', 'name': 'PLCA'} 205 | 206 | 207 | Reading/Writing Tags 208 | -------------------- 209 | 210 | Reading or writing tags is as simple as calling the ``read`` and ``write`` methods. Both methods accept any number of tags, 211 | and will automatically pack multiple tags into a *Multiple Service Packet Service (0x0A)* while making sure to stay below the connection size. 212 | If there is a tag value that cannot fit within the request/reply packet, it will automatically handle that tag independently 213 | using the *Read Tag Fragmented (0x52)* or *Write Tag Fragmented (0x53)* requests. 214 | 215 | Both methods will return ``Tag`` objects to reflect the success or failure of the operation. 216 | 217 | :: 218 | 219 | class Tag(NamedTuple): 220 | tag: str # the name of the tag, does not include ``{<# elements>}`` from request 221 | value: Any # value read or written, may be ``None`` if an error occurred 222 | type: Optional[str] = None # data type of tag, including ``[<# elements>]`` from request 223 | error: Optional[str] = None # ``None`` if successful, else the CIP error or exception thrown 224 | 225 | ``Tag`` objects are considered successful (truthy) if the ``value`` is not ``None`` and the ``error`` is ``None``. 226 | 227 | 228 | Examples:: 229 | 230 | with LogixDriver('10.20.30.100') as plc: 231 | plc.read('tag1', 'tag2', 'tag3') # read multiple tags 232 | plc.read('array{10}') # read 10 elements starting at 0 from an array 233 | plc.read('array[5]{20}) # read 20 elements starting at elements 5 from an array 234 | plc.read('string_tag') # read a string tag and get a string 235 | plc.read('a_udt_tag') # the response .value will be a dict like: {'attr1`: 1, 'attr2': 'a string', ...} 236 | 237 | # writes require a sequence of tuples of [(tag name, value), ... ] 238 | plc.write('tag1', 0) # single writes do not need to be passed as a tuple 239 | plc.write(('tag1', 0), ('tag2', 1), ('tag3', 2)) # write multiple tags 240 | plc.write(('array{5}', [1, 2, 3, 4, 5])) # write 5 elements to an array starting at the 0 element 241 | plc.write('array[10]{5}', [1, 2, 3, 4, 5]) # write 5 elements to an array starting at element 10 242 | plc.write('string_tag', 'Hello World!') # write to a string tag with a string 243 | plc.write('string_array[2]{5}', 'Write an array of strings'.split()) # write an array of 5 strings starting at element 2 244 | plc.write('a_udt_tag', {'attr1': 1, 'attr2': 'a string', ...}) # can also use a dict to write a struct 245 | 246 | # Check the results 247 | results = plc.read('tag1', 'tag2', 'tag3') 248 | if all(results): 249 | print('They all worked!') 250 | else: 251 | for result in results: 252 | if not result: 253 | print(f'Reading tag {result.tag} failed with error: {result.error}') 254 | 255 | .. Note:: 256 | 257 | Tag names for both ``read`` and ``write`` are case-sensitive and are required to be the same as they are named in 258 | the controller. This may change in the future. 259 | 260 | 261 | Unit Testing 262 | ============ 263 | 264 | ``pytest`` is used for unit testing. The ``tests`` directory contains an L5X export of the testing program 265 | that contains all tags necessary for testing. The only requirement for testing (besides a running PLC with the testing 266 | program) is the environment variable ``PLCPATH`` for the PLC defined. 267 | 268 | User Tests 269 | ---------- 270 | 271 | These tests are for users to run. There are a few tests that are specific to a demo 272 | plc, those are excluded. To run them you have the following options: 273 | 274 | with `tox`: 275 | 276 | - modify the ``PLCPATH`` variable in ``tox.ini`` 277 | - then run this command: ``tox -e user`` 278 | 279 | or with ``pytest``: 280 | 281 | .. code-block:: 282 | 283 | set PLCPATH=192.168.1.100 284 | pytest --ignore tests/online/test_demo_plc.py 285 | 286 | *(or the equivalent in your shell)* 287 | 288 | 289 | .. Note:: 290 | Test coverage is not complete, pull requests are welcome to help improve coverage. 291 | 292 | 293 | License 294 | ======= 295 | ``pycomm3`` is distributed under the MIT License 296 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/_static/cip_msg_plc_name_msg_cfg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ottowayi/pycomm3/4a458aade712a54cc2e9feeb4e129d2e71f581ab/docs/_static/cip_msg_plc_name_msg_cfg.png -------------------------------------------------------------------------------- /docs/_static/cip_msg_plc_name_resp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ottowayi/pycomm3/4a458aade712a54cc2e9feeb4e129d2e71f581ab/docs/_static/cip_msg_plc_name_resp.png -------------------------------------------------------------------------------- /docs/api_reference/cip_driver.rst: -------------------------------------------------------------------------------- 1 | CIPDriver API 2 | =============== 3 | 4 | .. autoclass:: pycomm3.CIPDriver 5 | :members: 6 | 7 | .. automethod:: __init__ -------------------------------------------------------------------------------- /docs/api_reference/data_types.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Data Types 3 | ========== 4 | 5 | .. automodule:: pycomm3.cip.data_types 6 | :members: 7 | 8 | ============ 9 | Custom Types 10 | ============ 11 | 12 | .. automodule:: pycomm3.custom_types 13 | :members: 14 | 15 | 16 | -------------------------------------------------------------------------------- /docs/api_reference/index.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | API Reference 3 | ============= 4 | 5 | .. toctree:: 6 | 7 | cip_driver 8 | logix_driver 9 | slc_driver 10 | data_types -------------------------------------------------------------------------------- /docs/api_reference/logix_driver.rst: -------------------------------------------------------------------------------- 1 | LogixDriver API 2 | =============== 3 | 4 | .. autoclass:: pycomm3.LogixDriver 5 | :members: 6 | 7 | .. automethod:: __init__ -------------------------------------------------------------------------------- /docs/api_reference/slc_driver.rst: -------------------------------------------------------------------------------- 1 | SLCDriver API 2 | =============== 3 | 4 | .. autoclass:: pycomm3.SLCDriver 5 | :members: -------------------------------------------------------------------------------- /docs/cip_reference.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | CIP Reference 3 | ============= 4 | 5 | Documented CIP service and class codes are available in enum-like classes that can be imported for use, mostly useful for 6 | generic messaging. The following classes may be imported directly from the ``pycomm3`` package. 7 | 8 | Ethernet/IP Encapsulation Commands 9 | ================================== 10 | 11 | .. literalinclude:: ../pycomm3/cip/services.py 12 | :pyobject: EncapsulationCommands 13 | 14 | 15 | CIP Services and Class Codes 16 | ============================= 17 | 18 | .. literalinclude:: ../pycomm3/cip/services.py 19 | :pyobject: Services 20 | 21 | .. literalinclude:: ../pycomm3/cip/object_library.py 22 | :pyobject: ClassCode 23 | 24 | .. literalinclude:: ../pycomm3/cip/object_library.py 25 | :pyobject: CommonClassAttributes 26 | 27 | 28 | Identity Object 29 | =============== 30 | 31 | .. literalinclude:: ../pycomm3/cip/object_library.py 32 | :pyobject: IdentityObjectInstanceAttributes 33 | 34 | 35 | Connection Manager Object 36 | ========================= 37 | 38 | .. literalinclude:: ../pycomm3/cip/services.py 39 | :pyobject: ConnectionManagerServices 40 | 41 | .. literalinclude:: ../pycomm3/cip/object_library.py 42 | :pyobject: ConnectionManagerInstances 43 | 44 | 45 | File Object 46 | =========== 47 | 48 | .. literalinclude:: ../pycomm3/cip/services.py 49 | :pyobject: FileObjectServices 50 | 51 | .. literalinclude:: ../pycomm3/cip/object_library.py 52 | :pyobject: FileObjectClassAttributes 53 | 54 | .. literalinclude:: ../pycomm3/cip/object_library.py 55 | :pyobject: FileObjectInstanceAttributes 56 | 57 | .. literalinclude:: ../pycomm3/cip/object_library.py 58 | :pyobject: FileObjectInstances -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | import pycomm3 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = 'pycomm3' 22 | copyright = '2021, Ian Ottoway' 23 | author = 'Ian Ottoway' 24 | 25 | 26 | # The full version, including alpha/beta/rc tags 27 | release = pycomm3.__version__ 28 | 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | 'sphinx.ext.autodoc', 37 | 'sphinx_autodoc_typehints', 38 | 'sphinx.ext.viewcode', 39 | 'sphinx.ext.autosectionlabel', 40 | 'sphinx.ext.todo', 41 | 'sphinxemoji.sphinxemoji', 42 | 'm2r2', 43 | ] 44 | 45 | autosectionlabel_prefix_document = True 46 | 47 | # Add any paths that contain templates here, relative to this directory. 48 | templates_path = ['_templates'] 49 | 50 | # List of patterns, relative to source directory, that match files and 51 | # directories to ignore when looking for source files. 52 | # This pattern also affects html_static_path and html_extra_path. 53 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 54 | 55 | 56 | # -- Options for HTML output ------------------------------------------------- 57 | 58 | # The theme to use for HTML and HTML Help pages. See the documentation for 59 | # a list of builtin themes. 60 | # 61 | html_theme = "furo" 62 | 63 | # Add any paths that contain custom static files (such as style sheets) here, 64 | # relative to this directory. They are copied after the builtin static files, 65 | # so a file named "default.css" will overwrite the builtin "default.css". 66 | html_static_path = ['_static'] 67 | html_theme_options = { 68 | 'globaltoc_maxdepth': -1, 69 | } 70 | 71 | # The name of the Pygments (syntax highlighting) style to use. 72 | pygments_style = 'sphinx' 73 | 74 | master_doc = 'index' 75 | 76 | autodoc_member_order = 'bysource' 77 | 78 | source_suffix = ['.rst', '.md'] 79 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | .. mdinclude:: ../CONTRIBUTING.md -------------------------------------------------------------------------------- /docs/examples/basic_rw_examples.rst: -------------------------------------------------------------------------------- 1 | ====================================== 2 | Basic Reading and Writing Tag Examples 3 | ====================================== 4 | 5 | Basic Reading 6 | ------------- 7 | 8 | Reading a single tag returns a Tag object. 9 | 10 | .. literalinclude:: ../../examples/basic_reads.py 11 | :pyobject: read_single 12 | 13 | >>> read_single() 14 | Tag(tag='DINT1', value=20, type='DINT', error=None) 15 | 16 | Reading multiple tags returns a list of Tag objects. 17 | 18 | .. literalinclude:: ../../examples/basic_reads.py 19 | :pyobject: read_multiple 20 | 21 | >>> read_multiple() 22 | [Tag(tag='DINT1', value=20, type='DINT', error=None), Tag(tag='SINT1', value=5, type='SINT', error=None), Tag(tag='REAL1', value=100.0009994506836, type='REAL', error=None)] 23 | 24 | An array is represented in a single Tag object, but the ``value`` attribute is a list. 25 | 26 | .. literalinclude:: ../../examples/basic_reads.py 27 | :pyobject: read_array 28 | 29 | .. literalinclude:: ../../examples/basic_reads.py 30 | :pyobject: read_array_slice 31 | 32 | >>> read_array() 33 | Tag(tag='DINT_ARY1', value=[0, 1000, 2000, 3000, 4000], type='DINT[5]', error=None) 34 | >>> read_array_slice() 35 | Tag(tag='DINT_ARY1[50]', value=[50000, 51000, 52000, 53000, 54000], type='DINT[5]', error=None) 36 | 37 | You can read strings just like a normal value, no need to handle the ``LEN`` and ``DATA`` attributes individually. 38 | 39 | .. literalinclude:: ../../examples/basic_reads.py 40 | :pyobject: read_strings 41 | 42 | >>> read_strings() 43 | [Tag(tag='STRING1', value='A Test String', type='STRING', error=None), Tag(tag='STRING_ARY1[2]', value=['THIRD', 'FoUrTh'], type='STRING[2]', error=None)] 44 | 45 | Structures can be read as a whole, assuming that no attributes have External Access set to None. Structure tags will be 46 | a single Tag object, but the ``value`` attribute will be a ``dict`` of ``{attribute: value}``. 47 | 48 | .. literalinclude:: ../../examples/basic_reads.py 49 | :pyobject: read_udt 50 | 51 | .. literalinclude:: ../../examples/basic_reads.py 52 | :pyobject: read_timer 53 | 54 | >>> read_udt() 55 | Tag(tag='SimpleUDT1_1', value={'bool': True, 'sint': 100, 'int': -32768, 'dint': -1, 'real': 0.0}, type='SimpleUDT1', error=None) 56 | >>> read_timer() 57 | Tag(tag='TIMER1', value={'CTL': [False, False, False, False, False, False, False, False, False, False, False, 58 | False, False, False, False, False, False, False, False, False, False, False, 59 | False, False, False, False, False, False, False, True, True, False], 60 | 'PRE': 30000, 'ACC': 30200, 'EN': False, 'TT': True, 'DN': True}, type='TIMER', error=None) 61 | 62 | .. note:: Most builtin data types appear to have a BOOL array (or DWORD) attribute called ``CTL`` that is not shown 63 | in the Logix tag browser. 64 | 65 | Basic Writing 66 | ------------- 67 | 68 | Writing a single tag returns a single Tag object response. 69 | 70 | .. literalinclude:: ../../examples/basic_writes.py 71 | :pyobject: write_single 72 | 73 | >>> write_single() 74 | Tag(tag='DINT2', value=100000000, type='DINT', error=None) 75 | 76 | Writing multiple tags will return a list of Tag objects. 77 | 78 | .. literalinclude:: ../../examples/basic_writes.py 79 | :pyobject: write_multiple 80 | 81 | >>> write_multiple() 82 | [Tag(tag='REAL2', value=25.2, type='REAL', error=None), Tag(tag='STRING3', value='A test for writing to a string.', type='STRING', error=None)] 83 | 84 | Writing a whole structure is possible too. As with reading, all attributes are required to NOT have an External Access of None. 85 | Also, when writing a structure your value must match the structure exactly and provide data for all attributes. The value 86 | should be a list of values or a dict of attribute name and value, nesting as needed for arrays or other structures with the target. 87 | This example shows a simple recipe UDT: 88 | 89 | +-------------------+---------------+ 90 | | Attribute | Data Type | 91 | +===================+===============+ 92 | | Enabled | BOOL | 93 | +-------------------+---------------+ 94 | | OpCodes | DINT[10] | 95 | +-------------------+---------------+ 96 | | Targets | REAL[10] | 97 | +-------------------+---------------+ 98 | | StepDescriptions | STRING[10] | 99 | +-------------------+---------------+ 100 | | TargetUnits | STRING8[10] | 101 | +-------------------+---------------+ 102 | | Name | STRING | 103 | +-------------------+---------------+ 104 | 105 | .. literalinclude:: ../../examples/basic_writes.py 106 | :pyobject: write_structure 107 | -------------------------------------------------------------------------------- /docs/examples/generic_messaging_examples.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Generic Messaging 3 | ================= 4 | 5 | .. py:currentmodule:: pycomm3 6 | 7 | The :meth:`LogixDriver.generic_message` works in a similar way to the MSG instruction in Logix. It allows the user 8 | to perform messaging services not directly implemented in the library. It is also used internally to implement some of the 9 | CIP services used by the library (Forward Open, get/set PLC time, etc). 10 | 11 | 12 | Accessing Drive Parameters 13 | ========================== 14 | 15 | While a drive may not be a PLC, we can use generic messaging to read parameters from it. The target drive is a PowerFlex 525 and using this 16 | `Rockwell KB Article`_ we can get the appropriate parameters to read/write parameters from the drive. 17 | 18 | 19 | .. literalinclude:: ../../examples/generic_messaging.py 20 | :pyobject: read_pf525_parameter 21 | 22 | >>> read_pf525_parameter() 23 | pf525_param, 500, None, None 24 | 25 | .. literalinclude:: ../../examples/generic_messaging.py 26 | :pyobject: write_pf525_parameter 27 | 28 | .. _Rockwell KB Article: https://rockwellautomation.custhelp.com/app/answers/answer_view/a_id/566003/loc/en_US#__highlight 29 | 30 | 31 | Reading Device Statuses 32 | ======================= 33 | 34 | ENBT/EN2T OK LED Status 35 | ----------------------- 36 | 37 | This message will get the current status of the OK LED from and ENBT or EN2T module. 38 | 39 | .. literalinclude:: ../../examples/generic_messaging.py 40 | :pyobject: enbt_ok_led_status 41 | 42 | Link Status 43 | ----------- 44 | 45 | This message will read the current link status for any ethernet module. 46 | 47 | .. literalinclude:: ../../examples/generic_messaging.py 48 | :pyobject: link_status 49 | 50 | 51 | Stratix Switch Power Status 52 | --------------------------- 53 | 54 | This message will read the current power status for both power inputs on a Stratix switch. 55 | 56 | .. literalinclude:: ../../examples/generic_messaging.py 57 | :pyobject: stratix_power_status 58 | 59 | 60 | IP Configuration 61 | ================ 62 | 63 | Static/DHCP/BOOTP Status 64 | ------------------------ 65 | 66 | This message will read the IP setting configuration type from an ethernet module. 67 | 68 | .. literalinclude:: ../../examples/generic_messaging.py 69 | :pyobject: ip_config 70 | 71 | Communication Module MAC Address 72 | -------------------------------- 73 | 74 | This message will read the MAC address of ethernet module where the current connection is opened. 75 | 76 | .. literalinclude:: ../../examples/generic_messaging.py 77 | :pyobject: get_mac_address 78 | 79 | 80 | Upload EDS File 81 | =============== 82 | 83 | This example shows how to use generic messaging to upload and save an EDS file from a device. 84 | 85 | .. literalinclude:: ../../examples/upload_eds.py -------------------------------------------------------------------------------- /docs/examples/index.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Examples 3 | ======== 4 | 5 | .. toctree:: 6 | :maxdepth: 3 7 | 8 | basic_rw_examples 9 | tag_examples 10 | generic_messaging_examples 11 | -------------------------------------------------------------------------------- /docs/examples/tag_examples.rst: -------------------------------------------------------------------------------- 1 | ===================================== 2 | Examples of Working with the Tag List 3 | ===================================== 4 | 5 | Data Types 6 | ---------- 7 | 8 | For UDT/AOI or built-in structure data-types, information and definitions are stored in the ``data_types`` property. 9 | This property allow you to query the PLC to determine what types of tags it may contain. For details on the contents of 10 | a data type definition view :ref:`usage/logixdriver:Structure Definitions`. 11 | 12 | Print out the public attributes for all structure types in the PLC: 13 | 14 | .. literalinclude:: ../../examples/tags.py 15 | :pyobject: find_attributes 16 | 17 | >>> find_attributes() 18 | STRING attributes: ['LEN', 'DATA'] 19 | TIMER attributes: ['CTL', 'PRE', 'ACC', 'EN', 'TT', 'DN'] 20 | CONTROL attributes: ['CTL', 'LEN', 'POS', 'EN', 'EU', 'DN', 'EM', 'ER', 'UL', 'IN', 'FD'] 21 | DateTime attributes: ['Yr', 'Mo', 'Da', 'Hr', 'Min', 'Sec', 'uSec'] 22 | ... 23 | 24 | 25 | Tag List 26 | -------- 27 | 28 | Part of the requirement for reading/writing tags is knowing the tag definitions stored in the PLC so that user does not 29 | need to provide any information about the tag besides it's name. By default, the tag list is uploaded on creation of the 30 | LogixDriver, for details reference the :ref:`api_reference/logix_driver:LogixDriver API`. 31 | 32 | Example showing how the tag list is stored: 33 | 34 | .. literalinclude:: ../../examples/tags.py 35 | :pyobject: tag_list_equal 36 | 37 | >>> tag_list_equal() 38 | They are the same! 39 | Calling get_tag_list() does the same thing. 40 | 41 | Filtering 42 | ^^^^^^^^^ 43 | 44 | There are multiple properties of tags that can be used to locate and filter down the tag list. For available properties, 45 | reference :ref:`usage/logixdriver:Tag Structure`. Examples below show some methods for filtering the tag list. 46 | 47 | Finding all PID tags: 48 | 49 | .. literalinclude:: ../../examples/tags.py 50 | :pyobject: find_pids 51 | 52 | >>> find_pids() 53 | ['FIC100_PID', 'TIC100_PID'] -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | :mod:`pycomm3` - A Python Ethernet/IP library for communicating with Allen-Bradley PLCs. 2 | ======================================================================================== 3 | 4 | 5 | .. include:: ../README.rst 6 | :start-after: <> 7 | :end-before: <> 8 | 9 | 10 | Contents 11 | -------- 12 | 13 | .. toctree:: 14 | getting_started 15 | usage/index 16 | examples/index 17 | api_reference/index 18 | cip_reference 19 | contributing 20 | releases 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/releases.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Release History 3 | =============== 4 | 5 | 1.2.14 6 | ====== 7 | 8 | - |:sparkles:| add support for hostnames in connection path 9 | 10 | 1.2.13 11 | ====== 12 | 13 | CIPDriver 14 | --------- 15 | - |:sparkles:| add ability to specify broadcast address for `discover()` #292 @tlf30 16 | 17 | 1.2.11 18 | ====== 19 | 20 | - |:sparkles:| update vendor id list #257 @PhilippHaefele 21 | 22 | 1.2.10 23 | ====== 24 | 25 | CIPDriver 26 | --------- 27 | - |:sparkles:| support port customization in the connection path 28 | - |:sparkles:| support comma delimiters in the connection path 29 | 30 | 1.2.9 31 | ===== 32 | 33 | SLCDriver 34 | --------- 35 | - |:sparkles:| added `get_datalog_queue` method @ddeckerCPF 36 | 37 | 1.2.8 38 | ===== 39 | 40 | LogixDriver 41 | ----------- 42 | 43 | - |:bug:| fixed issue reading single elements >32 from BOOL arrays 44 | 45 | 1.2.7 46 | ===== 47 | 48 | LogixDriver 49 | ----------- 50 | 51 | - |:bug:| fixed issue with program-scoped tags in `get_tag_info` #216 52 | 53 | 1.2.6 54 | ===== 55 | 56 | LogixDriver 57 | ----------- 58 | 59 | - |:bug:| fixed issue handling BOOLs in some predefined types #197 60 | 61 | 1.2.5 62 | ===== 63 | 64 | LogixDriver 65 | ----------- 66 | 67 | - |:bug:| fixed issue parsing struct definitions for predefined types for v32+ #186 68 | 69 | 1.2.4 70 | ===== 71 | 72 | LogixDriver 73 | ----------- 74 | 75 | - |:bug:| fixed issue for BOOL members inside structures that was introduced as part of 1.2.3 #182 76 | 77 | 1.2.3 78 | ===== 79 | 80 | LogixDriver 81 | ----------- 82 | 83 | - |:bug:| fixed issue with bit-level access to integers inside nested structs #170 84 | 85 | 1.2.2 86 | ===== 87 | 88 | CIPDriver 89 | --------- 90 | 91 | - |:sparkles:| added support for string CIP paths in `generic_message` for `route_path` 92 | - |:bug:| fixed bug where errors during discovery prevent any results from being returned 93 | - |:bug:| fixed issue where ``get_module_info`` would always use first hop in path instead of the last 94 | 95 | LogixDriver 96 | ----------- 97 | 98 | - |:bug:| fixed issue with multi-request message size tracking being off by 2 bytes 99 | - |:bug:| fixed issue with AOI structure handling with > 8 BOOL members being mapped to types larger than a USINT (SISAutomationIMA) 100 | 101 | 1.2.1 102 | ===== 103 | 104 | - |:sparkles:| added ability to configure custom logger via the `configure_default_logger` function 105 | 106 | 107 | 1.2.0 108 | ===== 109 | 110 | - |:bug:| fixed issue with logging configuration 111 | - |:art:| formatted project with black 112 | - |:memo:| misc. documentation updates 113 | 114 | LogixDriver 115 | ----------- 116 | 117 | - |:bug:| fixed issue with writing a tag multiple times failing after the first write 118 | - |:sparkles:| added `tags_json` property 119 | 120 | SLCDriver 121 | --------- 122 | 123 | - |:bug:| fixed issue with parsing IO addresses 124 | - |:zap:| improved address parsing speed by pre-compiling regex 125 | 126 | 127 | 128 | 1.1.1 129 | ===== 130 | 131 | LogixDriver 132 | ----------- 133 | 134 | - |:bug:| fixed read/write errors by preventing program-scoped tags from using instance ids in the request 135 | 136 | 137 | 1.1.0 138 | ===== 139 | 140 | LogixDriver 141 | ----------- 142 | 143 | - |:bug:| fixed bugs in handling of built-in types (TIMER, CONTROL, etc) 144 | - |:bug:| fixed bugs in structure tag handling when padding exists between attributes 145 | - |:sparkles:| changed the meaning of the element count for BOOL arrays 146 | - Previously, the ``{#}`` referred to the underlying ``DWORD`` elements of the ``BOOL`` array. 147 | A ``BOOL[64]`` array is actually a `DWORD[2]` array, so ``array{1}`` translated to BOOL elements 148 | 0-31 or the first ``DWORD`` element. Now, the ``{#}`` refers to the number of ``BOOL`` elements. So 149 | ``array{1}`` is only a single ``BOOL`` element and ``array{32}`` would be the 0-31 ``BOOL`` elements. 150 | - Refer to the documentation_ for limitations on writing. 151 | 152 | .. _documentation: https://docs.pycomm3.dev/en/latest/usage/logixdriver.html#bool-arrays 153 | 154 | 1.0.1 155 | ===== 156 | 157 | - |:bug:| Fixed incorrect/no error in response Tag for some failed requests in a multi-request 158 | - |:recycle:| Minor refactor to status and extended status parsing 159 | 160 | 161 | 162 | 1.0.0 163 | ===== 164 | 165 | - |:sparkles:| New type system to replace the ``Pack`` and ``Unpack`` helper classes 166 | - New types represent any CIP type or object and allow encoding and decoding of values 167 | - Allows users to create their own custom types 168 | - |:boom:| **[Breaking]** ``generic_message`` replaced the ``data_format`` argument with ``data_type``, see documentation for details. 169 | - |:sparkles:| Added a new ``discover()`` method for finding Ethernet/IP devices on the local network 170 | - |:sparkles:| Added a ``configure_default_logger`` method for simple logging setup 171 | - Packet contents are now logged using a custom ``VERBOSE`` level 172 | - |:art:| Internal package structure changed. 173 | - |:recycle:| Lots of refactoring, decoupling, etc 174 | - |:white_check_mark:| Increased test coverage 175 | - |:memo:| New and improved documentation 176 | - |:construction:| Still a work-in-progress 177 | 178 | 179 | Logix Driver 180 | ------------ 181 | 182 | - |:triangular_flag_on_post:| Upload of program-scoped tags is now enabled by default 183 | - Use ``init_program_tags=False`` in initializer for to upload controller-scoped only tags 184 | - |:boom:| Removed the ``init_info`` and ``micro800`` init args and the ``use_instance_ids`` property 185 | - These have all been automatic for awhile now, but were left for backwards compatibility 186 | - If you need to customize this behavior, override the ``_initialize_driver`` method 187 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-autodoc-typehints==1.15.3 3 | furo 4 | m2r2 5 | sphinxemoji 6 | -------------------------------------------------------------------------------- /docs/usage/cipdriver.rst: -------------------------------------------------------------------------------- 1 | .. py:currentmodule:: pycomm3 2 | 3 | =============== 4 | Using CIPDriver 5 | =============== 6 | 7 | The :class:`CIPDriver` is the base class for the other drivers, so everything 8 | on this page also applies to the other drivers as well. 9 | 10 | 11 | Discovery and Identification 12 | ---------------------------- 13 | 14 | The :class:`CIPDriver` provides to class methods for discovering and identifying 15 | devices. And because they are class methods, they can be used without creating an instance 16 | of a driver first. The :meth:`CIPDriver.discover` method will broadcast a request for 17 | all devices on the network to identify themselves. This is similar to how the RSLinx 18 | Ethernet/IP driver works. It returns a list of dictionaries, where each ``dict`` is the 19 | Identity Object of the device. 20 | 21 | >>> from pycomm3 import CIPDriver 22 | >>> CIPDriver.discover() 23 | 24 | For example, here is a response with 2 devices discovered: 25 | 26 | .. code-block:: 27 | 28 | [{'encap_protocol_version': 1, 'ip_address': '10.10.0.120', 'vendor': 'Rockwell Automation/Allen-Bradley', 29 | 'product_type': 'Communications Adapter', 'product_code': 185, 'revision': {'major': 2, 'minor': 7}, 30 | 'status': b'T\x00', 'serial': 'aabbcdd', 'product_name': '1763-L16BWA B/7.00', 'state': 0}, 31 | {'encap_protocol_version': 1, 'ip_address': '10.10.1.100', 'vendor': 'Rockwell Automation/Allen-Bradley', 32 | 'product_type': 'Communications Adapter', 'product_code': 191, 'revision': {'major': 20, 'minor': 19}, 33 | 'status': b'0\x00', 'serial': 'eeffgghh', 'product_name': '1769-L23E-QBFC1 Ethernet Port', 'state': 3}] 34 | 35 | The :meth:`CIPDriver.list_identity` method is similar, but can be used to identify a specific device. 36 | Instead of broadcasting the request to every device, it requires a ``path`` to send the request to. 37 | This ``path`` argument is the same type of CIP path used in creating a driver and detailed in 38 | :ref:`getting_started:Creating a Driver`. 39 | 40 | >>> from pycomm3 import CIPDriver 41 | >>> CIPDriver.list_identity('10.10.0.120') 42 | {'encap_protocol_version': 1, 'ip_address': '10.10.0.120', 'vendor': 'Rockwell Automation/Allen-Bradley', 43 | 'product_type': 'Communications Adapter', 'product_code': 185, 'revision': {'major': 2, 'minor': 7}, 44 | 'status': b'T\x00', 'serial': 'aabbcdd', 'product_name': '1763-L16BWA B/7.00', 'state': 0} 45 | >>> CIPDriver.list_identity('10.10.1.100') 46 | {'encap_protocol_version': 1, 'ip_address': '10.10.1.100', 'vendor': 'Rockwell Automation/Allen-Bradley', 47 | 'product_type': 'Communications Adapter', 'product_code': 191, 'revision': {'major': 20, 'minor': 19}, 48 | 'status': b'0\x00', 'serial': 'eeffgghh', 'product_name': '1769-L23E-QBFC1 Ethernet Port', 'state': 3} 49 | 50 | 51 | Module Identification 52 | ^^^^^^^^^^^^^^^^^^^^^ 53 | 54 | For rack-based devices, the :meth:`CIPDriver.get_module_info` method will return the identity for a ``slot`` 55 | in the rack. This method is *not* a class method, so it does require an instance of the driver to be created. 56 | 57 | >>> from pycomm3 import CIPDriver 58 | >>> driver = CIPDriver('10.10.1.100') 59 | >>> driver.open() 60 | >>> driver.get_module_info(0) # Slot 0: PLC 61 | {'vendor': 'Rockwell Automation/Allen-Bradley', 'product_type': 'Programmable Logic Controller', 'product_code': 51, 62 | 'revision': {'major': 16, 'minor': 22}, 'status': b'`\x10', 'serial': '00000000', 63 | 'product_name': '1756-L55/A 1756-M13/A LOGIX5555'} 64 | >>> driver.get_module_info(1) # Slot 1: EN2T 65 | {'vendor': 'Rockwell Automation/Allen-Bradley', 'product_type': 'Communications Adapter', 'product_code': 166, 66 | 'revision': {'major': 5, 'minor': 8}, 'status': b'0\x00', 'serial': '00000000', 'product_name': '1756-EN2T/B'} 67 | >>> driver.close() 68 | 69 | 70 | Generic Messaging 71 | ----------------- 72 | 73 | Generic messaging is a key feature of ``pycomm3``, it allows the user to send custom CIP messages or 74 | implement features not included in one of the drivers. In fact, many features available in the drivers 75 | are implemented using the :meth:`~CIPDriver.generic_message` method. This method operates in a similar 76 | way to *CIP Generic* messages in Logix with the ``MSG`` instruction. For more examples see the 77 | :ref:`examples/generic_messaging_examples:Generic Messaging` section. 78 | 79 | To demonstrate how a generic message can be used, below is the process that was used to implement the 80 | :meth:`~LogixDriver.get_plc_name` feature for the :class:`LogixDriver`. 81 | 82 | First, the `Obtaining the Controller's Program Name`_ article from the Rockwell Knowledge Base shows 83 | how to configure a ``MESSAGE`` to read the program name from a PLC. It contains all the information 84 | we need: CIP service, class, instance, etc. 85 | 86 | .. image:: ../_static/cip_msg_plc_name_msg_cfg.png 87 | 88 | 1. The service type is ``0x01``, which is the ``Get_Attributes_All`` service define in the 89 | *Common Industrial Protocol Specification, Volume 1, Chapter 4: CIP Object Model*. 90 | See :ref:`cip_reference:CIP Services and Class Codes` for the predefined CIP services, 91 | classes, and other objects available in ``pycomm3``. If the service is not already defined, 92 | you use either an ``int`` or a ``bytes`` string (``0x01``, ``1``, ``b'\x01``). 93 | 94 | 2. The class code, ``0x64`` is not named in the doc, but is defined as ``ClassCode.program_name``. 95 | 96 | 3. The instance number of the class we want, ``1``. 97 | 98 | 4. The attribute is ``0``, so we can ignore it and not set the ``attribute`` parameter. 99 | 100 | 5. Since we're not in the PLC, we're not storing the response in a tag. If we set the ``data_type`` 101 | parameter to a :class:`DataType`, that type will be used to decode the response. Else, the 102 | raw response ``bytes`` will be returned. 103 | 104 | Next, the screenshot below contains enough information for us to determine the data type that can be 105 | used to decode the response. 106 | 107 | .. image:: ../_static/cip_msg_plc_name_resp.png 108 | 109 | While the doc doesn't specifically say the response type, it's shows that it is stored in a ``SINT[50]``. 110 | The first two bytes contains the length of the string, which corresponds to a integer(``INT`` or ``UINT``). 111 | Then the string data is stored in the remainder of the array, since PLCs are limited to fixed-size arrays 112 | the destination tag needs to be long enough to contain the maximum size possible. In Python we do not 113 | have that limitation, but this information tells us that the response is a *string, with 1 byte per character, 114 | and the length of the string is stored in the first 2 bytes*. That corresponds to the CIP ``STRING`` 115 | data type, which is a standard type that is already defined and we can just use. 116 | 117 | Taking this information, we were able configure the :meth:`~CIPDriver.generic_message` method to read 118 | the PLC program name: 119 | 120 | .. literalinclude:: ../../pycomm3/logix_driver.py 121 | :pyobject: LogixDriver.get_plc_name 122 | 123 | .. tip:: 124 | 125 | Setting the ``name`` parameter is helpful because it will be used by the built in logging 126 | and can help differentiate between calls:: 127 | 128 | 2021-03-09 18:09:50,802 [INFO] pycomm3.cip_driver.CIPDriver.generic_message(): Sending generic message: get_plc_name 129 | 2021-03-09 18:09:50,802 [VERBOSE] pycomm3.cip_driver.CIPDriver._send(): >>> SEND >>> 130 | (0000) 70 00 1c 00 00 0b 02 0b 00 00 00 00 5f 70 79 63 p•••••••••••_pyc 131 | (0010) 6f 6d 6d 5f 00 00 00 00 00 00 00 00 0a 00 02 00 omm_•••••••••••• 132 | (0020) a1 00 04 00 c1 04 35 01 b1 00 08 00 53 00 01 02 ••••••5•••••S••• 133 | (0030) 20 64 24 01 d$• 134 | 2021-03-09 18:09:50,803 [DEBUG] pycomm3.cip_driver.CIPDriver.send(): Sent: GenericConnectedRequestPacket(message=[b'S\x00', b'\x01', b'\x02 d$\x01', b'']) 135 | 2021-03-09 18:09:50,807 [VERBOSE] pycomm3.cip_driver.CIPDriver._receive(): <<< RECEIVE <<< 136 | (0000) 70 00 36 00 00 0b 02 0b 00 00 00 00 00 00 00 00 p•6••••••••••••• 137 | (0010) 00 00 00 00 00 00 00 00 00 00 00 00 00 00 02 00 •••••••••••••••• 138 | (0020) a1 00 04 00 4a b7 cb 55 b1 00 22 00 53 00 81 00 ••••J••U••"•S••• 139 | (0030) 00 00 0c 00 70 79 63 6f 6d 6d 33 5f 64 65 6d 6f ••••pycomm3_demo 140 | (0040) 00 00 00 00 02 00 01 00 64 00 02 00 09 00 ••••••••d••••• 141 | 2021-03-09 18:09:50,807 [DEBUG] pycomm3.cip_driver.CIPDriver.send(): Received: GenericConnectedResponsePacket(service=b'\x01', command=b'p\x00', error=None) 142 | 2021-03-09 18:09:50,807 [INFO] pycomm3.cip_driver.CIPDriver.generic_message(): Generic message 'get_plc_name' completed 143 | 144 | 145 | .. _Obtaining the Controller's Program Name: https://rockwellautomation.custhelp.com/app/answers/answer_view/a_id/23341 -------------------------------------------------------------------------------- /docs/usage/index.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Driver Usage 3 | ============== 4 | .. toctree:: 5 | :maxdepth: 3 6 | 7 | cipdriver 8 | logixdriver 9 | slcdriver -------------------------------------------------------------------------------- /docs/usage/logixdriver.rst: -------------------------------------------------------------------------------- 1 | .. py:currentmodule:: pycomm3 2 | 3 | ================= 4 | Using LogixDriver 5 | ================= 6 | 7 | 8 | Tags and Data Types 9 | =================== 10 | 11 | When creating the driver it will automatically upload all of the controller scope tag and their data type definitions. 12 | These definitions are required for the :meth:`~LogixDriver.read` and :meth:`~LogixDriver.write` methods to function. 13 | Those methods abstract away a lot of the details required for actually implementing the Ethernet/IP protocol. Uploading 14 | the tags could take a few seconds depending on the size of program and the network. It was decided this small 15 | upfront overhead provided a greater benefit to the user since they would not have to worry about specific implementation 16 | details for different types of tags. The ``init_tags`` kwarg is ``True`` by default, meaning that all of the controller 17 | scoped tags will be uploaded. ``init_program_tags`` is a separate flag to control whether or not all the program-scoped 18 | tags are uploaded as well. By default, ``init_program_tags`` is ``True``, set to ``False`` to disable and only upload 19 | controller-scoped tags. 20 | 21 | Below shows how the init tag options are equivalent to calling the :meth:`~LogixDriver.get_tag_list` method. 22 | 23 | >>> plc1 = LogixDriver('10.20.30.100') 24 | >>> plc2 = LogixDriver('10.20.30.100', init_tags=False) 25 | >>> plc2.get_tag_list() 26 | >>> plc1.tags == plc2.tags 27 | True 28 | >>> plc3 = LogixDriver('10.20.30.100', init_program_tags=True) 29 | >>> plc4 = LogixDriver('10.20.30.100') 30 | >>> plc4.get_tag_list(program='*') # '*' means all programs 31 | >>> plc3.tags == plc4.tags 32 | True 33 | 34 | 35 | Tag Structure 36 | ------------- 37 | 38 | Each tag definition is a dict containing all the details retrieved from the PLC. :meth:`~LogixDriver.get_tag_list` 39 | returns a list of dicts for the tag list while the :attr:`LogixDriver.tags` property stores them as a dict of ``{tag name: definition}``. 40 | 41 | **Tag Definition Properties:** 42 | 43 | tag_name 44 | Symbolic name of the tag 45 | 46 | instance_id 47 | Internal PLC identifier for the tag. Used for reads/writes on v21+ controllers. Saves space in packet by not requiring 48 | the full tag name to be encoded into the request. 49 | 50 | tag_type 51 | - ``'atomic'`` base data types like BOOL, DINT, REAL, etc. 52 | - ``'struct'`` complex data types like STRING, TIMER, PID, etc as well as UDTs and AOIs. 53 | 54 | .. _data_type: 55 | 56 | data_type 57 | - ``'DINT'``/``'REAL'``/etc name of data type for atomic types 58 | - ``{data type definition}`` for structures, detailed in `Structure Definitions`_ 59 | 60 | data_type_name 61 | - the string name of the data type: ``'DINT'``/``'REAL'``/``'TIMER'``/``'MyCoolUDT'`` 62 | 63 | string 64 | **Optional** string size if the tag is a STRING type (or custom string) 65 | 66 | external_access 67 | ``'Read/Write'``/``'Read Only'``/``'None'`` matches the External Access tag property in the PLC 68 | 69 | dim 70 | number dimensions defined for the tag 71 | 72 | - ``0`` - not an array 73 | - ``1-3`` - a 1 to 3 dimension array tag, e.g. ``DINT[5] -> 1, DINT[5,5] -> 2, DINT[5,5,5] -> 3`` 74 | 75 | dimensions 76 | length of each dimension defined, ``0`` if dimension does not exist. ``[dim0, dim1, dim2]`` 77 | 78 | - ``DINT[5] -> [5, 0, 0]`` 79 | - ``DINT[5, 10] -> [5, 10, 0]`` 80 | - ``DINT[5, 10, 15] -> [5, 10, 15]`` 81 | 82 | alias 83 | ``True``/``False`` if the tag is an alias to another. 84 | 85 | .. note:: This is not documented, but an educated guess found through trial and error. 86 | 87 | type_class 88 | the :class:`~pycomm3.cip.data_types.DataType` that was created for this tag 89 | 90 | 91 | Structure Definitions 92 | --------------------- 93 | 94 | While uploading the tag list, any tags with complex data types will have the full definition of structure uploaded as well. 95 | Inside a tag definition, the `data_type`_ attribute will be a dict containing the structure definition. The :attr:`LogixDriver.data_types` 96 | property also provides access to these definitions as a dict of ``{data type name: definition}``. 97 | 98 | **Data Type Properties:** 99 | 100 | name 101 | Name of the data type, UDT, AOI, or builtin structure data types 102 | 103 | attributes 104 | List of names for each attribute in the structure. Does not include internal tags not shown in Logix, like the host 105 | DINT tag that BOOL attributes are mapped to. 106 | 107 | template 108 | ``dict`` with template definition. Used internally within LogixDriver, allows reading/writing of full structs and 109 | allows the read/write methods to monitor the request/response size. 110 | 111 | internal_tags 112 | A ``dict`` with each attribute (including internal, not shown in Logix attributes) of the structure containing the 113 | definition for the attribute, ``{attribute: {definition}}``. 114 | 115 | **Definition:** 116 | 117 | tag_type 118 | Same as `Tag Structure`_ 119 | 120 | data_type 121 | Same as `Tag Structure`_ 122 | 123 | data_type_name 124 | Same as `Tag Structure`_ 125 | 126 | string 127 | Same as `Tag Structure`_ 128 | 129 | offset 130 | Location/Byte offset of this tag's data in the response data. 131 | 132 | bit 133 | **Optional** BOOL tags are aliased to internal hidden integer tags, this indicates which bit it is aliased to. 134 | 135 | array 136 | **Optional** Length of the array if this tag is an array, ``0`` if not an array, 137 | 138 | .. note:: ``attributes`` and ``internal_tags`` do **NOT** include InOut parameters. 139 | 140 | type_class 141 | The :class:`~pycomm3.cip.data_types.DataType` type that was created to represent this structure 142 | 143 | 144 | Reading/Writing Tags 145 | ==================== 146 | 147 | All reading and writing is handled by the :meth:`~LogixDriver.read` and :meth:`~LogixDriver.write` methods. The original 148 | pycomm and other similar libraries will have different methods for handling different types like strings and arrays, this 149 | is not necessary in ``pycomm3`` due to uploading the tag list and creation of a :class:`~pycomm3.cip.data_types.DataType` 150 | class for each type. Both methods accept any number of tags, they will automatically use the *Multiple Service Packet (0x0A)* 151 | service and track the request/return data size making sure to stay below the connection size. If there is a tag value 152 | that cannot fit within the request/reply packet, it will automatically handle that tag independently using the 153 | *Read Tag Fragmented (0x52)* or *Write Tag Fragmented (0x53)* requests. Users do not have to worry about the number of 154 | tags or their size in any single request, this is all handled automatically by the driver. 155 | 156 | Program-Scoped Tags 157 | ------------------- 158 | 159 | Program-scoped tag names use the format `Program:.`. For example, to access a tag named `SomeTag` in 160 | the program `MainProgram` you would use `Program:MainProgram.SomeTag` in the request. The tag list uploaded by the 161 | driver will also keep this format for the tag names. 162 | 163 | 164 | Array Tags 165 | ---------- 166 | 167 | To access an index of an array, include the index inside square brackets after the tag name. The format is the same as 168 | in Logix, where multiple dimensions are comma separated, e.g. ``an_array[5]`` for the 5th element of ``an_array`` or 169 | ``array2[1,0]`` to access the first element of the second dimension of ``array2``. Not specifying an index is equivalent 170 | to index 0, i.e ``array == array[0]``. 171 | 172 | Whether reading or writing, the number of elements needs to be specified. To do so, specify the number 173 | of elements inside curly braces at the end of the tag name, e.g. ``an_array{5}`` for 5-elements of ``an_array``. 174 | If omitted, the number of elements is assumed to be 1, i.e. ``an_array == an_array[0] == an_array[0]{1}``. Only a single 175 | element count is used. For 2 and 3 dimensional arrays, the element count is the total number of elements across all 176 | dimensions. The tables below show a couple examples of how the element count works for multi-dimension arrays. 177 | 178 | 179 | ====================== ======== ============= 180 | array (``DINT[3, 2]``) array{4} array[1,1]{3} 181 | ====================== ======== ============= 182 | array[0, 0] X 183 | array[0, 1] X 184 | array[1, 0] X 185 | array[1, 1] X X 186 | array[2, 0] X 187 | array[2, 1] X 188 | ====================== ======== ============= 189 | 190 | ========================= ======== =============== 191 | array (``SINT[2, 2, 2]``) array{4} array[0,1,0]{5} 192 | ========================= ======== =============== 193 | array[0, 0, 0] X 194 | array[0, 0, 1] X 195 | array[0, 1, 0] X X 196 | array[0, 1, 1] X X 197 | array[1, 0, 0] X 198 | array[1, 0, 1] X 199 | array[1, 1, 0] X 200 | array[1, 1, 1] 201 | ========================= ======== =============== 202 | 203 | BOOL Arrays 204 | ^^^^^^^^^^^ 205 | 206 | BOOL arrays work a little differently due them being implemented as DWORD arrays in the PLC. (That is the reason you can 207 | only make BOOL arrays in multiples of 32, DWORDs are 32 bits.) The element count in the request (``'{#}'``) 208 | represents the number of BOOL elements. To write multiple elements to a BOOL array, you must write the entire 209 | underlying DWORD element. This means the list of values must be in multiples of 32 and the starting index must also 210 | be multiples of 32, e.g. ``'bools{32}'``, ``'bools[32]{64}'``. There is no limitation on reading multiple elements 211 | or reading and writing a single element. 212 | 213 | Reading Tags 214 | ------------ 215 | 216 | :meth:`LogixDriver.read` accepts any number of tags, all that is required is the tag names.Reading of entire structures 217 | is support as long as none of the attributes have an external access of *None*. 218 | To read a structure, just request the base name and the ``value`` for the ``Tag`` object will a a dict of ``{attribute: value}`` 219 | 220 | Read an atomic tag 221 | 222 | >>> plc.read('dint_tag') 223 | Tag(tag='dint_tag', value=0, type='DINT', error=None) 224 | 225 | Read multiple tags 226 | 227 | >>> plc.read('tag_1', 'tag_2', 'tag_3') 228 | [Tag(tag='tag_1', value=100, type='INT', error=None), Tag(tag='tag_2', value=True, type='BOOL', error=None), ...] 229 | 230 | Read a structure 231 | 232 | >>> plc.read('simple_udt') 233 | Tag(tag='simple_udt', value={'attr1': 0, 'attr2': False, 'attr3': 1.234}, type='SimpleUDT', error=None) 234 | 235 | Read arrays 236 | 237 | >>> plc.read('dint_array{5}') # starts at index 0 238 | Tag(tag='dint_array', value=[1, 2, 3, 4, 5], type='DINT[5]', error=None) 239 | >>> plc.read('dint_array[20]{3}') # read 3 elements starting at index 20 240 | Tag(tag='dint_array[20]', value=[20, 21, 22], type='DINT[3]', error=None) 241 | 242 | Verify all reads were successful 243 | 244 | >>> tag_list = ['tag1', 'tag2', ...] 245 | >>> results = plc.read(*tag_list) 246 | >>> if all(results): 247 | ... print('All tags read successfully') 248 | All tags read successfully 249 | 250 | 251 | Writing Tags 252 | ------------ 253 | 254 | :meth:`LogixDriver.write` method accepts any number of tag-value pairs of the tag name and value to be written. 255 | For writing a single tag, you can do ``write(, )``, but for multiple tags a sequence of tag-value tuples 256 | is required (``write((, ), (, ))``). For arrays, the value should be a list of the values to write. 257 | A ``RequestError`` will be raised if the value list is too short, else it will be truncated if too long. Writing a 258 | structure is supported as long as all attributes have Read/Write external access. The value for a struct should be a 259 | ``dict`` of ``{: }``, nesting as needed. It is not recommended to write full structures for builtin types, 260 | like ``TIMER``, ``PID``, etc. 261 | 262 | Write a tag 263 | 264 | >>> plc.write('dint_tag', 100) 265 | Tag(tag='dint_tag', value=100, type='DINT', error=None) 266 | 267 | Write many tags 268 | 269 | >>> plc.write(('tag_1', 1), ('tag_2', True), ('tag_3', 1.234)) 270 | [Tag(tag='tag_1', value=1, type='INT', error=None), Tag(tag='tag_2', value=True, type='BOOL', error=None), ...] 271 | 272 | Write arrays 273 | 274 | >>> plc.write('dint_array{10}', list(range(10))) # starts at index 0 275 | Tag(tag='dint_array', value=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], type='DINT[10]', error=None) 276 | >>> plc.write(('dint_array[10]{3}', [10, 11, 12])) # write 3 elements starting at index 10 277 | Tag(tag='dint_array[10]', value=[10, 11, 12], type='DINT[3]', error=None) 278 | 279 | Write structures 280 | 281 | >>> plc.write('my_udt', {'attr1': 100, 'attr2': [1, 2, 3, 4]}) 282 | Tag(tag='my_udt', value={'attr1': 100, 'attr2': [1, 2, 3, 4]}, type='MyUDT', error=None) 283 | 284 | Check if all writes were successful 285 | 286 | >>> tag_values = [('tag1', 10), ('tag2', True), ('tag3', 12.34)] 287 | >>> results = plc.write(*tag_values) 288 | >>> if all(results): 289 | ... print('All tags written successfully') 290 | All tags written successfully 291 | 292 | 293 | String Tags 294 | ----------- 295 | 296 | Strings are technically structures within the PLC, but are treated as atomic types in this library. There is no need 297 | to handle the ``LEN`` and ``DATA`` attributes, the structure is converted to/from Python ``str`` objects transparently. 298 | Any structures that contain only a DINT-``LEN`` and a SINT[]-``DATA`` attributes will be automatically treated as string tags. 299 | This allows the builtin STRING types plus custom strings to be handled automatically. Strings that are longer than the 300 | plc tag will be truncated when writing. 301 | 302 | >>> plc.read('string_tag') 303 | Tag(tag='string_tag', value='Hello World!', type='STRING', error=None) 304 | >>> plc.write(('short_string_tag', 'Test Write')) 305 | Tag(tag='short_string_tag', value='Test Write', type='STRING20', error=None) 306 | -------------------------------------------------------------------------------- /docs/usage/slcdriver.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Using SLCDriver 3 | =============== 4 | 5 | .. admonition:: **TODO** 6 | 7 | This document. -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | from .basic_reads import * 2 | from .basic_writes import * 3 | from .tags import * 4 | -------------------------------------------------------------------------------- /examples/basic_reads.py: -------------------------------------------------------------------------------- 1 | from pycomm3 import LogixDriver 2 | 3 | 4 | def read_single(): 5 | with LogixDriver('10.61.50.4/10') as plc: 6 | return plc.read('DINT1') 7 | 8 | 9 | def read_multiple(): 10 | tags = ['DINT1', 'SINT1', 'REAL1'] 11 | with LogixDriver('10.61.50.4/10') as plc: 12 | return plc.read(*tags) 13 | 14 | 15 | def read_array(): 16 | with LogixDriver('10.61.50.4/10') as plc: 17 | return plc.read('DINT_ARY1{5}') 18 | 19 | 20 | def read_array_slice(): 21 | with LogixDriver('10.61.50.4/10') as plc: 22 | return plc.read('DINT_ARY1[50]{5}') 23 | 24 | 25 | def read_strings(): 26 | with LogixDriver('10.61.50.4/10') as plc: 27 | return plc.read('STRING1', 'STRING_ARY1[2]{2}') 28 | 29 | 30 | def read_udt(): 31 | with LogixDriver('10.61.50.4/10') as plc: 32 | return plc.read('SimpleUDT1_1') 33 | 34 | 35 | def read_timer(): 36 | with LogixDriver('10.61.50.4/10') as plc: 37 | return plc.read('TIMER1') 38 | 39 | -------------------------------------------------------------------------------- /examples/basic_writes.py: -------------------------------------------------------------------------------- 1 | from pycomm3 import LogixDriver 2 | 3 | 4 | def write_single(): 5 | with LogixDriver('10.61.50.4/10') as plc: 6 | return plc.write(('DINT2', 100_000_000)) 7 | 8 | 9 | def write_multiple(): 10 | with LogixDriver('10.61.50.4/10') as plc: 11 | return plc.write(('REAL2', 25.2), ('STRING3', 'A test for writing to a string.')) 12 | 13 | 14 | def write_structure(): 15 | with LogixDriver('10.61.50.4/10') as plc: 16 | recipe_data = { 17 | 'Enabled': True, 18 | 'OpCodes': [10, 11, 4, 20, 6, 20, 6, 30, 5, 0], 19 | 'Targets': [100, 500, 85, 5, 15, 10.5, 20, 0, 0, 0], 20 | 'StepDescriptions': ['Set Water Temperature', 21 | 'Heated Water', 22 | 'Start Agitator', 23 | 'Hand Add - Flavor Part 1', 24 | 'Timed Mix', 25 | 'Hand Add - Flavor Part 2', 26 | 'Timed Mix', 27 | 'Transfer to Storage Tank', 28 | 'Disable Agitator', 29 | ''], 30 | 'TargetUnits': ['°F', 'lbs', '%', 'gal', 'min', 'lbs', 'min', '', '', ''], 31 | 'Name': 'Our Fictional Recipe', 32 | } 33 | 34 | plc.write(('Example_Recipe', recipe_data)) 35 | -------------------------------------------------------------------------------- /examples/generic_messaging.py: -------------------------------------------------------------------------------- 1 | from pycomm3 import CIPDriver, Services, ClassCode, INT, Array, USINT 2 | 3 | 4 | # Read PF525 Parameter 5 | def read_pf525_parameter(): 6 | drive_path = '10.10.10.100/bp/1/enet/192.168.1.55' 7 | 8 | with CIPDriver(drive_path) as drive: 9 | param = drive.generic_message( 10 | service=Services.get_attribute_single, 11 | class_code=b'\x93', 12 | instance=41, # Parameter 41 = Accel Time 13 | attribute=b'\x09', 14 | data_type=INT, 15 | connected=False, 16 | unconnected_send=True, 17 | route_path=True, 18 | name='pf525_param' 19 | ) 20 | print(param) 21 | 22 | 23 | # Write PF525 Parameter 24 | def write_pf525_parameter(): 25 | drive_path = '10.10.10.100/bp/1/enet/192.168.1.55' 26 | 27 | with CIPDriver(drive_path) as drive: 28 | drive.generic_message( 29 | service=Services.set_attribute_single, 30 | class_code=b'\x93', 31 | instance=41, # Parameter 41 = Accel Time 32 | attribute=b'\x09', 33 | request_data=INT.encode(500), # = 5 seconds * 100 34 | connected=False, 35 | unconnected_send=True, 36 | route_path=True, 37 | name='pf525_param' 38 | ) 39 | 40 | 41 | # Read OK LED Status From ENBT/EN2T 42 | def enbt_ok_led_status(): 43 | message_path = '10.10.10.100/bp/2' 44 | 45 | with CIPDriver(message_path) as device: 46 | data = device.generic_message( 47 | service=Services.get_attribute_single, 48 | class_code=b'\x01', # Values from RA Knowledgebase 49 | instance=1, # Values from RA Knowledgebase 50 | attribute=5, # Values from RA Knowledgebase 51 | data_type=INT, 52 | connected=False, 53 | unconnected_send=True, 54 | route_path=True, 55 | name='OK LED Status' 56 | ) 57 | # The LED Status is returned as a binary representation on bits 4, 5, 6, and 7. The decimal equivalents are: 58 | # 0 = Solid Red, 64 = Flashing Red, and 96 = Solid Green. The ENBT/EN2T do not display link lost through the OK LED. 59 | statuses = { 60 | 0: 'solid red', 61 | 64: 'flashing red', 62 | 96: 'solid green' 63 | } 64 | print(statuses.get(data.value), 'unknown') 65 | 66 | 67 | # Read Link Status of any Logix Ethernet Module 68 | def link_status(): 69 | message_path = '10.10.10.100/bp/2' 70 | 71 | with CIPDriver(message_path) as device: 72 | data = device.generic_message( 73 | service=Services.get_attribute_single, 74 | class_code=b'\xf6', # Values from RA Knowledgebase 75 | instance=1, # For multiport devices, change to "2" for second port, "3" for third port. 76 | # For CompactLogix, front port is "1" and back port is "2". 77 | attribute=2, # Values from RA Knowledgebase 78 | data_type=INT, 79 | connected=False, 80 | unconnected_send=True, 81 | route_path=True, 82 | name='LinkStatus' 83 | ) 84 | # Prints the binary representation of the link status. The definition of the bits are: 85 | # Bit 0 - Link Status - 0 means inactive link (Link Lost), 1 means active link. 86 | # Bit 1 - Half/Full Duplex - 0 means half duplex, 1 means full duplex 87 | # Bit 2 to 4 - Binary representation of auto-negotiation and speed detection status: 88 | # 0 = Auto-negotiation in progress 89 | # 1 = Auto-negotiation and speed detection failed 90 | # 2 = Auto-negotiation failed, speed detected 91 | # 3 = Auto-negotiation successful and speed detected 92 | # 4 = Manually forced speed and duplex 93 | # Bit 5 - Setting Requires Reset - if 1, a manual setting requires resetting of the module 94 | # Bit 6 - Local Hardware Fault - 0 indicates no hardware faults, 1 indicates a fault detected. 95 | print(bin(data.value)) 96 | 97 | 98 | # Get the status of both power inputs from a Stratix switch. 99 | def stratix_power_status(): 100 | message_path = '10.10.10.100/bp/2/enet/192.168.1.1' 101 | 102 | with CIPDriver(message_path) as device: 103 | data = device.generic_message( 104 | service=b'\x0e', 105 | class_code=863, # use decimal representation of hex class code 106 | instance=1, 107 | attribute=8, 108 | connected=False, 109 | unconnected_send=True, 110 | route_path=True, 111 | data_type=INT, 112 | name='Power Status' 113 | ) 114 | # Returns a binary representation of the power status. Bit 0 is PWR A, Bit 1 is PWR B. If 1, power is applied. If 0, power is off. 115 | pwr_a = 'on' if data.value & 0b_1 else 'off' 116 | pwr_b = 'on' if data.value & 0b_10 else 'off' 117 | print(f'PWR A: {pwr_a}, PWR B: {pwr_b}') 118 | 119 | 120 | # Get the IP Configuration from an Ethernet Module 121 | def ip_config(): 122 | message_path = '10.10.10.100/bp/2' 123 | 124 | with CIPDriver(message_path) as plc: # L85 125 | data = plc.generic_message( 126 | service=b'\x0e', 127 | class_code=b'\xf5', 128 | instance=1, 129 | attribute=3, 130 | connected=False, 131 | unconnected_send=True, 132 | route_path=True, 133 | data_type=INT, 134 | name='IP_config' 135 | ) 136 | 137 | statuses = { 138 | 0b_0000: 'static', 139 | 0b_0001: 'BOOTP', 140 | 0b_0010: 'DHCP' 141 | } 142 | 143 | ip_status = data.value & 0b_1111 # only need the first 4 bits 144 | print(statuses.get(ip_status, 'unknown')) 145 | 146 | 147 | # Get MAC address of 148 | def get_mac_address(): 149 | with CIPDriver('10.10.10.100') as plc: 150 | response = plc.generic_message( 151 | service=Services.get_attribute_single, 152 | class_code=ClassCode.ethernet_link, 153 | instance=1, 154 | attribute=3, 155 | data_type=USINT[6], 156 | connected=False 157 | ) 158 | 159 | if response: 160 | return ':'.join(f'{x:0>2x}' for x in response.value) 161 | else: 162 | print(f'error getting MAC address - {response.error}') 163 | -------------------------------------------------------------------------------- /examples/tags.py: -------------------------------------------------------------------------------- 1 | from pycomm3 import LogixDriver 2 | 3 | 4 | def find_attributes(): 5 | with LogixDriver('10.61.50.4/10') as plc: 6 | ... # do nothing, we're just letting the plc initialize the tag list 7 | 8 | for typ in plc.data_types: 9 | print(f'{typ} attributes: ', plc.data_types[typ]['attributes']) 10 | 11 | 12 | def tag_list_equal(): 13 | with LogixDriver('10.61.50.4/10') as plc: 14 | tag_list = plc.get_tag_list() 15 | if {tag['tag_name']: tag for tag in tag_list} == plc.tags: 16 | print('They are the same!') 17 | 18 | with LogixDriver('10.61.50.4/10', init_tags=False) as plc2: 19 | plc2.get_tag_list() 20 | 21 | if plc.tags == plc2.tags: 22 | print('Calling get_tag_list() does the same thing.') 23 | else: 24 | print('Calling get_tag_list() does NOT do the same.') 25 | 26 | 27 | def find_pids(): 28 | with LogixDriver('10.61.50.4/10') as plc: 29 | 30 | # PIDs are structures, the data_type attribute will be a dict with data type definition. 31 | # For tag types of 'atomic' the data type will a string, we need to skip those first. 32 | # Then we can just look for tags whose data type name matches 'PID' 33 | pid_tags = [ 34 | tag 35 | for tag, _def in plc.tags.items() 36 | if _def['data_type_name'] == 'PID' 37 | ] 38 | 39 | print(pid_tags) 40 | -------------------------------------------------------------------------------- /examples/upload_eds.py: -------------------------------------------------------------------------------- 1 | from pycomm3 import (CIPDriver, Services, ClassCode, FileObjectServices, FileObjectInstances, 2 | FileObjectInstanceAttributes, Struct, UDINT, USINT, n_bytes) 3 | import itertools 4 | import gzip 5 | from pathlib import Path 6 | 7 | SAVE_PATH = Path.home() 8 | 9 | 10 | def upload_eds(): 11 | """ 12 | Uploads the EDS and ICO files from the device and saves the files. 13 | """ 14 | with CIPDriver('192.168.1.236') as driver: 15 | if initiate_transfer(driver): 16 | file_data = upload_file(driver) 17 | encoding = get_file_encoding(driver) 18 | 19 | if encoding == 'zlib': 20 | # in this case the file has both the eds and ico files in it 21 | files = decompress_eds(file_data) 22 | 23 | for filename, file_data in files.items(): 24 | file_path = SAVE_PATH / filename 25 | file_path.write_bytes(file_data) 26 | 27 | elif encoding == 'binary': 28 | file_name = get_file_name(driver) 29 | file_path = SAVE_PATH / file_name 30 | file_path.write_bytes(file_data) 31 | else: 32 | print('Unsupported Encoding') 33 | else: 34 | print('Failed to initiate transfer') 35 | 36 | 37 | def initiate_transfer(driver): 38 | """ 39 | Initiates the transfer with the device 40 | """ 41 | resp = driver.generic_message( 42 | service=FileObjectServices.initiate_upload, 43 | class_code=ClassCode.file_object, 44 | instance=FileObjectInstances.eds_file_and_icon, 45 | route_path=True, 46 | unconnected_send=True, 47 | connected=False, 48 | request_data=b'\xFF', # max transfer size 49 | data_type=Struct(UDINT('FileSize'), USINT('TransferSize')) 50 | ) 51 | return resp 52 | 53 | 54 | def upload_file(driver): 55 | contents = b'' 56 | 57 | for i in itertools.cycle(range(256)): 58 | resp = driver.generic_message( 59 | service=FileObjectServices.upload_transfer, 60 | class_code=ClassCode.file_object, 61 | instance=FileObjectInstances.eds_file_and_icon, 62 | route_path=True, 63 | unconnected_send=True, 64 | connected=False, 65 | request_data=USINT.encode(i), 66 | data_type=Struct(USINT('TransferNumber'), USINT('PacketType'), n_bytes(-1, 'FileData')) 67 | 68 | ) 69 | 70 | if resp: 71 | packet_type = resp.value['PacketType'] 72 | data = resp.value['FileData'] 73 | 74 | contents += data 75 | 76 | # CIP Vol 1 Section 5-42.4.5 77 | # 0 - first packet 78 | # 1 - middle packet 79 | # 2 - last packet 80 | # 3 - Abort transfer 81 | # 4 - first & last packet 82 | # 5-255 - Reserved 83 | if packet_type not in (0, 1): 84 | break 85 | else: 86 | print(f'failed response {resp}') 87 | break 88 | 89 | contents = contents[:-2] # strip off checksum 90 | return contents 91 | 92 | 93 | def get_file_encoding(driver): 94 | """ 95 | get the encoding format for the eds file object 96 | """ 97 | attr = FileObjectInstanceAttributes.file_encoding_format 98 | 99 | resp = driver.generic_message( 100 | service=Services.get_attribute_single, 101 | class_code=ClassCode.file_object, 102 | attribute=attr.attr_id, 103 | instance=FileObjectInstances.eds_file_and_icon, 104 | route_path=True, 105 | unconnected_send=True, 106 | connected=False, 107 | data_type=attr.data_type, 108 | ) 109 | _enc_code = resp.value if resp else None 110 | EDS_ENCODINGS = { 111 | 0: 'binary', 112 | 1: 'zlib' 113 | } 114 | file_encoding = EDS_ENCODINGS.get(_enc_code, 'UNSUPPORTED ENCODING') 115 | return file_encoding 116 | 117 | 118 | def decompress_eds(contents): 119 | """ 120 | extract the eds and ico files from the uploaded file 121 | 122 | returns a dict of {file name: file contents} 123 | """ 124 | GZ_MAGIC_BYTES = b'\x1f\x8b' 125 | 126 | # there is actually 2 files, the eds file and the icon 127 | # we need to split the file contents since gzip 128 | # only supports single files 129 | 130 | end_file1 = contents.find(GZ_MAGIC_BYTES, 2) 131 | file1, file2 = contents[:end_file1], contents[end_file1:] 132 | eds = gzip.decompress(file1) 133 | ico = gzip.decompress(file2) 134 | eds_name = file1[10:file1.find(b'\x00', 10)].decode() 135 | ico_name = file2[10:file2.find(b'\x00', 10)].decode() 136 | 137 | return {eds_name: eds, ico_name: ico} 138 | 139 | 140 | def get_file_name(driver): 141 | """ 142 | Get the filename of the eds file object 143 | """ 144 | attr = FileObjectInstanceAttributes.file_name 145 | resp = driver.generic_message( 146 | service=Services.get_attribute_single, 147 | class_code=ClassCode.file_object, 148 | attribute=attr.attr_id, 149 | instance=FileObjectInstances.eds_file_and_icon, 150 | route_path=True, 151 | unconnected_send=True, 152 | connected=False, 153 | data_type=attr.data_type 154 | ) 155 | 156 | file_name = resp.value['FileName'][0] if resp else None 157 | return file_name 158 | 159 | 160 | if __name__ == '__main__': 161 | upload_eds() 162 | -------------------------------------------------------------------------------- /pycomm3/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | from ._version import __version__, __version_info__ 26 | from .logger import * 27 | from .const import * 28 | from .tag import Tag 29 | from .exceptions import * 30 | from .cip import * 31 | from .custom_types import * 32 | from .cip_driver import * 33 | from .logix_driver import * 34 | from .slc_driver import * 35 | -------------------------------------------------------------------------------- /pycomm3/_version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | __version_info__ = (1, 2, 14) 26 | __version__ = ".".join(f"{x}" for x in __version_info__) 27 | -------------------------------------------------------------------------------- /pycomm3/cip/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | from .data_types import * 26 | from .object_library import * 27 | from .services import * 28 | from .status_info import * 29 | from .pccc import * 30 | -------------------------------------------------------------------------------- /pycomm3/cip/object_library.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | from typing import NamedTuple, Union, Type 26 | 27 | from .data_types import ( 28 | DataType, 29 | Array, 30 | Struct, 31 | UINT, 32 | USINT, 33 | WORD, 34 | UDINT, 35 | SHORT_STRING, 36 | STRINGI, 37 | INT, 38 | BYTE, 39 | ) 40 | from ..map import EnumMap 41 | 42 | __all__ = [ 43 | "Attribute", 44 | "ConnectionManagerInstances", 45 | "ClassCode", 46 | "CommonClassAttributes", 47 | "IdentityObjectInstanceAttributes", 48 | "FileObjectClassAttributes", 49 | "FileObjectInstanceAttributes", 50 | "FileObjectInstances", 51 | ] 52 | 53 | 54 | class Attribute(NamedTuple): 55 | attr_id: Union[bytes, int] 56 | data_type: Union[DataType, Type[DataType]] 57 | 58 | 59 | class ConnectionManagerInstances(EnumMap): 60 | open_request = b"\x01" 61 | open_format_rejected = b"\x02" 62 | open_resource_rejected = b"\x03" 63 | open_other_rejected = b"\x04" 64 | close_request = b"\x05" 65 | close_format_request = b"\x06" 66 | close_other_request = b"\x07" 67 | connection_timeout = b"\x08" 68 | 69 | 70 | class ClassCode(EnumMap): 71 | identity_object = b"\x01" 72 | message_router = b"\x02" 73 | device_net = b"\x03" 74 | assembly = b"\x04" 75 | connection = b"\x05" 76 | connection_manager = b"\x06" 77 | register = b"\x07" 78 | discrete_input = b"\x08" 79 | discrete_output = b"\x09" 80 | analog_input = b"\x0A" 81 | analog_output = b"\x0B" 82 | presence_sensing = b"\x0E" 83 | parameter = b"\x0F" 84 | 85 | parameter_group = b"\x10" 86 | group = b"\x12" 87 | discrete_input_group = b"\x1D" 88 | discrete_output_group = b"\x1E" 89 | discrete_group = b"\x1F" 90 | 91 | analog_input_group = b"\x20" 92 | analog_output_group = b"\x21" 93 | analog_group = b"\x22" 94 | position_sensor = b"\x23" 95 | position_controller_supervisor = b"\x24" 96 | position_controller = b"\x25" 97 | block_sequencer = b"\x26" 98 | command_block = b"\x27" 99 | motor_data = b"\x28" 100 | control_supervisor = b"\x29" 101 | ac_dc_drive = b"\x2A" 102 | acknowledge_handler = b"\x2B" 103 | overload = b"\x2C" 104 | softstart = b"\x2D" 105 | selection = b"\x2E" 106 | 107 | s_device_supervisor = b"\x30" 108 | s_analog_sensor = b"\x31" 109 | s_analog_actuator = b"\x32" 110 | s_single_stage_controller = b"\x33" 111 | s_gas_calibration = b"\x34" 112 | trip_point = b"\x35" 113 | file_object = b"\x37" 114 | s_partial_pressure = b"\x38" 115 | safety_supervisor = b"\x39" 116 | safety_validator = b"\x3A" 117 | safety_discrete_output_point = b"\x3B" 118 | safety_discrete_output_group = b"\x3C" 119 | safety_discrete_input_point = b"\x3D" 120 | safety_discrete_input_group = b"\x3E" 121 | safety_dual_channel_output = b"\x3F" 122 | 123 | s_sensor_calibration = b"\x40" 124 | event_log = b"\x41" 125 | motion_axis = b"\x42" 126 | time_sync = b"\x43" 127 | modbus = b"\x44" 128 | modbus_serial_link = b"\x46" 129 | 130 | symbol_object = b"\x6b" 131 | template_object = b"\x6c" 132 | program_name = b"\x64" # Rockwell KB# 23341 133 | 134 | wall_clock_time = b"\x8b" # Micro800 CIP client messaging quick start 135 | 136 | controlnet = b"\xF0" 137 | controlnet_keeper = b"\xF1" 138 | controlnet_scheduling = b"\xF2" 139 | connection_configuration = b"\xF3" 140 | port = b"\xF4" 141 | tcp_ip_interface = b"\xF5" 142 | ethernet_link = b"\xF6" 143 | componet_link = b"\xF7" 144 | componet_repeater = b"\xF8" 145 | 146 | 147 | class CommonClassAttributes(EnumMap): 148 | revision = Attribute(1, UINT("revision")) 149 | max_instance = Attribute(2, UINT("max_instance")) 150 | number_of_instances = Attribute(3, UINT("number_of_instances")) 151 | optional_attribute_list = Attribute(4, UINT[UINT]) 152 | optional_service_list = Attribute(5, UINT[UINT]) 153 | max_id_number_class_attributes = Attribute(6, UINT("max_id_class_attrs")) 154 | max_id_number_instance_attributes = Attribute(7, UINT("max_id_instance_attrs")) 155 | 156 | 157 | class IdentityObjectInstanceAttributes(EnumMap): 158 | vendor_id = Attribute(1, UINT("vendor_id")) 159 | device_type = Attribute(2, UINT("device_type")) 160 | product_code = Attribute(3, UINT("product_code")) 161 | revision = Attribute(4, Struct(USINT("major"), USINT("minor"))) 162 | status = Attribute(5, WORD("status")) 163 | serial_number = Attribute(6, UDINT("serial_number")) 164 | product_name = Attribute(7, SHORT_STRING("product_name")) 165 | 166 | 167 | class FileObjectClassAttributes(EnumMap): 168 | directory = Attribute( 169 | 32, 170 | Struct(UINT("instance_number"), STRINGI("instance_name"), STRINGI("file_name")), 171 | ) # array of struct, len in attr 3 172 | 173 | 174 | class FileObjectInstanceAttributes(EnumMap): 175 | state = Attribute(1, USINT("state")) 176 | instance_name = Attribute(2, STRINGI("instance_name")) 177 | instance_format_version = Attribute(3, UINT("instance_format_version")) 178 | file_name = Attribute(4, STRINGI("file_name")) 179 | file_revision = Attribute(5, Struct(USINT("major"), USINT("minor"))) 180 | file_size = Attribute(6, UDINT("file_size")) 181 | file_checksum = Attribute(7, INT("file_checksum")) 182 | invocation_method = Attribute(8, USINT("invocation_method")) 183 | file_save_params = Attribute(9, BYTE("file_save_params")) 184 | file_type = Attribute(10, USINT("file_type")) 185 | file_encoding_format = Attribute(11, USINT("file_encoding_format")) 186 | 187 | 188 | class FileObjectInstances(EnumMap): 189 | eds_file_and_icon = 0xC8 190 | related_eds_files_and_icons = 0xC9 191 | -------------------------------------------------------------------------------- /pycomm3/cip/pccc.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | from itertools import chain 26 | from io import BytesIO 27 | 28 | from .data_types import INT, DINT, REAL, StringDataType, UINT 29 | 30 | from ..map import EnumMap 31 | 32 | 33 | class PCCCStringType(StringDataType): 34 | @classmethod 35 | def _slc_string_swap(cls, data): 36 | pairs = [ 37 | (x2, x1) for x1, x2 in (data[i : i + 2] for i in range(0, len(data), 2)) 38 | ] 39 | return bytes(chain.from_iterable(pairs)) 40 | 41 | 42 | class PCCC_ASCII(PCCCStringType): 43 | @classmethod 44 | def _encode(cls, value: str, *args, **kwargs) -> bytes: 45 | char1, char2 = value[:2] 46 | return (char2 or " ").encode(cls.encoding) + (char1 or " ").encode(cls.encoding) 47 | 48 | @classmethod 49 | def _decode(cls, stream: BytesIO) -> str: 50 | return cls._slc_string_swap(stream.read(2)).decode(cls.encoding) 51 | 52 | 53 | class PCCC_STRING(PCCCStringType): 54 | @classmethod 55 | def _encode(cls, value: str) -> bytes: 56 | _len = UINT.encode(len(value)) 57 | _data = cls._slc_string_swap(value.encode(cls.encoding)) 58 | return _len + _data 59 | 60 | @classmethod 61 | def _decode(cls, stream: BytesIO) -> str: 62 | _len = UINT.decode(stream) 63 | return cls._slc_string_swap(stream.read(82)).decode(cls.encoding) 64 | 65 | 66 | class PCCCDataTypes(EnumMap): 67 | _return_caps_only_ = True 68 | n = INT 69 | b = INT 70 | t = INT 71 | c = INT 72 | s = INT 73 | o = INT 74 | i = INT 75 | f = REAL 76 | a = PCCC_ASCII 77 | r = DINT 78 | st = PCCC_STRING 79 | l = DINT 80 | 81 | 82 | PCCC_CT = { 83 | "PRE": 1, 84 | "ACC": 2, 85 | "EN": 15, 86 | "TT": 14, 87 | "DN": 13, 88 | "CU": 15, 89 | "CD": 14, 90 | "OV": 12, 91 | "UN": 11, 92 | "UA": 10, 93 | } 94 | 95 | _PCCC_DATA_TYPE = { 96 | "N": b"\x89", 97 | "B": b"\x85", 98 | "T": b"\x86", 99 | "C": b"\x87", 100 | "S": b"\x84", 101 | "F": b"\x8a", 102 | "ST": b"\x8d", 103 | "A": b"\x8e", 104 | "R": b"\x88", 105 | "O": b"\x82", # or b'\x8b'? 106 | "I": b"\x83", # or b'\x8c'? 107 | "L": b"\x91", 108 | "MG": b"\x92", 109 | "PD": b"\x93", 110 | "PLS": b"\x94", 111 | } 112 | 113 | 114 | PCCC_DATA_TYPE = { 115 | **_PCCC_DATA_TYPE, 116 | **{v: k for k, v in _PCCC_DATA_TYPE.items()}, 117 | } 118 | 119 | 120 | PCCC_DATA_SIZE = { 121 | "N": 2, 122 | "L": 4, 123 | "B": 2, 124 | "T": 6, 125 | "C": 6, 126 | "S": 2, 127 | "F": 4, 128 | "ST": 84, 129 | "A": 2, 130 | "R": 6, 131 | "O": 2, 132 | "I": 2, 133 | "MG": 50, 134 | "PD": 46, 135 | "PLS": 12, 136 | } 137 | -------------------------------------------------------------------------------- /pycomm3/cip/services.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | from ..map import EnumMap 26 | from .data_types import USINT 27 | 28 | __all__ = [ 29 | "EncapsulationCommands", 30 | "ConnectionManagerServices", 31 | "Services", 32 | "MULTI_PACKET_SERVICES", 33 | "FileObjectServices", 34 | ] 35 | 36 | 37 | class EncapsulationCommands(EnumMap): 38 | nop = b"\x00\x00" 39 | list_targets = b"\x01\x00" 40 | list_services = b"\x04\x00" 41 | list_identity = b"\x63\x00" 42 | list_interfaces = b"\x64\x00" 43 | register_session = b"\x65\x00" 44 | unregister_session = b"\x66\x00" 45 | send_rr_data = b"\x6F\x00" 46 | send_unit_data = b"\x70\x00" 47 | 48 | 49 | class ConnectionManagerServices(EnumMap): 50 | forward_close = b"\x4E" 51 | unconnected_send = b"\x52" 52 | forward_open = b"\x54" 53 | get_connection_data = b"\x56" 54 | search_connection_data = b"\x57" 55 | get_connection_owner = b"\x5A" 56 | large_forward_open = b"\x5B" 57 | 58 | 59 | class Services(EnumMap): 60 | 61 | # Common CIP Services 62 | get_attributes_all = b"\x01" 63 | set_attributes_all = b"\x02" 64 | get_attribute_list = b"\x03" 65 | set_attribute_list = b"\x04" 66 | reset = b"\x05" 67 | start = b"\x06" 68 | stop = b"\x07" 69 | create = b"\x08" 70 | delete = b"\x09" 71 | multiple_service_request = b"\x0A" 72 | apply_attributes = b"\x0D" 73 | get_attribute_single = b"\x0E" 74 | set_attribute_single = b"\x10" 75 | find_next_object_instance = b"\x11" 76 | error_response = b"\x14" 77 | restore = b"\x15" 78 | save = b"\x16" 79 | nop = b"\x17" 80 | get_member = b"\x18" 81 | set_member = b"\x19" 82 | insert_member = b"\x1A" 83 | remove_member = b"\x1B" 84 | group_sync = b"\x1C" 85 | 86 | # Rockwell Custom Services 87 | read_tag = b"\x4C" 88 | read_tag_fragmented = b"\x52" 89 | write_tag = b"\x4D" 90 | write_tag_fragmented = b"\x53" 91 | read_modify_write = b"\x4E" 92 | get_instance_attribute_list = b"\x55" 93 | 94 | @classmethod 95 | def from_reply(cls, reply_service): 96 | """ 97 | Get service from reply service code 98 | """ 99 | val = cls.get(USINT.encode(USINT.decode(reply_service) - 128)) 100 | return val 101 | 102 | 103 | MULTI_PACKET_SERVICES = { 104 | Services.read_tag_fragmented, 105 | Services.write_tag_fragmented, 106 | Services.get_instance_attribute_list, 107 | Services.multiple_service_request, 108 | Services.get_attribute_list, 109 | } 110 | 111 | 112 | class FileObjectServices(EnumMap): 113 | initiate_upload = b"\x4B" 114 | initiate_download = b"\x4C" 115 | initiate_partial_read = b"\x4D" 116 | initiate_partial_write = b"\x4E" 117 | upload_transfer = b"\x4F" 118 | download_transfer = b"\x50" 119 | clear_file = b"\x51" 120 | -------------------------------------------------------------------------------- /pycomm3/const.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | from .cip import LogicalSegment, ClassCode 26 | 27 | 28 | HEADER_SIZE = 24 29 | 30 | 31 | MSG_ROUTER_PATH = [ 32 | LogicalSegment(ClassCode.message_router, "class_id"), 33 | LogicalSegment(0x01, "instance_id"), 34 | ] 35 | 36 | # used to estimate packet size and determine when to start a new packet 37 | MULTISERVICE_READ_OVERHEAD = 10 38 | 39 | MIN_VER_INSTANCE_IDS = 21 # using Symbol Instance Addressing not supported below version 21 40 | MIN_VER_LARGE_CONNECTIONS = 20 # >500 byte connections not supported below logix v20 41 | MIN_VER_EXTERNAL_ACCESS = 18 # ExternalAccess attributed added in v18 42 | 43 | MICRO800_PREFIX = "2080" # catalog number prefix for Micro800 PLCs 44 | 45 | EXTENDED_SYMBOL = b"\x91" 46 | 47 | SUCCESS = 0 48 | INSUFFICIENT_PACKETS = 6 49 | OFFSET_MESSAGE_REQUEST = 40 50 | PAD = b"\x00" 51 | PRIORITY = b"\x0a" 52 | TIMEOUT_TICKS = b"\x05" 53 | TIMEOUT_MULTIPLIER = b"\x07" 54 | TRANSPORT_CLASS = b"\xa3" 55 | BASE_TAG_BIT = 1 << 26 56 | 57 | SEC_TO_US = 1_000_000 # seconds to microseconds 58 | 59 | TEMPLATE_MEMBER_INFO_LEN = 8 # 2B bit/array len, 2B datatype, 4B offset 60 | STRUCTURE_READ_REPLY = b"\xa0\x02" 61 | 62 | SLC_CMD_CODE = b"\x0F" 63 | SLC_CMD_REPLY_CODE = b"\x4F" 64 | SLC_FNC_READ = b"\xa2" # protected typed logical read w/ 3 address fields 65 | SLC_FNC_WRITE = b"\xab" # protected typed logical masked write w/ 3 address fields 66 | SLC_REPLY_START = 61 67 | PCCC_PATH = b"\x67\x24\x01" 68 | -------------------------------------------------------------------------------- /pycomm3/custom_types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | import ipaddress 26 | from io import BytesIO 27 | from typing import Any, Type, Dict, Tuple, Union, Set 28 | 29 | from .cip import ( 30 | DataType, 31 | DerivedDataType, 32 | Struct, 33 | UINT, 34 | USINT, 35 | DWORD, 36 | UDINT, 37 | SHORT_STRING, 38 | n_bytes, 39 | StructType, 40 | StringDataType, 41 | PRODUCT_TYPES, 42 | VENDORS, 43 | INT, 44 | ULINT, 45 | ) 46 | from .cip.data_types import _StructReprMeta 47 | 48 | 49 | __all__ = [ 50 | "IPAddress", 51 | "ModuleIdentityObject", 52 | "ListIdentityObject", 53 | "StructTemplateAttributes", 54 | "FixedSizeString", 55 | "Revision", 56 | "StructTag", 57 | ] 58 | 59 | 60 | def FixedSizeString(size_: int, len_type_: Union[DataType, Type[DataType]] = UDINT): 61 | """ 62 | Creates a custom string tag type 63 | """ 64 | 65 | class FixedSizeString(StringDataType): 66 | size = size_ 67 | len_type = len_type_ 68 | 69 | @classmethod 70 | def _encode(cls, value: str, *args, **kwargs) -> bytes: 71 | return ( 72 | cls.len_type.encode(len(value)) 73 | + value.encode(cls.encoding) 74 | + b"\x00" * (cls.size - len(value)) 75 | ) 76 | 77 | @classmethod 78 | def _decode(cls, stream: BytesIO) -> str: 79 | _len = cls.len_type.decode(stream) 80 | _data = cls._stream_read(stream, cls.size)[:_len] 81 | return _data.decode(cls.encoding) 82 | 83 | return FixedSizeString 84 | 85 | 86 | class IPAddress(DerivedDataType): 87 | @classmethod 88 | def _encode(cls, value: str) -> bytes: 89 | return ipaddress.IPv4Address(value).packed 90 | 91 | @classmethod 92 | def _decode(cls, stream: BytesIO) -> Any: 93 | return ipaddress.IPv4Address(cls._stream_read(stream, 4)).exploded 94 | 95 | 96 | class Revision(Struct(USINT("major"), USINT("minor"))): 97 | ... 98 | 99 | 100 | class ModuleIdentityObject( 101 | Struct( 102 | UINT("vendor"), 103 | UINT("product_type"), 104 | UINT("product_code"), 105 | Revision("revision"), 106 | n_bytes(2, "status"), 107 | UDINT("serial"), 108 | SHORT_STRING("product_name"), 109 | ) 110 | ): 111 | @classmethod 112 | def _decode(cls, stream: BytesIO): 113 | values = super(ModuleIdentityObject, cls)._decode(stream) 114 | values["product_type"] = PRODUCT_TYPES.get(values["product_type"], "UNKNOWN") 115 | values["vendor"] = VENDORS.get(values["vendor"], "UNKNOWN") 116 | values["serial"] = f"{values['serial']:08x}" 117 | 118 | return values 119 | 120 | @classmethod 121 | def _encode(cls, values: Dict[str, Any]): 122 | values = values.copy() 123 | values["product_type"] = PRODUCT_TYPES[values["product_type"]] 124 | values["vendor"] = VENDORS[values["vendor"]] 125 | values["serial"] = int.from_bytes(bytes.fromhex(values["serial"]), "big") 126 | return super(ModuleIdentityObject, cls)._encode(values) 127 | 128 | 129 | class ListIdentityObject( 130 | Struct( 131 | UINT, 132 | UINT, 133 | UINT("encap_protocol_version"), 134 | INT, 135 | UINT, 136 | IPAddress("ip_address"), 137 | ULINT, 138 | UINT("vendor"), 139 | UINT("product_type"), 140 | UINT("product_code"), 141 | Revision("revision"), 142 | n_bytes(2, "status"), 143 | UDINT("serial"), 144 | SHORT_STRING("product_name"), 145 | USINT("state"), 146 | ) 147 | ): 148 | @classmethod 149 | def _decode(cls, stream: BytesIO): 150 | values = super(ListIdentityObject, cls)._decode(stream) 151 | values["product_type"] = PRODUCT_TYPES.get(values["product_type"], "UNKNOWN") 152 | values["vendor"] = VENDORS.get(values["vendor"], "UNKNOWN") 153 | values["serial"] = f"{values['serial']:08x}" 154 | 155 | return values 156 | 157 | 158 | StructTemplateAttributes = Struct( 159 | UINT("count"), 160 | Struct(UINT("attr_num"), UINT("status"), UDINT("size"))(name="object_definition_size"), 161 | Struct(UINT("attr_num"), UINT("status"), UDINT("size"))(name="structure_size"), 162 | Struct(UINT("attr_num"), UINT("status"), UINT("count"))(name="member_count"), 163 | Struct(UINT("attr_num"), UINT("status"), UINT("handle"))(name="structure_handle"), 164 | ) 165 | 166 | 167 | class _StructTagReprMeta(_StructReprMeta): 168 | def __repr__(cls): 169 | members = ", ".join(repr(m) for m in cls.members) 170 | return f"{cls.__name__}({members}, bool_members={cls.bits!r}, struct_size={cls.size!r})" # TODO 171 | 172 | 173 | def StructTag( 174 | # (datatype, offset) of each member of the struct, does not include bit members aliased to other members 175 | *members: Tuple[DataType, int], 176 | bit_members: Dict[str, Tuple[int, int]], # {member name, (offset, bit #) } 177 | private_members: Set[str], # private members that should not be in the final value 178 | struct_size: int, 179 | ) -> Type[StructType]: 180 | _members = [x[0] for x in members] 181 | _offsets_ = {member: offset for (member, offset) in members} 182 | _struct = Struct(*_members) 183 | 184 | class StructTag(_struct, metaclass=_StructTagReprMeta): 185 | bits = bit_members 186 | private = private_members 187 | size = struct_size 188 | _offsets = _offsets_ 189 | 190 | @classmethod 191 | def _decode(cls, stream: BytesIO): 192 | stream = BytesIO(stream.read(cls.size)) 193 | raw = stream.getvalue() 194 | values = {} 195 | 196 | for member in cls.members: 197 | offset = cls._offsets[member] 198 | if stream.tell() < offset: 199 | stream.read(offset - stream.tell()) 200 | values[member.name] = member.decode(stream) 201 | 202 | for bit_member, (offset, bit) in cls.bits.items(): 203 | bit_value = bool(raw[offset] & (1 << bit)) 204 | values[bit_member] = bit_value 205 | 206 | return {k: v for k, v in values.items() if k not in cls.private} 207 | 208 | @classmethod 209 | def _encode(cls, values: Dict[str, Any]): 210 | # make a copy so that private host members aren't added to the original 211 | values = {k: v for k, v in values.items()} 212 | 213 | value = bytearray(cls.size) 214 | for member in cls.members: 215 | if member.name in cls.private: 216 | continue 217 | offset = cls._offsets[member] 218 | encoded = member.encode(values[member.name]) 219 | value[offset : offset + len(encoded)] = encoded 220 | 221 | for bit_member, (offset, bit) in cls.bits.items(): 222 | val = values[bit_member] 223 | 224 | if val: 225 | value[offset] |= 1 << bit 226 | else: 227 | value[offset] &= ~(1 << bit) 228 | 229 | return value 230 | 231 | return StructTag 232 | -------------------------------------------------------------------------------- /pycomm3/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | 26 | class PycommError(Exception): 27 | """ 28 | Base exception for all exceptions raised by pycomm3 29 | """ 30 | 31 | 32 | class CommError(PycommError): 33 | """ 34 | For exceptions raised during connection related issues 35 | """ 36 | 37 | 38 | class DataError(PycommError): 39 | """ 40 | For exceptions raised during binary encoding/decoding of data 41 | """ 42 | 43 | 44 | class BufferEmptyError(DataError): 45 | """ 46 | Raised when trying to decode an empty buffer 47 | """ 48 | 49 | 50 | class ResponseError(PycommError): 51 | """ 52 | For exceptions raised during handling for responses to requests 53 | """ 54 | 55 | 56 | class RequestError(PycommError): 57 | """ 58 | For exceptions raised due to issues building requests or processing of user supplied data 59 | """ 60 | -------------------------------------------------------------------------------- /pycomm3/logger.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | import logging 26 | import sys 27 | 28 | __all__ = ["configure_default_logger", "LOG_VERBOSE"] 29 | 30 | LOG_VERBOSE = 5 31 | 32 | 33 | _logger = logging.getLogger("pycomm3") 34 | _logger.addHandler(logging.NullHandler()) 35 | 36 | 37 | def _verbose(self: logging.Logger, msg, *args, **kwargs): 38 | if self.isEnabledFor(LOG_VERBOSE): 39 | self._log(LOG_VERBOSE, msg, *args, **kwargs) 40 | 41 | 42 | logging.addLevelName(LOG_VERBOSE, "VERBOSE") 43 | logging.verbose = _verbose 44 | logging.Logger.verbose = _verbose 45 | 46 | 47 | def configure_default_logger(level: int = logging.INFO, filename: str = None, logger: str = None): 48 | """ 49 | Helper method to configure basic logging. `level` will set the logging level. 50 | To enable the verbose logging (where the contents of every packet sent/received is logged) 51 | import the `LOG_VERBOSE` level from the `pycomm3.logger` module. The default level is `logging.INFO`. 52 | 53 | To log to a file in addition to the terminal, set `filename` to the desired log file. 54 | 55 | By default this method only configures the 'pycomm3' logger, to also configure your own logger, 56 | set the `logger` argument to the name of the logger you wish to also configure. For the root logger 57 | use an empty string (``''``). 58 | """ 59 | loggers = [logging.getLogger('pycomm3'), ] 60 | if logger == '': 61 | loggers.append(logging.getLogger()) 62 | elif logger: 63 | loggers.append(logging.getLogger(logger)) 64 | 65 | formatter = logging.Formatter( 66 | fmt="{asctime} [{levelname}] {name}.{funcName}(): {message}", style="{" 67 | ) 68 | handler = logging.StreamHandler(stream=sys.stdout) 69 | handler.setFormatter(formatter) 70 | 71 | if filename: 72 | file_handler = logging.FileHandler(filename, encoding="utf-8") 73 | file_handler.setFormatter(formatter) 74 | 75 | for _log in loggers: 76 | _log.setLevel(level) 77 | _log.addHandler(handler) 78 | 79 | if filename: 80 | _log.addHandler(file_handler) 81 | -------------------------------------------------------------------------------- /pycomm3/map.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | __all__ = [ 26 | "EnumMap", 27 | ] 28 | 29 | 30 | def _default_value_key(value): 31 | return value 32 | 33 | 34 | class MapMeta(type): 35 | def __new__(cls, name, bases, classdict): 36 | enumcls = super().__new__(cls, name, bases, classdict) 37 | 38 | # get all non-private attributes 39 | members = { 40 | key: value 41 | for key, value in classdict.items() 42 | if not key.startswith("_") 43 | and not isinstance(value, (classmethod, staticmethod)) 44 | } 45 | # also add uppercase keys for each member (if they're not already lowercase) 46 | lower_members = { 47 | key.lower(): value 48 | for key, value in members.items() 49 | if key.lower() not in members 50 | } 51 | 52 | if enumcls.__dict__.get("_bidirectional_", True): 53 | # invert members to a value->key dict 54 | _value_key = enumcls.__dict__.get("_value_key_", _default_value_key) 55 | value_map = { 56 | _value_key(value): key.lower() for key, value in members.items() 57 | } 58 | else: 59 | value_map = {} 60 | 61 | # merge 3 previous dicts to get member lookup dict 62 | enumcls._members_ = {**members, **lower_members, **value_map} 63 | enumcls._attributes = list(members) 64 | 65 | # lookup by value only return CAPS keys if attribute set 66 | _only_caps = enumcls.__dict__.get("_return_caps_only_") 67 | enumcls._return_caps_only_ = _only_caps 68 | 69 | return enumcls 70 | 71 | def __getitem__(cls, item): 72 | val = cls._members_.__getitem__(_key(item)) 73 | if cls._return_caps_only_ and isinstance(val, str): 74 | val = val.upper() 75 | return val 76 | 77 | def get(cls, item, default=None): 78 | 79 | val = cls._members_.get(_key(item), default) 80 | 81 | if cls._return_caps_only_ and isinstance(val, str): 82 | val = val.upper() 83 | return val 84 | 85 | def __contains__(cls, item): 86 | return cls._members_.__contains__( 87 | item.lower() if isinstance(item, str) else item 88 | ) 89 | 90 | @property 91 | def attributes(cls): 92 | return cls._attributes 93 | 94 | 95 | def _key(item): 96 | return item.lower() if isinstance(item, str) else item 97 | 98 | 99 | class EnumMap(metaclass=MapMeta): 100 | """ 101 | A simple enum-like class that allows dict-like __getitem__() and get() lookups. 102 | __getitem__() and get() are case-insensitive and bidirectional 103 | 104 | example: 105 | 106 | class TestEnum(Pycomm3EnumMap): 107 | x = 100 108 | 109 | >>> TestEnum.x 110 | 100 111 | >>> TestEnum['X'] 112 | 100 113 | >>> TestEnum[100] 114 | x 115 | 116 | Note: this class is really only to be used internally, it doesn't cover anything more than simple subclasses 117 | (as in attributes only, don't add methods except for classmethods) 118 | It's really just to provide dict-like item access with enum-like attributes. 119 | 120 | """ 121 | 122 | ... 123 | -------------------------------------------------------------------------------- /pycomm3/packets/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | from ..map import EnumMap 26 | 27 | 28 | from .base import RequestPacket, ResponsePacket 29 | from .ethernetip import ( 30 | SendUnitDataRequestPacket, 31 | SendUnitDataResponsePacket, 32 | SendRRDataRequestPacket, 33 | SendRRDataResponsePacket, 34 | RegisterSessionRequestPacket, 35 | RegisterSessionResponsePacket, 36 | UnRegisterSessionRequestPacket, 37 | UnRegisterSessionResponsePacket, 38 | ListIdentityRequestPacket, 39 | ListIdentityResponsePacket, 40 | ) 41 | from .cip import ( 42 | GenericConnectedRequestPacket, 43 | GenericConnectedResponsePacket, 44 | GenericUnconnectedRequestPacket, 45 | GenericUnconnectedResponsePacket, 46 | ) 47 | from .logix import ( 48 | ReadTagRequestPacket, 49 | ReadTagResponsePacket, 50 | ReadTagFragmentedRequestPacket, 51 | ReadTagFragmentedResponsePacket, 52 | WriteTagRequestPacket, 53 | WriteTagResponsePacket, 54 | WriteTagFragmentedRequestPacket, 55 | WriteTagFragmentedResponsePacket, 56 | ReadModifyWriteRequestPacket, 57 | ReadModifyWriteResponsePacket, 58 | MultiServiceRequestPacket, 59 | MultiServiceResponsePacket, 60 | ) 61 | from .util import * 62 | -------------------------------------------------------------------------------- /pycomm3/packets/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | import logging 26 | from reprlib import repr as _r 27 | from typing import Optional 28 | 29 | from ..cip import DINT, UINT, UDINT 30 | from ..const import SUCCESS 31 | from ..exceptions import CommError 32 | 33 | __all__ = ["Packet", "ResponsePacket", "RequestPacket"] 34 | 35 | 36 | class Packet: 37 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 38 | 39 | 40 | class ResponsePacket(Packet): 41 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 42 | 43 | def __init__(self, request: "RequestPacket", raw_data: bytes = None): 44 | super().__init__() 45 | self.request = request 46 | self.raw = raw_data 47 | self._error = None 48 | self.service = None 49 | self.service_status = None 50 | self.data = None 51 | self.command = None 52 | self.command_status = None 53 | 54 | self._is_valid = False 55 | 56 | if raw_data is not None: 57 | self._parse_reply() 58 | else: 59 | self._error = "No response data received" 60 | 61 | def __bool__(self): 62 | return self.is_valid() 63 | 64 | @property 65 | def error(self) -> Optional[str]: 66 | if self.is_valid(): 67 | return None 68 | if self._error is not None: 69 | return self._error 70 | if self.command_status not in (None, SUCCESS): 71 | return self.command_extended_status() 72 | if self.service_status not in (None, SUCCESS): 73 | return self.service_extended_status() 74 | return "Unknown Error" 75 | 76 | def is_valid(self) -> bool: 77 | return all( 78 | ( 79 | self._error is None, 80 | self.command is not None, 81 | self.command_status == SUCCESS, 82 | ) 83 | ) 84 | 85 | def _parse_reply(self): 86 | try: 87 | self.command = self.raw[:2] 88 | self.command_status = DINT.decode( 89 | self.raw[8:12] 90 | ) # encapsulation status check 91 | except Exception as err: 92 | self.__log.exception("Failed to parse reply") 93 | self._error = f"Failed to parse reply - {err}" 94 | 95 | def command_extended_status(self) -> str: 96 | return "Unknown Error" 97 | 98 | def service_extended_status(self) -> str: 99 | return "Unknown Error" 100 | 101 | def __repr__(self): 102 | service = self.service or None 103 | return f"{self.__class__.__name__}(service={service!r}, command={self.command!r}, error={self.error!r})" 104 | 105 | __str__ = __repr__ 106 | 107 | 108 | class RequestPacket(Packet): 109 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 110 | _message_type = None 111 | _address_type = None 112 | _timeout = b"\x0a\x00" # 10 113 | _encap_command = None 114 | response_class = ResponsePacket 115 | type_ = None 116 | VERBOSE_DEBUG = False 117 | no_response = False 118 | 119 | def __init__(self): 120 | super().__init__() 121 | self.message = b"" 122 | self._msg_setup = False 123 | self._msg = [] # message data 124 | self._added = [] 125 | self.error = None 126 | 127 | def add(self, *value: bytes): 128 | self._added.extend(value) 129 | return self 130 | 131 | def _setup_message(self): 132 | self._msg_setup = True 133 | 134 | def build_message(self): 135 | if not self._msg_setup: 136 | self._setup_message() 137 | self._msg += self._added 138 | self.message = b"".join(self._msg) 139 | return self.message 140 | 141 | def build_request( 142 | self, target_cid: bytes, session_id: int, context: bytes, option: int, **kwargs 143 | ) -> bytes: 144 | msg = self.build_message() 145 | common = self._build_common_packet_format(msg, addr_data=target_cid) 146 | header = self._build_header( 147 | self._encap_command, len(common), session_id, context, option 148 | ) 149 | return header + common 150 | 151 | @staticmethod 152 | def _build_header(command, length, session_id, context, option) -> bytes: 153 | """Build the encapsulate message header 154 | 155 | The header is 24 bytes fixed length, and includes the command and the length of the optional data portion. 156 | 157 | :return: the header 158 | """ 159 | try: 160 | return b"".join( 161 | [ 162 | command, 163 | UINT.encode(length), # Length UINT 164 | UDINT.encode(session_id), # Session Handle UDINT 165 | b"\x00\x00\x00\x00", # Status UDINT 166 | context, # Sender Context 8 bytes 167 | UDINT.encode(option), # Option UDINT 168 | ] 169 | ) 170 | 171 | except Exception as err: 172 | raise CommError("Failed to build request header") from err 173 | 174 | def _build_common_packet_format(self, message, addr_data=None) -> bytes: 175 | addr_data = ( 176 | b"\x00\x00" 177 | if addr_data is None 178 | else UINT.encode(len(addr_data)) + addr_data 179 | ) 180 | 181 | return b"".join( 182 | [ 183 | b"\x00\x00\x00\x00", # Interface Handle: shall be 0 for CIP 184 | self._timeout, 185 | b"\x02\x00", # Item count: should be at list 2 (Address and Data) 186 | self._address_type, 187 | addr_data, 188 | self._message_type, 189 | UINT.encode(len(message)), 190 | message, 191 | ] 192 | ) 193 | 194 | def __repr__(self): 195 | return f"{self.__class__.__name__}(message={_r(self._msg)})" 196 | 197 | __str__ = __repr__ 198 | -------------------------------------------------------------------------------- /pycomm3/packets/cip.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | import logging 26 | from typing import Union, Any 27 | 28 | from ..util import cycle 29 | from .ethernetip import ( 30 | SendUnitDataResponsePacket, 31 | SendUnitDataRequestPacket, 32 | SendRRDataRequestPacket, 33 | SendRRDataResponsePacket, 34 | ) 35 | from .util import request_path, wrap_unconnected_send 36 | from ..cip import DataType 37 | 38 | 39 | class GenericConnectedResponsePacket(SendUnitDataResponsePacket): 40 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 41 | 42 | def __init__( 43 | self, request: "GenericConnectedRequestPacket", raw_data: bytes = None 44 | ): 45 | self.data_type = request.data_type 46 | self.value = None 47 | super().__init__(request, raw_data) 48 | 49 | def _parse_reply(self): 50 | super()._parse_reply() 51 | 52 | if self.data_type is None: 53 | self.value = self.data 54 | elif self.is_valid(): 55 | try: 56 | self.value = self.data_type.decode(self.data) 57 | except Exception as err: 58 | self.__log.exception("Failed to parse reply") 59 | self._error = f"Failed to parse reply - {err}" 60 | self.value = None 61 | 62 | 63 | class GenericConnectedRequestPacket(SendUnitDataRequestPacket): 64 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 65 | response_class = GenericConnectedResponsePacket 66 | 67 | def __init__( 68 | self, 69 | sequence: cycle, 70 | service: Union[int, bytes], 71 | class_code: Union[int, bytes], 72 | instance: Union[int, bytes], 73 | attribute: Union[int, bytes] = b"", 74 | request_data: Any = b"", 75 | data_type: DataType = None, 76 | ): 77 | super().__init__(sequence) 78 | self.data_type = data_type 79 | self.class_code = class_code 80 | self.instance = instance 81 | self.attribute = attribute 82 | self.service = service if isinstance(service, bytes) else bytes([service]) 83 | self.request_data = request_data 84 | 85 | def _setup_message(self): 86 | super()._setup_message() 87 | req_path = request_path(self.class_code, self.instance, self.attribute) 88 | self._msg += [self.service, req_path, self.request_data] 89 | 90 | 91 | class GenericUnconnectedResponsePacket(SendRRDataResponsePacket): 92 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 93 | 94 | def __init__( 95 | self, request: "GenericUnconnectedRequestPacket", raw_data: bytes = None 96 | ): 97 | self.data_type = request.data_type 98 | self.value = None 99 | super().__init__(request, raw_data) 100 | 101 | def _parse_reply(self): 102 | super()._parse_reply() 103 | 104 | if self.data_type is None: 105 | self.value = self.data 106 | elif self.is_valid(): 107 | try: 108 | self.value = self.data_type.decode(self.data) 109 | except Exception as err: 110 | self.__log.exception("Failed to parse reply") 111 | self._error = f"Failed to parse reply - {err}" 112 | self.value = None 113 | 114 | 115 | class GenericUnconnectedRequestPacket(SendRRDataRequestPacket): 116 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 117 | response_class = GenericUnconnectedResponsePacket 118 | 119 | def __init__( 120 | self, 121 | service: Union[int, bytes], 122 | class_code: Union[int, bytes], 123 | instance: Union[int, bytes], 124 | attribute: Union[int, bytes] = b"", 125 | request_data: bytes = b"", 126 | route_path: bytes = b"", 127 | unconnected_send: bool = False, 128 | data_type: DataType = None, 129 | ): 130 | super().__init__() 131 | self.data_type = data_type 132 | self.class_code = class_code 133 | self.instance = instance 134 | self.attribute = attribute 135 | self.service = service if isinstance(service, bytes) else bytes([service]) 136 | self.request_data = request_data 137 | self.route_path = route_path 138 | self.unconnected_send = unconnected_send 139 | 140 | def _setup_message(self): 141 | super()._setup_message() 142 | req_path = request_path(self.class_code, self.instance, self.attribute) 143 | 144 | if self.unconnected_send: 145 | msg = [ 146 | wrap_unconnected_send( 147 | b"".join((self.service, req_path, self.request_data)), 148 | self.route_path, 149 | ), 150 | ] 151 | else: 152 | msg = [self.service, req_path, self.request_data, self.route_path] 153 | 154 | self._msg += msg 155 | -------------------------------------------------------------------------------- /pycomm3/packets/ethernetip.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | import logging 26 | from itertools import cycle 27 | from typing import Generator 28 | 29 | from .base import RequestPacket, ResponsePacket 30 | from .util import get_extended_status, get_service_status 31 | 32 | from ..cip import ( 33 | MULTI_PACKET_SERVICES, 34 | UDINT, 35 | UINT, 36 | USINT, 37 | EncapsulationCommands, 38 | Services, 39 | ) 40 | from ..const import INSUFFICIENT_PACKETS, SUCCESS 41 | from ..custom_types import ListIdentityObject 42 | from ..map import EnumMap 43 | 44 | 45 | class DataItem(EnumMap): 46 | connected = b"\xb1\x00" 47 | unconnected = b"\xb2\x00" 48 | 49 | 50 | class AddressItem(EnumMap): 51 | connection = b"\xa1\x00" 52 | null = b"\x00\x00" 53 | uccm = b"\x00\x00" 54 | 55 | 56 | class SendUnitDataResponsePacket(ResponsePacket): 57 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 58 | 59 | def __init__(self, request: "SendUnitDataRequestPacket", raw_data: bytes = None): 60 | super().__init__(request, raw_data) 61 | 62 | def _parse_reply(self): 63 | try: 64 | super()._parse_reply() 65 | self.service = Services.get(Services.from_reply(self.raw[46:47])) 66 | self.service_status = USINT.decode(self.raw[48:49]) 67 | self.data = self.raw[50:] 68 | except Exception as err: 69 | self.__log.exception("Failed to parse reply") 70 | self._error = f"Failed to parse reply - {err}" 71 | 72 | def is_valid(self) -> bool: 73 | valid = self.service_status == SUCCESS or ( 74 | self.service_status == INSUFFICIENT_PACKETS 75 | and self.service in MULTI_PACKET_SERVICES 76 | ) 77 | return all((super().is_valid(), valid)) 78 | 79 | def command_extended_status(self) -> str: 80 | status = get_service_status(self.command_status) 81 | ext_status = get_extended_status(self.raw, 48) 82 | if ext_status: 83 | return f"{status} - {ext_status}" 84 | 85 | return status 86 | 87 | def service_extended_status(self) -> str: 88 | status = get_service_status(self.service_status) 89 | ext_status = get_extended_status(self.raw, 48) 90 | if ext_status: 91 | return f"{status} - {ext_status}" 92 | 93 | return status 94 | 95 | 96 | class SendUnitDataRequestPacket(RequestPacket): 97 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 98 | _message_type = DataItem.connected 99 | _address_type = AddressItem.connection 100 | response_class = SendUnitDataResponsePacket 101 | _encap_command = EncapsulationCommands.send_unit_data 102 | 103 | def __init__(self, sequence: cycle): 104 | super().__init__() 105 | self._sequence = next(sequence) if isinstance(sequence, Generator) else sequence 106 | 107 | def _setup_message(self): 108 | super()._setup_message() 109 | self._msg.append(UINT.encode(self._sequence)) 110 | 111 | def build_request( 112 | self, target_cid: bytes, session_id: int, context: bytes, option: int, **kwargs 113 | ): 114 | 115 | return super().build_request(target_cid, session_id, context, option, **kwargs) 116 | 117 | 118 | class SendRRDataResponsePacket(ResponsePacket): 119 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 120 | 121 | def __init__(self, request, raw_data: bytes = None, *args, **kwargs): 122 | super().__init__(request, raw_data) 123 | 124 | def _parse_reply(self): 125 | try: 126 | super()._parse_reply() 127 | self.service = Services.get(Services.from_reply(self.raw[40:41])) 128 | self.service_status = USINT.decode(self.raw[42:43]) 129 | self.data = self.raw[44:] 130 | except Exception as err: 131 | self.__log.exception("Failed to parse reply") 132 | self._error = f"Failed to parse reply - {err}" 133 | 134 | def is_valid(self) -> bool: 135 | return all((super().is_valid(), self.service_status == SUCCESS)) 136 | 137 | def command_extended_status(self) -> str: 138 | status = get_service_status(self.command_status) 139 | ext_status = get_extended_status(self.raw, 42) 140 | if ext_status: 141 | return f"{status} - {ext_status}" 142 | 143 | return status 144 | 145 | def service_extended_status(self) -> str: 146 | status = get_service_status(self.service_status) 147 | ext_status = get_extended_status(self.raw, 42) 148 | if ext_status: 149 | return f"{status} - {ext_status}" 150 | 151 | return status 152 | 153 | 154 | class SendRRDataRequestPacket(RequestPacket): 155 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 156 | _message_type = DataItem.unconnected 157 | _address_type = AddressItem.uccm 158 | _encap_command = EncapsulationCommands.send_rr_data 159 | response_class = SendRRDataResponsePacket 160 | 161 | def _build_common_packet_format(self, message, addr_data=None) -> bytes: 162 | return super()._build_common_packet_format(message, addr_data=None) 163 | 164 | 165 | class RegisterSessionResponsePacket(ResponsePacket): 166 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 167 | 168 | def __init__(self, request: "RegisterSessionRequestPacket", raw_data: bytes = None): 169 | self.session = None 170 | super().__init__(request, raw_data) 171 | 172 | def _parse_reply(self): 173 | try: 174 | super()._parse_reply() 175 | self.session = UDINT.decode(self.raw[4:8]) 176 | except Exception as err: 177 | self.__log.exception("Failed to parse reply") 178 | self._error = f"Failed to parse reply - {err}" 179 | 180 | def is_valid(self) -> bool: 181 | return all((super().is_valid(), self.session is not None)) 182 | 183 | def __repr__(self): 184 | return ( 185 | f"{self.__class__.__name__}(session={self.session!r}, error={self.error!r})" 186 | ) 187 | 188 | 189 | class RegisterSessionRequestPacket(RequestPacket): 190 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 191 | _encap_command = EncapsulationCommands.register_session 192 | response_class = RegisterSessionResponsePacket 193 | 194 | def __init__(self, protocol_version: bytes, option_flags: bytes = b"\x00\x00"): 195 | super().__init__() 196 | self.protocol_version = protocol_version 197 | self.option_flags = option_flags 198 | 199 | def _setup_message(self): 200 | self._msg += [self.protocol_version, self.option_flags] 201 | 202 | def _build_common_packet_format(self, message, addr_data=None) -> bytes: 203 | return message 204 | 205 | 206 | class UnRegisterSessionResponsePacket(ResponsePacket): 207 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 208 | 209 | def __repr__(self): 210 | return "UnRegisterSessionResponsePacket()" 211 | 212 | 213 | class UnRegisterSessionRequestPacket(RequestPacket): 214 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 215 | _encap_command = EncapsulationCommands.unregister_session 216 | response_class = UnRegisterSessionResponsePacket 217 | no_response = True 218 | 219 | def _build_common_packet_format(self, message, addr_data=None) -> bytes: 220 | return b"" 221 | 222 | 223 | class ListIdentityResponsePacket(ResponsePacket): 224 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 225 | 226 | def __init__(self, request: "ListIdentityRequestPacket", raw_data: bytes = None): 227 | self.identity = {} 228 | super().__init__(request, raw_data) 229 | 230 | def _parse_reply(self): 231 | try: 232 | super()._parse_reply() 233 | self.data = self.raw[26:] 234 | self.identity = ListIdentityObject.decode(self.data) 235 | except Exception as err: 236 | self.__log.exception("Failed to parse response") 237 | self._error = f"Failed to parse reply - {err}" 238 | 239 | def is_valid(self) -> bool: 240 | return all((super().is_valid(), self.identity is not None)) 241 | 242 | def __repr__(self): 243 | return f"{self.__class__.__name__}(identity={self.identity!r}, error={self.error!r})" 244 | 245 | 246 | class ListIdentityRequestPacket(RequestPacket): 247 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 248 | _encap_command = EncapsulationCommands.list_identity 249 | response_class = ListIdentityResponsePacket 250 | 251 | def _build_common_packet_format(self, message, addr_data=None) -> bytes: 252 | return b"" 253 | -------------------------------------------------------------------------------- /pycomm3/packets/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | import string 26 | 27 | from io import BytesIO 28 | from typing import Union, Optional 29 | 30 | from ..cip import ( 31 | ClassCode, 32 | ConnectionManagerServices, 33 | SERVICE_STATUS, 34 | EXTEND_CODES, 35 | StringDataType, 36 | ArrayType, 37 | UDINT, 38 | BitArrayType, 39 | LogicalSegment, 40 | PADDED_EPATH, 41 | DataSegment, 42 | UINT, 43 | USINT, 44 | ) 45 | from ..const import PRIORITY, TIMEOUT_TICKS, STRUCTURE_READ_REPLY 46 | 47 | __all__ = [ 48 | "wrap_unconnected_send", 49 | "request_path", 50 | "tag_request_path", 51 | "get_service_status", 52 | "get_extended_status", 53 | "parse_read_reply", 54 | "dword_to_bool_array", 55 | "print_bytes_msg", 56 | "PacketLazyFormatter", 57 | ] 58 | 59 | 60 | def wrap_unconnected_send(message: bytes, route_path: bytes) -> bytes: 61 | rp = request_path(class_code=ClassCode.connection_manager, instance=b"\x01") 62 | msg_len = len(message) 63 | return b"".join( 64 | [ 65 | ConnectionManagerServices.unconnected_send, 66 | rp, 67 | PRIORITY, 68 | TIMEOUT_TICKS, 69 | UINT.encode(msg_len), 70 | message, 71 | b"\x00" if msg_len % 2 else b"", 72 | route_path, 73 | ] 74 | ) 75 | 76 | 77 | def request_path( 78 | class_code: Union[int, bytes], 79 | instance: Union[int, bytes], 80 | attribute: Union[int, bytes] = b"", 81 | ) -> bytes: 82 | segments = [ 83 | LogicalSegment(class_code, "class_id"), 84 | LogicalSegment(instance, "instance_id"), 85 | ] 86 | 87 | if attribute: 88 | segments.append(LogicalSegment(attribute, "attribute_id")) 89 | 90 | return PADDED_EPATH.encode(segments, length=True) 91 | 92 | 93 | def tag_request_path(tag, tag_info, use_instance_ids): 94 | """ 95 | Returns the tag request path encoded as a packed EPATH, returns None on error. 96 | """ 97 | 98 | tags = tag.split(".") 99 | if tags: 100 | base, *attrs = tags 101 | base_tag, index = _find_tag_index(base) 102 | if ( 103 | use_instance_ids 104 | and not base.startswith("Program:") 105 | and tag_info.get("instance_id") 106 | ): 107 | segments = [ 108 | LogicalSegment(ClassCode.symbol_object, "class_id"), 109 | LogicalSegment(tag_info["instance_id"], "instance_id"), 110 | ] 111 | else: 112 | segments = [ 113 | DataSegment(base_tag), 114 | ] 115 | if index is None: 116 | return None 117 | 118 | segments += [LogicalSegment(int(idx), "member_id") for idx in index] 119 | 120 | for attr in attrs: 121 | attr, index = _find_tag_index(attr) 122 | 123 | attr_segments = [DataSegment(attr)] 124 | attr_segments += [LogicalSegment(int(idx), "member_id") for idx in index] 125 | 126 | segments += attr_segments 127 | 128 | return PADDED_EPATH.encode(segments, length=True) 129 | 130 | return None 131 | 132 | 133 | def _find_tag_index(tag): 134 | if "[" in tag: # Check if is an array tag 135 | t = tag[: len(tag) - 1] # Remove the last square bracket 136 | inside_value = t[t.find("[") + 1 :] # Isolate the value inside bracket 137 | index = inside_value.split( 138 | "," 139 | ) # Now split the inside value in case part of multidimensional array 140 | tag = t[: t.find("[")] # Get only the tag part 141 | else: 142 | index = [] 143 | return tag, index 144 | 145 | 146 | def get_service_status(status) -> str: 147 | return SERVICE_STATUS.get(status, f"Unknown Error ({status:0>2x})") 148 | 149 | 150 | def get_extended_status(msg, start) -> Optional[str]: 151 | stream = BytesIO(msg[start:]) 152 | status = USINT.decode(stream) 153 | # send_rr_data 154 | # 42 General Status 155 | # 43 Size of additional status 156 | # 44..n additional status 157 | 158 | # send_unit_data 159 | # 48 General Status 160 | # 49 Size of additional status 161 | # 50..n additional status 162 | extended_status_size = USINT.decode(stream) * 2 163 | extended_status = 0 164 | if extended_status_size != 0: 165 | # There is an additional status 166 | if extended_status_size == 1: 167 | extended_status = USINT.decode(stream) 168 | elif extended_status_size == 2: 169 | extended_status = UINT.decode(stream) 170 | elif extended_status_size == 4: 171 | extended_status = UDINT.decode(stream) 172 | else: 173 | return "[ERROR] Extended Status Size Unknown" 174 | try: 175 | return f"{EXTEND_CODES[status][extended_status]} ({status:0>2x}, {extended_status:0>2x})" 176 | except Exception: 177 | return None 178 | 179 | 180 | def parse_read_reply(data, data_type, elements): 181 | dt_name = data_type["data_type_name"] 182 | _type = data_type["type_class"] 183 | is_struct = data[:2] == STRUCTURE_READ_REPLY 184 | stream = BytesIO(data[4:] if is_struct else data[2:]) 185 | if issubclass(_type, ArrayType): 186 | _value = _type.decode(stream, length=elements) 187 | 188 | if elements == 1 and not issubclass(_type.element_type, BitArrayType): 189 | _value = _value[0] 190 | else: 191 | _value = _type.decode(stream) 192 | if is_struct and not issubclass(_type, StringDataType): 193 | _value = { 194 | attr: _value[attr] for attr in data_type["data_type"]["attributes"] 195 | } 196 | 197 | if dt_name == "DWORD": 198 | dt_name = f"BOOL[{elements * 32}]" 199 | 200 | elif elements > 1: 201 | dt_name = f"{dt_name}[{elements}]" 202 | 203 | return _value, dt_name 204 | 205 | 206 | def dword_to_bool_array(dword: Union[bytes, int]): 207 | dword = UDINT.decode(dword) if isinstance(dword, bytes) else dword 208 | bits = [x == "1" for x in bin(dword)[2:]] 209 | bools = [False for _ in range(32 - len(bits))] + bits 210 | bools.reverse() 211 | return bools 212 | 213 | 214 | def _to_hex(bites): 215 | return " ".join((f"{b:0>2x}" for b in bites)) 216 | 217 | 218 | PRINTABLE = set( 219 | b"".join( 220 | bytes(x, "ascii") 221 | for x in (string.ascii_letters, string.digits, string.punctuation, " ") 222 | ) 223 | ) 224 | 225 | 226 | def _to_ascii(bites): 227 | return "".join(f"{chr(b)}" if b in PRINTABLE else "•" for b in bites) 228 | 229 | 230 | def print_bytes_msg(msg): 231 | line_len = 16 232 | lines = (msg[i : i + line_len] for i in range(0, len(msg), line_len)) 233 | 234 | formatted_lines = ( 235 | f"({i * line_len:0>4x}) {_to_hex(line): <48} {_to_ascii(line)}" 236 | for i, line in enumerate(lines) 237 | ) 238 | 239 | return "\n".join(formatted_lines) 240 | 241 | 242 | class PacketLazyFormatter: 243 | def __init__(self, data): 244 | self._data = data 245 | 246 | def __str__(self): 247 | return print_bytes_msg(self._data) 248 | 249 | def __len__(self): 250 | return len(self._data) 251 | -------------------------------------------------------------------------------- /pycomm3/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ottowayi/pycomm3/4a458aade712a54cc2e9feeb4e129d2e71f581ab/pycomm3/py.typed -------------------------------------------------------------------------------- /pycomm3/socket_.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | import logging 26 | import socket 27 | import struct 28 | 29 | from .exceptions import CommError 30 | from .const import HEADER_SIZE 31 | 32 | 33 | class Socket: 34 | __log = logging.getLogger(f"{__module__}.{__qualname__}") 35 | 36 | def __init__(self, timeout=5.0): 37 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 38 | self.sock.settimeout(timeout) 39 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) 40 | 41 | def connect(self, host, port): 42 | try: 43 | self.sock.connect((socket.gethostbyname(host), port)) 44 | except socket.error: 45 | raise CommError(f"Failed to open socket to {host}:{port}") 46 | 47 | def send(self, msg, timeout=0): 48 | if timeout != 0: 49 | self.sock.settimeout(timeout) 50 | total_sent = 0 51 | while total_sent < len(msg): 52 | try: 53 | sent = self.sock.send(msg[total_sent:]) 54 | if sent == 0: 55 | raise CommError("socket connection broken.") 56 | total_sent += sent 57 | except socket.error as err: 58 | raise CommError("socket connection broken.") from err 59 | return total_sent 60 | 61 | def receive(self, timeout=0): 62 | try: 63 | if timeout != 0: 64 | self.sock.settimeout(timeout) 65 | data = self.sock.recv(256) 66 | data_len = struct.unpack_from(" 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | 26 | from typing import NamedTuple, Any, Optional 27 | from reprlib import repr as _r 28 | 29 | 30 | __all__ = ["Tag"] 31 | 32 | 33 | class Tag(NamedTuple): 34 | tag: str #: tag name of tag read/written or request name (generic message) 35 | value: Any #: value read/written, may be ``None`` on error 36 | type: Optional[str] = None #: data type of tag 37 | error: Optional[str] = None #: error message if unsuccessful, else ``None`` 38 | 39 | def __bool__(self): 40 | """ 41 | ``True`` if both ``value`` is not ``None`` and ``error`` is ``None``, ``False`` otherwise 42 | """ 43 | return self.value is not None and self.error is None 44 | 45 | def __str__(self): 46 | return f"{self.tag}, {_r(self.value)}, {self.type}, {self.error}" 47 | 48 | def __repr__(self): 49 | return f"{self.__class__.__name__}(tag={self.tag!r}, value={self.value!r}, type={self.type!r}, error={self.error!r})" 50 | -------------------------------------------------------------------------------- /pycomm3/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (c) 2021 Ian Ottoway 4 | # Copyright (c) 2014 Agostino Ruscito 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | # 24 | 25 | 26 | """ 27 | Various utility functions. 28 | """ 29 | 30 | from typing import Tuple 31 | 32 | 33 | def strip_array(tag: str) -> str: 34 | """ 35 | Strip off the array portion of the tag 36 | 37 | 'tag[100]' -> 'tag' 38 | 39 | """ 40 | if "[" in tag: 41 | return tag[: tag.find("[")] 42 | return tag 43 | 44 | 45 | def get_array_index(tag: str) -> Tuple[str, int]: 46 | """ 47 | Return tag name and array index from a 1-dim tag request 48 | 49 | 'tag[100]' -> ('tag', 100) 50 | """ 51 | if tag.endswith("]") and "[" in tag: 52 | tag, _tmp = tag.rsplit("[", maxsplit=1) 53 | idx = int(_tmp[:-1]) 54 | else: 55 | idx = None 56 | 57 | return tag, idx 58 | 59 | 60 | def cycle(stop, start=0): 61 | val = start 62 | while True: 63 | if val > stop: 64 | val = start 65 | 66 | yield val 67 | val += 1 68 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | 4 | __version__ = "0.0.0" 5 | with open("pycomm3/_version.py") as f: 6 | exec(f.read()) 7 | 8 | 9 | def read(file_name): 10 | return open(os.path.join(os.path.dirname(__file__), file_name)).read() 11 | 12 | 13 | setup( 14 | name="pycomm3", 15 | version=__version__, 16 | author="Ian Ottoway", 17 | author_email="ian@ottoway.dev", 18 | url="https://github.com/ottowayi/pycomm3", 19 | description="A Python Ethernet/IP library for communicating with Allen-Bradley PLCs.", 20 | long_description=read("README.rst"), 21 | license="MIT", 22 | packages=["pycomm3", "pycomm3.packets", "pycomm3.cip"], 23 | package_data={"pycomm3": ["py.typed"]}, 24 | python_requires=">=3.6.1", 25 | include_package_data=True, 26 | extras_require={ 27 | 'tests': ['pytest'] 28 | }, 29 | classifiers=[ 30 | "Development Status :: 4 - Beta", 31 | "Intended Audience :: Developers", 32 | "Intended Audience :: Manufacturing", 33 | "Natural Language :: English", 34 | "License :: OSI Approved :: MIT License", 35 | "Operating System :: OS Independent", 36 | "Programming Language :: Python", 37 | "Programming Language :: Python :: 3.6", 38 | "Programming Language :: Python :: 3.7", 39 | "Programming Language :: Python :: 3.8", 40 | "Programming Language :: Python :: 3.9", 41 | "Programming Language :: Python :: 3.10", 42 | "Topic :: Software Development :: Libraries :: Python Modules", 43 | "Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator", 44 | "Topic :: Scientific/Engineering :: Human Machine Interfaces", 45 | ], 46 | ) 47 | 48 | # Build and Publish Commands: 49 | # 50 | # python -m build 51 | # twine upload --skip-existing dist/* 52 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from math import isclose 2 | 3 | 4 | def tag_only(tag): 5 | """ 6 | simple function to remove the elements token from a tag name 7 | 8 | e.g. tag_only('tag_name{10}') -> 'tag_name' 9 | """ 10 | if '{' in tag: 11 | return tag[:tag.find('{')] 12 | else: 13 | return tag 14 | 15 | 16 | class REAL(float): 17 | """simple subclass of float so that tests can avoid floating point precision issues using ==""" 18 | 19 | def __new__(cls, float_string, rel_tol=1e-6): 20 | return float.__new__(cls, float_string) 21 | 22 | def __init__(self, value, rel_tol=1e-6): 23 | float.__init__(value) 24 | self.rel_tol = rel_tol 25 | 26 | def __eq__(self, other): 27 | return isclose(self, other, rel_tol=self.rel_tol) 28 | 29 | def __repr__(self): 30 | return f'REAL({float.__repr__(self)}, {self.rel_tol})' 31 | -------------------------------------------------------------------------------- /tests/offline/__init__.py: -------------------------------------------------------------------------------- 1 | class Mocket: 2 | """ 3 | A mocked socket 4 | """ 5 | def __init__(self, *responses: bytes): 6 | self._responses = iter(responses) 7 | 8 | def receive(self) -> bytes: 9 | try: 10 | return next(self._responses) 11 | except StopIteration: 12 | return b'' 13 | 14 | def send(self, *args, **kwargs): 15 | ... 16 | 17 | def close(self, *args, **kwargs): 18 | ... 19 | 20 | def connect(self, *args, **kwargs): 21 | ... 22 | -------------------------------------------------------------------------------- /tests/offline/test_cip_driver.py: -------------------------------------------------------------------------------- 1 | """Tests for the logix_driver.py file. 2 | 3 | There are quite a few methods in the CIPDriver which are difficult to 4 | read or test due to both code clarity and complexity issues. As well as 5 | there being no way to control the execution of many of the private 6 | methods through the public API. This has lead to testing of quite a few 7 | private API methods to achieve an acceptable test coverage. 8 | """ 9 | import itertools 10 | from unittest import mock 11 | 12 | import pytest 13 | 14 | from pycomm3 import ( 15 | PADDED_EPATH, 16 | UDINT, 17 | CIPDriver, 18 | CommError, 19 | DataError, 20 | PortSegment, 21 | PycommError, 22 | RequestError, 23 | ResponseError, 24 | parse_connection_path, 25 | ) 26 | from pycomm3.socket_ import Socket 27 | 28 | from . import Mocket 29 | 30 | CONNECT_PATH = "192.168.1.100" 31 | 32 | 33 | _simple_path = ("192.168.1.100", None, [PortSegment("bp", 0)]) 34 | _simple_paths = [ 35 | "192.168.1.100/bp/0", 36 | "192.168.1.100/backplane/0", 37 | r"192.168.1.100\bp\0", 38 | r"192.168.1.100\backplane\0", 39 | "192.168.1.100,bp,0", 40 | "192.168.1.100,1,0", 41 | ] 42 | 43 | _route_path = ( 44 | "192.168.1.100", 45 | None, 46 | [ 47 | PortSegment(port="backplane", link_address=1), 48 | PortSegment(port="enet", link_address="10.11.12.13"), 49 | PortSegment(port="bp", link_address=0), 50 | ], 51 | ) 52 | _route_paths = [ 53 | "192.168.1.100/backplane/1/enet/10.11.12.13/bp/0", 54 | r"192.168.1.100\backplane\1\enet\10.11.12.13\bp\0", 55 | "192.168.1.100,backplane,1,enet,10.11.12.13,bp,0", 56 | ] 57 | 58 | path_tests = [ 59 | *[(p, _simple_path) for p in _simple_paths], 60 | *[(p, _route_path) for p in _route_paths], 61 | ("192.168.1.100", ("192.168.1.100", None, [])), 62 | ('192.168.1.100:123', ('192.168.1.100', 123, [])), 63 | ('192.168.1.100:123/bp,0', ('192.168.1.100', 123, _simple_path[-1])) 64 | ] 65 | 66 | 67 | @pytest.mark.parametrize("path, expected_output", path_tests) 68 | def test_plc_path(path, expected_output): 69 | assert parse_connection_path(path) == expected_output 70 | 71 | 72 | auto_slot_path_tests = [ 73 | ("192.168.1.100", _simple_path), 74 | ("192.168.1.100/0", _simple_path), 75 | (r"192.168.1.100\0", _simple_path), 76 | ] 77 | 78 | 79 | @pytest.mark.parametrize("path, expected_output", auto_slot_path_tests) 80 | def test_plc_path_auto_slot(path, expected_output): 81 | assert parse_connection_path(path, auto_slot=True) == expected_output 82 | 83 | 84 | _bad_paths = [ 85 | "192.168.1.100/Z", 86 | "bp/0", 87 | "192.168.1.100/-1", 88 | "192.168.1.100/backplan/1", 89 | "192.168.1.100/backplane/1/10.11.12.13/bp/0", 90 | "192.168.1.100//bp/1", 91 | "192.168.1.100/", 92 | "192.168.1.100,bp,1,0", 93 | "192.168.1.100,", 94 | "192.168.1.100:abc", 95 | "192.168.1.100:/bp/0", 96 | "192.168.1.100:-123", 97 | ] 98 | 99 | 100 | @pytest.mark.parametrize("path", _bad_paths) 101 | def test_bad_plc_paths(path): 102 | with pytest.raises( 103 | (DataError, RequestError), 104 | ): 105 | ip, port, segments = parse_connection_path(path) 106 | PADDED_EPATH.encode(segments, length=True) 107 | 108 | 109 | def test_cip_get_module_info_raises_response_error_if_response_falsy(): 110 | with mock.patch.object(CIPDriver, "generic_message") as mock_generic_message: 111 | mock_generic_message.return_value = False 112 | with pytest.raises(ResponseError): 113 | driver = CIPDriver(CONNECT_PATH) 114 | driver.get_module_info(1) 115 | 116 | assert mock_generic_message.called 117 | 118 | 119 | def test_get_module_info_returns_expected_identity_dict(): 120 | EXPECTED_DICT = { 121 | "vendor": "Rockwell Automation/Allen-Bradley", 122 | "product_type": "Programmable Logic Controller", 123 | "product_code": 89, 124 | "revision": {"major": 20, "minor": 19}, 125 | "status": b"`0", 126 | "serial": "c00fa09b", 127 | "product_name": "1769-L23E-QBFC1 LOGIX5323E-QBFC1", 128 | } 129 | 130 | RESPONSE_BYTES = ( 131 | b"o\x00C\x00\x02\x13\x02\x0b\x00\x00\x00\x00_pycomm_\x00\x00\x00\x00\x00\x00\x00\x00\n" 132 | b"\x00\x02\x00\x00\x00\x00\x00\xb2\x003\x00\x81\x00\x00\x00\x01\x00\x0e\x00Y\x00\x14\x13" 133 | b"`0\x9b\xa0\x0f\xc0 1769-L23E-QBFC1 LOGIX5323E-QBFC1" 134 | ) 135 | 136 | driver = CIPDriver(CONNECT_PATH) 137 | driver._sock = Mocket(RESPONSE_BYTES) 138 | actual_response = driver.get_module_info(1) 139 | assert actual_response == EXPECTED_DICT 140 | 141 | 142 | viable_methods = ["_forward_close", "_un_register_session"] 143 | viable_exceptions = Exception.__subclasses__() + PycommError.__subclasses__() 144 | param_values = list(itertools.product(viable_methods, viable_exceptions)) 145 | 146 | 147 | @pytest.mark.parametrize(["mock_method", "exception"], param_values) 148 | def test_close_raises_commerror_on_any_exception(mock_method, exception): 149 | """Raise a CommError if any CIPDriver methods raise exception. 150 | 151 | There are two CIPDriver methods called within close: 152 | CIPDriver._forward_close() 153 | CIPDriver._un_register_session() 154 | 155 | If those internal methods change, this test will break. I think 156 | that's acceptable and any changes to this method should make the 157 | author very aware that they have changed this method. 158 | """ 159 | with mock.patch.object(CIPDriver, mock_method) as mock_method: 160 | mock_method.side_effect = exception 161 | with pytest.raises(CommError): 162 | driver = CIPDriver(CONNECT_PATH) 163 | driver._target_is_connected = True 164 | driver._session = 1 165 | driver.close() 166 | 167 | 168 | def test_context_manager_calls_open_close(): 169 | with mock.patch.object(CIPDriver, "open") as mock_close, mock.patch.object( 170 | CIPDriver, "close" 171 | ) as mock_open: 172 | with CIPDriver(CONNECT_PATH) as driver: 173 | ... 174 | assert mock_open.called 175 | assert mock_close.called 176 | 177 | 178 | def test_context_manager_calls_open_close_with_exception(): 179 | with mock.patch.object(CIPDriver, "open") as mock_close, mock.patch.object( 180 | CIPDriver, "close" 181 | ) as mock_open: 182 | try: 183 | with CIPDriver(CONNECT_PATH) as driver: 184 | x = 1 / 0 185 | except Exception: 186 | ... 187 | assert mock_open.called 188 | assert mock_close.called 189 | 190 | 191 | def test_close_raises_no_error_on_close_with_registered_session(): 192 | driver = CIPDriver(CONNECT_PATH) 193 | driver._session = 1 194 | driver._sock = Mocket() 195 | driver.close() 196 | 197 | 198 | def test_close_raises_commerror_on_socket_close_exception(): 199 | with mock.patch.object(Socket, "close") as mock_close: 200 | mock_close.side_effect = Exception 201 | with pytest.raises(CommError): 202 | driver = CIPDriver(CONNECT_PATH) 203 | driver._sock = Socket() 204 | driver.close() 205 | 206 | 207 | def test_close_calls_socket_close_if_socket(): 208 | with mock.patch.object(Mocket, "close") as mock_close: 209 | driver = CIPDriver(CONNECT_PATH) 210 | driver._sock = Mocket() 211 | driver.close() 212 | assert mock_close.called 213 | 214 | 215 | def test_open_raises_commerror_on_connect_fail(): 216 | with mock.patch.object(Socket, "connect") as mock_connect: 217 | mock_connect.side_effect = Exception 218 | driver = CIPDriver(CONNECT_PATH) 219 | with pytest.raises(CommError): 220 | driver.open() 221 | 222 | 223 | def test_open_returns_false_if_register_session_falsy(): 224 | driver = CIPDriver(CONNECT_PATH) 225 | driver._sock = Mocket() 226 | assert not driver.open() 227 | 228 | 229 | def test_open_returns_true_if_register_session_truthy(): 230 | with mock.patch.object(CIPDriver, "_register_session") as mock_register: 231 | mock_register.return_value = 1 232 | driver = CIPDriver(CONNECT_PATH) 233 | driver._sock = Mocket() 234 | assert driver.open() 235 | 236 | 237 | def test__forward_close_returns_false_if_no_response(): 238 | driver = CIPDriver(CONNECT_PATH) 239 | driver._sock = Mocket() 240 | driver._session = 1 241 | assert not driver._forward_close() 242 | 243 | 244 | def test__forward_close_returns_true_if_response(): 245 | driver = CIPDriver(CONNECT_PATH) 246 | driver._session = 1 247 | response = ( 248 | b"o\x00\x1e\x00\x02\x16\x02\x0b\x00\x00\x00\x00_pycomm_" 249 | b"\x00\x00\x00\x00\x00\x00\x00\x00\n\x00\x02\x00\x00\x00" 250 | b"\x00\x00\xb2\x00\x0e\x00\xce\x00\x00\x00'\x04\t\x10\xd6\x9c\x06=\x00\x00" 251 | ) 252 | driver._sock = Mocket(response) 253 | assert driver._forward_close() 254 | 255 | 256 | def test__forward_close_raises_commerror_if_session_zero(): 257 | driver = CIPDriver(CONNECT_PATH) 258 | with pytest.raises(CommError): 259 | driver._forward_close() 260 | 261 | 262 | @pytest.mark.parametrize("conf_session", range(1, 100)) 263 | def test__register_session_returns_configured_session(conf_session): 264 | driver = CIPDriver(CONNECT_PATH) 265 | driver._sock = Mocket(bytes(4) + UDINT.encode(conf_session) + bytes(20)) 266 | assert conf_session == driver._register_session() 267 | 268 | 269 | def test__register_session_returns_none_if_no_response(): 270 | driver = CIPDriver(CONNECT_PATH) 271 | driver._sock = Mocket() 272 | assert driver._register_session() is None 273 | 274 | 275 | def test__forward_open_returns_true_if_already_connected(): 276 | driver = CIPDriver(CONNECT_PATH) 277 | driver._target_is_connected = True 278 | assert driver._forward_open() 279 | 280 | 281 | def test__forward_open_returns_false_if_falsy_response(): 282 | driver = CIPDriver(CONNECT_PATH) 283 | driver._sock = Mocket() 284 | driver._session = 1 285 | assert not driver._forward_open() 286 | 287 | 288 | def test__forward_open_raises_commerror_if_session_is_zero(): 289 | driver = CIPDriver(CONNECT_PATH) 290 | with pytest.raises(CommError): 291 | driver._forward_open() 292 | -------------------------------------------------------------------------------- /tests/offline/test_logix_driver.py: -------------------------------------------------------------------------------- 1 | """Tests for the logix_driver.py file. 2 | 3 | The Logix Driver is beholden to the CIPDriver interface. Only tests 4 | which bind it to that interface should be allowed here. Tests binding 5 | to another interface such as Socket are an anti-pattern. 6 | 7 | There are quite a few methods in the LogixDriver which are difficult to 8 | read or test due to both code clarity issues and it being inconvenient. 9 | 10 | Also the vast majority of methods are private, I think that private 11 | methods should not be tested directly, but rather, their effects on 12 | public methods should be tested. 13 | 14 | pytest --cov=pycomm3 --cov-branch tests/offline/ 15 | ----------- coverage: platform linux, python 3.8.1-final-0 ----------- 16 | Name Stmts Miss Branch BrPart Cover 17 | ---------------------------------------------------------------- 18 | pycomm3/logix_driver.py 798 718 346 0 7% 19 | 20 | We're currently at 7% test coverage, I would like to increase that to >=50% 21 | and then continue to do so for the rest of the modules. 22 | """ 23 | from unittest import mock 24 | 25 | import pytest 26 | 27 | from pycomm3.cip_driver import CIPDriver 28 | from pycomm3.const import MICRO800_PREFIX, SUCCESS 29 | from pycomm3.exceptions import CommError, PycommError, RequestError 30 | from pycomm3.logix_driver import LogixDriver, encode_value 31 | from pycomm3.packets import RequestPacket, ResponsePacket 32 | from pycomm3.socket_ import Socket 33 | from pycomm3.tag import Tag 34 | from pycomm3.custom_types import ModuleIdentityObject 35 | 36 | CONNECT_PATH = '192.168.1.100/1' 37 | 38 | IDENTITY_CLX_V20 = {'vendor': 'Rockwell Automation/Allen-Bradley', 39 | 'product_type': 'Programmable Logic Controller', 'product_code': 0, 40 | 'revision': {'major': 20, 'minor': 0}, 41 | 'status': b'00', 'serial': '00000000', 42 | 'product_name': '1756-L55'} 43 | 44 | IDENTITY_CLX_V21 = {'vendor': 'Rockwell Automation/Allen-Bradley', 45 | 'product_type': 'Programmable Logic Controller', 'product_code': 0, 46 | 'revision': {'major': 21, 'minor': 0}, 47 | 'status': b'00', 'serial': '00000000', 48 | 'product_name': '1756-L62'} 49 | 50 | IDENTITY_CLX_V32 = {'vendor': 'Rockwell Automation/Allen-Bradley', 51 | 'product_type': 'Programmable Logic Controller', 'product_code': 0, 52 | 'revision': {'major': 32, 'minor': 0}, 53 | 'status': b'00', 'serial': '00000000', 54 | 'product_name': '1756-L85'} 55 | 56 | IDENTITY_M8000 = {'encap_protocol_version': 1, 57 | 'ip_address': '192.168.1.124', 58 | 'product_code': 259, 59 | 'product_name': '2080-LC50-48QWBS', 60 | 'product_type': 'Programmable Logic Controller', 61 | 'revision': {'major': 12, 'minor': 11}, 62 | 'serial': '12345678', 63 | 'state': 2, 64 | 'status': b'4\x00', 65 | 'vendor': 'Rockwell Automation/Allen-Bradley'} 66 | 67 | 68 | def test_open_call_init_driver_open(): 69 | """ 70 | This test is to make sure that the initialize driver method is called during 71 | the `open()` method of the driver. 72 | """ 73 | 74 | with mock.patch.object(CIPDriver, 'open') as mock_open, \ 75 | mock.patch.object(LogixDriver, '_initialize_driver') as mock_init: 76 | driver = LogixDriver(CONNECT_PATH) 77 | driver.open() 78 | assert mock_open.called 79 | assert mock_init.called 80 | 81 | 82 | def test_open_call_init_driver_with(): 83 | """ 84 | This test is to make sure that the initialize driver method is called during 85 | the `open()` method of the driver. 86 | """ 87 | 88 | with mock.patch.object(CIPDriver, 'open') as mock_open, \ 89 | mock.patch.object(LogixDriver, '_initialize_driver') as mock_init: 90 | 91 | with LogixDriver(CONNECT_PATH): 92 | ... 93 | assert mock_open.called 94 | assert mock_init.called 95 | 96 | 97 | @pytest.mark.parametrize('identity', [IDENTITY_CLX_V20, IDENTITY_CLX_V21, IDENTITY_CLX_V32]) 98 | def test_logix_init_for_version_support_instance_ids_large_connection(identity): 99 | with mock.patch.object(LogixDriver, '_list_identity') as mock_identity, \ 100 | mock.patch.object(LogixDriver, 'get_plc_info') as mock_get_info, \ 101 | mock.patch.object(LogixDriver, 'get_plc_name') as mock_get_name: 102 | 103 | mock_identity.return_value = identity 104 | mock_get_info.return_value = identity # this is the ListIdentity response 105 | # not the same as module idenity, but 106 | # has all the fields needed for the test 107 | 108 | plc = LogixDriver(CONNECT_PATH) 109 | plc._initialize_driver(False, False) 110 | 111 | assert plc._micro800 is False 112 | assert plc._cfg['use_instance_ids'] == (identity['revision']['major'] >= 21) 113 | assert mock_get_info.called 114 | assert mock_get_name.called 115 | 116 | 117 | @pytest.mark.parametrize('identity', [IDENTITY_M8000, ]) 118 | def test_logix_init_micro800(identity): 119 | with mock.patch.object(LogixDriver, '_list_identity') as mock_identity, \ 120 | mock.patch.object(LogixDriver, 'get_plc_info') as mock_get_info, \ 121 | mock.patch.object(LogixDriver, 'get_plc_name') as mock_get_name: 122 | 123 | mock_identity.return_value = identity 124 | mock_get_info.return_value = identity 125 | 126 | plc = LogixDriver(CONNECT_PATH) 127 | plc._initialize_driver(False, False) 128 | 129 | assert plc._micro800 is True 130 | assert plc._cfg['use_instance_ids'] is False 131 | assert mock_get_info.called 132 | assert not mock_get_name.called 133 | assert not plc._cfg['cip_path'] 134 | 135 | 136 | @pytest.mark.parametrize('identity', [IDENTITY_CLX_V20, IDENTITY_CLX_V21, IDENTITY_CLX_V32, IDENTITY_M8000]) 137 | def test_logix_init_calls_get_tag_list_if_init_tags(identity): 138 | with mock.patch.object(LogixDriver, '_list_identity') as mock_identity, \ 139 | mock.patch.object(LogixDriver, 'get_plc_info') as mock_get_info, \ 140 | mock.patch.object(LogixDriver, 'get_plc_name'), \ 141 | mock.patch.object(CIPDriver, 'open'), \ 142 | mock.patch.object(LogixDriver, 'get_tag_list') as mock_tag: 143 | 144 | mock_identity.return_value = identity 145 | mock_get_info.return_value = identity 146 | driver = LogixDriver(CONNECT_PATH, init_info=False, init_tags=True) 147 | driver._target_is_connected = True 148 | driver.open() 149 | assert mock_tag.called 150 | 151 | 152 | def test_logix_context_manager_calls_open_and_close(): 153 | with mock.patch.object(LogixDriver, 'open') as mock_open, \ 154 | mock.patch.object(LogixDriver, 'close') as mock_close: 155 | with LogixDriver(CONNECT_PATH, init_info=False, init_tags=False): 156 | pass 157 | 158 | assert mock_open.called 159 | assert mock_close.called 160 | 161 | 162 | def test__exit__returns_false_on_commerror(): 163 | ld = LogixDriver(CONNECT_PATH, init_info=False, init_tags=False) 164 | assert ld.__exit__(None, None, None) is True # Exit with no exception 165 | 166 | 167 | def test__exit__returns_true_on_no_error_and_no_exc_type(): 168 | with mock.patch.object(LogixDriver, 'close'): 169 | ld = LogixDriver(CONNECT_PATH, init_info=False, init_tags=False) 170 | assert ld.__exit__(None, None, None) is True 171 | 172 | 173 | def test__exit__returns_false_on_no_error_and_exc_type(): 174 | with mock.patch.object(LogixDriver, 'close'): 175 | ld = LogixDriver(CONNECT_PATH, init_info=False, init_tags=False) 176 | assert ld.__exit__('Some Exc Type', None, None) is False 177 | 178 | 179 | def test__repr___ret_str(): 180 | ld = LogixDriver(CONNECT_PATH, init_info=False, init_tags=False) 181 | _repr = repr(ld) 182 | assert repr 183 | assert isinstance(_repr, str) 184 | 185 | 186 | def test_default_logix_tags_are_empty_dict(): 187 | """Show that LogixDriver tags are an empty dict on init.""" 188 | ld = LogixDriver(CONNECT_PATH, init_info=False, init_tags=False) 189 | assert ld.tags == dict() 190 | 191 | 192 | def test_logix_connected_false_on_init_with_false_init_params(): 193 | ld = LogixDriver(CONNECT_PATH, init_info=False, init_tags=False) 194 | assert ld.connected is False 195 | 196 | 197 | def test_clx_get_plc_time_sends_packet(): 198 | with mock.patch.object(LogixDriver, 'send') as mock_send, \ 199 | mock.patch('pycomm3.cip_driver.with_forward_open'): 200 | ld = LogixDriver(CONNECT_PATH, init_info=False, init_tags=False) 201 | ld.get_plc_time() 202 | assert mock_send.called 203 | 204 | 205 | def test_clx_set_plc_time_sends_packet(): 206 | with mock.patch.object(LogixDriver, 'send') as mock_send, \ 207 | mock.patch('pycomm3.cip_driver.with_forward_open'): 208 | ld = LogixDriver(CONNECT_PATH, init_info=False, init_tags=False) 209 | ld.set_plc_time() 210 | assert mock_send.called 211 | 212 | 213 | # TODO: all of the tag list associated tests 214 | 215 | @pytest.mark.skip(reason="""tag parsing is extremely complex, and it's \ 216 | nearly impossible to test this without also reverse-engineering it""") 217 | def test__get_tag_list_returns_expected_user_tags(): 218 | EXPECTED_USER_TAGS = [{ 219 | 'tag_type': 'struct', # bit 15 is a 1 220 | 'instance_id': 1, 221 | 'tag_name': b"\x00\x01", 222 | 'symbol_type': "", 223 | 'symbol_address': "", 224 | 'symbol_object_address': "", 225 | 'software_control': "", 226 | 'external_access': "", 227 | 'dimensions': ["", "", ""] 228 | }] 229 | 230 | TEST_RESPONSE = ResponsePacket() 231 | # 0 -> 4 are the 'instance', dint 232 | # 4 -> 6 is the 'tag_length', uint, used internally 233 | # 8 -> 'tag_length' is 'tag_name' 234 | # 8+tag_length -> 10+tag_length is 'symbol_type' uint 235 | # 10+tag_length -> 14+tag_length is 'symbol_address' udint 236 | # 14+tag_length -> 18+tag_length is 'symbol_object_address' udint 237 | # 18+tag_length -> 22+tag_length is 'software_control' udint 238 | # 'dim1', 'dim2' and 'dim3' are the next 12 bytes, udint 239 | TEST_RESPONSE.data = \ 240 | b"\x00\x00\x00\x01" + \ 241 | b"\x00\x01" + \ 242 | b"\x00\x01" + \ 243 | b"\x00\x00\x00\x00\x00\x10" 244 | TEST_RESPONSE.command = "Something" 245 | TEST_RESPONSE.command_status = SUCCESS 246 | 247 | ld = LogixDriver(CONNECT_PATH, init_info=False, init_tags=False) 248 | with mock.patch.object(RequestPacket, 'send') as mock_send, \ 249 | mock.patch.object(CIPDriver, '_forward_open'), \ 250 | mock.patch.object(LogixDriver, '_parse_instance_attribute_list'): 251 | mock_send.return_value = TEST_RESPONSE 252 | actual_tags = ld.get_tag_list() 253 | assert EXPECTED_USER_TAGS == actual_tags 254 | -------------------------------------------------------------------------------- /tests/offline/test_slc.py: -------------------------------------------------------------------------------- 1 | """Tests for the SLCDriver 2 | 3 | The methods and functions in the slc_driver.py file are extraordinarily 4 | difficult to test. 5 | """ 6 | 7 | from pycomm3.const import SLC_REPLY_START, SUCCESS 8 | from pycomm3.packets import ResponsePacket, SendUnitDataResponsePacket, RequestPacket, SendUnitDataRequestPacket 9 | from pycomm3.cip_driver import CIPDriver 10 | from unittest import mock 11 | 12 | import pytest 13 | from pycomm3.exceptions import ResponseError, RequestError 14 | 15 | from pycomm3.slc_driver import SLCDriver, _parse_read_reply 16 | from pycomm3.tag import Tag 17 | 18 | CONNECT_PATH = '192.168.1.100/1' 19 | 20 | 21 | 22 | def test_slc__read_tag_raises_requesterror_for_none(): 23 | driver = SLCDriver(CONNECT_PATH) 24 | 25 | with mock.patch('pycomm3.slc_driver.parse_tag', return_value=None): 26 | with pytest.raises(RequestError): 27 | driver._read_tag(None) 28 | 29 | 30 | def test_slc__read_tag_returns_tag(): 31 | TEST_PARSED_TAG = { 32 | 'file_type': 'N', 33 | 'file_number': '1', 34 | 'element_number': '1', 35 | 'pos_number': '1', 36 | 'address_field': 2, 37 | 'element_count': 1, 38 | 'tag': 'Dummy Parsed Tag' 39 | } 40 | RESPONSE_PACKET = ResponsePacket(RequestPacket(), b'\x00') 41 | driver = SLCDriver(CONNECT_PATH) 42 | 43 | with mock.patch('pycomm3.slc_driver.parse_tag', return_value=TEST_PARSED_TAG), \ 44 | mock.patch.object(SLCDriver, 'send') as mock_send: 45 | mock_send.return_value = RESPONSE_PACKET 46 | assert type(driver._read_tag("Anything at all")) == Tag 47 | 48 | 49 | def test_slc_get_processor_type_returns_none_if_falsy_response(): 50 | driver = SLCDriver(CONNECT_PATH) 51 | RESPONSE_PACKET = SendUnitDataResponsePacket(SendUnitDataRequestPacket(driver._sequence), b'\x00') 52 | 53 | with mock.patch.object(SLCDriver, 'send') as mock_send, \ 54 | mock.patch.object(CIPDriver, '_forward_open'): 55 | mock_send.return_value = RESPONSE_PACKET 56 | assert driver.get_processor_type() is None 57 | 58 | 59 | def test_slc_get_processor_type_returns_none_if_parsing_exception(): 60 | driver = SLCDriver(CONNECT_PATH) 61 | EXPECTED_TYPE = None 62 | 63 | with mock.patch.object(SLCDriver, 'send') as mock_send, \ 64 | mock.patch.object(CIPDriver, '_forward_open'), \ 65 | mock.patch.object(ResponsePacket, '_parse_reply'): 66 | RESPONSE_PACKET = SendUnitDataResponsePacket(SendUnitDataRequestPacket(driver._sequence), b'\x00') 67 | RESPONSE_PACKET.command = "Something" 68 | RESPONSE_PACKET.command_status = SUCCESS 69 | 70 | mock_send.return_value = RESPONSE_PACKET 71 | assert EXPECTED_TYPE == driver.get_processor_type() 72 | 73 | 74 | def test__parse_read_reply_raises_dataerror_if_exception(): 75 | driver = SLCDriver(CONNECT_PATH) 76 | 77 | with pytest.raises(ResponseError): 78 | _parse_read_reply('bad', 'data') 79 | -------------------------------------------------------------------------------- /tests/offline/test_socket_.py: -------------------------------------------------------------------------------- 1 | """Tests for socket_.py. 2 | 3 | This wrapper around Python sockets is in the critical path of all 4 | functionality of this library. As such great care should be taken to 5 | understand and test it. 6 | 7 | The Socket class as it currently stands has no dependency injection 8 | capabilities, and as such the tests will need to make use of mocking in 9 | order to support the Python socket underneath. 10 | 11 | These tests currently bind the code fairly tightly to Python's socket 12 | object, but in this instance I think that's okay as I don't forsee this 13 | changing any time soon, and if it did I would rather that be obvious by 14 | breaking these tests. 15 | 16 | The only remaining untested code in Socket is the while loop in receive 17 | """ 18 | import socket 19 | from unittest import mock 20 | import struct 21 | 22 | import pycomm3 23 | import pytest 24 | from pycomm3.exceptions import CommError, PycommError 25 | from pycomm3.socket_ import Socket 26 | 27 | 28 | def test_socket_init_creates_socket(): 29 | with mock.patch('socket.socket') as mock_socket: 30 | my_sock = Socket() 31 | assert my_sock 32 | mock_socket.assert_called_once() 33 | 34 | 35 | def test_socket_connect_raises_commerror_on_failed_host_lookup(): 36 | """Test the Socket.connect method. 37 | 38 | This test covers both the calling of Python socket's connect and 39 | the pycomm exception being raised. 40 | """ 41 | with mock.patch.object(socket.socket, 'connect') as mock_socket_connect: 42 | with mock.patch.object(socket, 'gethostbyname') as mock_socket_gethost: 43 | mock_socket_gethost.side_effect = socket.gaierror 44 | my_sock = Socket() 45 | with pytest.raises(CommError): 46 | my_sock.connect('123.456.789.101', 12345) 47 | 48 | mock_socket_connect.assert_not_called() 49 | mock_socket_gethost.assert_called_once() 50 | 51 | 52 | def test_socket_connect_raises_commerror_on_timeout(): 53 | """Test the Socket.connect method. 54 | 55 | This test covers both the calling of Python socket's connect and 56 | the pycomm exception being raised. 57 | """ 58 | with mock.patch.object(socket.socket, 'connect') as mock_socket_connect: 59 | with mock.patch.object(socket, 'gethostbyname') as mock_socket_gethost: 60 | mock_socket_connect.side_effect = socket.timeout 61 | my_sock = Socket() 62 | with pytest.raises(CommError): 63 | my_sock.connect('123.456.789.101', 12345) 64 | 65 | mock_socket_connect.assert_called_once() 66 | mock_socket_gethost.assert_called_once() 67 | 68 | 69 | def test_socket_send_raises_commerror_on_no_bytes_sent(): 70 | TEST_MSG = b"Meaningless Data" 71 | 72 | with mock.patch.object(socket.socket, 'send') as mock_socket_send: 73 | mock_socket_send.return_value = 0 74 | my_sock = Socket() 75 | with pytest.raises(CommError): 76 | my_sock.send(msg=TEST_MSG) 77 | 78 | def test_socket_send_returns_length_of_bytes_sent(): 79 | BYTES_TO_SEND = b"Baah baah black sheep" 80 | 81 | with mock.patch.object(socket.socket, 'send') as mock_socket_send: 82 | mock_socket_send.return_value = len(BYTES_TO_SEND) 83 | 84 | my_sock = Socket() 85 | sent_bytes = my_sock.send(BYTES_TO_SEND) 86 | 87 | mock_socket_send.assert_called_once_with(BYTES_TO_SEND) 88 | assert sent_bytes == len(BYTES_TO_SEND) 89 | 90 | def test_socket_send_sets_timeout(): 91 | """Pass along our timeout arg to our socket.""" 92 | TIMEOUT_VALUE = 1 93 | SOCKET_SEND_RESPONSE = 20 # A nonzero number will prevent an exception. 94 | with mock.patch.object(socket.socket, 'settimeout') as mock_socket_settimeout, \ 95 | mock.patch.object(socket.socket, 'send') as mock_socket_send: 96 | mock_socket_send.return_value = SOCKET_SEND_RESPONSE 97 | 98 | my_sock = Socket() 99 | my_sock.send(b"Some Message", timeout=TIMEOUT_VALUE) 100 | 101 | mock_socket_settimeout.assert_called_with(TIMEOUT_VALUE) 102 | 103 | def test_socket_send_raises_commerror_on_socketerror(): 104 | TEST_MESSAGE = b"Useless Bytes" 105 | with mock.patch.object(socket.socket, 'send') as mock_socket_send: 106 | mock_socket_send.side_effect = socket.error 107 | 108 | my_sock = Socket() 109 | with pytest.raises(CommError): 110 | my_sock.send(TEST_MESSAGE) 111 | 112 | 113 | # Prefixing with the data_len value expected in a message. This 114 | # seems like an implementation detail that should live in cip_base 115 | # rather than directly in the Socket wrapper. 116 | DATA_LEN = 256 117 | DATA_LEN_BYTES = struct.pack('