From 1863a921e3af60fb4c5652a738a233bc2c3313b1 Mon Sep 17 00:00:00 2001 From: gavanderhoorn Date: Tue, 24 Jan 2023 10:41:05 +0100 Subject: [PATCH] Initial import --- .gitignore | 129 +++++++ LICENSE | 201 ++++++++++ README.md | 233 +++++++++++ requirements.txt | 3 + setup.cfg | 74 ++++ setup.py | 20 + src/comet_rpc/__init__.py | 103 +++++ src/comet_rpc/comet.py | 749 ++++++++++++++++++++++++++++++++++++ src/comet_rpc/exceptions.py | 95 +++++ src/comet_rpc/fr_errors.py | 32 ++ src/comet_rpc/fr_types.py | 83 ++++ src/comet_rpc/kliotyps.py | 70 ++++ src/comet_rpc/messages.py | 237 ++++++++++++ 13 files changed, 2029 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 src/comet_rpc/__init__.py create mode 100644 src/comet_rpc/comet.py create mode 100644 src/comet_rpc/exceptions.py create mode 100644 src/comet_rpc/fr_errors.py create mode 100644 src/comet_rpc/fr_types.py create mode 100644 src/comet_rpc/kliotyps.py create mode 100644 src/comet_rpc/messages.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5535e7 --- /dev/null +++ b/README.md @@ -0,0 +1,233 @@ +# comet_rpc + +[![license - apache 2.0](https://img.shields.io/:license-Apache%202.0-yellowgreen.svg)](https://opensource.org/licenses/Apache-2.0) +[![Github Issues](https://img.shields.io/github/issues/gavanderhoorn/comet_rpc.svg)](https://github.com/gavanderhoorn/comet_rpc/issues) + +## Discussion + +If you happen to find this useful, leave a quick note in the [Discussions section](https://github.com/gavanderhoorn/comet_rpc/discussions). +If something doesn't work, open an issue [on the tracker](https://github.com/gavanderhoorn/comet_rpc/issues). + +## Overview + +This is a low-level Python wrapper around the JSON-RPC interface offered by the `COMET` extension of FANUC's web server on R-30iB and R-30iB+ controllers (ie: V8.x and up). +`COMET` is used by iRProgrammer and a couple of other web-based UIs offered by FANUC on their more recent controller series. + +See [Requirements](#requirements) for some more information on required options. + +**NOTE**: this is only meant as an example of using `COMET`'s RPC interface. +There is no official documentation on this interface, and it's likely not intended to be used by anything other than FANUC's own products. +Use with caution. + +Proper integration of a FANUC controller with an external application or workcell should be done using either the PCDK, RMI (`R912`), OPC-UA, a fieldbus or similar technology. +The scripts and functionality provided here are only a convenience and are only intended to be used in academic and laboratory settings. +They allow incidental external access to a controller without needing to use any additional hardware. +Do not use this on production systems or in contexts where any kind of determinism is required. +The author recommends using PCDK, RMI, OPC-UA and/or any of the supported fieldbuses in those cases. + +## TOC + +1. [Status](#status) +1. [Requirements](#requirements) +1. [Compatibility](#compatibility) +1. [Installation](#installation) +1. [Example usage](#example-usage) +1. [Limitations / Known issues](#limitations-known-issues) +1. [Performance](#performance) +1. [Related projects](#related-projects) +1. [Bugs, feature requests, etc](#bugs-feature-requests-etc) +1. [FAQ](#faq) +1. [Disclaimer](#disclaimer) + +## Status + +This package is a work-in-progress. +As there is no documentation on `COMET`, it's been implemented based on information learned from observing iRProgrammer and related web-based UIs. + +Expect frequent breakage and missing functionality. + +See also the [FAQ](#faq). + +## Requirements + +Requirements are a little unclear at this moment, but it appears `COMET` is installed on all controllers with the base *Web Server* (`HTTP`) option and at least V8 of the system software (see notes about [Compatibility](#compatibility) below). +Interestingly, even though `COMET` is primarily used by iRProgrammer, option `J767` does not appear to be a requirement for it to be installed. + +The main other requirement is a functioning networking setup. +Make sure you can ping the controller and the controller's website shows up when opening `http://robot_ip` in a browser. +Configuration of *HTTP Authentication* is not needed, as `COMET` RPCs do not appear to be affected by it. + +## Compatibility + +### Controllers + +Compatibility has only been tested with R-30iB+ controllers running V9.30 and V9.40 of the system software. +It's possible R-30iB with V8.x supports the `COMET` RPC interface as well, but this has not been tested. + +### Operating Systems + +The library has been written for Python version 3. +No specific OS dependencies are known, meaning all platforms with a Python 3 interpreter should be supported. +Only Windows 10, Ubuntu Bionic and Focal have been extensively tested however. + +## Installation + +### Controller + +As the `COMET` RPC interface is part of the controller's web server, no installation nor setup on the FANUC side should be necessary. + +### Package + +It's recommended to use a virtual Python 3 environment and install the package in it. +The author has primarily used Python 3.8, but other versions are expected to work, though they are not actively tested. + +Future versions may be released to PyPi. + +Example (installs `comet_rpc` `0.1.0`; be sure to update the URL to download the desired version): + +```shell +python3 -m venv $HOME/venv_comet_rpc +source $HOME/venv_comet_rpc/bin/activate +pip install -U pip wheel setuptools +pip install https://github.com/gavanderhoorn/comet_rpc/archive/0.1.0.tar.gz +``` + +## Example usage + +The current version of this package does not come with any example scripts. +The subsection below is expected to be sufficient to clarify basic usage of the RPC interface. + +### Library + +This resets the controller, sets the override to 100% and finally reads the `DO[1]` IO port and retrieves its comment: + +```python +from comet_rpc import ( + exec_kcl, + IoType, + iogetpn, + iovalrd, + vmip_writeva, +) + +# IP address or hostname of the R-30iB(+) controller +server = "..." + +exec_kcl(server, "reset") +vmip_writeva(server, "*SYSTEM*", "$MCR.$GENOVERRIDE", value=100) +dout1_val = iovalrd(server, IoType.DigitalOut, index=1).value +dout1_cmt = iogetpn(server, IoType.DigitalOut, index=1).value +... +``` + +Note the lack of error detection and handling to keep the example brief. + +## Limitations / Known issues + +The following limitations and known issues exist: + +* `COMET` (and/or FANUC's web server) seems to return response documents with a `Content-type: text/html` header. + Because of this, `comet_rpc` just assumes it receives JSON, even if the `content-type` header states otherwise. +* `COMET` sometimes returns malformed response documents. + `comet_rpc` tries to detect this and either fixes those responses before parsing and validation, or mimics iRProgrammer's behaviour (which is to ignore). + This makes some actual errors hard to detect. + A better way to deal with this is being investigated. +* Only a subset of the RPCs exposed by `COMET` is supported by this library. + Future updates may add support for more RPCs. +* No version checking is implemented (ie: `comet_rpc` will not prevent invoking an RPC on a V8 controller which requires V9) +* Reading and writing (system) variables (`vmip_readva` and `vmip_writeva`) returns and takes `str` representations of those variables instead of more specific types as their values. + This is partly due to the way `COMET` expects and returns those values and partly due to limited insight into which types are used by FANUC for those values. + Future updates may improve on this. + +## Performance + +Even though this library should not be used when performance is a concern, some preliminary figures are presented in this section. + +See the following table for an indication of expected RPC performance: + +| Platform | SW version | RPC | avg ms/call | +|:---------|-----------:|-------------|------------:| +| RG | V9.30P/26 | IOVALRD | ~8 | +| RG | V9.30P/26 | VMIP_READVA | ~14 | +| R-30iB+ | V9.30P/?? | IOVALRD | ~18 | + +Note: this is without connection reuse, as it's unclear whether `COMET` supports this for regular RPC invocations. + +## Related projects + +For a similar library which doesn't use `COMET` (and is compatible with older controllers), see [gavanderhoorn/dominh](https://github.com/gavanderhoorn/dominh). + +## Bugs, feature requests, etc + +Please use the [GitHub issue tracker](https://github.com/gavanderhoorn/comet_rpc/issues). + +## FAQ + +### What's the status of COMET? + +It's likely only intended to be used by FANUC internally in their products. +There is no public documentation which mentions it, which implies it's not a public interface. + +### Should this be used in production? + +Apart from the fact that `comet_rpc` is a work-in-progress at the moment, it makes use of a (most likely) private interface. +This means FANUC has no obligation to maintain compatibility and they are free to change `COMET` in any way they see necessary without regards for users of this Python library. + +Both of these facts make use in production deployments problematic. + +See also the *NOTE* in the [Overview](#overview) section, and [Status](#status). + +### This is far from production-ready code + +Yes, I agree. +See also the *NOTE* in the *Overview* section. + +### Why did you not use Go/Rust/Java/Kotlin/Swift/anything but Python? + +Time and application requirements: target framework supported Python, so writing `comet_rpc` in Python made sense. + +### Should this not be async? + +Perhaps. +All implemented RPCs so far are executed in a blocking manner on the FANUC side though, with none of the streaming or event-based ones supported (`PMON_START_MON` et al.). +Future versions may change the default to `async` while offering a blocking version of the API for bw compatibility. + +### Does this use Karel? + +No. +`COMET` is an extension to / integrated with the embedded web server running on R-30iB(+) controllers and is a native binary. +It does not use Karel, nor is it run in the Karel VM. + +### Performance is not as good as it could be + +Compared to the PCDK: certainly, but if you need a more performant solution, ask FANUC for a PCDK license or use a fieldbus. +If you have ideas on how to improve performance, post an issue [on the tracker](https://github.com/gavanderhoorn/comet_rpc/issues). + +### Can I submit feature/enhancement requests? + +Of course! +I can't guarantee I'll have time to work on them though. + +### Would you take pull requests which add new features? + +Most certainly. +As long as new features (or enhancements of existing functionality) pass CI and are reasonably implemented, they will be merged. + +### What's the relation to Dominh? + +[gavanderhoorn/dominh](https://github.com/gavanderhoorn/dominh) is/was an experiment to see whether an RPC library for FANUC controllers could be created with as few required options on the controller as possible. +Because of this, it uses methods which are perhaps not the most efficient (such as Karel programs, KCL and `.stm` pages), but at least work on as many controllers as possible. + +`comet_rpc` is different: it's a low-level library which directly interfaces with an RPC interface implemented by FANUC, used by some of their web based UIs for CRX robots, iRProgrammer and remote iPendants. +There is no use of Karel, nor KCL. +The only functionality supported is what is offered by `COMET` and known from looking at iRProgrammer. + +Technically, Dominh and `comet_rpc` could be used at the same time. + +It's also likely Dominh will optionally use `comet_rpc` in the future to make some operations more efficient. + +## Disclaimer + +The author of this software is not affiliated with FANUC Corporation in any way. +All trademarks and registered trademarks are property of their respective owners, and company, product and service names mentioned in this readme or appearing in source code or other artefacts in this repository are used for identification purposes only. +Use of these names does not imply endorsement by FANUC Corporation. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4ee4bd2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pydantic +requests +typing_extensions diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..a1aca2d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,74 @@ +# Copyright (c) 2023, G.A. vd. Hoorn +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# author: G.A. vd. Hoorn + +[metadata] +name = comet_rpc +version = attr: comet_rpc.__version__ +description = "Low-level Python wrapper around the COMET RPC interface on Fanuc R-30iB(+) controllers (V8+)" +license = Apache License, Version 2.0 +license_files = LICENSE +long_description = file: README.md +long_description_content_type = text/markdown; charset=UTF-8 +home_page = https://github.com/gavanderhoorn/comet_rpc +project_urls = + Tracker = https://github.com/gavanderhoorn/comet_rpc/issues + Source = https://github.com/gavanderhoorn/comet_rpc +author = G.A. vd. Hoorn +author_email = g.a.vanderhoorn@tudelft.nl +keywords = fanuc, robotics, r-30ib, rpc, comet +platform = any +classifiers = + Development Status :: 3 - Alpha + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3.8 + Topic :: Software Development :: Libraries :: Python Modules + + +[options] +zip_safe = False +packages = find_namespace: +include_package_data = True +package_dir = + =src +python_requires = >=3.8 +setup_requires = + setuptools>=46.4.0 +install_requires = + importlib-metadata; python_version<"3.8" + pydantic>=1.10,<2.0 + requests>=2.28,<3.0 + typing_extensions>=4.4,<5.0 + + +[options.packages.find] +where = src +exclude = + tests + + +[flake8] +max_line_length = 88 +extend_ignore = E203, W503 +exclude = + .git + .eggs + __pycache__ + tests + build + dist diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..280664e --- /dev/null +++ b/setup.py @@ -0,0 +1,20 @@ +# Copyright (c) 2023, G.A. vd. Hoorn +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# author: G.A. vd. Hoorn + +from setuptools import setup + +if __name__ == "__main__": + setup() diff --git a/src/comet_rpc/__init__.py b/src/comet_rpc/__init__.py new file mode 100644 index 0000000..d081cc4 --- /dev/null +++ b/src/comet_rpc/__init__.py @@ -0,0 +1,103 @@ +# Copyright (c) 2023, G.A. vd. Hoorn +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# author: G.A. vd. Hoorn + +from .comet import ( + dpewrite_str, + exec_kcl, + get_raw_file, + gtfilist, + iodefpn, + iogetpn, + iogtall, + iovalrd, + iovalset, + local_start, + posregvalrd, + prog_abort, + regvalrd, + rprintf, + txchgprg, + txml_curang, + txml_curpos, + txsetlin, + vmip_readva, + vmip_writeva, +) + +from .exceptions import ( + AuthenticationException, + BadElementInStructureException, + BadVariableOrRegisterIndexException, + CometRpcException, + DeserialisationException, + DictElementNotFoundException, + InvalidIoIndexException, + InvalidIoTypeException, + LockedResourceException, + NoCommentOnIoPortException, + NoDataDefinedForProgramException, + NoSuchMethodException, + ProgramDoesNotExistException, + UnexpectedResponseContentException, + UnexpectedResultCodeException, + UnexpectedRpcStatusException, + UnknownVariableException, +) + +from .kliotyps import IoType + +__version__ = "0.1.0" + +__all__ = [ + "AuthenticationException", + "BadElementInStructureException", + "BadVariableOrRegisterIndexException", + "CometRpcException", + "DeserialisationException", + "DictElementNotFoundException", + "dpewrite_str", + "exec_kcl", + "get_raw_file", + "gtfilist", + "InvalidIoIndexException", + "InvalidIoTypeException", + "iodefpn", + "iogetpn", + "iogtall", + "IoType", + "iovalrd", + "iovalset", + "local_start", + "LockedResourceException", + "NoCommentOnIoPortException", + "NoDataDefinedForProgramException", + "NoSuchMethodException", + "posregvalrd", + "prog_abort", + "ProgramDoesNotExistException", + "regvalrd", + "rprintf", + "txchgprg", + "txml_curang", + "txml_curpos", + "txsetlin", + "UnexpectedResponseContentException", + "UnexpectedResultCodeException", + "UnexpectedRpcStatusException", + "UnknownVariableException", + "vmip_readva", + "vmip_writeva", +] diff --git a/src/comet_rpc/comet.py b/src/comet_rpc/comet.py new file mode 100644 index 0000000..69bbebe --- /dev/null +++ b/src/comet_rpc/comet.py @@ -0,0 +1,749 @@ +# Copyright (c) 2023, G.A. vd. Hoorn +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# author: G.A. vd. Hoorn + +from json import loads as json_loads +import requests +import typing as t + +from urllib import parse + +from .exceptions import ( + AuthenticationException, + BadElementInStructureException, + BadVariableOrRegisterIndexException, + DeserialisationException, + DictElementNotFoundException, + InvalidIoIndexException, + InvalidIoTypeException, + LockedResourceException, + NoCommentOnIoPortException, + NoDataDefinedForProgramException, + NoSuchMethodException, + ProgramDoesNotExistException, + UnexpectedResponseContentException, + UnexpectedResultCodeException, + UnexpectedRpcStatusException, + UnknownVariableException, +) +from .fr_errors import ErrorDictionary +from .kliotyps import IoType +from .messages import ( + DpeWriteStrResponse, + ExecKclCommandResponse, + GetFileListResponse, + GetRawFileResponse, + IoDefPnResponse, + IoGetAllResponse, + IoGetPnResponse, + LocalStartResponse, + PosRegValReadResponse, + ProgAbortResponse, + ReadIoResponse, + RegisterReadResponse, + RemotePrintfResponse, + RpcId, + RpcResponse, + TxChgPrgResponse, + TxMlCurAngResponse, + TxMlCurPosResponse, + TxSetLinResponse, + VariableReadResponse, + VariableWriteResponse, + WriteIoResponse, +) + + +def _call( + server: str, + function: RpcId, + return_raw: bool = False, + request_timeout: float = 1.0, + query_str: str = "", + **kwargs, +) -> t.Union[RpcResponse, str]: + """Invoke the RPC `function` via the COMET interface on `server`. + + This uses `requests.get(..)` to interact with `COMET` on the FANUC + controller. All keyword arguments are passed as parameters to + `requests.get(..)`, and they are expected to be the parameters the + invoked RPC requires. + + By default, this function will try to parse the returned JSON response + document. Callers can set `return_raw` to `True` to disable this and + get access to the raw response text. + + If `query_str` is not the empty string, no query arguments / parameters + will be encoded (ie: any `kwargs` present will be ignored). Instead, the + query string passed will be appended to the base COMET rpc URL path and + directly forwarded to `requests.get(..)`. + + NOTE: `query_str` MUST NOT include the question mark. + + So far, only RPCs which take their arguments in the form of query + parameters have been observed. Therefore, only the GET HTTP method + is used to interface with COMET. It's possible POST can also be used, + but there is no information on how or which RPCs take their arguments + in the form of a POST body. + + :param server: Hostname or IP address of COMET RPC server + :param function: The RPC to invoke + :param return_raw: Whether or not the caller expects a parsed response document + to be returned + :param request_timeout: How long to wait on a response from COMET + :param query_str: Query to send to COMET instead of keyword args + :param kwargs: All named key:value pairs will be forwarded as query parameters + to `requests.get(..)` + :returns: A parsed response document object or a raw response string (depending + on `return_raw`) + :raises AuthenticationException: If COMET returned an unauthenticated error + :raises DeserialisationException: If the response document could be parsed, but + contains an expected number of RPC elements + :raises LockedResourceException: If COMET returned an access is forbidden error + :raises NoSuchMethodException: If the COMET server does not recognise the passed + RPC ID + :raises UnexpectedResponseContentException: If the JSON response document was + malformed, or contained unexpected content + :raises UnexpectedResultCodeException: If COMET returned any status code than + OK (200) + """ + + # TODO: see whether we can use the server on :3080 instead + port = 80 + url = f"http://{server}:{port}/COMET/rpc" + # see how much we need to pretend to be iRProgrammer + headers = { + "Referer": f"http://{server}:{port}", + "Accept": "application/json, text/javascript, */*", + } + if query_str: + if kwargs: + raise ValueError("Keyword args cannot be combined with a 'query_str'") + + # be nice + if query_str[0] == "?": + query_str = query_str[1:] + if query_str[0] == "&": + query_str = query_str[1:] + + # assume caller has provided a custom query string, so do not construct + # nor encode a 'params' dict, but GET just the URL passed in + url = f"{url}?func={function.name}&{query_str}" + r = requests.get(url, headers=headers, timeout=request_timeout) + + else: + # COMET server expects percent-quoted entities, so quote ourselves using + # the correct function + params = parse.urlencode( + {"func": function.name, **kwargs}, quote_via=parse.quote + ) + r = requests.get(url, headers=headers, params=params, timeout=request_timeout) + + # provide caller with appropriate exceptions + if r.status_code == requests.codes.unauthorized: + raise AuthenticationException("Authentication failed (Karel?)") + if r.status_code == requests.codes.forbidden: + raise LockedResourceException("Access is forbidden/locked (Karel?)") + if r.status_code != requests.codes.ok: + raise UnexpectedResultCodeException( + f"Expected: {requests.codes.ok}, got: {r.status_code}" + ) + + # if requested, return the complete response in text form. This can help + # callers deal with malformed JSON returned by COMET sometimes (looking + # at you IOVALSET) + if return_raw: + return r.text + + # Check whether we got a malformed response document. It could look like + # this: + # + # {"FANUC":{"name":"","fastclock":"","RPC":]}} + # + # This has been reported on a real R-30iB+ running V9.3074 of the system + # software. iRProgrammer seems to ignore this, so we will as well. + # + # For now we add special handling for the IOVALSET case here. Not sure I + # like this very much. + # + # TODO: come up with a better way to deal with malformed responses + response_text = r.text + if '"RPC":]}}' in response_text: + if function == RpcId.IOVALSET: + # we can only assume the call succeeded, so fixup the response + response_text = response_text.replace( + '"RPC":]}}', + f'"RPC":[{{"rpc":"{RpcId.IOVALSET.value}","status":"0x0"}}]}}}}', + ) + + # no special handling, just inform caller + else: + raise UnexpectedResponseContentException( + f"Malformed response: '{response_text}'" + ) + + # try to parse as JSON. If we've patched the response document earlier + # this should now succeed for those cases where we initially received a + # problematic response as well. + ret = json_loads(response_text) + + # make sure we've received a "Fanuc RPC response" + if not len(ret) == 1 or "FANUC" not in ret: + raise UnexpectedResponseContentException("No 'FANUC' in response") + + # parse (and validate) response JSON + response = RpcResponse(**ret["FANUC"]) + num_rpc_eles = len(response.RPC) + if num_rpc_eles != 1: + raise DeserialisationException( + f"Too many response elements: expected 1, got {num_rpc_eles}" + ) + if response.RPC[0].rpc == -1: + raise NoSuchMethodException(f"No such RPC: '{function.value}'") + + # leave checking RPC status codes to caller. They'll have more context. + + return response + + +def dpewrite_str(server: str, error_code: int) -> DpeWriteStrResponse: + """Return a string rendering of `error_code`. + + :param server: Hostname or IP address of COMET RPC server + :param error_code: FANUC facility + error code integer + :returns: The parsed response document + :raises DictElementNotFoundException: If `error_code` cannot be found in any + dictionary + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call(server, function=RpcId.DPEWRITE_STR, ercode=error_code) + + ret = response.RPC[0] + + if ret.status == ErrorDictionary.DICT_005: + raise DictElementNotFoundException(f"No such element: 0x{error_code:06X}") + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + + return ret + + +def exec_kcl(server: str, cmd: str) -> ExecKclCommandResponse: + """Execute the KCL command `cmd` on the controller. + + This is similar to the `KCL` and `KCLDO` 'CGI programs' supported by FANUC + controllers, but wrapped in a JSON-RPC interface. + + Command strings may contain whitespace, which will be correctly encoded by + `comet_rpc` before passing it to `COMET`. + + Refer to the FANUC Karel Reference Manual for more information on KCL commands. + + :param server: Hostname or IP address of COMET RPC server + :param cmd: The KCL command string to execute + :returns: The parsed response document + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call(server, function=RpcId.CPKCL, kcl_cmd=cmd) + + ret = response.RPC[0] + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + return ret + + +def get_raw_file(server: str, file: str) -> GetRawFileResponse: + """Retrieve contents of `file` as a sequence of byte strings. + + NOTE: COMET returns lines from the file exactly as they are, so including + the last null-byte. + + :param server: Hostname or IP address of COMET RPC server + :param file: Windows-like path to the file to download + :returns: The parsed response document + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call(server, function=RpcId.GET_RAW_FILE, file=file) + + ret = response.RPC[0] + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + return ret + + +def gtfilist(server: str, path_name: str) -> GetFileListResponse: + """Download a directory listing for the drive/device/directory `path_name`. + + Setting `path_name="*"` will return all files and directories in the + current working directory on the controller (ie: active device and directory). + + NOTE: for listing files and directories in the root of a device, make sure + to append `:`, but not `\\`. + + :param server: Hostname or IP address of COMET RPC server + :param path_name: A Windows-style path to list the contents of + :returns: The parsed response document + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call(server, function=RpcId.GTFILIST, path_name=path_name) + + ret = response.RPC[0] + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + return ret + + +def iodefpn(server: str, typ: IoType, index: int, comment: str) -> IoDefPnResponse: + """Set the comment of the IO port at `index` to `comment`. + + :param server: Hostname or IP address of COMET RPC server + :param typ: The type of IO port + :param index: The specific port to retrieve the comment for (1-based) + :param comment: The new comment + :returns: The parsed response document + :raises InvalidIoIndexException: If the `index` is not a valid value for the + `type` specified + :raises InvalidIoTypeException: If `type` is not a recognised IO type + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call( + server, + function=RpcId.IODEFPN, + type=typ.value, + index=index, + comment=comment, + ) + + ret = response.RPC[0] + + # check call succeeded + if ret.status == ErrorDictionary.PRIO_001: + raise InvalidIoTypeException(f"Illegal port type: {typ.value}") + if ret.status == ErrorDictionary.PRIO_002: + raise InvalidIoIndexException(f"Illegal port number for port: {index}") + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + + return ret + + +def iogetpn(server: str, typ: IoType, index: int) -> IoGetPnResponse: + """Retrieve the comment of the IO port at `index`. + + :param server: Hostname or IP address of COMET RPC server + :param typ: The type of IO port + :param index: The specific port to retrieve the comment for (1-based) + :returns: The parsed response document + :raises InvalidIoIndexException: If the `index` is not a valid value for the + `type` specified + :raises InvalidIoTypeException: If `type` is not a recognised IO type + :raises NoCommentOnIoPortException: If the port has no comment configured + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call(server, function=RpcId.IOGETPN, type=typ.value, index=index) + + ret = response.RPC[0] + + # check call succeeded + if ret.status == ErrorDictionary.PRIO_001: + raise InvalidIoTypeException(f"Illegal port type: {typ.value}") + if ret.status == ErrorDictionary.PRIO_002: + raise InvalidIoIndexException(f"Illegal port number for port: {index}") + if ret.status == ErrorDictionary.PRIO_030: + raise NoCommentOnIoPortException(f"No comment available for port: {index}") + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + + return ret + + +def iogtall(server: str, typ: IoType, index: int, count: int) -> IoGetAllResponse: + """Retrieve state of `count` IO ports of type `type` starting at `index`. + + NOTE: might not be supported on all versions of V9.30 system software. + + NOTE 2: `COMET` does not appear to check whether the controller is configured + with the request type of IO port, nor whether the requested number of IO + ports to be read exists. This means requests for 1e6 ports of type 12345 will + be serviced. The returned data will be bogus, and the controller will + struggle to process the request. + + :param server: Hostname or IP address of COMET RPC server + :param typ: The type of IO port + :param index: The specific port to retrieve the comment for (1-based) + :param count: The number of IO ports to read + :returns: The parsed response document + :raises InvalidIoIndexException: If the `index` is not a valid value for the + `type` specified + :raises InvalidIoTypeException: If `type` is not a recognised IO type + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call( + server, function=RpcId.IOGTALL, type=typ.value, index=index, cnt=count + ) + + ret = response.RPC[0] + + if ret.status == ErrorDictionary.PRIO_001: + raise InvalidIoTypeException(f"Illegal port type: {typ.value}") + if ret.status == ErrorDictionary.PRIO_002: + raise InvalidIoIndexException(f"Illegal port number for port: {index}") + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + + return ret + + +def iovalrd(server: str, typ: IoType, index: int) -> ReadIoResponse: + """Retrieve the value of the IO port of type `typ` at `index`. + + :param server: Hostname or IP address of COMET RPC server + :param typ: The type of IO port to read from + :param index: The specific port to read from (1-based) + :returns: The parsed response document + :raises InvalidIoIndexException: If the `index` is not a valid value for the + `type` specified + :raises InvalidIoTypeException: If `type` is not a recognised IO type + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call(server, function=RpcId.IOVALRD, type=typ.value, index=index) + + ret = response.RPC[0] + + # check call succeeded + if ret.status == ErrorDictionary.PRIO_001: + raise InvalidIoTypeException(f"Illegal port type: {typ.value}") + if ret.status == ErrorDictionary.PRIO_002: + raise InvalidIoIndexException(f"Illegal port number for port: {index}") + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + + return ret + + +def iovalset(server: str, typ: IoType, index: int, value: int) -> WriteIoResponse: + """Update the `value` of the IO port of type `typ` at `index`. + + `value` will be `int`, even for `BOOLEAN` ports on the controller. In those + cases, zero appears to be interpreted as `FALSE` (or `OFF`), while any non-zero + value appears to be interpreted as `TRUE` (or `ON`). + + Analogue IO ports should also be written to using `int` (instead of `float`), + just as in Karel programs. + + NOTE: certain system software versions (V9.3074 on R-30iB+ fi) appear to + return a malformed response for this RPC. _call(..) handles this for us by + patching up the returned JSON. Just as iRProgrammer, we pretend everything + is fine (as we have no way of knowing whether it isn't). This will make + detecting errors more difficult/impossible, but there doesn't appear to be + a way around this. + + :param server: Hostname or IP address of COMET RPC server + :param typ: The type of IO port to write to + :param index: The specific port to write to (1-based) + :param value: The value to write to the port + :returns: The parsed response document + :raises InvalidIoIndexException: If the `index` is not a valid value for the + `type` specified + :raises InvalidIoTypeException: If `type` is not a recognised IO type + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call( + server, function=RpcId.IOVALSET, type=typ.value, index=index, value=value + ) + + ret = response.RPC[0] + + # check call succeeded + if ret.status == ErrorDictionary.PRIO_001: + raise InvalidIoTypeException(f"Illegal port type: {typ.value}") + if ret.status == ErrorDictionary.PRIO_002: + raise InvalidIoIndexException(f"Illegal port number for port: {index}") + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + + return ret + + +def local_start(server: str, value: int) -> LocalStartResponse: + response = _call(server, function=RpcId.LOCAL_START, value=value) + + ret = response.RPC[0] + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + return ret + + +def posregvalrd(server: str, index: int, grp_num: int = 1) -> PosRegValReadResponse: + """Retrieve the contents of the position register at `index` for group `grp_num`. + + :param server: Hostname or IP address of COMET RPC server + :param index: The index of the position register (1-based) + :param grp_num: The motion group to retrieve the position register contents for + :returns: The parsed response document + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call(server, function=RpcId.POSREGVALRD, grp_num=grp_num, index=index) + + ret = response.RPC[0] + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + return ret + + +def prog_abort(server: str, prog_name: str = "*ALL*") -> ProgAbortResponse: + """Attempt to ABORT the program `prog_name`. + + Set `prog_name` to `"*ALL"` to attempt to abort all (user) tasks. + + :param server: Hostname or IP address of COMET RPC server + :param prog_name: Name of the program to abort + :returns: The parsed response document + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call(server, function=RpcId.PGABORT, task_name=prog_name) + + ret = response.RPC[0] + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + return ret + + +def regvalrd(server: str, index: int) -> RegisterReadResponse: + """Retrieve the contents of the register at `index`. + + Depending on the value stored in the register, this will return an `int` or + a `float`. + + :param server: Hostname or IP address of COMET RPC server + :param index: The index of the position register (1-based) + :returns: The parsed response document + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call(server, function=RpcId.REGVALRD, index=index) + + ret = response.RPC[0] + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + return ret + + +def rprintf(server: str, line: str) -> RemotePrintfResponse: + """Append the string `line` to the console log on the controller. + + This will cause the string `line` to be appended to the console log on the + FANUC controller (and consequently show up in `CONSLOG.DG` and `CONSTAIL.DG`) + as if it was logged by a program running on the controller. + + Log lines may contain whitespace, which will be correctly encoded by + `comet_rpc` before passing it to `COMET`. + + :param server: Hostname or IP address of COMET RPC server + :param line: The line to append to the log + :returns: The parsed response document + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + # COMET expects an anonymous query arg for RPRINTF, which requests doesn't + # support, so compose query string ourselves and pass to _call(..) + query_str = f"={parse.quote(line)}" + response = _call(server, function=RpcId.RPRINTF, query_str=query_str) + ret = response.RPC[0] + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + return ret + + +def txchgprg(server: str, prog_name: str) -> TxChgPrgResponse: + """Open the program `prog_name` on the TP. + + :param server: Hostname or IP address of COMET RPC server + :param prog_name: Name of the program to open + :returns: The parsed response document + :raises ProgramDoesNotExistException: If the program `prog_name` does not actually + exist on the controller + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call(server, function=RpcId.TXCHGPRG, prog_name=prog_name.upper()) + + ret = response.RPC[0] + + # check call succeeded + if ret.status == ErrorDictionary.MEMO_073: + raise ProgramDoesNotExistException(prog_name.upper()) + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + + return ret + + +def txml_curang(server: str, grp_num: int = 1) -> TxMlCurAngResponse: + """Returns the current pose of group `grp_num`, in joint angles. + + :param server: Hostname or IP address of COMET RPC server + :param grp_num: The motion group (ie: robot) for which to retrieve the current pose + :returns: The parsed response document + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call(server, function=RpcId.TXML_CURANG, grp_num=grp_num) + + ret = response.RPC[0] + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + return ret + + +def txml_curpos( + server: str, pos_rep: int, pos_type: int = 6, grp_num: int = 1 +) -> TxMlCurPosResponse: + """Returns the current pose of group `grp_num`, as a Cartesian pose. + + :param server: Hostname or IP address of COMET RPC server + :param pos_rep: + :param pos_type: + :param grp_num: The motion group (ie: robot) for which to retrieve the current pose + :returns: The parsed response document + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call( + server, + function=RpcId.TXML_CURPOS, + pos_rep=pos_rep, + pos_type=pos_type, + grp_num=grp_num, + ) + + ret = response.RPC[0] + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + return ret + + +def txsetlin(server: str, prog_name: str, line_num: int = 1) -> TxSetLinResponse: + """Open the program `prog_name` and move cursor to `line_num`. + + NOTE: this most likely can only open TP programs, not Karel. + + :param server: Hostname or IP address of COMET RPC server + :param prog_name: Name of the program to open + :param line_num: Line number within `prog_name` to move cursor to (1-based) + :returns: The parsed response document + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + """ + response = _call( + server, + function=RpcId.TXSETLIN, + prog_name=prog_name.upper(), + line_num=line_num, + ) + + ret = response.RPC[0] + if ret.status != 0: + raise UnexpectedRpcStatusException(ret.status) + return ret + + +def vmip_readva(server: str, prog_name: str, var_name: str) -> VariableReadResponse: + """Read the variable 'var_name' in program 'prog_name'. + + Set `prog_name` to `"*SYSTEM*"` to read system variables. + + :param server: Hostname or IP address of COMET RPC server + :param prog_name: Name of the program hosting the variable + :param var_name: Name of the variable to read + :returns: The parsed response document + :raises BadVariableOrRegisterIndexException: If the name used to refer to the + variable is not formatted properly + :raises NoDataDefinedForProgramException: If the program does not exist or hosts + no readable variable data + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + :raises UnknownVariableException: If the variable cannot be found + """ + response = _call( + server, + function=RpcId.VMIP_READVA, + prog_name=prog_name.upper(), + var_name=var_name.upper(), + ) + + ret = response.RPC[0] + + # check call succeeded + if ret.status != 0: + if ret.status == ErrorDictionary.VARS_006: + raise UnknownVariableException(f"'{var_name.upper()}'") + if ret.status == ErrorDictionary.VARS_011: + raise NoDataDefinedForProgramException(f"'{prog_name.upper()}'") + if ret.status == ErrorDictionary.VARS_024: + raise BadVariableOrRegisterIndexException(f"'{var_name.upper()}'") + raise UnexpectedRpcStatusException(ret.status) + + return ret + + +def vmip_writeva( + server: str, prog_name: str, var_name: str, value: t.Union[str, int, float] +) -> VariableWriteResponse: + """Write 'value' to the variable 'var_name' in program 'prog_name'. + + Set `prog_name` to `"*SYSTEM*"` to write to system variables. + + `value` will always be submitted as a string, even for (system) variables + which are of a different type. `COMET` apparently tries to parse the + string representation and converts it to the required type when possible. + The string representations are expected to be identical to those found in + `.VA` files. + + NOTE: this seems to be mostly used to write to variables with primitive + types (ie: `INTEGER`, `REAL`, `BOOLEAN` and `STRING[n]`). Writing to + arrays should be done one element at a time. Structures can most likely + also only be written to by addressing individual fields. + + :param server: Hostname or IP address of COMET RPC server + :param prog_name: Name of the program hosting the variable + :param var_name: Name of the variable to read + :param value: The (string representation of the) value to write to the variable + :returns: The parsed response document + :raises BadElementInStructureException: If the variable name does not correctly + refer to a field + :raises BadVariableOrRegisterIndexException: If the name used to refer to the + variable is not formatted properly + :raises NoDataDefinedForProgramException: If the program does not exist or hosts + no readable variable data + :raises UnexpectedRpcStatusException: on any other non-zero RPC status code + :raises UnknownVariableException: If the variable cannot be found + """ + response = _call( + server, + function=RpcId.VMIP_WRITEVA, + prog_name=prog_name.upper(), + var_name=var_name.upper(), + value=value, + ) + + ret = response.RPC[0] + + # check call succeeded + if ret.status != 0: + if ret.status == ErrorDictionary.VARS_006: + raise UnknownVariableException(f"'{var_name.upper()}'") + if ret.status == ErrorDictionary.VARS_011: + raise NoDataDefinedForProgramException(f"'{prog_name.upper()}'") + if ret.status == ErrorDictionary.VARS_024: + raise BadVariableOrRegisterIndexException(f"'{var_name.upper()}'") + if ret.status == ErrorDictionary.VARS_049: + raise BadElementInStructureException(f"'{var_name.upper()}'") + raise UnexpectedRpcStatusException(ret.status) + + return ret diff --git a/src/comet_rpc/exceptions.py b/src/comet_rpc/exceptions.py new file mode 100644 index 0000000..b529468 --- /dev/null +++ b/src/comet_rpc/exceptions.py @@ -0,0 +1,95 @@ +# Copyright (c) 2023, G.A. vd. Hoorn +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# author: G.A. vd. Hoorn + + +class CometRpcException(Exception): + pass + + +class LockedResourceException(CometRpcException): + pass + + +class AuthenticationException(CometRpcException): + pass + + +class UnexpectedResultCodeException(CometRpcException): + pass + + +class UnexpectedResponseContentException(CometRpcException): + pass + + +class UnexpectedRpcStatusException(CometRpcException): + def __init__(self, status): + self._status = status + + @property + def status(self) -> int: + return self._status + + +class DeserialisationException(CometRpcException): + pass + + +class InvalidIoTypeException(CometRpcException): + pass + + +class InvalidIoIndexException(CometRpcException): + pass + + +class NoSuchMethodException(CometRpcException): + pass + + +# VARS-006 +class UnknownVariableException(CometRpcException): + pass + + +# VARS-011 +class NoDataDefinedForProgramException(CometRpcException): + pass + + +# VARS-024: You are attempting to use an invalid index into an array or path +class BadVariableOrRegisterIndexException(CometRpcException): + pass + + +# VARS-049: ASCII value specified is invalid +class BadElementInStructureException(CometRpcException): + pass + + +# MEMO-073: The specified program does not exist in the system +class ProgramDoesNotExistException(CometRpcException): + pass + + +# DICT-005: Dictionary element not found +class DictElementNotFoundException(CometRpcException): + pass + + +# PRIO-030: +class NoCommentOnIoPortException(CometRpcException): + pass diff --git a/src/comet_rpc/fr_errors.py b/src/comet_rpc/fr_errors.py new file mode 100644 index 0000000..e9ac041 --- /dev/null +++ b/src/comet_rpc/fr_errors.py @@ -0,0 +1,32 @@ +# Copyright (c) 2023, G.A. vd. Hoorn +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# author: G.A. vd. Hoorn + +from enum import IntEnum + + +class ErrorDictionary(IntEnum): + DICT_005 = 0x210005 + + MEMO_073 = 0x070049 + + PRIO_001 = 0x0D0001 + PRIO_002 = 0x0D0002 + PRIO_030 = 0x0D001E + + VARS_006 = 0x100006 + VARS_011 = 0x10000B + VARS_024 = 0x100018 + VARS_049 = 0x100031 diff --git a/src/comet_rpc/fr_types.py b/src/comet_rpc/fr_types.py new file mode 100644 index 0000000..98db9c5 --- /dev/null +++ b/src/comet_rpc/fr_types.py @@ -0,0 +1,83 @@ +# Copyright (c) 2023, G.A. vd. Hoorn +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# author: G.A. vd. Hoorn + +from dataclasses import dataclass +from enum import IntEnum + + +class PositionType(IntEnum): + XYZWPR = 2 + JOINTPOS = 9 + + +@dataclass +class Configuration: + flip: bool + up: bool + top: bool + turn_no1: int + turn_no2: int + turn_no3: int + + # TODO: this will be incorrect for non-6-axes systems + def __repr__(self) -> str: + f = "F" if self.flip else "N" + u = "U" if self.up else "D" + t = "T" if self.top else "B" + return f"Config: {f} {u} {t}, {self.turn_no1}, {self.turn_no2}, {self.turn_no3}" + + +@dataclass +class JointPos9: + group: int + j1: float + j2: float + j3: float + j4: float + j5: float + j6: float + j7: float + j8: float + j9: float + + # TODO: this will be incorrect for non-6-axes systems + def __repr__(self) -> str: + return ( + f"Group: {self.group}\n" + f"J1: {self.j1:8.3f} J2: {self.j2:8.3f} J3: {self.j3:8.3f}\n" + f"J4: {self.j4:8.3f} J5: {self.j5:8.3f} J6: {self.j6:8.3f}\n" + ) + + +@dataclass +class XyzWpr: + config: Configuration + group: int + x: float + y: float + z: float + w: float + p: float + r: float + + # TODO: this might be incorrect for non-6-axes systems + def __repr__(self) -> str: + return ( + f"Group: {self.group}\n" + f"Config: {self.config}\n" + f"X: {self.x:8.3f} Y: {self.y:8.3f} Z: {self.z:8.3f}\n" + f"W: {self.w:8.3f} P: {self.p:8.3f} R: {self.r:8.3f}\n" + ) diff --git a/src/comet_rpc/kliotyps.py b/src/comet_rpc/kliotyps.py new file mode 100644 index 0000000..034c376 --- /dev/null +++ b/src/comet_rpc/kliotyps.py @@ -0,0 +1,70 @@ +# Copyright (c) 2023, G.A. vd. Hoorn +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# author: G.A. vd. Hoorn + +from enum import IntEnum + + +class IoType(IntEnum): + """from kliotyps.kl, V9.40""" + + DigitalIn = 1 # Digital input + DigitalOut = 2 # Digital output + AnalogIn = 3 # Analog input + AnalogOut = 4 # Analog output + ToolOut = 5 # Tool output + PlcIn = 6 # PLC input + PlcOut = 7 # PLC output + RobotDigitalIn = 8 # Robot digital input + RobotDigitalOut = 9 # Robot digital output + BrakeOut = 10 # Brake output + # old names (?) + OpPanelIn = 11 # Operator panels input + OpPanelOut = 12 # Operator panels output + # new names (?) + SoPanelIn = 11 # Same as OpPanelIn + SoPanelOut = 12 # Same as OpPanelOut + Estop = 13 # Emergency stop + TpIn = 14 # Teach pendant digital input + TpOut = 15 # Teach pendant digital output + WeldDigitalIn = 16 # Weld inputs + WeldDigitalOut = 17 # Weld outputs + GroupedIn = 18 # Grouped inputs (16 bits) + GroupedOut = 19 # Grouped outputs (16 bits) + UserOpPanelIn = 20 # User operator's panel input + UserOpPanelOut = 21 # User operator's panel output + LaserDigIn = 22 # Laser DIN + LaserDigOut = 23 # Laser DOUT + LaserAnaIn = 24 # Laser AIN + LaserAnaOut = 25 # Laser AOUT + WeldStickInput = 26 # Weld stick input + WeldStickOutput = 27 # Weld stick output + MemImgBoolean = 28 # Memory image boolean's + MemImgDigIn = 29 # Memory image din's + DummyBoolPort = 30 # Dummy boolean port type + DummyNumPort = 31 # Dummy numeric port type + ProcessAxis = 32 # Process axes + InternalOpPanelInput = 33 # Internal operator's panel input + InternalOpPanelOutput = 34 # Internal operator's panel output + Flag = 35 # Flag (F[ ]) + Marker = 36 # Marker (M[ ]) + GroupedIn32 = 37 # Grouped inputs (32 bits) + GroupedOut32 = 38 # Grouped outputs (32 bits) + + # physical only + InternalRelayBackup = 41 # Backuped internal relay + InternalRelay = 42 # No backuped internal relay + InternalRegBackup = 43 # Backuped internal register + InternalReg = 44 # No backuped internal register diff --git a/src/comet_rpc/messages.py b/src/comet_rpc/messages.py new file mode 100644 index 0000000..d7121f2 --- /dev/null +++ b/src/comet_rpc/messages.py @@ -0,0 +1,237 @@ +# Copyright (c) 2023, G.A. vd. Hoorn +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# author: G.A. vd. Hoorn + +from base64 import urlsafe_b64decode + +from enum import Enum + +import typing as t +import typing_extensions as te + +from pydantic import ( + BaseModel, + Field, + validator, +) + +from .fr_types import PositionType +from .kliotyps import IoType + + +# TODO: use StrEnum when we have Python 3.11+ +class RpcId(str, Enum): + # COMET's response-JSON quotes integers, and it looks like discriminators + # are checked before validators are run (so the conversion to int in + # BaseRpcResponse only happens after the discriminator field has been + # checked) + CPKCL = "87" + DPEWRITE_STR = "83" + DPREAD = "148" + ERPOST = "85" + GET_RAW_FILE = "251" + GTFILIST = "234" + IODEFPN = "68" + IOGETPN = "67" + IOGTALL = "226" + IOVALRD = "62" + IOVALSET = "63" + LOCAL_START = "245" + MMCREMN = "22" + MMMSOPEN = "9" + PGABORT = "102" + POSREGVALRD = "248" + RECPOS = "236" + REGVALRD = "247" + RPRINTF = "89" + TXCHGPRG = "43" + TXML_CURANG = "91" + TXML_CURPOS = "90" + TXSETLIN = "237" + VMIP_READVA = "31" + VMIP_WRITEVA = "32" + + +class BaseRpcResponse(BaseModel): + rpc: int + status: int + + @validator("status", pre=True) + def set_status(cls, v, values, **kwargs): + return int(v, 16) if isinstance(v, str) else v + + +class ReadIoResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.IOVALRD] + type: IoType + index: int + value: int + + +class WriteIoResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.IOVALSET] + + +class VariableReadResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.VMIP_READVA] + prog_name: str + var_name: str + type_code: int + value: str + + +class VariableWriteResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.VMIP_WRITEVA] + + +class RegisterReadResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.REGVALRD] + type: int + value: t.Union[int, float] + comment: str + + +class PosRegValReadResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.POSREGVALRD] + type: PositionType + comment: str + value: str + + +class DpReadResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.DPREAD] + value: str + + +class DpeWriteStrResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.DPEWRITE_STR] + value: str + + +class TxSetLinResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.TXSETLIN] + + +class TxChgPrgResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.TXCHGPRG] + + +class LocalStartResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.LOCAL_START] + + +class ExecKclCommandResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.CPKCL] + + +class RemotePrintfResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.RPRINTF] + + +class IoGetPnResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.IOGETPN] + value: str + + +class IoDefPnResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.IODEFPN] + + +class IoGetAllResponseElement(BaseModel): + index: int + val: int + sim: bool + comment: str + + +class IoGetAllResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.IOGTALL] + value: t.List[IoGetAllResponseElement] + + +class GetRawFileLine(BaseModel): + buf: bytes + + @validator("buf", pre=True) + def decode_buf(cls, v): + return urlsafe_b64decode(v) + + +class GetRawFileResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.GET_RAW_FILE] + lines: t.List[GetRawFileLine] + + +class GetFileListResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.GTFILIST] + value: str + + +class TxMlCurPosResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.TXML_CURPOS] + value: str + + +class TxMlCurAngResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.TXML_CURANG] + value: str + + +class ProgAbortResponse(BaseRpcResponse): + rpc: t.Literal[RpcId.PGABORT] + + +# from https://github.com/pydantic/pydantic/discussions/3754#discussioncomment-2076473 +AnnotatedResponseType = te.Annotated[ + t.Union[ + ExecKclCommandResponse, + DpeWriteStrResponse, + DpReadResponse, + GetRawFileResponse, + GetFileListResponse, + IoGetPnResponse, + IoDefPnResponse, + IoGetAllResponse, + LocalStartResponse, + PosRegValReadResponse, + ProgAbortResponse, + ReadIoResponse, + RegisterReadResponse, + RemotePrintfResponse, + TxChgPrgResponse, + TxSetLinResponse, + TxMlCurAngResponse, + TxMlCurPosResponse, + VariableReadResponse, + WriteIoResponse, + ], + # unfortunately, it looks like discriminator values are checked before type + # coercion, so 'rpc' will still be a 'str' instead of an 'int' + Field(discriminator="rpc"), +] + + +class RpcResponse(BaseModel): + name: str + fastclock: int + # this will/can break for some COMET responses + # + # Only responses with a single element in the 'RPC' field have been + # observed, but it's formatted as an unbounded list, so technically + # it would be possible for multiple elements to be returned. + # + # either a specific type, or on error, try BaseRpcResponse (it's likely + # parsing fails because status != 0x00) + RPC: t.List[t.Union[AnnotatedResponseType, BaseRpcResponse]]