├── test ├── __init__.py ├── qtmprotocol_test.py └── qrtconnection_test.py ├── setup.cfg ├── examples ├── qt_example │ ├── requirements.txt │ ├── qt_example.py │ └── ui │ │ └── Main.ui ├── basic_example.py ├── calibration_example.py ├── control_example.py ├── advanced_example.py ├── image_example.py ├── stream_6dof_example.py └── asyncio_everything.py ├── qtm_rt ├── data │ └── Demo.qtm ├── control.py ├── __init__.py ├── reboot.py ├── receiver.py ├── discovery.py ├── protocol.py ├── qrt.py └── packet.py ├── requirements-dev.txt ├── .gitignore ├── docs ├── deprecated.rst ├── index.rst ├── Makefile ├── make.bat └── conf.py ├── setup.py ├── LICENSE └── README.md /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description_file = README.md 3 | -------------------------------------------------------------------------------- /examples/qt_example/requirements.txt: -------------------------------------------------------------------------------- 1 | qtm_rt 2 | PyQt5==5.9 3 | Quamash==0.6.1 -------------------------------------------------------------------------------- /qtm_rt/data/Demo.qtm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qualisys/qualisys_python_sdk/HEAD/qtm_rt/data/Demo.qtm -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | build==1.2.1 2 | pytest-asyncio==0.23.7 3 | pytest-mock==3.14.0 4 | pytest==8.2.2 5 | sphinx==7.3.7 6 | twine==5.1.0 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /dist 3 | /qtm_rt.egg-info 4 | /env 5 | /env3 6 | /.venv 7 | /.idea 8 | _build 9 | _static 10 | _templates 11 | *.pyc 12 | *.pyproj 13 | *.sublime-* 14 | /.vscode 15 | /.pytest_cache 16 | */__pycache__ 17 | .coverage* 18 | -------------------------------------------------------------------------------- /qtm_rt/control.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from .qrt import QRTConnection 5 | 6 | LOG = logging.getLogger("qtm_rt") 7 | 8 | 9 | class TakeControl: 10 | """ Context manager for taking control and releasing control of QTM """ 11 | 12 | def __init__(self, connection: QRTConnection, password: str): 13 | self.connection = connection 14 | self.password = password 15 | 16 | async def __aenter__(self): 17 | await self.connection.take_control(self.password) 18 | LOG.info("Took control") 19 | 20 | async def __aexit__(self, exc_type, exc, _): 21 | if self.connection.has_transport() is not None: 22 | await self.connection.release_control() 23 | LOG.info("Released control") 24 | -------------------------------------------------------------------------------- /qtm_rt/__init__.py: -------------------------------------------------------------------------------- 1 | """ Python SDK for QTM """ 2 | 3 | import logging 4 | import sys 5 | import os 6 | 7 | PYTHON3 = sys.version_info.major == 3 8 | 9 | if PYTHON3: 10 | from .discovery import Discover 11 | from .reboot import reboot 12 | from .qrt import connect, QRTConnection 13 | from .protocol import QRTCommandException 14 | from .control import TakeControl 15 | 16 | from .packet import QRTPacket, QRTEvent 17 | from .receiver import Receiver 18 | 19 | # pylint: disable=C0330 20 | 21 | LOG = logging.getLogger("qtm_rt") 22 | LOG_LEVEL = os.getenv("QTM_LOGGING", None) 23 | 24 | LEVEL = logging.DEBUG if LOG_LEVEL == "debug" else logging.INFO 25 | logging.basicConfig( 26 | level=LEVEL, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 27 | ) 28 | 29 | 30 | __author__ = "mge" 31 | -------------------------------------------------------------------------------- /docs/deprecated.rst: -------------------------------------------------------------------------------- 1 | .. _deprecated_version: 2 | 3 | 3.0.0 4 | ------------------- 5 | 6 | The package has been renamed to qtm_rt (qtm-rt on pip). Otherwise everything is identical to 2.1.2. 7 | Older versions will remain under the qtm name to avoid breaking existing code. 8 | 9 | To install the old version: 10 | 11 | .. code-block:: console 12 | 13 | python -m pip install qtm==2.1.2 14 | 15 | 2.0.0 16 | ------------------- 17 | 18 | The basic functionality is the same, but the package now 19 | uses `asyncio `_ instead of `twisted `_. 20 | This reduces dependencies and simplifies installation but raises the required version of Python to 3.5. 21 | If you cannot use Python 3, stay on the earlier versions of this SDK. 22 | 23 | To install the old version: 24 | 25 | .. code-block:: console 26 | 27 | python -m pip install qtm==1.0.2 -------------------------------------------------------------------------------- /examples/basic_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Minimal usage example 3 | Connects to QTM and streams 3D data forever 4 | (start QTM first, load file, Play->Play with Real-Time output) 5 | """ 6 | 7 | import asyncio 8 | import qtm_rt 9 | 10 | 11 | def on_packet(packet): 12 | """ Callback function that is called everytime a data packet arrives from QTM """ 13 | print("Framenumber: {}".format(packet.framenumber)) 14 | header, markers = packet.get_3d_markers() 15 | print("Component info: {}".format(header)) 16 | for marker in markers: 17 | print("\t", marker) 18 | 19 | 20 | async def setup(): 21 | """ Main function """ 22 | connection = await qtm_rt.connect("127.0.0.1") 23 | if connection is None: 24 | return 25 | 26 | await connection.stream_frames(components=["3d"], on_packet=on_packet) 27 | 28 | 29 | if __name__ == "__main__": 30 | asyncio.ensure_future(setup()) 31 | asyncio.get_event_loop().run_forever() 32 | -------------------------------------------------------------------------------- /qtm_rt/reboot.py: -------------------------------------------------------------------------------- 1 | """ Implementation of QTM cameras reboot protocol """ 2 | 3 | import asyncio 4 | import logging 5 | 6 | LOG = logging.getLogger("qtm_rt") 7 | 8 | DEFAULT_DISCOVERY_PORT = 9930 9 | 10 | 11 | async def reboot(ip_address): 12 | """ async function to reboot QTM cameras """ 13 | _, protocol = await asyncio.get_event_loop().create_datagram_endpoint( 14 | QRebootProtocol, 15 | local_addr=(ip_address, 0), 16 | allow_broadcast=True, 17 | ) 18 | 19 | LOG.info("Sending reboot on %s", ip_address) 20 | protocol.send_reboot() 21 | 22 | 23 | class QRebootProtocol: 24 | """ Oqus/Miqus/Arqus discovery protocol implementation""" 25 | 26 | def __init__(self): 27 | self.transport = None 28 | 29 | def connection_made(self, transport): 30 | """ On socket creation """ 31 | self.transport = transport 32 | 33 | def send_reboot(self): 34 | """ Sends reboot package broadcast """ 35 | self.transport.sendto(b"reboot", ("", DEFAULT_DISCOVERY_PORT)) 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | version = "3.0.2" 4 | 5 | setup( 6 | name="qtm-rt", 7 | version=version, 8 | description="QTM Python SDK", 9 | long_description="For older versions, see \"qtm\" package.", 10 | url="https://github.com/qualisys/qualisys_python_sdk", 11 | download_url="https://github.com/qualisys/qualisys_python_sdk/tarball/{}".format( 12 | version 13 | ), 14 | author="Qualisys AB", 15 | author_email="support@qualisys.com", 16 | license="MIT", 17 | packages=["qtm_rt"], 18 | package_data={"qtm_rt": ["data/demo.qtm"]}, 19 | classifiers=[ 20 | "Operating System :: OS Independent", 21 | "Intended Audience :: Developers", 22 | "License :: OSI Approved :: MIT License", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3.5", 25 | "Programming Language :: Python :: 3.6", 26 | "Programming Language :: Python :: 3.7", 27 | "Topic :: Scientific/Engineering", 28 | "Topic :: Utilities", 29 | ], 30 | python_requires=">=3.5.3", 31 | zip_safe=True, 32 | ) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2016 Qualisys 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 | -------------------------------------------------------------------------------- /examples/calibration_example.py: -------------------------------------------------------------------------------- 1 | """ Example that takes control of QTM, connects and starts a calibration. """ 2 | 3 | import asyncio 4 | import logging 5 | import qtm_rt 6 | import xml.etree.ElementTree as ET 7 | 8 | LOG = logging.getLogger("example") 9 | 10 | async def setup(): 11 | """ main function """ 12 | 13 | connection = await qtm_rt.connect("127.0.0.1") 14 | 15 | if connection is None: 16 | return -1 17 | 18 | async with qtm_rt.TakeControl(connection, "password"): 19 | 20 | state = await connection.get_state() 21 | if state != qtm_rt.QRTEvent.EventConnected: 22 | await connection.new() 23 | try: 24 | await connection.await_event(qtm_rt.QRTEvent.EventConnected, timeout=10) 25 | except asyncio.TimeoutError: 26 | LOG.error("Failed to start new measurement") 27 | return -1 28 | 29 | try: 30 | cal_response = await connection.calibrate() 31 | except asyncio.TimeoutError: 32 | LOG.error("Timeout waiting for calibration result.") 33 | except Exception as e: 34 | LOG.error(e) 35 | else: 36 | root = ET.fromstring(cal_response) 37 | print(ET.tostring(root, pretty_print=True).decode()) 38 | 39 | # tell qtm to stop streaming 40 | await connection.stream_frames_stop() 41 | 42 | if __name__ == "__main__": 43 | asyncio.run(setup()) 44 | -------------------------------------------------------------------------------- /qtm_rt/receiver.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from qtm_rt.packet import QRTPacketType 4 | from qtm_rt.packet import QRTPacket, QRTEvent 5 | from qtm_rt.packet import RTheader, RTEvent, RTCommand 6 | 7 | LOG = logging.getLogger("qtm_rt") 8 | 9 | 10 | class Receiver(object): 11 | def __init__(self, handlers): 12 | self._handlers = handlers 13 | self._received_data = b"" 14 | 15 | def data_received(self, data): 16 | """ Received from QTM and route accordingly """ 17 | self._received_data += data 18 | h_size = RTheader.size 19 | data = self._received_data 20 | data_len = len(data); 21 | 22 | while data_len >= h_size: 23 | size, type_ = RTheader.unpack_from(data, 0) 24 | if data_len >= size: 25 | self._parse_received(data[h_size:size], type_) 26 | data = data[size:] 27 | data_len = len(data); 28 | else: 29 | break; 30 | 31 | self._received_data = data 32 | 33 | def _parse_received(self, data, type_): 34 | type_ = QRTPacketType(type_) 35 | 36 | if ( 37 | type_ == QRTPacketType.PacketError 38 | or type_ == QRTPacketType.PacketCommand 39 | or type_ == QRTPacketType.PacketXML 40 | ): 41 | data = data[:-1] 42 | elif type_ == QRTPacketType.PacketData: 43 | data = QRTPacket(data) 44 | elif type_ == QRTPacketType.PacketEvent: 45 | event, = RTEvent.unpack(data) 46 | data = QRTEvent(ord(event)) 47 | 48 | try: 49 | self._handlers[type_](data) 50 | except KeyError: 51 | LOG.error("Non handled packet type! - %s", type_) 52 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Qualisys SDK for Python's documentation! 2 | =================================================== 3 | 4 | This document describes the Qualisys SDK for Python version 3.0.2 5 | 6 | **NOTE:** Major versions introduces breaking changes. :ref:`More info...` 7 | 8 | .. contents:: 9 | :depth: 2 10 | :local: 11 | 12 | .. toctree:: 13 | :hidden: 14 | 15 | deprecated.rst 16 | 17 | 18 | Installation: 19 | ------------- 20 | 21 | This package is a pure python package and requires at least Python 3.5.3, the easiest way to install it is: 22 | 23 | .. code-block:: console 24 | 25 | python -m pip install qtm-rt 26 | 27 | Example usage: 28 | -------------- 29 | 30 | The following code demonstrates how to stream 3D markers from QTM. To keep the code short, it assumes that QTM is 31 | already streaming data, either live or RT from file. 32 | 33 | .. literalinclude:: ../examples/basic_example.py 34 | 35 | 36 | QTM RT Protocol 37 | --------------- 38 | 39 | An instance of QRTConnection is returned when qtm_rt.connect successfully connects to QTM. 40 | 41 | Functions marked as async need to be run in a async function and awaited, please see example above. 42 | 43 | .. autofunction:: qtm_rt.connect 44 | 45 | QRTConnection 46 | ~~~~~~~~~~~~~ 47 | 48 | .. autoclass:: qtm_rt.QRTConnection 49 | :members: 50 | 51 | QRTPacket 52 | ~~~~~~~~~ 53 | 54 | .. autoclass:: qtm_rt.QRTPacket 55 | :members: 56 | 57 | QRTEvent 58 | ~~~~~~~~~ 59 | 60 | .. autoclass:: qtm_rt.QRTEvent 61 | :members: 62 | :undoc-members: 63 | 64 | QRTComponentType 65 | ~~~~~~~~~~~~~~~~ 66 | 67 | .. autoclass:: qtm_rt.packet.QRTComponentType 68 | :members: 69 | :undoc-members: 70 | 71 | Exceptions 72 | ~~~~~~~~~~ 73 | 74 | .. autoclass:: qtm_rt.QRTCommandException 75 | 76 | -------------------------------------------------------------------------------- /examples/control_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example that takes control of QTM, starts a measurement and stops after 3 | 10 seconds, then saves the measurement data to a file. 4 | """ 5 | 6 | import asyncio 7 | import logging 8 | 9 | 10 | import qtm_rt 11 | 12 | LOG = logging.getLogger("example") 13 | 14 | 15 | async def setup(): 16 | """ 17 | Main function that connects to the QTM server, initiates a measurement 18 | process, and saves the captured data to a file. 19 | """ 20 | connection = await qtm_rt.connect("127.0.0.1") 21 | 22 | if connection is None: 23 | return -1 24 | 25 | async with qtm_rt.TakeControl(connection, "password"): 26 | state = await connection.get_state() 27 | if state != qtm_rt.QRTEvent.EventConnected: 28 | await connection.new() 29 | try: 30 | await connection.await_event(qtm_rt.QRTEvent.EventConnected, timeout=10) 31 | except asyncio.TimeoutError: 32 | LOG.error("Failed to start new measurement") 33 | return -1 34 | 35 | # Start the measurement 36 | await connection.start() 37 | await connection.await_event(qtm_rt.QRTEvent.EventCaptureStarted, timeout=10) 38 | 39 | # Wait for QTM to measure for 10 seconds 40 | await asyncio.sleep(10) 41 | 42 | # Stop the measurement 43 | await connection.stop() 44 | await connection.await_event(qtm_rt.QRTEvent.EventCaptureStopped, timeout=10) 45 | 46 | # Save the measurement to a file 47 | await connection.save("Demo.qtm") 48 | 49 | LOG.info("Measurement saved to Demo.qtm") 50 | 51 | connection.disconnect() 52 | 53 | if __name__ == "__main__": 54 | loop = asyncio.get_event_loop() 55 | loop.run_until_complete(setup()) 56 | -------------------------------------------------------------------------------- /examples/advanced_example.py: -------------------------------------------------------------------------------- 1 | """ Example that takes control of QTM, streams data etc """ 2 | 3 | import asyncio 4 | import logging 5 | 6 | import qtm_rt 7 | 8 | LOG = logging.getLogger("example") 9 | 10 | 11 | async def package_receiver(queue): 12 | """ Asynchronous function that processes queue until None is posted in queue """ 13 | LOG.info("Entering package_receiver") 14 | while True: 15 | packet = await queue.get() 16 | if packet is None: 17 | break 18 | 19 | LOG.info("Framenumber %s", packet.framenumber) 20 | header, cameras = packet.get_2d_markers() 21 | LOG.info("Component info: %s", header) 22 | 23 | for i, camera in enumerate(cameras, 1): 24 | LOG.info("Camera %d", i) 25 | for marker in camera: 26 | LOG.info("\t%s", marker) 27 | 28 | LOG.info("Exiting package_receiver") 29 | 30 | 31 | async def shutdown(delay, connection, receiver_future, queue): 32 | 33 | # wait desired time before exiting 34 | await asyncio.sleep(delay) 35 | 36 | # make sure package_receiver task exits 37 | queue.put_nowait(None) 38 | await receiver_future 39 | 40 | # tell qtm to stop streaming 41 | await connection.stream_frames_stop() 42 | 43 | # stop the event loop, thus exiting the run_forever call 44 | loop.stop() 45 | 46 | 47 | async def setup(): 48 | """ main function """ 49 | 50 | connection = await qtm_rt.connect("127.0.0.1") 51 | 52 | if connection is None: 53 | return -1 54 | 55 | async with qtm_rt.TakeControl(connection, "password"): 56 | 57 | state = await connection.get_state() 58 | if state != qtm_rt.QRTEvent.EventConnected: 59 | await connection.new() 60 | try: 61 | await connection.await_event(qtm_rt.QRTEvent.EventConnected, timeout=10) 62 | except asyncio.TimeoutError: 63 | LOG.error("Failed to start new measurement") 64 | return -1 65 | 66 | queue = asyncio.Queue() 67 | 68 | receiver_future = asyncio.ensure_future(package_receiver(queue)) 69 | 70 | await connection.stream_frames(components=["2d"], on_packet=queue.put_nowait) 71 | 72 | asyncio.ensure_future(shutdown(30, connection, receiver_future, queue)) 73 | 74 | 75 | if __name__ == "__main__": 76 | loop = asyncio.get_event_loop() 77 | asyncio.ensure_future(setup()) 78 | loop.run_forever() 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Qualisys SDK for Python 2 | ================================ 3 | 4 | ![PyPI - Version](https://img.shields.io/pypi/v/qtm_rt) 5 | 6 | The Qualisys SDK for Python implements our RealTime(RT) protocol and works with Python 3.5 and above. 7 | 8 | Installation 9 | ------------ 10 | 11 | The easiest way to install the qtm_rt package is by using [pip]((https://pip.pypa.io/en/stable/installing/)): 12 | 13 | ``` 14 | python -m pip install pip --upgrade # Upgrade to latest pip 15 | python -m pip install qtm-rt 16 | ``` 17 | 18 | It's also possible to install from github: 19 | 20 | ``` 21 | python -m pip install git+https://github.com/qualisys/qualisys_python_sdk.git 22 | ``` 23 | 24 | Or just clone the repo and copy the qtm_rt folder into you project folder, 25 | 26 | Documentation 27 | ------------- 28 | 29 | https://qualisys.github.io/qualisys_python_sdk/index.html 30 | 31 | Examples 32 | -------- 33 | 34 | See the examples folder. 35 | 36 | Missing RT features and limitations 37 | ----------------------------------- 38 | 39 | Implementation only uses little endian, should connect to standard port 22223. 40 | Protocol version must be 1.8 or later. 41 | 42 | GetCaptureC3D is not implemented. 43 | GetCaptureQTM is not implemented. 44 | 45 | No support for selecting analog channel. 46 | 47 | Development 48 | ----------- 49 | 50 | Use the following `bash` commands in sequence to build the distribution and 51 | documentation: 52 | 53 | ``` 54 | # Setup build environment 55 | python -m venv .venv 56 | source ./.venv/Scripts/activate 57 | pip install -r requirements-dev.txt 58 | 59 | # Run tests 60 | pytest test/ 61 | 62 | # Build source tarball and python wheel in dist/ 63 | python -m build 64 | 65 | # Build sphinx documentation in docs/_build/html/ 66 | make -C docs html 67 | 68 | # Copy build output to gh-pages branch (checked out in a separate clone of the repository in ../qualisys_python_sdk_gh_pages) 69 | # Make sure to keep v102/, v103/ and v212/ directories with the old documentation. 70 | cp -r docs/_build/html/* ../qualisys_python_sdk_gh_pages 71 | # Commit the changes with a message like "Update documentation to version x.y.z" 72 | git -C ../qualisys_python_sdk_gh_pages add . 73 | git -C ../qualisys_python_sdk_gh_pages commit 74 | git -C ../qualisys_python_sdk_gh_pages push origin gh-pages 75 | 76 | # Upload new version to https://pypi.org/project/qtm-rt (needs API key) 77 | twine upload dist/* 78 | 79 | # Git tag and manually make release on github 80 | git tag vx.y.z 81 | git push --tags 82 | ``` 83 | -------------------------------------------------------------------------------- /examples/image_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example that enables images from a camera and writes a single frame to a file. 3 | """ 4 | 5 | import argparse 6 | import asyncio 7 | import logging 8 | import os 9 | import qtm_rt 10 | import xml.etree.ElementTree as ET 11 | 12 | 13 | def output_format(output_path): 14 | extension = os.path.splitext(output_path)[1].lower() 15 | if extension == ".jpg": 16 | return "jpg" 17 | if extension == ".png": 18 | return "png" 19 | 20 | raise RuntimeError("Unsupported output format {extension}") 21 | 22 | 23 | def enable_disable_cameras(output_path, target_camera_id, xml_str): 24 | format = output_format(output_path) 25 | xml = ET.fromstring(xml_str) 26 | for camera in xml.findall("./Image/Camera"): 27 | camera_id = camera.find("ID").text 28 | is_target_camera = camera_id == target_camera_id 29 | logging.info("Setting Enabled for Camera %s to %s", camera_id, is_target_camera) 30 | camera.find("Enabled").text = str(is_target_camera) 31 | 32 | if is_target_camera: 33 | camera.find("Format").text = format 34 | 35 | xml.tag = "QTM_Settings" 36 | return ET.tostring(xml).decode("utf-8") 37 | 38 | 39 | async def main(password, target_camera_id, output_path): 40 | connection = await qtm_rt.connect("127.0.0.1") 41 | 42 | if connection is None: 43 | raise RuntimeError("Failed to connect") 44 | 45 | settings = await connection.get_parameters(parameters=["image"]) 46 | updated_settings = enable_disable_cameras(output_path, target_camera_id, settings) 47 | 48 | async with qtm_rt.TakeControl(connection, password): 49 | logging.debug("%s", await connection.send_xml(updated_settings)) 50 | 51 | frame = await connection.get_current_frame(components=["image"]) 52 | info, images = frame.get_image() 53 | logging.info("%s", info) 54 | logging.info("%s", images[0][0]) 55 | with open(output_path, "wb") as f: 56 | logging.info("Writing %s", output_path) 57 | f.write(images[0][1]) 58 | 59 | connection.disconnect() 60 | 61 | 62 | if __name__ == "__main__": 63 | parser = argparse.ArgumentParser( 64 | description="Get single camera image from QTM over RT protocol." 65 | ) 66 | parser.add_argument("-o", "--output", required=True, help="Output file path.") 67 | parser.add_argument("-c", "--camera", required=True, help="Camera ID.") 68 | parser.add_argument("-p", "--password", default="", help="RT server password.") 69 | args = parser.parse_args() 70 | 71 | asyncio.run(main(args.password, args.camera, args.output)) 72 | -------------------------------------------------------------------------------- /test/qtmprotocol_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for QTMProtocol 3 | """ 4 | 5 | import asyncio 6 | 7 | import pytest 8 | 9 | from qtm_rt.protocol import QTMProtocol, QRTCommandException 10 | from qtm_rt.packet import QRTEvent, RTEvent 11 | 12 | # pylint: disable=W0621, C0111, W0212 13 | 14 | 15 | @pytest.fixture 16 | def qtmprotocol(event_loop) -> QTMProtocol: 17 | return QTMProtocol(loop=event_loop) 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_send_command_not_connected(qtmprotocol: QTMProtocol): 22 | 23 | with pytest.raises(QRTCommandException): 24 | await qtmprotocol.send_command("dummy") 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_await_any_event_timeout(qtmprotocol: QTMProtocol): 29 | awaitable = qtmprotocol.await_event(timeout=0.1) 30 | with pytest.raises(asyncio.TimeoutError): 31 | await awaitable 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_await_any_event(qtmprotocol: QTMProtocol): 36 | awaitable = qtmprotocol.await_event(timeout=1) 37 | asyncio.get_running_loop().call_later(0, lambda: qtmprotocol._on_event(QRTEvent.EventConnected)) 38 | result = await awaitable 39 | 40 | assert result == QRTEvent.EventConnected 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_await_specific_event(qtmprotocol: QTMProtocol): 45 | awaitable = qtmprotocol.await_event(event=QRTEvent.EventConnected, timeout=1) 46 | asyncio.get_running_loop().call_later( 47 | 0, lambda: qtmprotocol._on_event(QRTEvent.EventConnected) 48 | ) 49 | result = await awaitable 50 | 51 | assert result == QRTEvent.EventConnected 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_await_event_multiple(qtmprotocol: QTMProtocol): 56 | awaitable = qtmprotocol.await_event(event=QRTEvent.EventConnected, timeout=1) 57 | 58 | asyncio.get_running_loop().call_later( 59 | 0, lambda: qtmprotocol._on_event(QRTEvent.EventConnectionClosed) 60 | ) 61 | asyncio.get_running_loop().call_later( 62 | 0.1, lambda: qtmprotocol._on_event(QRTEvent.EventConnected) 63 | ) 64 | 65 | result = await awaitable 66 | 67 | assert result == QRTEvent.EventConnected 68 | 69 | 70 | @pytest.mark.asyncio 71 | async def test_await_multiple(qtmprotocol: QTMProtocol): 72 | loop = asyncio.get_event_loop() 73 | awaitable1 = loop.create_task(qtmprotocol.await_event(event=QRTEvent.EventConnected)) 74 | awaitable2 = loop.create_task(qtmprotocol.await_event(event=QRTEvent.EventConnectionClosed)) 75 | 76 | done, _ = await asyncio.wait( 77 | [awaitable1, awaitable2], return_when=asyncio.FIRST_EXCEPTION 78 | ) 79 | 80 | with pytest.raises(Exception): 81 | done.pop().result() 82 | -------------------------------------------------------------------------------- /examples/stream_6dof_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Streaming 6Dof from QTM 3 | """ 4 | 5 | import asyncio 6 | import xml.etree.ElementTree as ET 7 | import pkg_resources 8 | 9 | import qtm_rt 10 | 11 | QTM_FILE = pkg_resources.resource_filename("qtm_rt", "data/Demo.qtm") 12 | 13 | 14 | def create_body_index(xml_string): 15 | """ Extract a name to index dictionary from 6dof settings xml """ 16 | xml = ET.fromstring(xml_string) 17 | 18 | body_to_index = {} 19 | for index, body in enumerate(xml.findall("*/Body/Name")): 20 | body_to_index[body.text.strip()] = index 21 | 22 | return body_to_index 23 | 24 | def body_enabled_count(xml_string): 25 | xml = ET.fromstring(xml_string) 26 | return sum(enabled.text == "true" for enabled in xml.findall("*/Body/Enabled")) 27 | 28 | async def main(): 29 | 30 | # Connect to qtm 31 | connection = await qtm_rt.connect("127.0.0.1") 32 | 33 | # Connection failed? 34 | if connection is None: 35 | print("Failed to connect") 36 | return 37 | 38 | # Take control of qtm, context manager will automatically release control after scope end 39 | async with qtm_rt.TakeControl(connection, "password"): 40 | 41 | realtime = False 42 | 43 | if realtime: 44 | # Start new realtime 45 | await connection.new() 46 | else: 47 | # Load qtm file 48 | await connection.load(QTM_FILE) 49 | 50 | # start rtfromfile 51 | await connection.start(rtfromfile=True) 52 | 53 | # Get 6dof settings from qtm 54 | xml_string = await connection.get_parameters(parameters=["6d"]) 55 | body_index = create_body_index(xml_string) 56 | 57 | print("{} of {} 6DoF bodies enabled".format(body_enabled_count(xml_string), len(body_index))) 58 | 59 | wanted_body = "L-frame" 60 | 61 | def on_packet(packet): 62 | info, bodies = packet.get_6d() 63 | print( 64 | "Framenumber: {} - Body count: {}".format( 65 | packet.framenumber, info.body_count 66 | ) 67 | ) 68 | 69 | if wanted_body is not None and wanted_body in body_index: 70 | # Extract one specific body 71 | wanted_index = body_index[wanted_body] 72 | position, rotation = bodies[wanted_index] 73 | print("{} - Pos: {} - Rot: {}".format(wanted_body, position, rotation)) 74 | else: 75 | # Print all bodies 76 | for position, rotation in bodies: 77 | print("Pos: {} - Rot: {}".format(position, rotation)) 78 | 79 | # Start streaming frames 80 | await connection.stream_frames(components=["6d"], on_packet=on_packet) 81 | 82 | # Wait asynchronously 5 seconds 83 | await asyncio.sleep(5) 84 | 85 | # Stop streaming 86 | await connection.stream_frames_stop() 87 | 88 | 89 | if __name__ == "__main__": 90 | # Run our asynchronous function until complete 91 | asyncio.get_event_loop().run_until_complete(main()) 92 | -------------------------------------------------------------------------------- /qtm_rt/discovery.py: -------------------------------------------------------------------------------- 1 | """ Implementation of QTM discovery protocol """ 2 | 3 | import struct 4 | import asyncio 5 | from collections import namedtuple 6 | import logging 7 | 8 | from .protocol import RTheader, QRTPacketType 9 | 10 | # pylint: disable=C0103 11 | 12 | LOG = logging.getLogger("qtm_rt") 13 | 14 | QRTDiscoveryP1 = struct.Struct("H") 16 | QRTDiscoveryPacketSize = QRTDiscoveryP1.size + QRTDiscoveryP2.size 17 | QRTDiscoveryBasePort = struct.Struct(">H") 18 | 19 | QRTDiscoveryResponse = namedtuple("QRTDiscoveryResponse", "info host port") 20 | 21 | class QRTDiscoveryProtocol: 22 | """ Oqus/Miqus/Arqus discovery protocol implementation""" 23 | 24 | def __init__(self, receiver=None): 25 | self.port = None 26 | self.receiver = receiver 27 | self.transport = None 28 | 29 | def connection_made(self, transport): 30 | """ On socket creation """ 31 | self.transport = transport 32 | 33 | sock = transport.get_extra_info("socket") 34 | self.port = sock.getsockname()[1] 35 | 36 | def datagram_received(self, datagram, address): 37 | """ Parse response from QTM instances """ 38 | size, _ = RTheader.unpack_from(datagram, 0) 39 | info, = struct.unpack_from("{0}s".format(size - 3 - 8), datagram, RTheader.size) 40 | base_port, = QRTDiscoveryBasePort.unpack_from(datagram, size - 2) 41 | 42 | if self.receiver is not None: 43 | self.receiver(QRTDiscoveryResponse(info, address[0], base_port)) 44 | 45 | def send_discovery_packet(self): 46 | """ Send discovery packet for QTM to respond to """ 47 | if self.port is None: 48 | return 49 | 50 | self.transport.sendto( 51 | QRTDiscoveryP1.pack( 52 | QRTDiscoveryPacketSize, QRTPacketType.PacketDiscover.value 53 | ) 54 | + QRTDiscoveryP2.pack(self.port), 55 | ("255.255.255.255", 22226) 56 | ) 57 | 58 | def error_received(self, error): 59 | """ On error """ 60 | LOG.exception("QRTDiscoveryProtocol %s", error) 61 | 62 | class Discover: 63 | """async discovery of qtm instances""" 64 | 65 | def __init__(self, ip_address): 66 | self.ip_address = ip_address 67 | self.queue = asyncio.Queue() 68 | self.first = True 69 | 70 | def __aiter__(self): 71 | return self 72 | 73 | async def __anext__(self) -> QRTDiscoveryResponse: 74 | 75 | loop = asyncio.get_event_loop() 76 | if self.first: 77 | 78 | protocol_factory = lambda: QRTDiscoveryProtocol( 79 | receiver=self.queue.put_nowait 80 | ) 81 | 82 | _, protocol = await loop.create_datagram_endpoint( 83 | protocol_factory, 84 | local_addr=(self.ip_address, 0), 85 | allow_broadcast=True, 86 | ) 87 | 88 | LOG.debug("Sending discovery packet on %s", self.ip_address) 89 | protocol.send_discovery_packet() 90 | self.first = False 91 | 92 | call_handle = loop.call_later(0.2, lambda: self.queue.put_nowait(None)) 93 | result = await self.queue.get() 94 | if result is None: 95 | LOG.debug("Discovery timed out") 96 | raise StopAsyncIteration 97 | 98 | LOG.debug(result) 99 | call_handle.cancel() 100 | return result 101 | 102 | 103 | -------------------------------------------------------------------------------- /examples/asyncio_everything.py: -------------------------------------------------------------------------------- 1 | """ Example that does a bit of everything, error handling, streaming, events etc """ 2 | 3 | import logging 4 | import asyncio 5 | import argparse 6 | import pkg_resources 7 | 8 | import qtm_rt 9 | 10 | 11 | logging.basicConfig(level=logging.INFO) 12 | LOG = logging.getLogger("example") 13 | 14 | 15 | QTM_FILE = pkg_resources.resource_filename("qtm_rt", "data/Demo.qtm") 16 | 17 | 18 | class AsyncEnumerate: 19 | """ Simple async enumeration class """ 20 | 21 | def __init__(self, aiterable, start=0): 22 | self.aiterable = aiterable 23 | self.i = start 24 | 25 | def __aiter__(self): 26 | return self 27 | 28 | async def __anext__(self): 29 | item = await self.aiterable.__anext__() 30 | result = (self.i, item) 31 | self.i += 1 32 | return result 33 | 34 | 35 | async def packet_receiver(queue): 36 | """ Asynchronous function that processes queue until None is posted in queue """ 37 | LOG.info("Entering packet_receiver") 38 | while True: 39 | packet = await queue.get() 40 | if packet is None: 41 | break 42 | 43 | LOG.info("Framenumber %s", packet.framenumber) 44 | LOG.info("Exiting packet_receiver") 45 | 46 | 47 | async def choose_qtm_instance(interface): 48 | """ List running QTM instances, asks for input and return chosen QTM """ 49 | instances = {} 50 | print("Available QTM instances:") 51 | async for i, qtm_instance in AsyncEnumerate(qtm_rt.Discover(interface), start=1): 52 | instances[i] = qtm_instance 53 | print("{} - {}".format(i, qtm_instance.info)) 54 | 55 | try: 56 | 57 | choice = int(input("Connect to: ")) 58 | 59 | if choice not in instances: 60 | raise ValueError 61 | 62 | except ValueError: 63 | LOG.error("Invalid choice") 64 | return None 65 | 66 | return instances[choice].host 67 | 68 | 69 | async def main(interface=None): 70 | """ Main function """ 71 | 72 | qtm_ip = await choose_qtm_instance(interface) 73 | if qtm_ip is None: 74 | return 75 | 76 | while True: 77 | 78 | connection = await qtm_rt.connect(qtm_ip, 22223, version="1.18") 79 | 80 | if connection is None: 81 | return 82 | 83 | await connection.get_state() 84 | await connection.byte_order() 85 | 86 | async with qtm_rt.TakeControl(connection, "password"): 87 | 88 | result = await connection.close() 89 | if result == b"Closing connection": 90 | await connection.await_event(qtm_rt.QRTEvent.EventConnectionClosed) 91 | 92 | await connection.load(QTM_FILE) 93 | 94 | await connection.start(rtfromfile=True) 95 | 96 | (await connection.get_current_frame(components=["3d"])).get_3d_markers() 97 | 98 | queue = asyncio.Queue() 99 | 100 | asyncio.ensure_future(packet_receiver(queue)) 101 | 102 | try: 103 | await connection.stream_frames( 104 | components=["incorrect"], on_packet=queue.put_nowait 105 | ) 106 | except qtm_rt.QRTCommandException as exception: 107 | LOG.info("exception %s", exception) 108 | 109 | await connection.stream_frames( 110 | components=["3d"], on_packet=queue.put_nowait 111 | ) 112 | 113 | await asyncio.sleep(0.5) 114 | await connection.byte_order() 115 | await asyncio.sleep(0.5) 116 | await connection.stream_frames_stop() 117 | queue.put_nowait(None) 118 | 119 | await connection.get_parameters(parameters=["3d"]) 120 | await connection.stop() 121 | 122 | await connection.await_event() 123 | 124 | await connection.new() 125 | await connection.await_event(qtm_rt.QRTEvent.EventConnected) 126 | 127 | await connection.start() 128 | await connection.await_event(qtm_rt.QRTEvent.EventWaitingForTrigger) 129 | 130 | await connection.trig() 131 | await connection.await_event(qtm_rt.QRTEvent.EventCaptureStarted) 132 | 133 | await asyncio.sleep(0.5) 134 | 135 | await connection.set_qtm_event() 136 | await asyncio.sleep(0.001) 137 | await connection.set_qtm_event("with_label") 138 | 139 | await asyncio.sleep(0.5) 140 | 141 | await connection.stop() 142 | await connection.await_event(qtm_rt.QRTEvent.EventCaptureStopped) 143 | 144 | await connection.save(r"measurement.qtm") 145 | 146 | await asyncio.sleep(3) 147 | 148 | await connection.close() 149 | 150 | connection.disconnect() 151 | 152 | 153 | def parse_args(): 154 | parser = argparse.ArgumentParser(description="Example to connect to QTM") 155 | parser.add_argument( 156 | "--ip", 157 | type=str, 158 | required=False, 159 | default="127.0.0.1", 160 | help="IP of interface to search for QTM instances", 161 | ) 162 | 163 | return parser.parse_args() 164 | 165 | 166 | if __name__ == "__main__": 167 | args = parse_args() 168 | asyncio.get_event_loop().run_until_complete(main(interface=args.ip)) 169 | -------------------------------------------------------------------------------- /qtm_rt/protocol.py: -------------------------------------------------------------------------------- 1 | """ 2 | QTM RT Protocol implementation 3 | """ 4 | 5 | import asyncio 6 | import struct 7 | import collections 8 | import logging 9 | 10 | from qtm_rt.packet import QRTPacketType 11 | from qtm_rt.packet import QRTPacket, QRTEvent 12 | from qtm_rt.packet import RTheader, RTEvent, RTCommand 13 | from qtm_rt.receiver import Receiver 14 | 15 | # pylint: disable=C0330 16 | 17 | LOG = logging.getLogger("qtm_rt") 18 | 19 | 20 | class QRTCommandException(Exception): 21 | """ 22 | Basic RT Command Exception 23 | """ 24 | 25 | def __init__(self, value): 26 | super(QRTCommandException, self).__init__() 27 | self.value = value 28 | 29 | def __str__(self): 30 | return repr(self.value) 31 | 32 | 33 | class QTMProtocol(asyncio.Protocol): 34 | """ 35 | QTM RT Protocol implementation 36 | Should be constructed by ::qrt.connect 37 | """ 38 | 39 | def __init__(self, *, loop=None, on_disconnect=None, on_event=None): 40 | self._received_data = b"" 41 | 42 | self.on_disconnect = on_disconnect 43 | self.on_event = on_event 44 | self.on_packet = None 45 | 46 | self.request_queue = collections.deque() 47 | self.event_future = None 48 | self._start_streaming = False 49 | 50 | self.loop = loop or asyncio.get_event_loop() 51 | self.transport = None 52 | 53 | self._handlers = { 54 | QRTPacketType.PacketError: self._on_error, 55 | QRTPacketType.PacketData: self._on_data, 56 | QRTPacketType.PacketCommand: self._on_command, 57 | QRTPacketType.PacketEvent: self._on_event, 58 | QRTPacketType.PacketXML: self._on_xml, 59 | QRTPacketType.PacketNoMoreData: self._on_no_more_data 60 | } 61 | 62 | self._receiver = Receiver(self._handlers) 63 | 64 | async def set_version(self, version): 65 | """ Set version of RT protocol used to communicate with QTM """ 66 | version_cmd = "version %s" % version 67 | # No need to check response, will throw if error 68 | await self.send_command(version_cmd) 69 | 70 | async def _wait_loop(self, event): 71 | while True: 72 | self.event_future = self.event_future or self.loop.create_future() 73 | result = await self.event_future 74 | 75 | if event is None or event == result: 76 | break 77 | return result 78 | 79 | async def await_event(self, event=None, timeout=None): 80 | """ Wait for any or specified event """ 81 | if self.event_future is not None: 82 | raise Exception("Can't wait on multiple events!") 83 | 84 | result = await asyncio.wait_for(self._wait_loop(event), timeout) 85 | return result 86 | 87 | def send_command( 88 | self, command, callback=True, command_type=QRTPacketType.PacketCommand 89 | ): 90 | """ Sends commands to QTM """ 91 | if self.transport is not None: 92 | cmd_length = len(command) 93 | LOG.debug("S: %s", command) 94 | self.transport.write( 95 | struct.pack( 96 | RTCommand % cmd_length, 97 | RTheader.size + cmd_length + 1, 98 | command_type.value, 99 | command.encode(), 100 | b"\0", 101 | ) 102 | ) 103 | 104 | future = self.loop.create_future() 105 | if callback: 106 | self.request_queue.append(future) 107 | else: 108 | future.set_result(None) 109 | return future 110 | 111 | raise QRTCommandException("Not connected!") 112 | 113 | def receive_response(self): 114 | """ Sends commands to QTM """ 115 | if self.transport is not None: 116 | future = self.loop.create_future() 117 | self.request_queue.append(future) 118 | return future 119 | 120 | raise QRTCommandException("Not connected!") 121 | 122 | def connection_made(self, transport): 123 | LOG.info("Connected") 124 | self.transport = transport 125 | 126 | def set_on_packet(self, on_packet): 127 | """ Set callback to use when packet arrives """ 128 | self.on_packet = on_packet 129 | self._start_streaming = on_packet is not None 130 | 131 | def data_received(self, data): 132 | """ Received from QTM and route accordingly """ 133 | self._receiver.data_received(data) 134 | 135 | def _deliver_promise(self, data): 136 | try: 137 | future = self.request_queue.pop() 138 | future.set_result(data) 139 | except IndexError: 140 | pass 141 | 142 | def _on_data(self, packet): 143 | if self.on_packet is not None: 144 | if self._start_streaming: 145 | self._deliver_promise(b"Ok") 146 | self._start_streaming = False 147 | 148 | self.on_packet(packet) 149 | else: 150 | self._deliver_promise(packet) 151 | return 152 | 153 | def _on_no_more_data(self, packet): 154 | if self.on_packet is not None: 155 | if self._start_streaming: 156 | self._deliver_promise(b"Ok") 157 | self._start_streaming = False 158 | return 159 | 160 | def _on_event(self, event): 161 | LOG.info(event) 162 | 163 | if self.event_future is not None: 164 | future, self.event_future = self.event_future, None 165 | future.set_result(event) 166 | 167 | if self.on_event: 168 | self.on_event(event) 169 | 170 | def _on_error(self, response): 171 | LOG.debug("Error: %s", response) 172 | if self._start_streaming: 173 | self.set_on_packet(None) 174 | try: 175 | future = self.request_queue.pop() 176 | future.set_exception(QRTCommandException(response)) 177 | except IndexError: 178 | raise QRTCommandException(response) 179 | 180 | def _on_xml(self, response): 181 | LOG.debug("XML: %s ...", response[: min(len(response), 70)]) 182 | self._deliver_promise(response) 183 | 184 | def _on_command(self, response): 185 | LOG.debug("R: %s", response) 186 | if response != b"QTM RT Interface connected": 187 | self._deliver_promise(response) 188 | 189 | def connection_lost(self, exc): 190 | self.transport = None 191 | LOG.info("Disconnected") 192 | if self.on_disconnect is not None: 193 | self.on_disconnect(exc) 194 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " singlehtml to make a single large HTML file" 23 | @echo " pickle to make pickle files" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " qthelp to make HTML files and a qthelp project" 27 | @echo " applehelp to make an Apple Help Book" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " epub3 to make an epub3" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " xml to make Docutils-native XML files" 41 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 42 | @echo " linkcheck to check all external links for integrity" 43 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 44 | @echo " coverage to run coverage check of the documentation (if enabled)" 45 | @echo " dummy to check syntax errors of document sources" 46 | 47 | .PHONY: clean 48 | clean: 49 | rm -rf $(BUILDDIR)/* 50 | 51 | .PHONY: html 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | .PHONY: dirhtml 58 | dirhtml: 59 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 62 | 63 | .PHONY: singlehtml 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | .PHONY: pickle 70 | pickle: 71 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 72 | @echo 73 | @echo "Build finished; now you can process the pickle files." 74 | 75 | .PHONY: json 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | .PHONY: htmlhelp 82 | htmlhelp: 83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 84 | @echo 85 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 86 | ".hhp project file in $(BUILDDIR)/htmlhelp." 87 | 88 | .PHONY: qthelp 89 | qthelp: 90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 91 | @echo 92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/QualisysRealtimeSDKforPython.qhcp" 95 | @echo "To view the help file:" 96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/QualisysRealtimeSDKforPython.qhc" 97 | 98 | .PHONY: applehelp 99 | applehelp: 100 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 101 | @echo 102 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 103 | @echo "N.B. You won't be able to view it unless you put it in" \ 104 | "~/Library/Documentation/Help or install it in your application" \ 105 | "bundle." 106 | 107 | .PHONY: devhelp 108 | devhelp: 109 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 110 | @echo 111 | @echo "Build finished." 112 | @echo "To view the help file:" 113 | @echo "# mkdir -p $$HOME/.local/share/devhelp/QualisysRealtimeSDKforPython" 114 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/QualisysRealtimeSDKforPython" 115 | @echo "# devhelp" 116 | 117 | .PHONY: epub 118 | epub: 119 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 120 | @echo 121 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 122 | 123 | .PHONY: epub3 124 | epub3: 125 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 126 | @echo 127 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 128 | 129 | .PHONY: latex 130 | latex: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo 133 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 134 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 135 | "(use \`make latexpdf' here to do that automatically)." 136 | 137 | .PHONY: latexpdf 138 | latexpdf: 139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 140 | @echo "Running LaTeX files through pdflatex..." 141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 143 | 144 | .PHONY: latexpdfja 145 | latexpdfja: 146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 147 | @echo "Running LaTeX files through platex and dvipdfmx..." 148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 150 | 151 | .PHONY: text 152 | text: 153 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 154 | @echo 155 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 156 | 157 | .PHONY: man 158 | man: 159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 160 | @echo 161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 162 | 163 | .PHONY: texinfo 164 | texinfo: 165 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 166 | @echo 167 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 168 | @echo "Run \`make' in that directory to run these through makeinfo" \ 169 | "(use \`make info' here to do that automatically)." 170 | 171 | .PHONY: info 172 | info: 173 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 174 | @echo "Running Texinfo files through makeinfo..." 175 | make -C $(BUILDDIR)/texinfo info 176 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 177 | 178 | .PHONY: gettext 179 | gettext: 180 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 181 | @echo 182 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 183 | 184 | .PHONY: changes 185 | changes: 186 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 187 | @echo 188 | @echo "The overview file is in $(BUILDDIR)/changes." 189 | 190 | .PHONY: linkcheck 191 | linkcheck: 192 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 193 | @echo 194 | @echo "Link check complete; look for any errors in the above output " \ 195 | "or in $(BUILDDIR)/linkcheck/output.txt." 196 | 197 | .PHONY: doctest 198 | doctest: 199 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 200 | @echo "Testing of doctests in the sources finished, look at the " \ 201 | "results in $(BUILDDIR)/doctest/output.txt." 202 | 203 | .PHONY: coverage 204 | coverage: 205 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 206 | @echo "Testing of coverage in the sources finished, look at the " \ 207 | "results in $(BUILDDIR)/coverage/python.txt." 208 | 209 | .PHONY: xml 210 | xml: 211 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 212 | @echo 213 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 214 | 215 | .PHONY: pseudoxml 216 | pseudoxml: 217 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 218 | @echo 219 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 220 | 221 | .PHONY: dummy 222 | dummy: 223 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 224 | @echo 225 | @echo "Build finished. Dummy builder generates no files." 226 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. epub3 to make an epub3 31 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 32 | echo. text to make text files 33 | echo. man to make manual pages 34 | echo. texinfo to make Texinfo files 35 | echo. gettext to make PO message catalogs 36 | echo. changes to make an overview over all changed/added/deprecated items 37 | echo. xml to make Docutils-native XML files 38 | echo. pseudoxml to make pseudoxml-XML files for display purposes 39 | echo. linkcheck to check all external links for integrity 40 | echo. doctest to run all doctests embedded in the documentation if enabled 41 | echo. coverage to run coverage check of the documentation if enabled 42 | echo. dummy to check syntax errors of document sources 43 | goto end 44 | ) 45 | 46 | if "%1" == "clean" ( 47 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 48 | del /q /s %BUILDDIR%\* 49 | goto end 50 | ) 51 | 52 | 53 | REM Check if sphinx-build is available and fallback to Python version if any 54 | %SPHINXBUILD% 1>NUL 2>NUL 55 | if errorlevel 9009 goto sphinx_python 56 | goto sphinx_ok 57 | 58 | :sphinx_python 59 | 60 | set SPHINXBUILD=python -m sphinx.__init__ 61 | %SPHINXBUILD% 2> nul 62 | if errorlevel 9009 ( 63 | echo. 64 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 65 | echo.installed, then set the SPHINXBUILD environment variable to point 66 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 67 | echo.may add the Sphinx directory to PATH. 68 | echo. 69 | echo.If you don't have Sphinx installed, grab it from 70 | echo.http://sphinx-doc.org/ 71 | exit /b 1 72 | ) 73 | 74 | :sphinx_ok 75 | 76 | 77 | if "%1" == "html" ( 78 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 79 | if errorlevel 1 exit /b 1 80 | echo. 81 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 82 | goto end 83 | ) 84 | 85 | if "%1" == "dirhtml" ( 86 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 87 | if errorlevel 1 exit /b 1 88 | echo. 89 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 90 | goto end 91 | ) 92 | 93 | if "%1" == "singlehtml" ( 94 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 95 | if errorlevel 1 exit /b 1 96 | echo. 97 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 98 | goto end 99 | ) 100 | 101 | if "%1" == "pickle" ( 102 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 103 | if errorlevel 1 exit /b 1 104 | echo. 105 | echo.Build finished; now you can process the pickle files. 106 | goto end 107 | ) 108 | 109 | if "%1" == "json" ( 110 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 111 | if errorlevel 1 exit /b 1 112 | echo. 113 | echo.Build finished; now you can process the JSON files. 114 | goto end 115 | ) 116 | 117 | if "%1" == "htmlhelp" ( 118 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 119 | if errorlevel 1 exit /b 1 120 | echo. 121 | echo.Build finished; now you can run HTML Help Workshop with the ^ 122 | .hhp project file in %BUILDDIR%/htmlhelp. 123 | goto end 124 | ) 125 | 126 | if "%1" == "qthelp" ( 127 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 128 | if errorlevel 1 exit /b 1 129 | echo. 130 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 131 | .qhcp project file in %BUILDDIR%/qthelp, like this: 132 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\QualisysRealtimeSDKforPython.qhcp 133 | echo.To view the help file: 134 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\QualisysRealtimeSDKforPython.ghc 135 | goto end 136 | ) 137 | 138 | if "%1" == "devhelp" ( 139 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 140 | if errorlevel 1 exit /b 1 141 | echo. 142 | echo.Build finished. 143 | goto end 144 | ) 145 | 146 | if "%1" == "epub" ( 147 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 148 | if errorlevel 1 exit /b 1 149 | echo. 150 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 151 | goto end 152 | ) 153 | 154 | if "%1" == "epub3" ( 155 | %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 156 | if errorlevel 1 exit /b 1 157 | echo. 158 | echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. 159 | goto end 160 | ) 161 | 162 | if "%1" == "latex" ( 163 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 164 | if errorlevel 1 exit /b 1 165 | echo. 166 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdf" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "latexpdfja" ( 181 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 182 | cd %BUILDDIR%/latex 183 | make all-pdf-ja 184 | cd %~dp0 185 | echo. 186 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 187 | goto end 188 | ) 189 | 190 | if "%1" == "text" ( 191 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 192 | if errorlevel 1 exit /b 1 193 | echo. 194 | echo.Build finished. The text files are in %BUILDDIR%/text. 195 | goto end 196 | ) 197 | 198 | if "%1" == "man" ( 199 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 200 | if errorlevel 1 exit /b 1 201 | echo. 202 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 203 | goto end 204 | ) 205 | 206 | if "%1" == "texinfo" ( 207 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 208 | if errorlevel 1 exit /b 1 209 | echo. 210 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 211 | goto end 212 | ) 213 | 214 | if "%1" == "gettext" ( 215 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 216 | if errorlevel 1 exit /b 1 217 | echo. 218 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 219 | goto end 220 | ) 221 | 222 | if "%1" == "changes" ( 223 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 224 | if errorlevel 1 exit /b 1 225 | echo. 226 | echo.The overview file is in %BUILDDIR%/changes. 227 | goto end 228 | ) 229 | 230 | if "%1" == "linkcheck" ( 231 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 232 | if errorlevel 1 exit /b 1 233 | echo. 234 | echo.Link check complete; look for any errors in the above output ^ 235 | or in %BUILDDIR%/linkcheck/output.txt. 236 | goto end 237 | ) 238 | 239 | if "%1" == "doctest" ( 240 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 241 | if errorlevel 1 exit /b 1 242 | echo. 243 | echo.Testing of doctests in the sources finished, look at the ^ 244 | results in %BUILDDIR%/doctest/output.txt. 245 | goto end 246 | ) 247 | 248 | if "%1" == "coverage" ( 249 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 250 | if errorlevel 1 exit /b 1 251 | echo. 252 | echo.Testing of coverage in the sources finished, look at the ^ 253 | results in %BUILDDIR%/coverage/python.txt. 254 | goto end 255 | ) 256 | 257 | if "%1" == "xml" ( 258 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 259 | if errorlevel 1 exit /b 1 260 | echo. 261 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 262 | goto end 263 | ) 264 | 265 | if "%1" == "pseudoxml" ( 266 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 267 | if errorlevel 1 exit /b 1 268 | echo. 269 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 270 | goto end 271 | ) 272 | 273 | if "%1" == "dummy" ( 274 | %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy 275 | if errorlevel 1 exit /b 1 276 | echo. 277 | echo.Build finished. Dummy builder generates no files. 278 | goto end 279 | ) 280 | 281 | :end 282 | -------------------------------------------------------------------------------- /examples/qt_example/qt_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of combining the qtm package with Qt 3 | Requires PyQt5 and quamash 4 | Use pip to install requirements: 5 | pip install -r requirements.txt 6 | 7 | Only tested on Windows, get_interfaces() needs alternative implementation for other platforms 8 | """ 9 | 10 | import sys 11 | import asyncio 12 | import subprocess 13 | import re 14 | import xml.etree.cElementTree as ET 15 | 16 | from PyQt5.QtWidgets import QApplication, QMainWindow 17 | from PyQt5.QtCore import pyqtSignal, QObject, pyqtProperty 18 | from PyQt5 import uic 19 | 20 | import qtm_rt 21 | from qtm_rt import QRTEvent 22 | from quamash import QSelectorEventLoop 23 | 24 | main_window_class, _ = uic.loadUiType("./ui/main.ui") 25 | 26 | 27 | def start_async_task(task): 28 | asyncio.ensure_future(task) 29 | 30 | 31 | def get_interfaces(): 32 | result = subprocess.check_output("ipconfig /all").decode("utf-8") 33 | result = result.splitlines() 34 | for line in result: 35 | if "IPv4" in line: 36 | found = re.findall(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", line) 37 | for ip in found: 38 | yield ip 39 | 40 | 41 | class QDiscovery(QObject): 42 | discoveringChanged = pyqtSignal(bool) 43 | discoveredQTM = pyqtSignal(str, str) 44 | 45 | def __init__(self, *args): 46 | super().__init__(*args) 47 | self._discovering = False 48 | self._found_qtms = {} 49 | 50 | @pyqtProperty(bool, notify=discoveringChanged) 51 | def discovering(self): 52 | return self._discovering 53 | 54 | @discovering.setter 55 | def discovering(self, value): 56 | if value != self._discovering: 57 | self._discovering = value 58 | self.discoveringChanged.emit(value) 59 | 60 | def discover(self): 61 | self.discovering = True 62 | 63 | self._found_qtms = {} 64 | for interface in get_interfaces(): 65 | start_async_task(self._discover_qtm(interface)) 66 | 67 | async def _discover_qtm(self, interface): 68 | 69 | try: 70 | async for qtm_instance in qtm_rt.Discover(interface): 71 | info = qtm_instance.info.decode("utf-8").split(",")[0] 72 | 73 | if not info in self._found_qtms: 74 | self.discoveredQTM.emit(info, qtm_instance.host) 75 | self._found_qtms[info] = True 76 | except Exception: 77 | pass 78 | 79 | self.discovering = False 80 | 81 | 82 | class MainUI(QMainWindow, main_window_class): 83 | def __init__(self, *args): 84 | super().__init__(*args) 85 | self.setupUi(self) 86 | 87 | # Discovery 88 | self._discovery = QDiscovery() 89 | self._discovery.discoveringChanged.connect(self._is_discovering) 90 | self._discovery.discoveredQTM.connect(self._qtm_discovered) 91 | self.discover_button.clicked.connect(self._discovery.discover) 92 | 93 | # Connection 94 | self.connect_button.clicked.connect(self.connect_qtm) 95 | self.disconnect_button.clicked.connect(self.disconnect_qtm) 96 | self._is_streaming = False 97 | 98 | # Trajectory & sixdof 99 | self._trajectory_index = None 100 | self.trajectory_combo.currentIndexChanged.connect( 101 | self._trajectory_index_changed 102 | ) 103 | self._sixdof_index = None 104 | self.sixdof_combo.currentIndexChanged.connect(self._sixdof_index_changed) 105 | 106 | # Settings 107 | for setting in [ 108 | "all", 109 | "general", 110 | "3d", 111 | "6d", 112 | "analog", 113 | "force", 114 | "gazevector", 115 | "image", 116 | ]: 117 | self.settings_combo.addItem(setting) 118 | self.settings_combo.currentIndexChanged[str].connect( 119 | self._settings_index_changed 120 | ) 121 | 122 | self._to_be_cleared = [ 123 | self.x_trajectory, 124 | self.y_trajectory, 125 | self.z_trajectory, 126 | self.x_sixdof, 127 | self.y_sixdof, 128 | self.z_sixdof, 129 | self.settings_viewer, 130 | self.trajectory_combo, 131 | self.sixdof_combo, 132 | ] 133 | 134 | self._discovery.discover() 135 | 136 | def _is_discovering(self, discovering): 137 | if discovering: 138 | self.qtm_combo.clear() 139 | self.discover_button.setEnabled(not discovering) 140 | 141 | def _settings_index_changed(self, setting): 142 | start_async_task(self._get_settings(setting)) 143 | 144 | def _qtm_discovered(self, info, ip): 145 | self.qtm_combo.addItem("{} {}".format(info, ip)) 146 | self.connect_button.setEnabled(True) 147 | 148 | def connect_qtm(self): 149 | self.connect_button.setEnabled(False) 150 | self.discover_button.setEnabled(False) 151 | self.qtm_combo.setEnabled(False) 152 | 153 | start_async_task(self._connect_qtm()) 154 | 155 | async def _connect_qtm(self): 156 | ip = self.qtm_combo.currentText().split(" ")[1] 157 | 158 | self._connection = await qtm_rt.connect( 159 | ip, on_disconnect=self.on_disconnect, on_event=self.on_event 160 | ) 161 | 162 | if self._connection is None: 163 | self.on_disconnect("Failed to connect") 164 | return 165 | 166 | await self._connection.take_control("password") 167 | await self._connection.get_state() 168 | 169 | self.disconnect_button.setEnabled(True) 170 | self.settings_combo.setEnabled(True) 171 | 172 | def disconnect_qtm(self): 173 | self._connection.disconnect() 174 | 175 | def on_disconnect(self, reason): 176 | self.disconnect_button.setEnabled(False) 177 | self.connect_button.setEnabled(True) 178 | self.discover_button.setEnabled(True) 179 | self.qtm_combo.setEnabled(True) 180 | self.settings_combo.setEnabled(False) 181 | 182 | for item in self._to_be_cleared: 183 | item.clear() 184 | 185 | def _sixdof_index_changed(self, index): 186 | self._sixdof_index = index 187 | 188 | def _trajectory_index_changed(self, index): 189 | self._trajectory_index = index 190 | 191 | def set_3d_values(self, controls, values): 192 | for control, component in zip(controls, values): 193 | control.setText("{0:.3f}".format(component)) 194 | 195 | def on_packet(self, packet): 196 | if qtm_rt.packet.QRTComponentType.Component3d in packet.components: 197 | _, markers = packet.get_3d_markers() 198 | if self._trajectory_index is not None: 199 | marker = markers[self._trajectory_index] 200 | self.set_3d_values( 201 | [self.x_trajectory, self.y_trajectory, self.z_trajectory], marker 202 | ) 203 | 204 | if qtm_rt.packet.QRTComponentType.Component6d in packet.components: 205 | _, sixdofs = packet.get_6d() 206 | if self._sixdof_index is not None: 207 | position, _ = sixdofs[self._sixdof_index] 208 | self.set_3d_values( 209 | [self.x_sixdof, self.y_sixdof, self.z_sixdof], position 210 | ) 211 | 212 | def on_event(self, event): 213 | start_async_task(self._async_event_handler(event)) 214 | 215 | async def _async_event_handler(self, event): 216 | 217 | if event == QRTEvent.EventRTfromFileStarted or event == QRTEvent.EventConnected: 218 | await self._setup_qtm() 219 | 220 | elif ( 221 | event == QRTEvent.EventRTfromFileStopped 222 | or event == QRTEvent.EventConnectionClosed 223 | ) and self._is_streaming: 224 | start_async_task(self._stop_stream()) 225 | 226 | async def _setup_qtm(self, stream=True): 227 | await self._get_sixdof() 228 | await self._get_labels() 229 | await self._get_settings(self.settings_combo.currentText()) 230 | await self._start_stream() 231 | 232 | async def _get_settings(self, setting="all"): 233 | result = await self._connection.get_parameters(parameters=[setting]) 234 | 235 | self.settings_viewer.setText(result.decode("utf-8")) 236 | 237 | async def _get_sixdof(self): 238 | result = await self._connection.get_parameters(parameters=["6d"]) 239 | 240 | try: 241 | xml = ET.fromstring(result) 242 | except ET.ParseError: 243 | print(result) 244 | return 245 | 246 | self.sixdof_combo.clear() 247 | for label in (label.text for label in xml.iter("Name")): 248 | self.sixdof_combo.addItem(label) 249 | 250 | async def _get_labels(self): 251 | result = await self._connection.get_parameters(parameters=["3d"]) 252 | 253 | xml = ET.fromstring(result) 254 | self.trajectory_combo.clear() 255 | for label in (label.text for label in xml.iter("Name")): 256 | self.trajectory_combo.addItem(label) 257 | 258 | async def _stop_stream(self): 259 | await self._connection.stream_frames_stop() 260 | self._is_streaming = False 261 | 262 | async def _start_stream(self): 263 | result = await self._connection.stream_frames( 264 | frames="frequency:10", components=["3d", "6d"], on_packet=self.on_packet 265 | ) 266 | if result == b"Ok": 267 | self._is_streaming = True 268 | 269 | 270 | def main(): 271 | 272 | app = QApplication(sys.argv) 273 | 274 | # Create and set an event loop that combines qt and asyncio 275 | loop = QSelectorEventLoop(app) 276 | asyncio.set_event_loop(loop) 277 | 278 | main_window = MainUI() 279 | main_window.show() 280 | sys.exit(app.exec_()) 281 | 282 | 283 | if __name__ == "__main__": 284 | main() 285 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Qualisys Realtime SDK for Python documentation build configuration file, created by 5 | # sphinx-quickstart on Mon Oct 3 09:58:46 2016. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | import os 21 | import sys 22 | sys.path.insert(0, os.path.abspath('..')) 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'sphinx.ext.autodoc', 'sphinx.ext.githubpages' 35 | ] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix(es) of source filenames. 41 | # You can specify multiple suffix as a list of string: 42 | # 43 | # source_suffix = ['.rst', '.md'] 44 | source_suffix = '.rst' 45 | 46 | # The encoding of source files. 47 | # 48 | # source_encoding = 'utf-8-sig' 49 | 50 | # The master toctree document. 51 | master_doc = 'index' 52 | 53 | # General information about the project. 54 | project = 'Qualisys Realtime SDK for Python' 55 | copyright = '2021, Qualisys' 56 | author = 'Qualisys' 57 | 58 | # The version info for the project you're documenting, acts as replacement for 59 | # |version| and |release|, also used in various other places throughout the 60 | # built documents. 61 | # 62 | # The short X.Y version. 63 | version = '2.1' 64 | # The full version, including alpha/beta/rc tags. 65 | release = '2.1.1' 66 | 67 | # The language for content autogenerated by Sphinx. Refer to documentation 68 | # for a list of supported languages. 69 | # 70 | # This is also used if you do content translation via gettext catalogs. 71 | # Usually you set "language" from the command line for these cases. 72 | language = 'en' 73 | 74 | # There are two options for replacing |today|: either, you set today to some 75 | # non-false value, then it is used: 76 | # 77 | # today = '' 78 | # 79 | # Else, today_fmt is used as the format for a strftime call. 80 | # 81 | # today_fmt = '%B %d, %Y' 82 | 83 | # List of patterns, relative to source directory, that match files and 84 | # directories to ignore when looking for source files. 85 | # This patterns also effect to html_static_path and html_extra_path 86 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 87 | 88 | # The reST default role (used for this markup: `text`) to use for all 89 | # documents. 90 | # 91 | # default_role = None 92 | 93 | # If true, '()' will be appended to :func: etc. cross-reference text. 94 | # 95 | # add_function_parentheses = True 96 | 97 | # If true, the current module name will be prepended to all description 98 | # unit titles (such as .. function::). 99 | # 100 | # add_module_names = True 101 | 102 | # If true, sectionauthor and moduleauthor directives will be shown in the 103 | # output. They are ignored by default. 104 | # 105 | # show_authors = False 106 | 107 | # The name of the Pygments (syntax highlighting) style to use. 108 | pygments_style = 'sphinx' 109 | 110 | # A list of ignored prefixes for module index sorting. 111 | # modindex_common_prefix = [] 112 | 113 | # If true, keep warnings as "system message" paragraphs in the built documents. 114 | # keep_warnings = False 115 | 116 | # If true, `todo` and `todoList` produce output, else they produce nothing. 117 | todo_include_todos = False 118 | 119 | # -- Options for HTML output ---------------------------------------------- 120 | 121 | # The theme to use for HTML and HTML Help pages. See the documentation for 122 | # a list of builtin themes. 123 | # 124 | html_theme = 'alabaster' 125 | 126 | # Theme options are theme-specific and customize the look and feel of a theme 127 | # further. For a list of options available for each theme, see the 128 | # documentation. 129 | # 130 | # html_theme_options = {} 131 | 132 | # Add any paths that contain custom themes here, relative to this directory. 133 | # html_theme_path = [] 134 | 135 | # The name for this set of Sphinx documents. 136 | # " v documentation" by default. 137 | # 138 | # html_title = 'Qualisys Realtime SDK for Python v0.6' 139 | 140 | # A shorter title for the navigation bar. Default is the same as html_title. 141 | # 142 | # html_short_title = None 143 | 144 | # The name of an image file (relative to this directory) to place at the top 145 | # of the sidebar. 146 | # 147 | # html_logo = None 148 | 149 | # The name of an image file (relative to this directory) to use as a favicon of 150 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 151 | # pixels large. 152 | # 153 | # html_favicon = None 154 | 155 | # Add any paths that contain custom static files (such as style sheets) here, 156 | # relative to this directory. They are copied after the builtin static files, 157 | # so a file named "default.css" will overwrite the builtin "default.css". 158 | html_static_path = [] 159 | 160 | # Add any extra paths that contain custom files (such as robots.txt or 161 | # .htaccess) here, relative to this directory. These files are copied 162 | # directly to the root of the documentation. 163 | # 164 | # html_extra_path = [] 165 | 166 | # If not None, a 'Last updated on:' timestamp is inserted at every page 167 | # bottom, using the given strftime format. 168 | # The empty string is equivalent to '%b %d, %Y'. 169 | # 170 | # html_last_updated_fmt = None 171 | 172 | # If true, SmartyPants will be used to convert quotes and dashes to 173 | # typographically correct entities. 174 | # 175 | # html_use_smartypants = True 176 | 177 | # Custom sidebar templates, maps document names to template names. 178 | # 179 | # html_sidebars = {} 180 | 181 | # Additional templates that should be rendered to pages, maps page names to 182 | # template names. 183 | # 184 | # html_additional_pages = {} 185 | 186 | # If false, no module index is generated. 187 | # 188 | # html_domain_indices = True 189 | 190 | # If false, no index is generated. 191 | # 192 | # html_use_index = True 193 | 194 | # If true, the index is split into individual pages for each letter. 195 | # 196 | # html_split_index = False 197 | 198 | # If true, links to the reST sources are added to the pages. 199 | # 200 | # html_show_sourcelink = True 201 | 202 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 203 | # 204 | # html_show_sphinx = True 205 | 206 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 207 | # 208 | # html_show_copyright = True 209 | 210 | # If true, an OpenSearch description file will be output, and all pages will 211 | # contain a tag referring to it. The value of this option must be the 212 | # base URL from which the finished HTML is served. 213 | # 214 | # html_use_opensearch = '' 215 | 216 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 217 | # html_file_suffix = None 218 | 219 | # Language to be used for generating the HTML full-text search index. 220 | # Sphinx supports the following languages: 221 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 222 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' 223 | # 224 | # html_search_language = 'en' 225 | 226 | # A dictionary with options for the search language support, empty by default. 227 | # 'ja' uses this config value. 228 | # 'zh' user can custom change `jieba` dictionary path. 229 | # 230 | # html_search_options = {'type': 'default'} 231 | 232 | # The name of a javascript file (relative to the configuration directory) that 233 | # implements a search results scorer. If empty, the default will be used. 234 | # 235 | # html_search_scorer = 'scorer.js' 236 | 237 | # Output file base name for HTML help builder. 238 | htmlhelp_basename = 'QualisysRealtimeSDKforPythondoc' 239 | 240 | # -- Options for LaTeX output --------------------------------------------- 241 | 242 | latex_elements = { 243 | # The paper size ('letterpaper' or 'a4paper'). 244 | # 245 | # 'papersize': 'letterpaper', 246 | 247 | # The font size ('10pt', '11pt' or '12pt'). 248 | # 249 | # 'pointsize': '10pt', 250 | 251 | # Additional stuff for the LaTeX preamble. 252 | # 253 | # 'preamble': '', 254 | 255 | # Latex figure (float) alignment 256 | # 257 | # 'figure_align': 'htbp', 258 | } 259 | 260 | # Grouping the document tree into LaTeX files. List of tuples 261 | # (source start file, target name, title, 262 | # author, documentclass [howto, manual, or own class]). 263 | latex_documents = [ 264 | (master_doc, 'QualisysRealtimeSDKforPython.tex', 265 | 'Qualisys Realtime SDK for Python Documentation', 'Qualisys', 'manual'), 266 | ] 267 | 268 | # The name of an image file (relative to this directory) to place at the top of 269 | # the title page. 270 | # 271 | # latex_logo = None 272 | 273 | # For "manual" documents, if this is true, then toplevel headings are parts, 274 | # not chapters. 275 | # 276 | # latex_use_parts = False 277 | 278 | # If true, show page references after internal links. 279 | # 280 | # latex_show_pagerefs = False 281 | 282 | # If true, show URL addresses after external links. 283 | # 284 | # latex_show_urls = False 285 | 286 | # Documents to append as an appendix to all manuals. 287 | # 288 | # latex_appendices = [] 289 | 290 | # It false, will not define \strong, \code, itleref, \crossref ... but only 291 | # \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added 292 | # packages. 293 | # 294 | # latex_keep_old_macro_names = True 295 | 296 | # If false, no module index is generated. 297 | # 298 | # latex_domain_indices = True 299 | 300 | # -- Options for manual page output --------------------------------------- 301 | 302 | # One entry per manual page. List of tuples 303 | # (source start file, name, description, authors, manual section). 304 | man_pages = [(master_doc, 'qualisysrealtimesdkforpython', 305 | 'Qualisys Realtime SDK for Python Documentation', [author], 1)] 306 | 307 | # If true, show URL addresses after external links. 308 | # 309 | # man_show_urls = False 310 | 311 | # -- Options for Texinfo output ------------------------------------------- 312 | 313 | # Grouping the document tree into Texinfo files. List of tuples 314 | # (source start file, target name, title, author, 315 | # dir menu entry, description, category) 316 | texinfo_documents = [ 317 | (master_doc, 'QualisysRealtimeSDKforPython', 318 | 'Qualisys Realtime SDK for Python Documentation', author, 319 | 'QualisysRealtimeSDKforPython', 'One line description of project.', 320 | 'Miscellaneous'), 321 | ] 322 | 323 | # Documents to append as an appendix to all manuals. 324 | # 325 | # texinfo_appendices = [] 326 | 327 | # If false, no module index is generated. 328 | # 329 | # texinfo_domain_indices = True 330 | 331 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 332 | # 333 | # texinfo_show_urls = 'footnote' 334 | 335 | # If true, do not generate a @detailmenu in the "Top" node's menu. 336 | # 337 | # texinfo_no_detailmenu = False 338 | 339 | autodoc_member_order = 'bysource' 340 | -------------------------------------------------------------------------------- /examples/qt_example/ui/Main.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 905 10 | 537 11 | 12 | 13 | 14 | MainWindow 15 | 16 | 17 | 18 | 19 | 20 | 21 | Streaming Info 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | Trajectories 30 | 31 | 32 | 33 | 34 | 35 | 36 | 9 37 | 38 | 39 | 9 40 | 41 | 42 | 9 43 | 44 | 45 | 9 46 | 47 | 48 | 49 | 50 | Qt::LeftToRight 51 | 52 | 53 | Y: 54 | 55 | 56 | Qt::AlignCenter 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 0 65 | 0 66 | 67 | 68 | 69 | true 70 | 71 | 72 | 73 | 74 | 75 | 76 | Qt::LeftToRight 77 | 78 | 79 | X: 80 | 81 | 82 | Qt::AlignCenter 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 0 91 | 0 92 | 93 | 94 | 95 | true 96 | 97 | 98 | 99 | 100 | 101 | 102 | Qt::LeftToRight 103 | 104 | 105 | Z: 106 | 107 | 108 | Qt::AlignCenter 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 0 117 | 0 118 | 119 | 120 | 121 | true 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 6DOF 138 | 139 | 140 | 141 | 142 | 143 | 144 | 9 145 | 146 | 147 | 9 148 | 149 | 150 | 9 151 | 152 | 153 | 9 154 | 155 | 156 | 157 | 158 | X: 159 | 160 | 161 | Qt::AlignCenter 162 | 163 | 164 | 165 | 166 | 167 | 168 | Z: 169 | 170 | 171 | Qt::AlignCenter 172 | 173 | 174 | 175 | 176 | 177 | 178 | Y: 179 | 180 | 181 | Qt::AlignCenter 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 0 190 | 0 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 0 200 | 0 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 0 210 | 0 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | QTM Settings 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | true 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 0 252 | 0 253 | 254 | 255 | 256 | 257 | 240 258 | 156 259 | 260 | 261 | 262 | QDockWidget::DockWidgetMovable 263 | 264 | 265 | QTM Controls 266 | 267 | 268 | 1 269 | 270 | 271 | 272 | 273 | 9 274 | 275 | 276 | 9 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | QTM: 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 0 295 | 0 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | Discover QTM 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | false 315 | 316 | 317 | Connect 318 | 319 | 320 | 321 | 322 | 323 | 324 | false 325 | 326 | 327 | Disconnect 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | Qt::Vertical 337 | 338 | 339 | 340 | 20 341 | 40 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | -------------------------------------------------------------------------------- /qtm_rt/qrt.py: -------------------------------------------------------------------------------- 1 | """ QTM RT protocol implementation """ 2 | 3 | import asyncio 4 | import logging 5 | from functools import wraps 6 | 7 | from qtm_rt.packet import QRTPacketType, QRTPacket 8 | from qtm_rt.protocol import QTMProtocol, QRTCommandException 9 | 10 | # pylint: disable=C0330 11 | 12 | LOG = logging.getLogger("qtm_rt") # pylint: disable C0103 13 | 14 | 15 | def validate_response(expected_responses): 16 | """ Decorator to validate responses from QTM """ 17 | 18 | def internal_decorator(function): 19 | @wraps(function) 20 | async def wrapper(*args, **kwargs): 21 | 22 | response = await function(*args, **kwargs) 23 | 24 | for expected_response in expected_responses: 25 | if response.startswith(expected_response): 26 | return response 27 | 28 | raise QRTCommandException( 29 | "Expected %s but got %s" % (expected_responses, response) 30 | ) 31 | 32 | return wrapper 33 | 34 | return internal_decorator 35 | 36 | 37 | class QRTConnection(object): 38 | """Represent a connection to QTM. 39 | 40 | Returned by :func:`~qtm_rt.connect` when successfuly connected to QTM. 41 | """ 42 | 43 | def __init__(self, protocol: QTMProtocol, timeout): 44 | super(QRTConnection, self).__init__() 45 | self._protocol = protocol 46 | self._timeout = timeout 47 | 48 | def disconnect(self): 49 | """Disconnect from QTM.""" 50 | self._protocol.transport.close() 51 | 52 | def has_transport(self): 53 | """ Check if connected to QTM """ 54 | return self._protocol.transport is not None 55 | 56 | async def qtm_version(self): 57 | """Get the QTM version. 58 | """ 59 | return await asyncio.wait_for( 60 | self._protocol.send_command("qtmversion"), timeout=self._timeout 61 | ) 62 | 63 | async def byte_order(self): 64 | """Get the byte order used when communicating 65 | (should only ever be little endian using this library). 66 | """ 67 | return await asyncio.wait_for( 68 | self._protocol.send_command("byteorder"), timeout=self._timeout 69 | ) 70 | 71 | async def get_state(self): 72 | """Get the latest state change of QTM. If the :func:`~qtm_rt.connect` on_event 73 | callback was set the callback will be called as well. 74 | 75 | :rtype: A :class:`qtm_rt.QRTEvent` 76 | """ 77 | await self._protocol.send_command("getstate", callback=False) 78 | return await self._protocol.await_event() 79 | 80 | async def await_event(self, event=None, timeout=30): 81 | """Wait for an event from QTM. 82 | 83 | :param event: A :class:`qtm_rt.QRTEvent` 84 | to wait for a specific event. Otherwise wait for any event. 85 | 86 | :param timeout: Max time to wait for event. 87 | 88 | :rtype: A :class:`qtm_rt.QRTEvent` 89 | """ 90 | return await self._protocol.await_event(event, timeout=timeout) 91 | 92 | async def get_parameters(self, parameters=None): 93 | """Get the settings for the requested component(s) of QTM in XML format. 94 | 95 | :param parameters: A list of parameters to request. 96 | Could be 'all' or any combination 97 | of 'general', '3d', '6d', 'analog', 'force', 'gazevector', 'eyetracker', 'image', 98 | 'skeleton', 'skeleton:global', 'calibration'. 99 | :rtype: An XML string containing the requested settings. 100 | See QTM RT Documentation for details. 101 | """ 102 | 103 | if parameters is None: 104 | parameters = ["all"] 105 | else: 106 | for parameter in parameters: 107 | if not parameter in [ 108 | "all", 109 | "general", 110 | "3d", 111 | "6d", 112 | "analog", 113 | "force", 114 | "gazevector", 115 | "eyetracker", 116 | "image", 117 | "skeleton", 118 | "skeleton:global", 119 | "calibration", 120 | ]: 121 | raise QRTCommandException("%s is not a valid parameter" % parameter) 122 | 123 | cmd = "getparameters %s" % " ".join(parameters) 124 | return await asyncio.wait_for( 125 | self._protocol.send_command(cmd), timeout=self._timeout 126 | ) 127 | 128 | async def get_current_frame(self, components=None) -> QRTPacket: 129 | """Get measured values from QTM for a single frame. 130 | 131 | :param components: A list of components to receive, could be any combination of 132 | '2d', '2dlin', '3d', '3dres', '3dnolabels', 133 | '3dnolabelsres', 'analog', 'analogsingle', 'force', 'forcesingle', '6d', '6dres', 134 | '6deuler', '6deulerres', 'gazevector', 'eyetracker', 'image', 'timecode', 135 | 'skeleton', 'skeleton:global' 136 | 137 | :rtype: A :class:`qtm_rt.QRTPacket` containing requested components 138 | """ 139 | 140 | _validate_components(components) 141 | 142 | cmd = "getcurrentframe %s" % " ".join(components) 143 | return await asyncio.wait_for( 144 | self._protocol.send_command(cmd), timeout=self._timeout 145 | ) 146 | 147 | async def stream_frames(self, frames="allframes", components=None, on_packet=None): 148 | """Stream measured frames from QTM until :func:`~qtm_rt.QRTConnection.stream_frames_stop` 149 | is called. 150 | 151 | 152 | :param frames: Which frames to receive, possible values are 'allframes', 153 | 'frequency:n' or 'frequencydivisor:n' where n should be desired value. 154 | :param components: A list of components to receive, could be any combination of 155 | '2d', '2dlin', '3d', '3dres', '3dnolabels', 156 | '3dnolabelsres', 'analog', 'analogsingle', 'force', 'forcesingle', '6d', '6dres', 157 | '6deuler', '6deulerres', 'gazevector', 'eyetracker', 'image', 'timecode', 158 | 'skeleton', 'skeleton:global' 159 | 160 | :rtype: The string 'Ok' if successful 161 | """ 162 | 163 | _validate_components(components) 164 | 165 | self._protocol.set_on_packet(on_packet) 166 | 167 | cmd = "streamframes %s %s" % (frames, " ".join(components)) 168 | return await asyncio.wait_for( 169 | self._protocol.send_command(cmd), timeout=self._timeout 170 | ) 171 | 172 | async def stream_frames_stop(self): 173 | """Stop streaming frames.""" 174 | 175 | self._protocol.set_on_packet(None) 176 | 177 | cmd = "streamframes stop" 178 | await self._protocol.send_command(cmd, callback=False) 179 | 180 | @validate_response([b"You are now master"]) 181 | async def take_control(self, password): 182 | """Take control of QTM. 183 | 184 | :param password: Password as entered in QTM. 185 | """ 186 | cmd = "takecontrol %s" % password 187 | return await asyncio.wait_for( 188 | self._protocol.send_command(cmd), timeout=self._timeout 189 | ) 190 | 191 | @validate_response([b"You are now a regular client"]) 192 | async def release_control(self): 193 | """Release control of QTM. 194 | """ 195 | 196 | cmd = "releasecontrol" 197 | return await asyncio.wait_for( 198 | self._protocol.send_command(cmd), timeout=self._timeout 199 | ) 200 | 201 | @validate_response([b"Creating new connection", b"Already connected"]) 202 | async def new(self): 203 | """Create a new measurement. 204 | """ 205 | cmd = "new" 206 | return await asyncio.wait_for( 207 | self._protocol.send_command(cmd), timeout=self._timeout 208 | ) 209 | 210 | @validate_response( 211 | [ 212 | b"Closing connection", 213 | b"File closed", 214 | b"Closing file", 215 | b"No connection to close", 216 | ] 217 | ) 218 | async def close(self): 219 | """Close a measurement 220 | """ 221 | cmd = "close" 222 | return await asyncio.wait_for( 223 | self._protocol.send_command(cmd), timeout=self._timeout 224 | ) 225 | 226 | @validate_response([b"Starting measurement", b"Starting RT from file"]) 227 | async def start(self, rtfromfile=False): 228 | """Start RT from file. You need to be in control of QTM to be able to do this. 229 | """ 230 | cmd = "start" + (" rtfromfile" if rtfromfile else "") 231 | return await asyncio.wait_for( 232 | self._protocol.send_command(cmd), timeout=self._timeout 233 | ) 234 | 235 | @validate_response([b"Stopping measurement"]) 236 | async def stop(self): 237 | """Stop RT from file.""" 238 | cmd = "stop" 239 | return await asyncio.wait_for( 240 | self._protocol.send_command(cmd), timeout=self._timeout 241 | ) 242 | 243 | @validate_response([b"Measurement loaded"]) 244 | async def load(self, filename): 245 | """Load a measurement. 246 | 247 | :param filename: Path to measurement you want to load. 248 | """ 249 | cmd = "load %s" % filename 250 | return await asyncio.wait_for( 251 | self._protocol.send_command(cmd), timeout=self._timeout 252 | ) 253 | 254 | @validate_response([b"Measurement saved"]) 255 | async def save(self, filename, overwrite=False): 256 | """Save a measurement. 257 | 258 | :param filename: Filename you wish to save as. 259 | :param overwrite: If QTM should overwrite existing measurement. 260 | """ 261 | cmd = "save %s%s" % (filename, " overwrite" if overwrite else "") 262 | return await asyncio.wait_for( 263 | self._protocol.send_command(cmd), timeout=self._timeout 264 | ) 265 | 266 | @validate_response([b"Project loaded"]) 267 | async def load_project(self, project_path): 268 | """Load a project. 269 | 270 | :param project_path: Path to project you want to load. 271 | """ 272 | cmd = "loadproject %s" % project_path 273 | return await asyncio.wait_for( 274 | self._protocol.send_command(cmd), timeout=self._timeout 275 | ) 276 | 277 | @validate_response([b"Trig ok"]) 278 | async def trig(self): 279 | """Trigger QTM, only possible when QTM is configured to use Software/Wireless trigger""" 280 | cmd = "trig" 281 | return await asyncio.wait_for( 282 | self._protocol.send_command(cmd), timeout=self._timeout 283 | ) 284 | 285 | @validate_response([b"Event set"]) 286 | async def set_qtm_event(self, event=None): 287 | """Set event in QTM.""" 288 | cmd = "event%s" % ("" if event is None else " " + event) 289 | return await asyncio.wait_for( 290 | self._protocol.send_command(cmd), timeout=self._timeout 291 | ) 292 | 293 | async def send_xml(self, xml): 294 | """Used to update QTM settings, see QTM RT protocol for more information. 295 | 296 | :param xml: XML document as a str. See QTM RT Documentation for details. 297 | """ 298 | return await asyncio.wait_for( 299 | self._protocol.send_command(xml, command_type=QRTPacketType.PacketXML), 300 | timeout=self._timeout, 301 | ) 302 | 303 | async def calibrate(self, timeout=600): # Timeout 10 min. 304 | """Start calibration and return calibration result. 305 | 306 | :param timeout_: Calibration timeout. 307 | 308 | :rtype: An XML string containing the calibration result. 309 | See QTM RT Documentation for details. 310 | """ 311 | cmd = "calibrate" 312 | response = await asyncio.wait_for( 313 | self._protocol.send_command(cmd), timeout=self._timeout) 314 | 315 | if response != b"Starting calibration": 316 | raise QRTCommandException(response) 317 | 318 | return await asyncio.wait_for( 319 | self._protocol.receive_response(), timeout=timeout) 320 | 321 | 322 | 323 | # TODO GetCaptureC3D 324 | # TODO GetCaptureQTM 325 | 326 | 327 | async def connect( 328 | host, 329 | port=22223, 330 | version="1.25", 331 | on_event=None, 332 | on_disconnect=None, 333 | timeout=5, 334 | loop=None, 335 | ) -> QRTConnection: 336 | """Async function to connect to QTM 337 | 338 | :param host: Address of the computer running QTM. 339 | :param port: Port number to connect to, should be the port configured for little endian. 340 | :param version: Version of the rt protocol to use. Default is the latest version. 341 | The Qualisys Python sdk does not support versions older than 1.8. 342 | :param on_disconnect: Function to be called when a disconnect from QTM occurs. 343 | :param on_event: Function to be called when there's an event from QTM. 344 | :param timeout: The default timeout time for calls to QTM. 345 | :param loop: Alternative event loop, will use asyncio default if None. 346 | 347 | :rtype: A :class:`.QRTConnection` 348 | """ 349 | loop = loop or asyncio.get_event_loop() 350 | 351 | try: 352 | _, protocol = await loop.create_connection( 353 | lambda: QTMProtocol( 354 | loop=loop, on_event=on_event, on_disconnect=on_disconnect 355 | ), 356 | host, 357 | port, 358 | ) 359 | except (ConnectionRefusedError, TimeoutError, OSError) as exception: 360 | LOG.error(exception) 361 | return None 362 | 363 | try: 364 | await protocol.set_version(version) 365 | except QRTCommandException as exception: 366 | LOG.error(exception) 367 | return None 368 | except TypeError as exception: # TODO: fix test requiring this (test_connect_set_version) 369 | LOG.error(exception) 370 | return None 371 | 372 | return QRTConnection(protocol, timeout=timeout) 373 | 374 | 375 | def _validate_components(components): 376 | for component in components: 377 | if not component.lower() in [ 378 | "2d", 379 | "2dlin", 380 | "3d", 381 | "3dres", 382 | "3dnolabels", 383 | "3dnolabelsres", 384 | "analog", 385 | "analogsingle", 386 | "force", 387 | "forcesingle", 388 | "6d", 389 | "6dres", 390 | "6deuler", 391 | "6deulerres", 392 | "gazevector", 393 | "eyetracker", 394 | "image", 395 | "timecode", 396 | "skeleton", 397 | "skeleton:global", 398 | ]: 399 | raise QRTCommandException("%s is not a valid component" % component) 400 | 401 | -------------------------------------------------------------------------------- /test/qrtconnection_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for connect and QRTConnection 3 | """ 4 | 5 | import asyncio 6 | 7 | import pytest 8 | 9 | from qtm_rt.qrt import QRTConnection, connect 10 | from qtm_rt.protocol import QTMProtocol, QRTCommandException 11 | 12 | 13 | # pylint: disable=W0621, C0111, C0330, E1101, W0212 14 | 15 | 16 | @pytest.mark.parametrize("exception", [ConnectionRefusedError, TimeoutError, OSError]) 17 | @pytest.mark.asyncio 18 | async def test_connect_connection_fail(exception, mocker): 19 | mocker.patch.object( 20 | asyncio.get_running_loop(), "create_connection", mocker.MagicMock(side_effect=exception) 21 | ) 22 | result = await connect("192.0.2.0") 23 | assert result is None 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_connect_no_loop(mocker): 28 | mocker.patch("asyncio.get_event_loop", mocker.MagicMock(return_value=asyncio.get_running_loop())) 29 | 30 | mocker.patch.object( 31 | asyncio.get_running_loop(), 32 | "create_connection", 33 | mocker.MagicMock(side_effect=ConnectionRefusedError), 34 | ) 35 | 36 | await connect("192.0.2.0") 37 | assert asyncio.get_event_loop.call_count == 1 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_connect_loop(mocker): 42 | mocker.patch("asyncio.get_event_loop", mocker.MagicMock()) 43 | 44 | mocker.patch.object( 45 | asyncio.get_running_loop(), 46 | "create_connection", 47 | mocker.MagicMock(side_effect=ConnectionRefusedError), 48 | ) 49 | 50 | await connect("192.0.2.0", loop=asyncio.get_running_loop()) 51 | assert asyncio.get_event_loop.call_count == 0 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_connect_set_version(mocker): 56 | protocol = mocker.MagicMock() 57 | 58 | async def side_effect(*_): 59 | return None, protocol 60 | 61 | mocker.patch.object( 62 | asyncio.get_running_loop(), "create_connection", mocker.MagicMock(side_effect=side_effect) 63 | ) 64 | 65 | await connect("192.0.2.0") 66 | assert protocol.set_version.call_count == 1 67 | 68 | 69 | @pytest.mark.asyncio 70 | async def test_connect_success(mocker): 71 | async def set_version(*_): 72 | pass 73 | 74 | async def side_effect(*_): 75 | return None, protocol 76 | 77 | protocol = mocker.MagicMock() 78 | protocol.set_version = set_version 79 | 80 | mocker.patch.object( 81 | asyncio.get_running_loop(), "create_connection", mocker.MagicMock(side_effect=side_effect) 82 | ) 83 | 84 | connection = await connect("192.0.2.0") 85 | 86 | assert isinstance(connection, QRTConnection) 87 | 88 | 89 | async def async_function(*_, **__): 90 | pass 91 | 92 | 93 | @pytest.fixture 94 | def a_qrt(mocker): 95 | protocol = mocker.MagicMock( 96 | spec=QTMProtocol, name="QTMProtocol", side_effect=async_function 97 | ) 98 | protocol.transport = mocker.MagicMock(name="transport") 99 | protocol.send_command.side_effect = async_function 100 | protocol.await_event.side_effect = async_function 101 | return QRTConnection(protocol, 5) 102 | 103 | 104 | def test_disconnect(a_qrt): 105 | a_qrt.disconnect() 106 | assert a_qrt._protocol.transport.close.call_count == 1 107 | 108 | 109 | @pytest.mark.asyncio 110 | async def test_qtm_version(a_qrt): 111 | await a_qrt.qtm_version() 112 | a_qrt._protocol.send_command.assert_called_once_with("qtmversion") 113 | 114 | 115 | @pytest.mark.asyncio 116 | async def test_byte_order(a_qrt): 117 | await a_qrt.byte_order() 118 | a_qrt._protocol.send_command.assert_called_once_with("byteorder") 119 | 120 | 121 | @pytest.mark.asyncio 122 | async def test_get_state(a_qrt): 123 | await a_qrt.get_state() 124 | 125 | a_qrt._protocol.send_command.assert_called_once_with("getstate", callback=False) 126 | assert a_qrt._protocol.await_event.call_count == 1 127 | 128 | 129 | @pytest.mark.asyncio 130 | async def test_get_parameters_none(a_qrt): 131 | await a_qrt.get_parameters() 132 | a_qrt._protocol.send_command.assert_called_once_with("getparameters all") 133 | 134 | 135 | @pytest.mark.asyncio 136 | async def test_get_parameters_fail(a_qrt): 137 | with pytest.raises(QRTCommandException): 138 | await a_qrt.get_parameters(parameters=["fail"]) 139 | 140 | 141 | @pytest.mark.parametrize( 142 | "parameters", 143 | [ 144 | ["all"], 145 | ["general"], 146 | ["3d"], 147 | ["6d"], 148 | ["analog"], 149 | ["force"], 150 | ["gazevector"], 151 | ["image"], 152 | ["general", "3d"], 153 | ["general", "3d", "analog"], 154 | ], 155 | ) 156 | @pytest.mark.asyncio 157 | async def test_get_parameters(parameters, a_qrt): 158 | await a_qrt.get_parameters(parameters=parameters) 159 | 160 | a_qrt._protocol.send_command.assert_called_once_with( 161 | "getparameters {}".format(" ".join(parameters)) 162 | ) 163 | 164 | 165 | @pytest.mark.parametrize( 166 | "components", 167 | [ 168 | ["2D"], 169 | ["2DLin"], 170 | ["3D"], 171 | ["3DRes"], 172 | ["3DNoLabels"], 173 | ["3DNoLabelsRes"], 174 | ["Analog"], 175 | ["AnalogSingle"], 176 | ["Force"], 177 | ["ForceSingle"], 178 | ["6D"], 179 | ["6DRes"], 180 | ["6DEuler"], 181 | ["6DEulerRes"], 182 | ["GazeVector"], 183 | ["Image"], 184 | ["Timecode"], 185 | ], 186 | ) 187 | @pytest.mark.asyncio 188 | async def test_stream_frames(components, a_qrt): 189 | await a_qrt.stream_frames(components=components) 190 | 191 | a_qrt._protocol.send_command.assert_called_once_with( 192 | "streamframes {} {}".format("allframes", " ".join(components)) 193 | ) 194 | 195 | 196 | @pytest.mark.asyncio 197 | async def test_stream_frames_fail(a_qrt): 198 | with pytest.raises(QRTCommandException): 199 | await a_qrt.stream_frames(components=["fail"]) 200 | 201 | 202 | @pytest.mark.asyncio 203 | async def test_stream_frames_stop(a_qrt): 204 | await a_qrt.stream_frames_stop() 205 | a_qrt._protocol.send_command.assert_called_once_with( 206 | "streamframes stop", callback=False 207 | ) 208 | 209 | 210 | @pytest.mark.asyncio 211 | async def test_take_control(a_qrt): 212 | async def got_control(*_): 213 | return b"You are now master" 214 | 215 | a_qrt._protocol.send_command.side_effect = got_control 216 | 217 | password = "password" 218 | await a_qrt.take_control(password) 219 | a_qrt._protocol.send_command.assert_called_once_with( 220 | "takecontrol {}".format(password) 221 | ) 222 | 223 | 224 | @pytest.mark.asyncio 225 | async def test_take_control_fail(a_qrt): 226 | async def no_control(*_): 227 | return b"Fail" 228 | 229 | a_qrt._protocol.send_command.side_effect = no_control 230 | 231 | password = "password" 232 | with pytest.raises(QRTCommandException): 233 | await a_qrt.take_control(password) 234 | 235 | a_qrt._protocol.send_command.assert_called_once_with( 236 | "takecontrol {}".format(password) 237 | ) 238 | 239 | 240 | @pytest.mark.asyncio 241 | async def test_release_control(a_qrt): 242 | async def release_control(*_): 243 | return b"You are now a regular client" 244 | 245 | a_qrt._protocol.send_command.side_effect = release_control 246 | 247 | await a_qrt.release_control() 248 | a_qrt._protocol.send_command.assert_called_once_with("releasecontrol") 249 | 250 | 251 | @pytest.mark.asyncio 252 | async def test_release_control_fail(a_qrt): 253 | async def no_control(*_): 254 | return b"Fail" 255 | 256 | a_qrt._protocol.send_command.side_effect = no_control 257 | 258 | with pytest.raises(QRTCommandException): 259 | await a_qrt.release_control() 260 | 261 | a_qrt._protocol.send_command.assert_called_once_with("releasecontrol") 262 | 263 | 264 | @pytest.mark.asyncio 265 | async def test_new(a_qrt): 266 | async def new(*_): 267 | return b"Creating new connection" 268 | 269 | a_qrt._protocol.send_command.side_effect = new 270 | await a_qrt.new() 271 | a_qrt._protocol.send_command.assert_called_once_with("new") 272 | 273 | 274 | @pytest.mark.asyncio 275 | async def test_new_fail(a_qrt): 276 | async def fail(*_): 277 | return b"Fail" 278 | 279 | a_qrt._protocol.send_command.side_effect = fail 280 | 281 | with pytest.raises(QRTCommandException): 282 | await a_qrt.new() 283 | 284 | a_qrt._protocol.send_command.assert_called_once_with("new") 285 | 286 | 287 | @pytest.mark.asyncio 288 | async def test_calibrate(a_qrt): 289 | async def calibrate(*_): 290 | return b"Starting calibration" 291 | 292 | async def xml(*_): 293 | return b"XML" 294 | 295 | a_qrt._protocol.send_command.side_effect = calibrate 296 | a_qrt._protocol.receive_response.side_effect = xml 297 | 298 | response = await a_qrt.calibrate() 299 | 300 | if response != b"XML": 301 | pytest.fail("Calibration result error") 302 | 303 | a_qrt._protocol.send_command.assert_called_once_with("calibrate") 304 | 305 | 306 | 307 | @pytest.mark.asyncio 308 | async def test_calibrate_fail(a_qrt): 309 | async def calibrate(*_): 310 | return b"Can not start calibration" 311 | 312 | async def xml(*_): 313 | return b"XML" 314 | 315 | a_qrt._protocol.send_command.side_effect = calibrate 316 | a_qrt._protocol.receive_response.side_effect = xml 317 | 318 | with pytest.raises(QRTCommandException): 319 | response = await a_qrt.calibrate() 320 | 321 | a_qrt._protocol.send_command.assert_called_once_with("calibrate") 322 | 323 | 324 | @pytest.mark.asyncio 325 | async def test_close(a_qrt): 326 | async def close(*_): 327 | return b"Closing connection" 328 | 329 | a_qrt._protocol.send_command.side_effect = close 330 | await a_qrt.close() 331 | a_qrt._protocol.send_command.assert_called_once_with("close") 332 | 333 | 334 | @pytest.mark.asyncio 335 | async def test_close_fail(a_qrt): 336 | async def fail(*_): 337 | return b"Fail" 338 | 339 | a_qrt._protocol.send_command.side_effect = fail 340 | 341 | with pytest.raises(QRTCommandException): 342 | await a_qrt.close() 343 | 344 | a_qrt._protocol.send_command.assert_called_once_with("close") 345 | 346 | 347 | @pytest.mark.asyncio 348 | async def test_start(a_qrt): 349 | async def start(*_): 350 | return b"Starting measurement" 351 | 352 | a_qrt._protocol.send_command.side_effect = start 353 | await a_qrt.start() 354 | a_qrt._protocol.send_command.assert_called_once_with("start") 355 | 356 | 357 | @pytest.mark.asyncio 358 | async def test_start_rtfromfile(a_qrt): 359 | async def start(*_): 360 | return b"Starting RT from file" 361 | 362 | a_qrt._protocol.send_command.side_effect = start 363 | await a_qrt.start(rtfromfile=True) 364 | a_qrt._protocol.send_command.assert_called_once_with("start rtfromfile") 365 | 366 | 367 | @pytest.mark.asyncio 368 | async def test_start_fail(a_qrt): 369 | async def fail(*_): 370 | return b"Fail" 371 | 372 | a_qrt._protocol.send_command.side_effect = fail 373 | 374 | with pytest.raises(QRTCommandException): 375 | await a_qrt.start() 376 | 377 | a_qrt._protocol.send_command.assert_called_once_with("start") 378 | 379 | 380 | @pytest.mark.asyncio 381 | async def test_stop(a_qrt): 382 | async def stop(*_): 383 | return b"Stopping measurement" 384 | 385 | a_qrt._protocol.send_command.side_effect = stop 386 | await a_qrt.stop() 387 | a_qrt._protocol.send_command.assert_called_once_with("stop") 388 | 389 | 390 | @pytest.mark.asyncio 391 | async def test_stop_fail(a_qrt): 392 | async def fail(*_): 393 | return b"Fail" 394 | 395 | a_qrt._protocol.send_command.side_effect = fail 396 | 397 | with pytest.raises(QRTCommandException): 398 | await a_qrt.stop() 399 | 400 | a_qrt._protocol.send_command.assert_called_once_with("stop") 401 | 402 | 403 | @pytest.mark.asyncio 404 | async def test_load(a_qrt): 405 | async def load(*_): 406 | return b"Measurement loaded" 407 | 408 | filename = "test" 409 | 410 | a_qrt._protocol.send_command.side_effect = load 411 | await a_qrt.load(filename) 412 | a_qrt._protocol.send_command.assert_called_once_with("load {}".format(filename)) 413 | 414 | 415 | @pytest.mark.asyncio 416 | async def test_load_fail(a_qrt): 417 | async def fail(*_): 418 | return b"Fail" 419 | 420 | a_qrt._protocol.send_command.side_effect = fail 421 | 422 | with pytest.raises(QRTCommandException): 423 | await a_qrt.load("fail") 424 | 425 | 426 | @pytest.mark.asyncio 427 | async def test_save(a_qrt): 428 | async def save(*_): 429 | return b"Measurement saved" 430 | 431 | filename = "test" 432 | 433 | a_qrt._protocol.send_command.side_effect = save 434 | await a_qrt.save(filename) 435 | a_qrt._protocol.send_command.assert_called_once_with("save {}".format(filename)) 436 | 437 | 438 | @pytest.mark.asyncio 439 | async def test_save_fail(a_qrt): 440 | async def fail(*_): 441 | return b"Fail" 442 | 443 | a_qrt._protocol.send_command.side_effect = fail 444 | 445 | with pytest.raises(QRTCommandException): 446 | await a_qrt.load("fail") 447 | 448 | 449 | @pytest.mark.asyncio 450 | async def test_loadproject(a_qrt): 451 | async def loadproject(*_): 452 | return b"Project loaded" 453 | 454 | filename = "test" 455 | 456 | a_qrt._protocol.send_command.side_effect = loadproject 457 | await a_qrt.load_project(filename) 458 | a_qrt._protocol.send_command.assert_called_once_with( 459 | "loadproject {}".format(filename) 460 | ) 461 | 462 | 463 | @pytest.mark.asyncio 464 | async def test_loadproject_fail(a_qrt): 465 | async def fail(*_): 466 | return b"Fail" 467 | 468 | a_qrt._protocol.send_command.side_effect = fail 469 | 470 | with pytest.raises(QRTCommandException): 471 | await a_qrt.load_project("fail") 472 | 473 | 474 | @pytest.mark.asyncio 475 | async def test_trig(a_qrt): 476 | async def trig(*_): 477 | return b"Trig ok" 478 | 479 | a_qrt._protocol.send_command.side_effect = trig 480 | await a_qrt.trig() 481 | a_qrt._protocol.send_command.assert_called_once_with("trig") 482 | 483 | 484 | @pytest.mark.asyncio 485 | async def test_trig_fail(a_qrt): 486 | async def fail(*_): 487 | return b"Fail" 488 | 489 | a_qrt._protocol.send_command.side_effect = fail 490 | 491 | with pytest.raises(QRTCommandException): 492 | await a_qrt.trig() 493 | 494 | 495 | @pytest.mark.asyncio 496 | async def test_set_qtm_event(a_qrt): 497 | async def set_qtm_event(*_): 498 | return b"Event set" 499 | 500 | a_qrt._protocol.send_command.side_effect = set_qtm_event 501 | await a_qrt.set_qtm_event() 502 | a_qrt._protocol.send_command.assert_called_once_with("event") 503 | 504 | 505 | @pytest.mark.asyncio 506 | async def test_set_qtm_event_name(a_qrt): 507 | async def set_qtm_event(*_): 508 | return b"Event set" 509 | 510 | event = "test" 511 | 512 | a_qrt._protocol.send_command.side_effect = set_qtm_event 513 | await a_qrt.set_qtm_event(event) 514 | a_qrt._protocol.send_command.assert_called_once_with("event {}".format(event)) 515 | 516 | 517 | @pytest.mark.asyncio 518 | async def test_set_qtm_event_fail(a_qrt): 519 | async def fail(*_): 520 | return b"Fail" 521 | 522 | a_qrt._protocol.send_command.side_effect = fail 523 | 524 | with pytest.raises(QRTCommandException): 525 | await a_qrt.set_qtm_event() 526 | 527 | 528 | # TODO XML test 529 | -------------------------------------------------------------------------------- /qtm_rt/packet.py: -------------------------------------------------------------------------------- 1 | """ Definition of packets and binary formats from QTM """ 2 | 3 | from collections import namedtuple 4 | from functools import wraps 5 | import struct 6 | 7 | from enum import Enum 8 | 9 | # pylint: disable=C0103, C0330, E1101, W0212 10 | 11 | # Used in protocol 12 | RTheader = struct.Struct(" 0: 363 | component_position, sample_number = QRTPacket._get_exact( 364 | RTSampleNumber, data, component_position 365 | ) 366 | 367 | RTAnalogChannel.format = struct.Struct( 368 | RTAnalogChannel.format_str % device.sample_count 369 | ) 370 | for _ in range(device.channel_count): 371 | component_position, channel = QRTPacket._get_tuple( 372 | RTAnalogChannel, data, component_position 373 | ) 374 | append_components((device, sample_number, channel)) 375 | 376 | return components 377 | 378 | @ComponentGetter(QRTComponentType.ComponentAnalogSingle, RTAnalogComponent) 379 | def get_analog_single( 380 | self, component_info=None, data=None, component_position=None 381 | ): 382 | """Get a single analog data channel.""" 383 | components = [] 384 | append_components = components.append 385 | for _ in range(component_info.device_count): 386 | component_position, device = QRTPacket._get_exact( 387 | RTAnalogDeviceSingle, data, component_position 388 | ) 389 | 390 | RTAnalogDeviceSamples.format = struct.Struct( 391 | RTAnalogDeviceSamples.format_str % device.channel_count 392 | ) 393 | component_position, sample = QRTPacket._get_tuple( 394 | RTAnalogDeviceSamples, data, component_position 395 | ) 396 | append_components((device, sample)) 397 | return components 398 | 399 | @ComponentGetter(QRTComponentType.ComponentForce, RTForceComponent) 400 | def get_force(self, component_info=None, data=None, component_position=None): 401 | """Get force data.""" 402 | components = [] 403 | append_components = components.append 404 | for _ in range(component_info.plate_count): 405 | component_position, plate = QRTPacket._get_exact( 406 | RTForcePlate, data, component_position 407 | ) 408 | force_list = [] 409 | for _ in range(plate.force_count): 410 | component_position, force = QRTPacket._get_exact( 411 | RTForce, data, component_position 412 | ) 413 | force_list.append(force) 414 | append_components((plate, force_list)) 415 | return components 416 | 417 | @ComponentGetter(QRTComponentType.ComponentForceSingle, RTForceComponent) 418 | def get_force_single(self, component_info=None, data=None, component_position=None): 419 | """Get a single force data channel.""" 420 | components = [] 421 | append_components = components.append 422 | for _ in range(component_info.plate_count): 423 | component_position, plate = QRTPacket._get_exact( 424 | RTForcePlateSingle, data, component_position 425 | ) 426 | component_position, force = QRTPacket._get_exact( 427 | RTForce, data, component_position 428 | ) 429 | append_components((plate, force)) 430 | return components 431 | 432 | @ComponentGetter(QRTComponentType.Component6d, RT6DComponent) 433 | def get_6d(self, component_info=None, data=None, component_position=None): 434 | """Get 6D data.""" 435 | components = [] 436 | append_components = components.append 437 | for _ in range(component_info.body_count): 438 | component_position, position = QRTPacket._get_exact( 439 | RT6DBodyPosition, data, component_position 440 | ) 441 | component_position, matrix = QRTPacket._get_tuple( 442 | RT6DBodyRotation, data, component_position 443 | ) 444 | append_components((position, matrix)) 445 | return components 446 | 447 | @ComponentGetter(QRTComponentType.Component6dRes, RT6DComponent) 448 | def get_6d_residual(self, component_info=None, data=None, component_position=None): 449 | """Get 6D data with residual.""" 450 | components = [] 451 | append_components = components.append 452 | for _ in range(component_info.body_count): 453 | component_position, position = QRTPacket._get_exact( 454 | RT6DBodyPosition, data, component_position 455 | ) 456 | component_position, matrix = QRTPacket._get_tuple( 457 | RT6DBodyRotation, data, component_position 458 | ) 459 | component_position, residual = QRTPacket._get_exact( 460 | RT6DBodyResidual, data, component_position 461 | ) 462 | append_components((position, matrix, residual)) 463 | return components 464 | 465 | @ComponentGetter(QRTComponentType.Component6dEuler, RT6DComponent) 466 | def get_6d_euler(self, component_info=None, data=None, component_position=None): 467 | """Get 6D data with euler rotations.""" 468 | components = [] 469 | append_components = components.append 470 | for _ in range(component_info.body_count): 471 | component_position, position = QRTPacket._get_exact( 472 | RT6DBodyPosition, data, component_position 473 | ) 474 | component_position, euler = QRTPacket._get_exact( 475 | RT6DBodyEuler, data, component_position 476 | ) 477 | append_components((position, euler)) 478 | return components 479 | 480 | @ComponentGetter(QRTComponentType.Component6dEulerRes, RT6DComponent) 481 | def get_6d_euler_residual( 482 | self, component_info=None, data=None, component_position=None 483 | ): 484 | """Get 6D data with residuals and euler rotations.""" 485 | components = [] 486 | append_components = components.append 487 | for _ in range(component_info.body_count): 488 | component_position, position = QRTPacket._get_exact( 489 | RT6DBodyPosition, data, component_position 490 | ) 491 | component_position, euler = QRTPacket._get_exact( 492 | RT6DBodyEuler, data, component_position 493 | ) 494 | component_position, residual = QRTPacket._get_exact( 495 | RT6DBodyResidual, data, component_position 496 | ) 497 | append_components((position, euler, residual)) 498 | return components 499 | 500 | @ComponentGetter(QRTComponentType.ComponentImage, RTImageComponent) 501 | def get_image(self, component_info=None, data=None, component_position=None): 502 | """Get image.""" 503 | components = [] 504 | append_components = components.append 505 | for _ in range(component_info.image_count): 506 | component_position, image_info = QRTPacket._get_exact( 507 | RTImage, data, component_position 508 | ) 509 | append_components((image_info, data[component_position:component_position + image_info.image_size])) 510 | component_position += image_info.image_size 511 | return components 512 | 513 | @ComponentGetter(QRTComponentType.Component3d, RT3DComponent) 514 | def get_3d_markers(self, component_info=None, data=None, component_position=None): 515 | """Get 3D markers.""" 516 | return self._get_3d_markers( 517 | RT3DMarkerPosition, component_info, data, component_position 518 | ) 519 | 520 | @ComponentGetter(QRTComponentType.Component3dRes, RT3DComponent) 521 | def get_3d_markers_residual( 522 | self, component_info=None, data=None, component_position=None 523 | ): 524 | """Get 3D markers with residual.""" 525 | return self._get_3d_markers( 526 | RT3DMarkerPositionResidual, component_info, data, component_position 527 | ) 528 | 529 | @ComponentGetter(QRTComponentType.Component3dNoLabels, RT3DComponent) 530 | def get_3d_markers_no_label( 531 | self, component_info=None, data=None, component_position=None 532 | ): 533 | """Get 3D markers without label.""" 534 | return self._get_3d_markers( 535 | RT3DMarkerPositionNoLabel, component_info, data, component_position 536 | ) 537 | 538 | @ComponentGetter(QRTComponentType.Component3dNoLabelsRes, RT3DComponent) 539 | def get_3d_markers_no_label_residual( 540 | self, component_info=None, data=None, component_position=None 541 | ): 542 | """Get 3D markers without label with residual.""" 543 | return self._get_3d_markers( 544 | RT3DMarkerPositionNoLabelResidual, component_info, data, component_position 545 | ) 546 | 547 | @ComponentGetter(QRTComponentType.Component2d, RT2DComponent) 548 | def get_2d_markers( 549 | self, component_info=None, data=None, component_position=None, index=None 550 | ): 551 | """Get 2D markers. 552 | 553 | :param index: Specify which camera to get 2D from, will be returned as 554 | first entry in the returned array. 555 | """ 556 | return self._get_2d_markers( 557 | data, component_info, component_position, index=index 558 | ) 559 | 560 | @ComponentGetter(QRTComponentType.Component2dLin, RT2DComponent) 561 | def get_2d_markers_linearized( 562 | self, component_info=None, data=None, component_position=None, index=None 563 | ): 564 | """Get 2D linearized markers. 565 | 566 | :param index: Specify which camera to get 2D from, will be returned as 567 | first entry in the returned array. 568 | """ 569 | 570 | return self._get_2d_markers( 571 | data, component_info, component_position, index=index 572 | ) 573 | 574 | @ComponentGetter(QRTComponentType.ComponentSkeleton, RTSkeletonComponent) 575 | def get_skeletons(self, component_info=None, data=None, component_position=None): 576 | """Get skeletons 577 | """ 578 | 579 | components = [] 580 | append_components = components.append 581 | for _ in range(component_info.skeleton_count): 582 | component_position, info = QRTPacket._get_exact( 583 | RTSegmentCount, data, component_position 584 | ) 585 | 586 | segments = [] 587 | for __ in range(info.segment_count): 588 | component_position, segment = QRTPacket._get_exact( 589 | RTSegmentId, data, component_position 590 | ) 591 | component_position, position = QRTPacket._get_exact( 592 | RTSegmentPosition, data, component_position 593 | ) 594 | component_position, rotation = QRTPacket._get_exact( 595 | RTSegmentRotation, data, component_position 596 | ) 597 | 598 | segments.append((segment.id, position, rotation)) 599 | append_components(segments) 600 | return components 601 | 602 | @ComponentGetter(QRTComponentType.ComponentGazeVector, RTGazeVectorComponent) 603 | def get_gaze_vectors(self, component_info=None, data=None, component_position=None): 604 | """Get gaze vectors 605 | """ 606 | 607 | components = [] 608 | append_components = components.append 609 | for _ in range(component_info.vector_count): 610 | component_position, info = QRTPacket._get_exact( 611 | RTGazeVectorInfo, data, component_position) 612 | 613 | samples = [] 614 | if info.sample_count > 0: 615 | for _ in range(info.sample_count): 616 | component_position, unit_vector = QRTPacket._get_exact( 617 | RTGazeVectorUnitVector, data, component_position) 618 | 619 | component_position, position = QRTPacket._get_exact( 620 | RTGazeVectorPosition, data, component_position) 621 | 622 | samples.append((unit_vector, position)) 623 | 624 | append_components((info, samples)) 625 | 626 | return components 627 | 628 | @ComponentGetter(QRTComponentType.ComponentEyeTracker, RTEyeTrackerComponent) 629 | def get_eye_trackers(self, component_info=None, data=None, component_position=None): 630 | """Get eye trackers 631 | """ 632 | 633 | components = [] 634 | append_components = components.append 635 | for _ in range(component_info.eye_tracker_count): 636 | component_position, info = QRTPacket._get_exact( 637 | RTEyeTrackerInfo, data, component_position) 638 | 639 | samples = [] 640 | if info.sample_count > 0: 641 | for _ in range(info.sample_count): 642 | component_position, diameter = QRTPacket._get_exact( 643 | RTEyeTrackerDiameter, data, component_position 644 | ) 645 | samples.append(diameter) 646 | 647 | append_components((info, samples)) 648 | 649 | return components 650 | 651 | --------------------------------------------------------------------------------