├── setup.cfg ├── LICENSE ├── setup.py ├── .gitignore ├── README.md └── cipclient.py /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | # This includes the license file(s) in the wheel. 3 | # https://wheel.readthedocs.io/en/stable/user_guide.html#including-license-files-in-the-generated-wheel-file 4 | license_files = LICENSE 5 | 6 | [bdist_wheel] 7 | # This flag says to generate wheels that support both Python 2 and Python 8 | # 3. If your code will not run unchanged on both Python 2 and 3, you will 9 | # need to generate separate wheels for each Python version that you 10 | # support. Removing this line (or setting universal to 0) will prevent 11 | # bdist_wheel from trying to make a universal wheel. For more see: 12 | # https://packaging.python.org/guides/distributing-packages-using-setuptools/#wheels 13 | universal=0 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 klenae 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """A setuptools based setup module. 2 | 3 | See: 4 | https://packaging.python.org/guides/distributing-packages-using-setuptools/ 5 | https://github.com/pypa/sampleproject 6 | """ 7 | 8 | # Always prefer setuptools over distutils 9 | from setuptools import setup 10 | from os import path 11 | 12 | here = path.abspath(path.dirname(__file__)) 13 | 14 | # Get the long description from the README file 15 | with open(path.join(here, "README.md"), encoding="utf-8") as f: 16 | long_description = f.read() 17 | 18 | # Arguments marked as "Required" below must be included for upload to PyPI. 19 | # Fields marked as "Optional" may be commented out. 20 | 21 | setup( 22 | name="python-cipclient", # Required 23 | version="0.0.2", # Required 24 | description="""A Python-based socket client for communicating 25 | with Crestron control processors via CIP.""", # Optional 26 | long_description=long_description, # Optional 27 | long_description_content_type="text/markdown", # Required for .md content 28 | url="https://github.com/klenae/python-cipclient", # Optional 29 | author="Katherine Lenae", # Optional 30 | author_email="klenae@gmail.com", # Optional 31 | classifiers=[ # Optional 32 | "Development Status :: 3 - Alpha", 33 | "Intended Audience :: Developers", 34 | "Topic :: Home Automation", 35 | "License :: OSI Approved :: MIT License", 36 | "Programming Language :: Python :: 3", 37 | "Operating System :: OS Independent", 38 | "Programming Language :: Python :: 3.6", 39 | "Programming Language :: Python :: 3.7", 40 | "Programming Language :: Python :: 3.8", 41 | ], 42 | keywords="development cip home-automation", 43 | python_requires=">=3.6", 44 | py_modules=["cipclient"], 45 | ) 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Misc. dev testing files 132 | cip_test.py 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-cipclient 2 | [![License](https://img.shields.io/github/license/klenae/python-cipclient)](https://github.com/klenae/python-cipclient/blob/master/LICENSE) 3 | [![PyPI](https://img.shields.io/pypi/v/python-cipclient)](https://pypi.org/project/python-cipclient/) 4 | ![Python Version](https://img.shields.io/pypi/pyversions/python-cipclient) 5 | ![PyPI - Wheel](https://img.shields.io/pypi/wheel/python-cipclient) 6 | [![Black](https://img.shields.io/badge/code%20style-black-000000)](https://github.com/ambv/black) 7 | [![Issues](https://img.shields.io/github/issues/klenae/python-cipclient)](https://github.com/klenae/python-cipclient/issues) 8 | 9 | A Python module for communicating with Crestron control processors via the 10 | Crestron-over-IP (CIP) protocol. 11 | 12 | --- 13 | 14 | #### _NOTICE: This module is not produced, endorsed, maintained or supported by Crestron Electronics Incorporated. 'XPanel', 'Smart Graphics' and 'SIMPL Windows' are all trademarks of Crestron. The author is not affiliated in any way with Crestron with the exception of owning and using some of their hardware._ 15 | 16 | This is a Python-based socket client that facilitates communications with a Crestron control processor using the Crestron-over-IP (CIP) protocol. Familiarity with and access to Crestron's development tools, processes and terminology are required to configure the control processor in a way that allows this module to be used. 17 | 18 | 19 | ## Installation 20 | This module is available throught the [Python Package Index](https://pypi.org/project/python-cipclient/), and can be installed using the pip package-management system: 21 | 22 | `pip install python-cipclient` 23 | 24 | ## Usage and API 25 | This module works by connecting to an "XPanel 2.0 Smart Graphics" symbol defined in a SIMPL Windows program. Once the control processor has been programmed accordingly, you can communicate with it using the API as described below. 26 | 27 | ### Getting Started 28 | Here is a simple example that demonstrates setting and getting join states using this module. 29 | 30 | ```python 31 | import logging 32 | import time 33 | import cipclient 34 | 35 | # uncomment the line below to enable debugging output to console 36 | # logging.basicConfig(level=logging.DEBUG, format="[%(levelname)s] (%(threadName)-10s) %(message)s") 37 | 38 | # set up the client to connect to hostname "processor" at IP-ID 0x0A 39 | cip = cipclient.CIPSocketClient("processor", 0x0A) 40 | 41 | # initiate the socket connection and start worker threads 42 | cip.start() 43 | time.sleep(1.5) 44 | 45 | # you can force this client and the processor to resync using an update request 46 | cip.update_request() # note that this also occurs automatically on first connection 47 | 48 | # for joins coming from this client going to the processor 49 | cip.set("d", 1, 1) # set digital join 1 high 50 | cip.set("d", 132, 0) # set digital join 132 low 51 | cip.set("a", 12, 32456) # set analog join 12 to 32456 52 | cip.set("s", 101, "Hello Crestron!") # set serial join 101 to "Hello Crestron!" 53 | cip.pulse(2) # pulses digital join 2 (sets it high then immediately sets it low again) 54 | cip.press(3) # emulates a touchpanel button press on digital join 3 (stays high until released) 55 | cip.release(3) # emulates a touchpanel button release on digital join 3 56 | 57 | # for joins coming from the processor going to this client 58 | digital_34 = cip.get("d", 34) # returns the current state of digital join 34 59 | analog_109 = cip.get("a", 109) # returns the current state of analog join 109 60 | serial_223 = cip.get("s", 223) # returns the current state of serial join 223 61 | 62 | # you should really subscribe to incoming (processor > client) joins rather than polling 63 | def my_callback(sigtype, join, state): 64 | print(f"{sigtype} {join} : {state}") 65 | 66 | cip.subscribe("d", 1, my_callback) # run 'my_callback` when digital join 1 changes 67 | 68 | # this will close the socket connection when you're finished 69 | cip.stop() 70 | ``` 71 | 72 | ### Detailed Descriptions 73 | `start()` should be called once after instantiating a CIPSocketClient to initiate the socket connection and start the required worker threads. When the socket connection is first established, the standard CIP registration and update request procedures are performed automatically. 74 | 75 | `stop()` should be called once when you're finished with the CIPSocketClient to close the socket connection and shut down the worker threads. 76 | 77 | `update_request()` can be used while connected to initiate the update request (two-way synchronization) procedure. 78 | 79 | `set(sigtype, join, value)` is used to set the state of joins coming from the CIPSocketClient as seen by the control processor. `sigtype` can be `"d"` for digital joins, `"a"` for analog joins or `"s"` for serial joins. `join` is the join number. `value` can be `0` or `1` for digital joins, `0` - `65535` for analog joins or a string for serial joins. 80 | 81 | `press(join)` sets digital `join` high using special CIP processing intended for joins that should be automatically reset to a low state if the connection is broken or times out unexpectedly. 82 | 83 | `release(join)` sets digital `join` low. Used in conjunction with `press()`. 84 | 85 | `pulse(join)` sends a momentary pulse on digital `join` by setting the join high then immediately setting it low again. 86 | 87 | `get(sigtype, join, direction="in")` returns the current state of the specified join as it exists within the CIPSocketClient's state machine. (Join changes are always sent from the control processor to the client at the moment they change. The client tracks all incoming messages and stores the current state of every join in its state machine.) `sigtype` can be `"d"`, `"a"` or `"s"` for digital, analog or serial signals. `join` is the join number. `direction` is an optional argument, which is set to `"in"` by default to retrieve the state of incoming joins. If you need to get the last state of a join that was sent from the client to the control processor, you can specify `direction="out"`. 88 | 89 | `subscribe(sigtype, join, callback, direction="in")` is used to specify a callback function that should be called any time the specified join changes state. `sigtype`, `join` and `direction` function the same as in the `get` method described above. `callback` is the name of the function that should be called on each change. `sigtype`, `join` and `state` will be passed to the specified callback in that order. See the example above in the *Getting Started* section. 90 | 91 | -------------------------------------------------------------------------------- /cipclient.py: -------------------------------------------------------------------------------- 1 | """A Python module for communicating with a Crestron control processor via CIP.""" 2 | 3 | # Standard Imports 4 | import binascii 5 | import logging 6 | import queue 7 | import socket 8 | import threading 9 | import time 10 | 11 | _logger = logging.getLogger(__name__) 12 | 13 | 14 | class SendThread(threading.Thread): 15 | """Process outgoing CIP packets and generates heartbeat packets.""" 16 | 17 | def __init__(self, cip): 18 | """Set up the CIP outgoing packet processing thread.""" 19 | self._stop_event = threading.Event() 20 | self.cip = cip 21 | threading.Thread.__init__(self, name="Send") 22 | 23 | def run(self): 24 | """Start the CIP outgoing packet processing thread.""" 25 | _logger.debug("started") 26 | 27 | time_asleep_heartbeat = 0 28 | time_asleep_buttons = 0 29 | 30 | while not self._stop_event.is_set(): 31 | while not self.cip.tx_queue.empty(): 32 | tx = self.cip.tx_queue.get() 33 | if self.cip.restart_connection is False: 34 | _logger.debug(f"TX: <{str(binascii.hexlify(tx), 'ascii')}>") 35 | try: 36 | self.cip.socket.sendall(tx) 37 | except socket.error: 38 | with self.cip.restart_lock: 39 | self.cip.restart_connection = True 40 | time_asleep_heartbeat = 0 41 | 42 | time.sleep(0.01) 43 | 44 | if self.cip.connected is True and self.cip.restart_connection is False: 45 | time_asleep_heartbeat += 0.01 46 | if time_asleep_heartbeat >= 15: 47 | self.cip.tx_queue.put(b"\x0D\x00\x02\x00\x00") 48 | time_asleep_heartbeat = 0 49 | 50 | time_asleep_buttons += 0.01 51 | if time_asleep_buttons >= 0.50 and len(self.cip.buttons_pressed): 52 | with self.cip.buttons_lock: 53 | for join in self.cip.buttons_pressed: 54 | try: 55 | if self.cip.join["out"]["d"][join][0] == 1: 56 | self.cip.tx_queue.put( 57 | self.cip.buttons_pressed[join] 58 | ) 59 | except KeyError: 60 | pass 61 | time_asleep_buttons = 0 62 | 63 | _logger.debug("stopped") 64 | 65 | def join(self, timeout=None): 66 | """Stop the CIP outgoing packet processing thread.""" 67 | self._stop_event.set() 68 | threading.Thread.join(self, timeout) 69 | 70 | 71 | class ReceiveThread(threading.Thread): 72 | """Process incoming CIP packets.""" 73 | 74 | def __init__(self, cip): 75 | """Set up the CIP incoming packet processing thread.""" 76 | self._stop_event = threading.Event() 77 | self.cip = cip 78 | threading.Thread.__init__(self, name="Receive") 79 | 80 | def run(self): 81 | """Start the CIP incoming packet processing thread.""" 82 | _logger.debug("started") 83 | 84 | while not self._stop_event.is_set(): 85 | try: 86 | if self.cip.restart_connection is False: 87 | rx = self.cip.socket.recv(4096) 88 | _logger.debug(f'RX: <{str(binascii.hexlify(rx), "ascii")}>') 89 | 90 | position = 0 91 | length = len(rx) 92 | 93 | while position < length: 94 | if (length - position) < 4: 95 | _logger.warning("Packet is too short") 96 | break 97 | 98 | payload_length = (rx[position + 1] << 8) + rx[position + 2] 99 | packet_length = payload_length + 3 100 | 101 | if (length - position) < packet_length: 102 | _logger.warning("Packet length mismatch") 103 | break 104 | 105 | packet_type = rx[position] 106 | payload = rx[position + 3 : position + 3 + payload_length] 107 | 108 | self.cip._processPayload(packet_type, payload) 109 | position += packet_length 110 | else: 111 | time.sleep(0.1) 112 | 113 | except (socket.error, socket.timeout) as e: 114 | if e.args[0] != "timed out": 115 | with self.cip.restart_lock: 116 | self.cip.restart_connection = True 117 | 118 | _logger.debug("stopped") 119 | 120 | def join(self, timeout=None): 121 | """Stop the CIP incoming packet processing thread.""" 122 | self._stop_event.set() 123 | threading.Thread.join(self, timeout) 124 | 125 | 126 | class EventThread(threading.Thread): 127 | """Process join event queue.""" 128 | 129 | def __init__(self, cip): 130 | """Set up the join event processing thread.""" 131 | self._stop_event = threading.Event() 132 | self.cip = cip 133 | threading.Thread.__init__(self, name="Event") 134 | 135 | def run(self): 136 | """Start the join event processing thread.""" 137 | _logger.debug("started") 138 | 139 | while not self._stop_event.is_set(): 140 | if not self.cip.event_queue.empty(): 141 | direction, sigtype, join, value = self.cip.event_queue.get() 142 | 143 | with self.cip.join_lock: 144 | try: 145 | self.cip.join[direction][sigtype[0]][join][0] = value 146 | for callback in self.cip.join[direction][sigtype[0]][join][1:]: 147 | callback(sigtype[0], join, value) 148 | except KeyError: 149 | self.cip.join[direction][sigtype[0]][join] = [ 150 | value, 151 | ] 152 | _logger.debug(f" : {sigtype} {direction} {join} = {value}") 153 | 154 | if direction == "out": 155 | tx = bytearray(self.cip._cip_packet[sigtype]) 156 | cip_join = join - 1 157 | if sigtype[0] == "d": 158 | packed_join = (cip_join // 256) + ((cip_join % 256) * 256) 159 | if value == 0: 160 | packed_join |= 0x80 161 | tx += packed_join.to_bytes(2, "big") 162 | if sigtype == "db": 163 | with self.cip.buttons_lock: 164 | if value == 1: 165 | self.cip.buttons_pressed[join] = tx 166 | elif join in self.cip.buttons_pressed: 167 | self.cip.buttons_pressed.pop(join) 168 | elif sigtype == "a": 169 | tx += cip_join.to_bytes(2, "big") 170 | tx += value.to_bytes(2, "big") 171 | elif sigtype == "s": 172 | tx[2] = 8 + len(value) 173 | tx[6] = 4 + len(value) 174 | tx += cip_join.to_bytes(2, "big") 175 | tx += b"\x03" 176 | tx += bytearray(value, "ascii") 177 | if ( 178 | self.cip.connected is True 179 | and self.cip.restart_connection is False 180 | ): 181 | self.cip.tx_queue.put(tx) 182 | 183 | time.sleep(0.001) 184 | 185 | _logger.debug("stopped") 186 | 187 | def join(self, timeout=None): 188 | """Stop the join event processing thread.""" 189 | self._stop_event.set() 190 | threading.Thread.join(self, timeout) 191 | 192 | 193 | class ConnectionThread(threading.Thread): 194 | """Manage the socket connection to the control processor.""" 195 | 196 | def __init__(self, cip): 197 | """Set up the socket management thread.""" 198 | self._stop_event = threading.Event() 199 | self.cip = cip 200 | threading.Thread.__init__(self, name="Connection") 201 | 202 | def run(self): 203 | """Start the socket management thread.""" 204 | _logger.debug("started") 205 | 206 | warning_posted = False 207 | 208 | while not self._stop_event.is_set(): 209 | 210 | try: 211 | self.cip.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 212 | self.cip.socket.settimeout(self.cip.timeout) 213 | self.cip.socket.connect((self.cip.host, self.cip.port)) 214 | except socket.error: 215 | self.cip.socket.close() 216 | if warning_posted is False: 217 | _logger.debug( 218 | f"attempting to connect to {self.cip.host}:{self.cip.port}, " 219 | "no success yet" 220 | ) 221 | warning_posted = True 222 | if not self._stop_event.is_set(): 223 | time.sleep(10) 224 | else: 225 | warning_posted = False 226 | _logger.debug(f"connected to {self.cip.host}:{self.cip.port}") 227 | if not self.cip.restart_connection: 228 | self.cip.event_thread.start() 229 | self.cip.send_thread.start() 230 | self.cip.receive_thread.start() 231 | self.cip.restart_connection = False 232 | while ( 233 | not self._stop_event.is_set() 234 | and self.cip.restart_connection is False 235 | ): 236 | time.sleep(1) 237 | if not self._stop_event.is_set(): 238 | self.cip.connected = False 239 | self.cip.socket.close() 240 | _logger.debug(f"lost connection to {self.cip.host}:{self.cip.port}") 241 | time.sleep(10) 242 | else: 243 | self.cip.send_thread.join() 244 | self.cip.event_thread.join() 245 | self.cip.receive_thread.join() 246 | 247 | self.cip.socket.close() 248 | 249 | _logger.debug("stopped") 250 | 251 | def join(self, timeout=None): 252 | """Stop the socket management thread.""" 253 | self._stop_event.set() 254 | threading.Thread.join(self, timeout) 255 | 256 | 257 | class CIPSocketClient: 258 | """Facilitate communications with a Crestron control processor via CIP.""" 259 | 260 | _cip_packet = { 261 | "d": b"\x05\x00\x06\x00\x00\x03\x00", # standard digital join 262 | "db": b"\x05\x00\x06\x00\x00\x03\x27", # button-style digital join 263 | "dp": b"\x05\x00\x06\x00\x00\x03\x27", # pulse-style digital join 264 | "a": b"\x05\x00\x08\x00\x00\x05\x14", # analog join 265 | "s": b"\x12\x00\x00\x00\x00\x00\x00\x34", # serial join 266 | } 267 | 268 | def __init__(self, host, ipid, port=41794, timeout=2): 269 | """Set up CIP client instance.""" 270 | self.host = host 271 | self.ipid = ipid.to_bytes(length=1, byteorder="big") 272 | self.port = port 273 | self.timeout = timeout 274 | self.socket = None 275 | self.connected = False 276 | self.restart_lock = threading.Lock() 277 | self.restart_connection = False 278 | self.buttons_pressed = {} 279 | self.buttons_lock = threading.Lock() 280 | 281 | self.send_thread = SendThread(self) 282 | self.receive_thread = ReceiveThread(self) 283 | self.event_thread = EventThread(self) 284 | self.connection_thread = ConnectionThread(self) 285 | 286 | self.tx_queue = queue.Queue() 287 | self.event_queue = queue.Queue() 288 | 289 | self.join_lock = threading.Lock() 290 | self.join = { 291 | "in": {"d": {}, "a": {}, "s": {}}, 292 | "out": {"d": {}, "a": {}, "s": {}}, 293 | } 294 | 295 | def start(self): 296 | """Start the CIP client instance.""" 297 | if self.connection_thread.is_alive(): 298 | _logger.error("start() called while already running") 299 | else: 300 | _logger.debug("start requested") 301 | self.connection_thread.start() 302 | 303 | def stop(self): 304 | """Stop the CIP client instance.""" 305 | if not self.connection_thread.is_alive(): 306 | _logger.error("stop() called while already stopped") 307 | else: 308 | _logger.debug("stop requested") 309 | self.connection_thread.join() 310 | 311 | def set(self, sigtype, join, value): 312 | """Set an outgoing join.""" 313 | if sigtype == "d": 314 | if (value != 0) and (value != 1): 315 | _logger.error(f"set(): '{value}' is not a valid digital signal state") 316 | return 317 | elif sigtype == "a": 318 | if (type(value) is not int) or (value > 65535): 319 | _logger.error(f"set(): '{value}' is not a valid analog signal value") 320 | return 321 | elif sigtype == "s": 322 | value = str(value) 323 | else: 324 | _logger.debug(f"set(): '{sigtype}' is not a valid signal type") 325 | return 326 | 327 | self.event_queue.put(("out", sigtype, join, value)) 328 | 329 | def press(self, join): 330 | """Set a digital output join to the active state using CIP button logic.""" 331 | self.event_queue.put(("out", "db", join, 1)) 332 | 333 | def release(self, join): 334 | """Set a digital output join to the inactive state using CIP button logic.""" 335 | self.event_queue.put(("out", "db", join, 0)) 336 | 337 | def pulse(self, join): 338 | """Generate an active-inactive pulse on the specified digital output join.""" 339 | self.event_queue.put(("out", "dp", join, 1)) 340 | self.event_queue.put(("out", "dp", join, 0)) 341 | 342 | def get(self, sigtype, join, direction="in"): 343 | """Get the current value of a join.""" 344 | if (direction != "in") and (direction != "out"): 345 | raise ValueError(f"get(): '{direction}' is not a valid signal direction") 346 | if (sigtype != "d") and (sigtype != "a") and (sigtype != "s"): 347 | raise ValueError(f"get(): '{sigtype}' is not a valid signal type") 348 | 349 | with self.join_lock: 350 | try: 351 | value = self.join[direction][sigtype][join][0] 352 | except KeyError: 353 | if sigtype == "s": 354 | value = "" 355 | else: 356 | value = 0 357 | return value 358 | 359 | def update_request(self): 360 | """Send an update request to the control processor.""" 361 | if self.connected is True: 362 | self.tx_queue.put(b"\x05\x00\x05\x00\x00\x02\x03\x00") 363 | else: 364 | _logger.debug("update_request(): not currently connected") 365 | 366 | def subscribe(self, sigtype, join, callback, direction="in"): 367 | """Subscribe to join change events by specifying callback functions.""" 368 | if (direction != "in") and (direction != "out"): 369 | raise ValueError( 370 | f"subscribe(): '{direction}' is not a valid signal direction" 371 | ) 372 | if (sigtype != "d") and (sigtype != "a") and (sigtype != "s"): 373 | raise ValueError(f"subscribe(): '{sigtype}' is not a valid signal type") 374 | 375 | with self.join_lock: 376 | if join not in self.join[direction][sigtype]: 377 | if sigtype == "s": 378 | value = "" 379 | else: 380 | value = 0 381 | self.join[direction][sigtype][join] = [ 382 | value, 383 | ] 384 | self.join[direction][sigtype][join].append(callback) 385 | 386 | def _processPayload(self, ciptype, payload): 387 | """Process CIP packets.""" 388 | _logger.debug( 389 | f'> Type 0x{ciptype:02x} <{str(binascii.hexlify(payload), "ascii")}>' 390 | ) 391 | length = len(payload) 392 | restartRequired = False 393 | 394 | if ciptype == 0x0D or ciptype == 0x0E: 395 | # heartbeat 396 | _logger.debug(" Heartbeat") 397 | elif ciptype == 0x05: 398 | # data 399 | datatype = payload[3] 400 | 401 | if datatype == 0x00: 402 | # digital join 403 | join = (((payload[5] & 0x7F) << 8) | payload[4]) + 1 404 | state = ((payload[5] & 0x80) >> 7) ^ 0x01 405 | self.event_queue.put(("in", "d", join, state)) 406 | _logger.debug(f" Incoming Digital Join {join:04} = {state}") 407 | elif datatype == 0x14: 408 | join = ((payload[4] << 8) | payload[5]) + 1 409 | value = (payload[6] << 8) + payload[7] 410 | self.event_queue.put(("in", "a", join, value)) 411 | _logger.debug(f" Incoming Analog Join {join:04} = {value}") 412 | elif datatype == 0x03: 413 | # update request 414 | update_request_type = payload[4] 415 | if update_request_type == 0x00: 416 | # standard update request 417 | _logger.debug(" Standard update request") 418 | elif update_request_type == 0x16: 419 | # penultimate update request 420 | _logger.debug(" Mysterious penultimate update-response") 421 | elif update_request_type == 0x1C: 422 | # end-of-query 423 | _logger.debug(" End-of-query") 424 | self.tx_queue.put(b"\x05\x00\x05\x00\x00\x02\x03\x1d") 425 | self.tx_queue.put(b"\x0D\x00\x02\x00\x00") 426 | self.connected = True 427 | with self.join_lock: 428 | for sigtype, joins in self.join["out"].items(): 429 | for j in joins: 430 | self.set(sigtype, j, joins[j][0]) 431 | elif update_request_type == 0x1D: 432 | # end-of-query acknowledgement 433 | _logger.debug(" End-of-query acknowledgement") 434 | else: 435 | # unexpected update request packet 436 | _logger.debug("! We don't know what to do with this update request") 437 | elif datatype == 0x08: 438 | # date/time 439 | cip_date = str(binascii.hexlify(payload[4:]), "ascii") 440 | _logger.debug( 441 | f" Received date/time from control processor <" 442 | f"{cip_date[2:4]}:{cip_date[4:6]}:" 443 | f"{cip_date[6:8]} {cip_date[8:10]}/" 444 | f"{cip_date[10:12]}/20{cip_date[12:]}>" 445 | ) 446 | else: 447 | # unexpected data packet 448 | _logger.debug("! We don't know what to do with this data") 449 | elif ciptype == 0x12: 450 | join = ((payload[5] << 8) | payload[6]) + 1 451 | value = str(payload[8:], "ascii") 452 | self.event_queue.put(("in", "s", join, value)) 453 | _logger.debug(f" Incoming Serial Join {join:04} = {value}") 454 | elif ciptype == 0x0F: 455 | # registration request 456 | _logger.debug(" Client registration request") 457 | tx = ( 458 | b"\x01\x00\x0b\x00\x00\x00\x00\x00" 459 | + self.ipid 460 | + b"\x40\xff\xff\xf1\x01" 461 | ) 462 | self.tx_queue.put(tx) 463 | elif ciptype == 0x02: 464 | # registration result 465 | ipid_string = str(binascii.hexlify(self.ipid), "ascii") 466 | 467 | if length == 3 and payload == b"\xff\xff\x02": 468 | _logger.error(f"! The specified IPID (0x{ipid_string}) does not exist") 469 | restartRequired = True 470 | elif length == 4 and payload == b"\x00\x00\x00\x1f": 471 | _logger.debug(f" Registered IPID 0x{ipid_string}") 472 | self.tx_queue.put(b"\x05\x00\x05\x00\x00\x02\x03\x00") 473 | else: 474 | _logger.error(f"! Error registering IPID 0x{ipid_string}") 475 | restartRequired = True 476 | elif ciptype == 0x03: 477 | # control system disconnect 478 | _logger.debug("! Control system disconnect") 479 | restartRequired = True 480 | else: 481 | # unexpected packet 482 | _logger.debug("! We don't know what to do with this packet") 483 | 484 | if restartRequired: 485 | with self.restart_lock: 486 | self.restart_connection = True 487 | --------------------------------------------------------------------------------