├── requirements.txt ├── Xsens DOT User Manual.pdf ├── Xsens DOT BLE Services Specifications.pdf ├── setup.py ├── LICENSE ├── README.md └── xdc.py /requirements.txt: -------------------------------------------------------------------------------- 1 | bleak==0.19.5 2 | -------------------------------------------------------------------------------- /Xsens DOT User Manual.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamkewley/xdc/HEAD/Xsens DOT User Manual.pdf -------------------------------------------------------------------------------- /Xsens DOT BLE Services Specifications.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adamkewley/xdc/HEAD/Xsens DOT BLE Services Specifications.pdf -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | import os 3 | 4 | __version__ = '0.0.1' 5 | 6 | here = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | # read README for long description 9 | with open(os.path.join(here, "README.md")) as f: 10 | readme_content = f.read() 11 | 12 | # list dependencies 13 | with open(os.path.join(here, "requirements.txt")) as f: 14 | requirements = f.read().split("\n") 15 | 16 | setuptools.setup( 17 | name='xdc', 18 | version=__version__, 19 | description="Use an XSens DOT from pure python code, with no external dependencies", 20 | long_description=readme_content, 21 | url="https://github.com/adamkewley/xdc", 22 | license="Apache 2.0", 23 | classifiers=[ 24 | 'Development Status :: 4 - Beta', 25 | 'Intended Audience :: Developers', 26 | 'License :: OSI Approved :: Apache Software License', 27 | ], 28 | keywords="XSens DOT", 29 | py_modules=["xdc"], 30 | author="Adam Kewley", 31 | author_email="contact@adamkewley.com", 32 | install_requires=requirements, 33 | python_requires=">=3", 34 | ) 35 | 36 | 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xdc (XSens DOT Connector) 2 | 3 | > Use an XSens DOT from pure python code, using `bleak` 4 | 5 | > ⚠️**EXPERIMENTAL** ⚠️: this is just something I'm hacking together to move a 6 | project forward. It is not a full-fat library, nor robust. 7 | 8 | The Python code in here is a low-level [Bluetooth Low-Energy](https://en.wikipedia.org/wiki/Bluetooth_Low_Energy) 9 | client implementation that can pull useful information from an [XSens DOT](https://www.xsens.com/xsens-dot). The 10 | implementation is pure Python that is only dependent on the Python standard library and [bleak](https://github.com/hbldh/bleak). 11 | 12 | This implements an extremely basic wrapper over XSens's "raw" BLE specification, rather than relying on any XSens 13 | library code. The motivation for this is that other solutions out there involve using different platforms 14 | (e.g. the Android SDK /w Kotlin or Java, NodeJS) or involve installing third-party applications. It's *much* cleaner 15 | to have a system that is written in one language with minimal dependencies, which is why I 16 | wrote these bindings. 17 | 18 | ## Requirements 19 | 20 | - `python>=3.9`: may work on earlier pythons. Haven't tested 21 | - `pip`: to install `bleak`. Not a hard requirement, if you know how to manually install packages 22 | 23 | 24 | ## Instal Dependencies 25 | 26 | ```bash 27 | pip3 install -r requirements.txt 28 | ``` 29 | 30 | ## API Usage Examples 31 | 32 | ```python 33 | import xdc 34 | 35 | # xdc code here, e.g.: 36 | 37 | ## DEVICE/CONNECTION LAYER: ## 38 | 39 | # scan for all BLE devices the computer can see 40 | # 41 | # returns a list of `bleak.backends.device.BLEDevice` 42 | xdc.scan_all() 43 | 44 | # scan for all DOT devices the computer can see 45 | # 46 | # returns a list of `bleak.backends.device.BLEDevice` 47 | xdc.scan() 48 | 49 | # take an element from the scan list (`bleak.backends.device.BLEDevice`) 50 | device = xdc.scan()[0] 51 | 52 | # take the address (a string) of the device 53 | # 54 | # handy, because you can save the string to a config file etc. and use it 55 | # to reconnect to the device after reboots etc. 56 | address = device.address 57 | 58 | # finds a BLE device by an address string 59 | # 60 | # returns `bleak.backends.device.BLEDevice` 61 | xdc.find_by_address(address) 62 | 63 | # same as above, but ensures the address points to a DOT by checking 64 | # whether the device is a DOT after establishing the connection 65 | # 66 | # returns `bleak.backends.device.BLEDevice` 67 | dot = xdc.find_dot_by_address(address) 68 | 69 | ## EXAMPLE CHARACTERISTIC USE (see source code for more examples) ## 70 | 71 | # read the "Device Info Characteristic" for the given DOT 72 | # 73 | # returns `xdc.DeviceInfoCharacteristic` 74 | xdc.device_info_read(dot) 75 | 76 | # read the "Device Control Characteristic" for the given DOT 77 | # 78 | # returns `xdc.DeviceControlCharacteristic` 79 | control_chr = xdc.device_control_read(dot) 80 | 81 | # (example of modifying a characteristic before sending the 82 | # modification to the DOT) 83 | control_chr.output_rate = 4 84 | 85 | # write a (potentially, modified) `xdc.DeviceControlCharacteristic` to 86 | # the DOT. This is how you control the device 87 | xdc.device_control_write(dot, control_chr) 88 | 89 | 90 | # !!! HIGH-LEVEL CONVENIENCE API !!! 91 | # 92 | # this API is easy to use, but slow: it requires setting up and 93 | # tearing down a new BLE connection every time a method is called 94 | # 95 | # see: `with xdc.Dot(...)` context manager, and `async` examples 96 | # below if you need higher performance 97 | 98 | # make the DOT flash its LED light a little bit, so that you can identify it 99 | xdc.identify(dot) 100 | 101 | # turn the DOT off 102 | # 103 | # turn it back on by pressing the DOT's button or shaking it 104 | xdc.power_off(dot) 105 | 106 | # enable powering the DOT on whenever the micro-USB charger is plugged in 107 | # 108 | # (handy for development) 109 | xdc.enable_power_on_by_usb_plug_in(dot) 110 | 111 | # disable powering the DOT on whenever the micro-USB charger is plugged in 112 | # 113 | # (opposite of the above) 114 | xdc.disable_power_on_by_usb_plug_in(dot) 115 | 116 | # set the output rate of the DOT 117 | # 118 | # this is the frequency at which the reporting characteristic (i.e. the 119 | # thing that is emitted whenever the DOT reports telemetry) reports 120 | # 121 | # must be 1, 4, 10, 12, 15, 20, 30, 60, 120 (see official XSens spec: Device Control Characteristic) 122 | xdc.set_output_rate(dot, 10) 123 | 124 | # reset the output rate to the default rate 125 | xdc.reset_output_rate(dot) 126 | 127 | 128 | ## READING DATA FROM THE DOT ## 129 | # 130 | # Once you enable reporting, the DOT will asynchronously send telemetry data to the computer. 131 | # 132 | # Robust downstream code should assume that notifications sometimes go missing (e.g. due to 133 | # connection issues) 134 | 135 | # e.g. #1: create a function that is called whenever the computer receives a device 136 | # report notification from the DOT 137 | # 138 | # - after giving this function to `device_report_start_notify`, it will be 139 | # called by the backend 140 | # 141 | # - the callee (i.e. this function) should handle the message bytes as 142 | # appropriate (e.g. by pumping them into a parser) 143 | # 144 | def on_device_report(message_id, message_bytes): 145 | # parse the message bytes as a characteristic 146 | parsed = xdc.DeviceReportCharacteristic.from_bytes(message_bytes) 147 | print(parsed) 148 | 149 | # e.g. #2 create a function that is called whenever the computer receives a long 150 | # payload report from the DOT 151 | # 152 | # - same as above, but for a different payload message type (see the XSens 153 | # BLE specification for specifics) 154 | def on_long_payload_report(message_id, message_bytes): 155 | print(message_bytes) 156 | 157 | # e.g. #3 create a function that is called whenever the computer receives a medium 158 | # payload report from the DOT 159 | # 160 | # - same as above, but for a different payload message type (see the XSens 161 | # BLE specification for specifics) 162 | def on_medium_payload_report(message_id, message_bytes): 163 | print(message_bytes) 164 | 165 | # e.g. #4 create a function that is called whenever the computer receives a short 166 | # payload report from the DOT 167 | # 168 | # - same as above, but for a different payload message type (see the XSens 169 | # BLE specification for specifics) 170 | def on_short_payload_report(message_id, message_bytes): 171 | print(message_bytes) 172 | 173 | # e.g. #5 create a function that is called whenever the computer receives a battery 174 | # report from the DOT 175 | # 176 | # - same as above, but for a different payload message type (see the XSens 177 | # BLE specification for specifics) 178 | def on_battery_report(message_id, message_bytes): 179 | print(message_bytes) 180 | 181 | ## SYNCHRONOUS API (simpler, but not exactly how the communication actually works) 182 | 183 | with xdc.Dot(dot) as device: 184 | # subscribe to notifications 185 | device.device_report_start_notify(on_device_report) 186 | device.long_payload_start_notify(on_long_payload_report) 187 | device.medium_payload_start_notify(on_medium_payload_report) 188 | device.short_payload_start_notify(on_short_payload_report) 189 | 190 | # make the calling (synchronous) pump the asynchronous event queue forever 191 | # 192 | # this is required, because the main thread is responsible for pumping the 193 | # message queue that contains the above notifications. If you don't pump 194 | # the queue then you won't see the notifications 195 | device.pump_forever() 196 | 197 | 198 | ## ASYNCHRONOUS API (this is actually how communication with the `bleak` backend actually works) 199 | 200 | # define an asynchronous function that should be used as the entrypoint for the asynchronous 201 | # event loop (`asyncio.run_until_complete`) 202 | async def arun(): 203 | async with xdc.Dot(dot) as device: 204 | # asynchronously subscribe to notifications 205 | await device.adevice_report_start_notify(on_device_report) 206 | await device.along_payload_start_notify(on_long_payload_report) 207 | await device.amedium_payload_start_notify(on_medium_payload_report) 208 | await device.ashort_payload_start_notify(on_short_payload_report) 209 | 210 | # sleep for some amount of time, while pumping the message queue 211 | # 212 | # note: this differs from python's `sleep` function, because it doesn't cause the 213 | # calling (asynchronous) thread to entirely sleep - it still processes any 214 | # notifications that come in, unlike the synchronous API 215 | await asyncio.sleep(10) 216 | 217 | # (optional): unsubscribe to the notifications 218 | await device.adevice_report_stop_notify() 219 | await device.along_payload_stop_notify() 220 | await device.amedium_payload_stop_notify() 221 | await device.ashort_payload_stop_notify() 222 | 223 | # start running the async task from the calling thread (by making the calling thread fully 224 | # pump the event loop until the task is complete) 225 | 226 | import asyncio 227 | loop = asyncio.new_event_loop() 228 | loop.run_until_complete(arun()) 229 | ``` 230 | 231 | ## General Tips & Tricks 232 | 233 | People have emailed me about using this library. To be clear, `xdc` is an **experimental** library. I am far too busy to 234 | productionize it right now (with tests, full documentation etc.). This is why it feels a bit hacky. 235 | 236 | Just to answer some previous questions I have received about `xdc`: 237 | 238 | - It's a library I tinker with occasionally in my spare time. My primary area of interest is C++; specifically, 239 | [OpenSim Creator](https://github.com/ComputationalBiomechanicsLab/opensim-creator), which may eventually include 240 | in-UI XDC support, if I ever get the time. 241 | 242 | - The entire implementation of `xdc` is in one file, `xdc.py`. I have tried to make the code simple. You may 243 | find that you can hack around some of `xdc`'s, uh, "quirks" by reading through the source and changing a line or two 244 | 245 | - Synchronous methods use a standard naming convention, e.g. `xdc.identify`. Asynchronous equivalents typically prefix 246 | an `a` before the method name, e.g. `xdc.aidentify`. In almost all cases, the synchronous API will call into the 247 | asynchronous API because the underlying BLE library being used (`bleak`) is asynchronous by design (which is a 248 | reasonable design decision, given how BLE devices typically work). 249 | 250 | - Almost all DOT "messages" are named `[Message]Characteristic` in the source code to reflect how they are represented 251 | by BLE. Most of the characteristics described in the official XSens DOT documentation are represented by an equivalent 252 | python class (e.g. `xdc.DeviceControlCharacteristic`). Almost all characteristics have `UUID`, `SIZE`, `from_bytes`, and 253 | `to_bytes` properties/methods. The `xdc` API effectively pumps raw byte-messages into- and out-of these classes 254 | 255 | - `xdc` is composed of roughly 4 layers of API: 256 | 257 | - Lowest-level byte parsers and characteristic representations (i.e. any class with `Characteristic` in the name) 258 | 259 | - Low-level asynchronous `Dot` lifetime wrapper. I.e. the thing that lets you write `async with xdc.Dot(dev) as device:`. This is 260 | effectively what all the higher-level and synchronous APIs defer to eventually 261 | 262 | - Medium-level `Dot` synchronous lifetime wrapper. I.e. the thing that lets you write `with xdc.Dot(dev) as device:`. This is the 263 | same class as the asynchronous one, but wraps up calling into/out-of the asynchronous event loop. All methods on this class 264 | ultimately use the asynchronous API of the `Dot` lifetime wrapper 265 | 266 | - High-level asynchronous free-functions (e.g. `xdc.aidentify(device)`, `xdc.ascan()`). These are higher-level free functions that probably use `bleak` and the `Dot` lifetime wrapper internally. These are lower-performance than using the `Dot` lifetime wrapper yourself because they internally need to connect and tear-down a `Dot` wrapper on every call 267 | 268 | - Highest-level synchronous free-functions (e.g. `xdc.idenfity(device)`, `xdc.power_off(device)`). These are high-level free functions that internally use the asynchronous event loop, `bleak`, and the `Dot` lifetime wrapper. These are the lowest-performance API because they need to hop into the asynchronous event loop, create a `Dot` connection, tear it down, and exit the event loop. 269 | 270 | - Overall, it's recommended to use the highest-level API to test whether the DOT works etc. (it's the easiest API to use), but you will probably find that your code needs to go deeper and deeper into the lower-levels once you (e.g.) need certain performance guarantees, or need to handle receiving notifications from multiple DOTs concurrently, etc. - there are no silver bullets in unreliable hardware communication protocols 271 | -------------------------------------------------------------------------------- /xdc.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | import struct 4 | import typing 5 | 6 | import bleak # for BLE communication 7 | 8 | 9 | # SECTION: general utility funcitons/classes 10 | # 11 | # these are general functions/classes for doing stuff like printing 12 | # classes, parsing binary, etc. 13 | 14 | 15 | # returns a pretty-printed representation of an arbitrary class 16 | def _pretty_print(obj) -> str: 17 | return f"{obj.__class__.__name__}({', '.join('%s=%s' % item for item in vars(obj).items())})" 18 | 19 | # a helper class that encapsulates a reader "cursor" that indexes a 20 | # location in an array of bytes. 21 | # 22 | # Provides methods for reading multi-byte sequences (floats, ints, etc.) from the 23 | # array, advancing the cursor as it reads. Multi-byte sequences are parsed according 24 | # to XSens's binary spec (little-endian integers, IEE754 floats) 25 | class _ResponseReader: 26 | 27 | # initializes a reader that's pointing to the start of `data` 28 | def __init__(self, data): 29 | self.pos = 0 30 | self.data = data 31 | 32 | # returns number of remaining bytes that the reader can still read 33 | def remaining(self) -> int: 34 | return len(self.data) - self.pos 35 | 36 | # read `n` raw bytes from the reader's current position and advance 37 | # the current position by `n` 38 | def read_bytes(self, n : int) -> bytes: 39 | rv = self.data[self.pos:self.pos+n] 40 | self.pos += n 41 | return rv 42 | 43 | # read 1 byte as an unsigned int 44 | def read_u8(self) -> int: 45 | return int.from_bytes(self.read_bytes(1), "little", signed=False) 46 | 47 | # read 2 bytes as an unsigned little-endian int 48 | def read_u16(self) -> int: 49 | return int.from_bytes(self.read_bytes(2), "little", signed=False) 50 | 51 | # read 4 bytes as an unsigned little-endian int 52 | def read_u32(self) -> int: 53 | return int.from_bytes(self.read_bytes(4), "little", signed=False) 54 | 55 | # read 8 bytes as an unsigned little-endian int 56 | def read_u64(self) -> int: 57 | return int.from_bytes(self.read_bytes(8), "little", signed=False) 58 | 59 | # read 4 bytes as a IEE754 floating point number 60 | def read_f32(self) -> float: 61 | return struct.unpack('f', self.read_bytes(4)) 62 | 63 | 64 | # SECTION: SERVICES (as defined in the BLE spec) 65 | # 66 | # the XSens DOT provides BLE services with the following prefixes on the 67 | # UUID: 68 | # 69 | # 0x1000 (e.g. 15171000-4947-...): configuration service 70 | # 0x2000 : measurement service 71 | # 0x3000 : charging status/battery level service 72 | # 0x7000 : message service 73 | 74 | # Configuration Service: Device Info Characteristic (sec 2.1, p8 in the BLE spec) 75 | # 76 | # read-only characteristic for top-level device information 77 | class DeviceInfoCharacteristic: 78 | UUID = "15171001-4947-11E9-8646-D663BD873D93" 79 | SIZE = 34 80 | 81 | # returns a `DeviceInfoCharacteristic` read from a `_ResponseReader` 82 | def _from_reader(reader : _ResponseReader): 83 | assert reader.remaining() >= DeviceInfoCharacteristic.SIZE 84 | 85 | rv = DeviceInfoCharacteristic() 86 | rv.address = reader.read_bytes(6) 87 | rv.version_major = reader.read_u8() 88 | rv.version_minor = reader.read_u8() 89 | rv.version_revision = reader.read_u8() 90 | rv.build_year = reader.read_u16() # 2019 ~ 2100 91 | rv.build_month = reader.read_u8() # 1 ~ 12 92 | rv.build_date = reader.read_u8() # 1 ~ 31 93 | rv.build_hour = reader.read_u8() # 1 ~ 23 94 | rv.build_minute = reader.read_u8() # 0 ~ 59 95 | rv.build_second = reader.read_u8() # 0 ~ 59 96 | rv.softdevice_version = reader.read_u32() 97 | rv.serial_number = reader.read_u64() 98 | rv.short_product_code = reader.read_bytes(6) # e.g. "XS-T01" 99 | 100 | return rv 101 | 102 | # returns a `DeviceInfoCharacteristic` parsed from bytes 103 | def from_bytes(bites): 104 | reader = _ResponseReader(bites) 105 | return DeviceInfoCharacteristic._from_reader(reader) 106 | 107 | def __repr__(self): 108 | return _pretty_print(self) 109 | 110 | # Configuration Service: Device Control Characteristic (sec 2.2, p9 in the BLE spec) 111 | # 112 | # read/write characteristic for top-level control (i.e. mode) of the DOT. 113 | class DeviceControlCharacteristic: 114 | UUID = "15171002-4947-11E9-8646-D663BD873D93" 115 | SIZE = 16 116 | 117 | # returns a `DeviceControlCharacteristic` read from a `_ResponseReader` 118 | def _from_reader(reader : _ResponseReader): 119 | assert reader.remaining() >= DeviceControlCharacteristic.SIZE 120 | 121 | rv = DeviceControlCharacteristic() 122 | rv.visit_index = reader.read_u8() 123 | rv.identifying = reader.read_u8() 124 | rv.power_options = reader.read_u8() 125 | rv.power_saving_timeout_x_mins = reader.read_u8() # 0 ~ 30 126 | rv.power_saving_timeout_x_secs = reader.read_u8() # 0 ~ 60 127 | rv.power_saving_timeout_y_mins = reader.read_u8() # 0 ~ 30 128 | rv.power_saving_timeout_y_secs = reader.read_u8() # 0 ~ 60 129 | rv.device_tag_len = reader.read_u8() 130 | rv.device_tag = reader.read_bytes(16) # default "Xsens DOT" 131 | rv.output_rate = reader.read_u16() # 1, 4, 10, 12, 15, 20, 30, 60, 120hz 132 | rv.filter_profile_index = reader.read_u8() 133 | rv.reserved = reader.read_bytes(5) # just in case someone's interested 134 | 135 | return rv 136 | 137 | # returns a `DeviceControlCharacteristic` parsed from bytes 138 | def from_bytes(bites): 139 | reader = _ResponseReader(bites) 140 | return DeviceControlCharacteristic._from_reader(reader) 141 | 142 | # returns bytes serialized from the `DeviceControlCharacteristic`'s data 143 | def to_bytes(self): 144 | rv = bytearray() 145 | rv += self.visit_index.to_bytes(1, "little") 146 | rv += self.identifying.to_bytes(1, "little") 147 | rv += self.power_options.to_bytes(1, "little") 148 | rv += self.power_saving_timeout_x_mins.to_bytes(1, "little") 149 | rv += self.power_saving_timeout_x_secs.to_bytes(1, "little") 150 | rv += self.power_saving_timeout_y_mins.to_bytes(1, "little") 151 | rv += self.power_saving_timeout_y_secs.to_bytes(1, "little") 152 | rv += self.device_tag_len.to_bytes(1, "little") 153 | rv += self.device_tag 154 | rv += self.output_rate.to_bytes(2, "little") 155 | rv += self.filter_profile_index.to_bytes(1, "little") 156 | rv += self.reserved 157 | 158 | return rv 159 | 160 | def __repr__(self): 161 | return _pretty_print(self) 162 | 163 | # Configuration Service: Device Report Characteristic (sec 2.3, p10 in the BLE spec) 164 | # 165 | # notification characteristic for various events the DOT may emit (e.g. 166 | # power off, button pressed) 167 | class DeviceReportCharacteristic: 168 | UUID = "15171004-4947-11E9-8646-D663BD873D93" 169 | SIZE = 36 170 | 171 | # returns a `DeviceReportCharacteristic` read from a `_ResponseReader` 172 | def _from_reader(reader : _ResponseReader): 173 | assert reader.remaining() >= DeviceReportCharacteristic.SIZE 174 | 175 | rv = DeviceReportCharacteristic() 176 | rv.typeid = reader.read_u8() 177 | 178 | if rv.typeid == 1: 179 | # power off report 180 | pass 181 | elif rv.typeid == 4: 182 | # power saving report 183 | pass 184 | elif rv.typeid == 5: 185 | # button callback report 186 | rv.length = reader.read_u8() 187 | if rv.length == 4: 188 | rv.timestamp = reader.read_u32() 189 | elif rv.length == 8: 190 | rv.timestamp = reader.read_u64() 191 | else: 192 | # unknown report type 193 | pass 194 | 195 | # record unused bytes in-case future DOT devices make them 196 | # contain useful information (so users can still use this lib 197 | # to parse them afterwards) 198 | rv.unused = reader.read_bytes(reader.end - reader.pos) 199 | 200 | return rv 201 | 202 | # returns a `DeviceReportCharacteristic` parsed from bytes 203 | def from_bytes(bites): 204 | reader = _ResponseReader(bites) 205 | return DeviceReportCharacteristic._from_reader(reader) 206 | 207 | def __repr__(self): 208 | return _pretty_print(self) 209 | 210 | # Measurement Service: Control Characteristic (sec 3.1, p12 in the BLE spec) 211 | # 212 | # read/write characteristic that controls the DOT's measurement state (i.e. if/what 213 | # measurements are enabled) 214 | class ControlCharacteristic: 215 | UUID = "15172001-4947-11E9-8646-D663BD873D93" 216 | SIZE = 3 217 | 218 | # returns a `ControlCharacteristic` read from a `_ResponseReader` 219 | def _from_reader(reader : _ResponseReader): 220 | assert reader.remaining() >= ControlCharacteristic.SIZE 221 | 222 | rv = ControlCharacteristic() 223 | rv.Type = reader.read_u8() 224 | rv.action = reader.read_u8() 225 | rv.payload_mode = reader.read_u8() 226 | 227 | return rv 228 | 229 | # parse bytes as characteristic data 230 | def from_bytes(bites): 231 | reader = _ResponseReader(bites) 232 | return ControlCharacteristic.read(reader) 233 | 234 | # convert characteristic data back to bytes 235 | def to_bytes(self): 236 | assert self.Type < 0xff 237 | assert self.action <= 1 238 | assert self.payload_mode <= 24 239 | 240 | rv = bytearray() 241 | rv += self.Type.to_bytes(1, "little") 242 | rv += self.action.to_bytes(1, "little") 243 | rv += self.payload_mode.to_bytes(1, "little") 244 | 245 | return rv 246 | 247 | def __repr__(self): 248 | return _pretty_print(self) 249 | 250 | 251 | # the next bunch of classes are parsers etc. for the wide variety of measurement structs 252 | # that the measurement service can emit 253 | 254 | # measurement data (sec. 3.5 in BLE spec): timestamp: "Timestamp of the sensor in microseconds" 255 | class Timestamp: 256 | SIZE = 4 257 | 258 | # returns a `Timestamp` read from a `_ResponseReader` 259 | def _from_reader(reader : _ResponseReader): 260 | assert reader.remaining() >= Timestamp.SIZE 261 | 262 | rv = Timestamp() 263 | rv.microseconds = reader.read_u32() 264 | 265 | return rv 266 | 267 | def __repr__(self): 268 | return _pretty_print(self) 269 | 270 | # measurement data (sec. 3.5 in BLE spec): quaternion: "The orientation expressed as a quaternion" 271 | class Quaternion: 272 | SIZE = 16 273 | 274 | # returns a `Quaternion` read from a `_ResponseReader` 275 | def _from_reader(reader : _ResponseReader): 276 | assert reader.remaining() >= Quaternion.size 277 | 278 | rv = Quaternion() 279 | rv.w = reader.read_f32() 280 | rv.x = reader.read_f32() 281 | rv.y = reader.read_f32() 282 | rv.z = reader.read_f32() 283 | 284 | return rv 285 | 286 | def __repr__(self): 287 | return _pretty_print(self) 288 | 289 | # measurement data (sec. 3.5 in BLE spec): euler angles: "The orientation expressed as Euler angles, degree" 290 | class EulerAngles: 291 | SIZE = 12 292 | 293 | # returns a `EulerAngles` read from a `_ResponseReader` 294 | def _from_reader(reader : _ResponseReader): 295 | assert reader.remaining() >= EulerAngles.SIZE 296 | 297 | rv = EulerAngles() 298 | rv.x = reader.read_f32() 299 | rv.y = reader.read_f32() 300 | rv.z = reader.read_f32() 301 | 302 | return rv 303 | 304 | def __repr__(self): 305 | return _pretty_print(self) 306 | 307 | # measurement data (sec. 3.5 in the BLE spec): free acceleration: "Acceleration in local earth coordinate and the local gravity is deducted, m/s^2" 308 | class FreeAcceleration: 309 | SIZE = 12 310 | 311 | # returns a `FreeAcceleration` read from a `_ResponseReader` 312 | def _from_reader(reader : _ResponseReader): 313 | assert reader.remaining() >= FreeAcceleration.SIZE 314 | 315 | rv = FreeAcceleration() 316 | rv.x = reader.read_f32() 317 | rv.y = reader.read_f32() 318 | rv.z = reader.read_f32() 319 | 320 | return rv 321 | 322 | def __repr__(self): 323 | return _pretty_print(self) 324 | 325 | # measurement data (sec. 3.5 in BLE spec): dq: "Orientation change during a time interval" 326 | class Dq: 327 | SIZE = 16 328 | 329 | # returns a `Dq` read from a `_ResponseReader` 330 | def _from_reader(reader : _ResponseReader): 331 | assert reader.remaining() >= Dq.SIZE 332 | 333 | rv = Dq() 334 | rv.w = reader.read_f32() 335 | rv.x = reader.read_f32() 336 | rv.y = reader.read_f32() 337 | rv.z = reader.read_f32() 338 | 339 | return rv 340 | 341 | def __repr__(self): 342 | return _pretty_print(self) 343 | 344 | # measurement data (sec. 3.5 in the BLE spec): dv: "Velocity change during a time interval, m/s" 345 | class Dv: 346 | SIZE = 12 347 | 348 | # returns a `Dv` read from a `_ResponseReader` 349 | def _from_reader(reader : _ResponseReader): 350 | assert reader.remaining() >= Dv.SIZE 351 | 352 | rv = Dv() 353 | rv.x = reader.read_f32() 354 | rv.y = reader.read_f32() 355 | rv.z = reader.read_f32() 356 | 357 | return rv 358 | 359 | def __repr__(self): 360 | return _pretty_print(self) 361 | 362 | # measurement data (sec. 3.5 in the BLE spec): Acceleration: "Calibrated acceleration in sensor coordinate, m/s^2" 363 | class Acceleration: 364 | SIZE = 12 365 | 366 | # returns a `Acceleration` read from a `_ResponseReader` 367 | def _from_reader(reader : _ResponseReader): 368 | assert reader.remaining() >= Acceleration.SIZE 369 | 370 | rv = Acceleration() 371 | rv.x = reader.read_f32() 372 | rv.y = reader.read_f32() 373 | rv.z = reader.read_f32() 374 | 375 | return rv 376 | 377 | def __repr__(self): 378 | return _pretty_print(self) 379 | 380 | # measurement data (sec. 3.5 in the BLE spec): Angular Velocity: "Rate of turn in sensor coordinate, dps" 381 | class AngularVelocity: 382 | SIZE = 12 383 | 384 | # returns an `AngularVelocity` read from a `_ResponseReader` 385 | def _from_reader(reader : _ResponseReader): 386 | assert reader.remaining() >= AngularVelocity.SIZE 387 | 388 | rv = AngularVelocity() 389 | rv.x = reader.read_f32() 390 | rv.y = reader.read_f32() 391 | rv.z = reader.read_f32() 392 | 393 | return rv 394 | 395 | def __repr__(self): 396 | return _pretty_print(self) 397 | 398 | # measurement data (sec. 3.5 in the BLE spec): Magnetic Field: "Magnetic field in the sensor coordinate, a.u." 399 | class MagneticField: 400 | SIZE = 6 401 | 402 | # returns a `MagneticField` read from a `_ResponseReader` 403 | def _from_reader(reader : _ResponseReader): 404 | assert reader.remaining() >= MagneticField.SIZE 405 | 406 | rv = MagneticField() 407 | rv.x = reader.read_bytes(2) 408 | rv.y = reader.read_bytes(2) 409 | rv.z = reader.read_bytes(2) 410 | 411 | return rv 412 | 413 | def __repr__(self): 414 | return _pretty_print(self) 415 | 416 | # measurement data (sec. 3.5 in the BLE spec): Status: "See section 3.5.1 of the BLE spec" 417 | class Status: 418 | SIZE = 2 419 | 420 | # returns a `Status` read from a `_ResponseReader` 421 | def _from_reader(reader : _ResponseReader): 422 | assert reader.remaining() >= Status.SIZE 423 | 424 | rv = Status() 425 | rv.value = reader.read_u16() 426 | 427 | return rv 428 | 429 | def __repr__(self): 430 | return _pretty_print(self) 431 | 432 | # measurement data (sec. 3.5. in the BLE spec): ClipCountAcc: "Count of ClipAcc in status" 433 | class ClipCountAcc: 434 | SIZE = 1 435 | 436 | # returns a `ClipCountAcc` read from a `_ResponseReader` 437 | def _from_reader(reader : _ResponseReader): 438 | assert reader.remaining() >= ClipCountAcc.SIZE 439 | 440 | rv = ClipCountAcc() 441 | rv.value = reader.read_u8() 442 | 443 | return rv 444 | 445 | def __repr__(self): 446 | return _pretty_print(self) 447 | 448 | # measurement data (sec. 3.5. in the BLE spec): ClipCountGyr: "Count of ClipGyr in status" 449 | class ClipCountGyr: 450 | SIZE = 1 451 | 452 | # returns a `ClipCountGyr` read from a `_ResponseReader` 453 | def _from_reader(reader : _ResponseReader): 454 | assert reader.remaining() >= ClipCountGyr.SIZE 455 | 456 | rv = ClipCountGyr() 457 | rv.value = reader.read_u8() 458 | 459 | return rv 460 | 461 | def __repr__(self): 462 | return _pretty_print(self) 463 | 464 | 465 | # data for long-payload measurements 466 | # 467 | # the DOT can emit data in long (63-byte), medium (40-byte), and short (20-byte) lengths. 468 | # What those bytes parse out to depends on the payload mode (see "Measurement Service Control 469 | # Characteristic") is set to. 470 | class LongPayloadCharacteristic: 471 | UUID = "15172002-4947-11E9-8646-D663BD873D93" 472 | 473 | # a long payload measurement response called "Custom Mode 4" in the BLE spec 474 | class LongPayloadCustomMode4: 475 | SIZE = 51 476 | 477 | # no parser: it's not officially supported by XSens 478 | 479 | 480 | # data for medium-payload measurements 481 | # 482 | # the DOT can emit data in long (63-byte), medium (40-byte), and short (20-byte) lengths. 483 | # What those bytes parse out to depends on the payload mode (see "Measurement Service Control 484 | # Characteristic") is set to. 485 | class MediumPayloadCharacteristic: 486 | UUID = "15172003-4947-11E9-8646-D663BD873D93" 487 | 488 | # a medium-payload measurement response that contains "Extended Quaternion" data 489 | class MediumPayloadExtendedQuaternion: 490 | SIZE = 36 491 | 492 | # returns a `MediumPayloadExtendedQuaternion` read from a `_ResponseReader` 493 | def _from_reader(reader : _ResponseReader): 494 | assert reader.remaining() >= MediumPayloadExtendedQuaternion.SIZE 495 | 496 | rv = MediumPayloadExtendedQuaternion() 497 | rv.timestamp = Timestamp._from_reader(reader) 498 | rv.quaternion = Quaternion._from_reader(reader) 499 | rv.free_acceleration = FreeAcceleration._from_reader(reader) 500 | rv.status = Status._from_reader(reader) 501 | rv.clip_count_acc = ClipCountAcc._from_reader(reader) 502 | rv.clip_count_gyr = ClipCountGyr._from_reader(reader) 503 | 504 | return rv 505 | 506 | def __repr__(self): 507 | return _pretty_print(self) 508 | 509 | # a medium-payload measurement response that contains "Complete Quaternion" data 510 | class MediumPayloadCompleteQuaternion: 511 | SIZE = 32 512 | 513 | # returns a `MediumPayloadCompleteQuaternion` read from a `_ResponseReader` 514 | def _from_reader(reader : _ResponseReader): 515 | assert reader.remaining() >= MediumPayloadCompleteQuaternion.SIZE 516 | 517 | rv = MediumPayloadCompleteQuaternion() 518 | rv.timestamp = Timestamp._from_reader(reader) 519 | rv.quaternion = Quaternion._from_reader(reader) 520 | rv.free_acceleration = FreeAcceleration._from_reader(reader) 521 | 522 | return rv 523 | 524 | def __repr__(self): 525 | return _pretty_print(self) 526 | 527 | # a medium-payload measurement response that contains "Extended Euler" data 528 | class MediumPayloadExtendedEuler: 529 | SIZE = 32 530 | 531 | # returns a `MediumPayloadExtendedEuler` read from a `_ResponseReader` 532 | def _from_reader(reader : _ResponseReader): 533 | assert reader.remaining() >= MediumPayloadExtendedEuler.SIZE 534 | 535 | rv = MediumPayloadExtendedEuler() 536 | rv.timestamp = Timestamp._from_reader(reader) 537 | rv.euler = EulerAngles._from_reader(reader) 538 | rv.free_acceleration = FreeAcceleration._from_reader(reader) 539 | rv.status = Status._from_reader(reader) 540 | rv.clip_count_acc = ClipCountAcc._from_reader(reader) 541 | rv.clip_count_gyr = ClipCountGyr._from_reader(reader) 542 | 543 | return rv 544 | 545 | def __repr__(self): 546 | return _pretty_print(self) 547 | 548 | # a medium-payload measurement response that contains "Complete Euler" data 549 | class MediumPayloadCompleteEuler: 550 | SIZE = 28 551 | 552 | # returns a `MediumPayloadCompleteEuler` read from a `_ResponseReader` 553 | def _from_reader(reader : _ResponseReader): 554 | assert reader.remaining() >= MediumPayloadCompleteEuler.SIZE 555 | 556 | rv = MediumPayloadCompleteEuler() 557 | rv.timestamp = Timestamp._from_reader(reader) 558 | rv.euler = EulerAngles._from_reader(reader) 559 | rv.free_acceleration = FreeAcceleration._from_reader(reader) 560 | 561 | return rv 562 | 563 | def __repr__(self): 564 | return _pretty_print(self) 565 | 566 | # a medium-payload measurement reponse that contains "High Fidelity (with mag)" data 567 | class MediumPayloadHighFidelityWithMag: 568 | SIZE = 35 569 | 570 | # no parser: XSens claims you need to use the SDK to get this 571 | 572 | # a medium-payload measurement response that contains "High Fidelity" data 573 | class MediumPayloadHighFidelity: 574 | SIZE = 29 575 | 576 | # no parser: XSens claims you need to use the SDK to get this 577 | 578 | # a medium-payload measurement response that contains "Delta Quantities (with Mag)" data 579 | class MediumPayloadDeltaQuantitiesWithMag: 580 | SIZE = 38 581 | 582 | # returns a `MediumPayloadDeltaQuantitiesWithMag` read from a `_ResponseReader` 583 | def _from_reader(reader : _ResponseReader): 584 | assert reader.remaining() >= MediumPayloadDeltaQuantitiesWithMag.SIZE 585 | 586 | rv = MediumPayloadDeltaQuantitiesWithMag() 587 | rv.timestamp = Timestamp._from_reader(reader) 588 | rv.dq = Dq._from_reader(reader) 589 | rv.dv = Dv._from_reader(reader) 590 | rv.magnetic_field = MagneticField._from_reader(reader) 591 | 592 | return rv 593 | 594 | # a medium-payload measurement response that contains "Delta Quantites" data 595 | class MediumPayloadDeltaQuantities: 596 | SIZE = 32 597 | 598 | # returns a `MediumPayloadDeltaQuantities` read from a `_ResponseReader` 599 | def _from_reader(reader : _ResponseReader): 600 | assert reader.remaining() >= MediumPayloadDeltaQuantities.SIZE 601 | 602 | rv = MediumPayloadDeltaQuantities() 603 | rv.timestamp = Timestamp._from_reader(reader) 604 | rv.dq = Dq._from_reader(reader) 605 | rv.dv = Dv._from_reader(reader) 606 | 607 | return rv 608 | 609 | def __repr__(self): 610 | return _pretty_print(self) 611 | 612 | # a medium-payload measurement response that contains "Rate Quantities (with Mag)" data 613 | class MediumPayloadRateQuantitiesWithMag: 614 | SIZE = 34 615 | 616 | # returns a `MediumPayloadRateQuantitiesWithMag` read from a `_ResponseReader` 617 | def _from_reader(reader : _ResponseReader): 618 | assert reader.remaining() >= MediumPayloadRateQuantitiesWithMag.SIZE 619 | 620 | rv = MediumPayloadRateQuantitiesWithMag() 621 | rv.timestamp = Timestamp._from_reader(reader) 622 | rv.acceleration = Acceleration._from_reader(reader) 623 | rv.angular_velocity = AngularVelocity._from_reader(reader) 624 | rv.magnetic_field = MagneticField._from_reader(reader) 625 | 626 | def __repr__(self): 627 | return _pretty_print(self) 628 | 629 | # a medium-payload measurement response that contains "Rate Quantities" data 630 | class MediumPayloadRateQuantities: 631 | SIZE = 28 632 | 633 | # returns a `MediumPayloadRateQuantities` read from a `_ResponseReader` 634 | def _from_reader(reader : _ResponseReader): 635 | assert reader.remaining() >= MediumPayloadRateQuantities.SIZE 636 | 637 | rv = MediumPayloadRateQuantities() 638 | rv.timestamp = Timestamp._from_reader(reader) 639 | rv.acceleration = Acceleration._from_reader(reader) 640 | rv.angular_velocity = AngularVelocity._from_reader(reader) 641 | 642 | return rv 643 | 644 | def __repr__(self): 645 | return _pretty_print(self) 646 | 647 | # a medium-payload measurement response that contains "Custom Mode 1" data 648 | class MediumPayloadCustomMode1: 649 | SIZE = 40 650 | 651 | # returns a `MediumPayloadCustomMode1` read from a `_ResponseReader` 652 | def _from_reader(reader : _ResponseReader): 653 | assert reader.remaining() >= MediumPayloadCustomMode1.SIZE 654 | 655 | rv = MediumPayloadCustomMode1() 656 | rv.timestamp = Timestamp._from_reader(reader) 657 | rv.euler = EulerAngles._from_reader(reader) 658 | rv.free_acceleration = FreeAcceleration._from_reader(reader) 659 | rv.angular_velocity = AngularVelocity._from_reader(reader) 660 | 661 | return rv 662 | 663 | def __repr__(self): 664 | return _pretty_print(self) 665 | 666 | # a medium-payload measurement response that contains "Custom Mode 2" data 667 | class MediumPayloadCustomMode2: 668 | SIZE = 34 669 | 670 | # returns a `MediumPayloadCustomMode2` read from a `_ResponseReader` 671 | def _from_reader(reader : _ResponseReader): 672 | assert reader.remaining() >= MediumPayloadCustomMode2.SIZE 673 | 674 | rv = MediumPayloadCustomMode2() 675 | rv.timestamp = Timestamp._from_reader(reader) 676 | rv.euler = EulerAngles._from_reader(reader) 677 | rv.free_acceleration = FreeAcceleration._from_reader(reader) 678 | rv.magnetic_field = MagneticField._from_reader(reader) 679 | 680 | return rv 681 | 682 | def __repr__(self): 683 | return _pretty_print(self) 684 | 685 | # a medium-payload measurement response that contains "Custom Mode 3" data 686 | class MediumPayloadCustomMode3: 687 | SIZE = 32 688 | 689 | # returns a `MediumPayloadCustomMode3` read from a `_ResponseReader` 690 | def _from_reader(reader : _ResponseReader): 691 | assert reader.remaining() >= MediumPayloadCustomMode3.SIZE 692 | 693 | rv = MediumPayloadCustomMode3() 694 | rv.timestamp = Timestamp._from_reader(reader) 695 | rv.quaternion = Quaternion._from_reader(reader) 696 | rv.angular_velocity = AngularVelocity._from_reader(reader) 697 | 698 | return rv 699 | 700 | def __repr__(self): 701 | return _pretty_print(self) 702 | 703 | 704 | # data for short-payload measurements 705 | # 706 | # the DOT can emit data in long (63-byte), medium (40-byte), and short (20-byte) lengths. 707 | # What those bytes parse out to depends on the payload mode (see "Measurement Service Control 708 | # Characteristic") is set to. 709 | class ShortPayloadCharacteristic: 710 | UUID = "15172004-4947-11E9-8646-D663BD873D93" 711 | 712 | # a short-payload measurement response that contains "Orientation Euler" data 713 | class ShortPayloadOrientationEuler: 714 | SIZE = 16 715 | 716 | # returns a `ShortPayloadOrientationEuler` read from a `_ResponseReader` 717 | def _from_reader(reader : _ResponseReader): 718 | assert reader.remaining() >= ShortPayloadOrientationEuler.SIZE 719 | 720 | rv = ShortPayloadOrientationEuler() 721 | rv.timestamp = Timestamp._from_reader(reader) 722 | rv.euler = EulerAngles._from_reader(reader) 723 | 724 | return rv 725 | 726 | def __repr__(self): 727 | return _pretty_print(self) 728 | 729 | # a short-payload measurement response that contains "Orientation Quaternion" data 730 | class ShortPayloadOrientationQuaternion: 731 | SIZE = 20 732 | 733 | # returns a `ShortPayloadOrientationQuaternion` read from a `_ResponseReader` 734 | def _from_reader(reader : _ResponseReader): 735 | assert reader.remaining() >= ShortPayloadOrientationQuaternion.SIZE 736 | 737 | rv = ShortPayloadOrientationQuaternion() 738 | rv.timestamp = Timestamp._from_reader(reader) 739 | rv.quaternion = Quaternion._from_reader(reader) 740 | 741 | return rv 742 | 743 | def __repr__(self): 744 | return _pretty_print(self) 745 | 746 | # a short-payload measurement response that contains "Free Acceleration" data 747 | class ShortPayloadFreeAcceleration: 748 | SIZE = 16 749 | 750 | # returns a `ShortPayloadFreeAcceleration` read from a `_ResponseReader` 751 | def _from_reader(reader : _ResponseReader): 752 | assert reader.remaining() >= ShortPayloadFreeAcceleration.SIZE 753 | 754 | rv = ShortPayloadFreeAcceleration() 755 | rv.timestamp = Timestamp._from_reader(reader) 756 | rv.free_acceleration = FreeAcceleration._from_reader(reader) 757 | 758 | return rv 759 | 760 | def __repr__(self): 761 | return _pretty_print(self) 762 | 763 | # Measurement Service: Orientation Reset Control Characteristic (sec. 3.6, p17 in the BLE spec) 764 | class OrientationResetControlCharacteristic: 765 | UUID = "15172006-4947-11E9-8646-D663BD873D93" 766 | SIZE = 2 767 | 768 | # returns a `OrientationResetControlCharacteristic` read from a `_ResponseReader` 769 | def _from_reader(reader : _ResponseReader): 770 | assert reader.remaining() >= OrientationResetControlCharacteristic.SIZE 771 | 772 | rv = OrientationResetControlCharacteristic() 773 | rv.Type = reader.read_u16() 774 | 775 | return rv 776 | 777 | # returns a `OrientationResetControlCharacteristic` parsed from a byte sequence 778 | def from_bytes(bites): 779 | reader = _ResponseReader(bites) 780 | return OrientationResetControlCharacteristic.read(reader) 781 | 782 | # returns a serialized representation of the `OrientationResetControlCharacteristic` 783 | def to_bytes(self): 784 | rv = bytearray() 785 | rv += self.Type.to_bytes(2, "little") 786 | return rv 787 | 788 | def __repr__(self): 789 | return _pretty_print(self) 790 | 791 | # Measurement Service: Orientation Reset Status Characteristic (sec. 3.7, p17 in the BLE spec) 792 | class OrientationResetStatusCharacteristic: 793 | UUID = "15172007-4947-11E9-8646-D663BD873D93" 794 | SIZE = 1 795 | 796 | # returns a `OrientationResetStatusCharacteristic` read from a `_ResponseReader` 797 | def _from_reader(reader : _ResponseReader): 798 | assert reader.remaining() >= OrientationResetStatusCharacteristic.SIZE 799 | 800 | rv = OrientationResetStatusCharacteristic() 801 | rv.reset_result = reader.read_u8() 802 | 803 | return rv 804 | 805 | # returns a `OrientationResetStatusCharacteristic` parsed from a byte sequence 806 | def from_bytes(bites): 807 | reader = _ResponseReader(bites) 808 | return OrientationResetStatusCharacteristic._from_reader(reader) 809 | 810 | def __repr__(self): 811 | return _pretty_print(self) 812 | 813 | # OrientationResetDataCharacteristic: not for public use 814 | 815 | # Battery Service: Battery Characteristic (sec. 4.1, p19 in the BLE spec) 816 | class BatteryCharacteristic: 817 | UUID = "15173001-4947-11E9-8646-D663BD873D93" 818 | SIZE = 2 819 | 820 | # returns a `BatteryCharacteristic` read from a `_ResponseReader` 821 | def _from_reader(reader : _ResponseReader): 822 | assert reader.remaining() >= BatteryCharacteristic.SIZE 823 | 824 | rv = BatteryCharacteristic() 825 | rv.battery_level = reader.read_u8() 826 | rv.charging_status = reader.read_u8() 827 | 828 | return rv 829 | 830 | # returns a `BatteryCharacteristic` parsed from a byte sequence 831 | def from_bytes(bites): 832 | reader = _ResponseReader(bites) 833 | return BatteryCharacteristic._from_reader(reader) 834 | 835 | def __repr__(self): 836 | return _pretty_print(self) 837 | 838 | 839 | # SECTION: `Dot` context manager 840 | # 841 | # this is a lifetime wrapper around a BLE connection to a single DOT 842 | 843 | 844 | # The `Dot` class provides: 845 | # 846 | # - Methods for connecting to the underlying DOT device through the Bluetooth Low-Energy 847 | # (BLE) connection (`Dot.connect`, `Dot.disconnect`, `with`, and `async with`) 848 | # 849 | # - Low-level methods for reading, writing, and receiving notifications from the low-level 850 | # BLE characteristic the DOT exposes 851 | # 852 | # - High-level methods that use the low-level methods (e.g. turn the DOT on, identify it) 853 | # 854 | # This class acts as a lifetime wrapper around the underlying BLE connection, so 855 | # you should use it in something like a `with` or `async with` block. Using the async 856 | # API (methods prefixed with `a`) and `async with` block is better. The underlying BLE 857 | # implementation is asynchronous. The synchronous API (other methods and plain `with` 858 | # blocks) is more convenient, but has to hop into an asynchronous event loop until the 859 | # entire method call is complete. The asynchronous equivalents have the opportunity to 860 | # cooperatively yield so that other events can be processed while waiting for the response. 861 | # It is practically a necessity to use the async API if handling a large amount of DOTs 862 | # from one thread (otherwise, you will experience head-of-line blocking and have a harder 863 | # time handling the side-effects of notications). 864 | class Dot: 865 | 866 | # init/enter/exit: connect/disconnect to the DOT 867 | 868 | # init a new `Dot` instance 869 | # 870 | # initializes the underlying connection client, but does not connect to 871 | # the DOT. Use `.connect`/`.disconnect`, or (better) a context manager 872 | # (`with Dot(ble) as dot`), or (better again) an async context manager 873 | # (`async with Dot(ble) as dot`) to setup/teardown the connection 874 | def __init__(self, ble_device): 875 | self.dev = ble_device 876 | self.client = bleak.BleakClient(self.dev.address) 877 | 878 | # automatically called when entering `async with` blocks 879 | async def __aenter__(self): 880 | await self.client.__aenter__() 881 | return self 882 | 883 | # automatically called when exiting `async with` blocks 884 | async def __aexit__(self, exc_type, value, traceback): 885 | await self.client.__aexit__(exc_type, value, traceback) 886 | 887 | # automatically called when entering (synchronous) `with` blocks 888 | def __enter__(self): 889 | self.loop = asyncio.new_event_loop() 890 | self.loop.run_until_complete(self.__aenter__()) 891 | return self 892 | 893 | # automatically called when exiting (synchronous) `with` blocks 894 | def __exit__(self, exc_type, value, traceback): 895 | self.loop.run_until_complete(self.__aexit__(exc_type, value, traceback)) 896 | 897 | # (dis)connection methods 898 | 899 | # asynchronously establishes a connection to the DOT 900 | async def aconnect(self): 901 | return await self.client.connect() 902 | 903 | # synchronously establishes a connection to the DOT 904 | def connect(self): 905 | self.loop.run_until_complete(self.aconnect()) 906 | 907 | # asynchronously terminates the connection to the DOT 908 | async def adisconnect(self): 909 | return await self.client.disconnect() 910 | 911 | # synchronously terminates the connection to the DOT 912 | def disconnect(self): 913 | return self.loop.run_until_complete(self.adisconnect()) 914 | 915 | # low-level characteristic accessors 916 | 917 | # asynchronously reads the "Device Info Characteristic" (sec. 2.1 in the BLE spec) 918 | async def adevice_info_read(self): 919 | resp = await self.client.read_gatt_char(DeviceInfoCharacteristic.UUID) 920 | return DeviceInfoCharacteristic.from_bytes(resp) 921 | 922 | # synchronously reads the "Device Info Characteristic" (sec. 2.1 in the BLE spec) 923 | def device_info_read(self): 924 | return self.loop.run_until_complete(self.adevice_info_read()) 925 | 926 | # asynchronously reads the "Device Control Characteristic" (sec. 2.2. in the BLE spec) 927 | async def adevice_control_read(self): 928 | resp = await self.client.read_gatt_char(DeviceControlCharacteristic.UUID) 929 | return DeviceControlCharacteristic.from_bytes(resp) 930 | 931 | # synchronously reads the "Device Control Characteristic" (sec. 2.2. in the BLE spec) 932 | def device_control_read(self): 933 | return self.loop.run_until_complete(self.adevice_control_read()) 934 | 935 | # asynchronously writes the "Device Control Characteristic" (sec. 2.2. in the BLE spec) 936 | # 937 | # arg must be a `DeviceControlCharacteristic` with its fields set to appropriate 938 | # values (read the BLE spec to see which values are supported) 939 | async def adevice_control_write(self, device_control_characteristic : DeviceControlCharacteristic): 940 | msg_bytes = device_control_characteristic.to_bytes() 941 | await self.client.write_gatt_char(DeviceControlCharacteristic.UUID, msg_bytes, True) 942 | 943 | # synchronously writes the "Device Control Characteristic" (sec. 2.2. in the BLE spec) 944 | # 945 | # arg must be a `DeviceControlCharacteristic` with its fields set to appropriate 946 | # values (read the BLE spec to see which values are supported) 947 | def device_control_write(self, device_control_characteristic : DeviceControlCharacteristic): 948 | self.loop.run_until_complete(self.adevice_control_write(device_control_characteristic)) 949 | 950 | # asynchronously enable notifications from the "Device Report Characteristic" (sec. 951 | # 2.3 in the BLE spec) 952 | # 953 | # once notifications are enabled, `callback` will be called with two arguments: 954 | # a message ID and the raw message bytes (which can be parsed using 955 | # `DeviceReportCharacteristic.parse`). Notifications arrive from the DOT whenever 956 | # a significant event happens (e.g. a button press). See the BLE spec for which 957 | # events trigger from which actions. 958 | async def adevice_report_start_notify(self, callback): 959 | await self.client.start_notify(DeviceReportCharacteristic.UUID, callback) 960 | 961 | # synchronously enable notifications from the "Device Report Characteristic" (sec. 962 | # 2.3 in the BLE spec) 963 | # 964 | # once notifications are enabled, `callback` will be called with two arguments: 965 | # a message ID and the raw message bytes (which can be parsed using 966 | # `DeviceReportCharacteristic.parse`). Notifications arrive from the DOT whenever 967 | # a significant event happens (e.g. a button press). See the BLE spec for which 968 | # events trigger from which actions. 969 | def device_report_start_notify(self, callback): 970 | self.loop.run_until_complete(self.adevice_report_start_notify(callback)) 971 | 972 | # asynchronously disable notifications from the "Device Report Characteristic" (sec. 973 | # 2.3 in the BLE spec) 974 | # 975 | # this disables notifications that were enabled by the `device_report_start_notify` 976 | # method. After this action completes, the `callback` in the enable call will no longer 977 | # be called 978 | async def adevice_report_stop_notify(self): 979 | await self.client.stop_notify(DeviceReportCharacteristic.UUID) 980 | 981 | # synchronously disable notifications from the "Device Report Characteristic" (sec. 982 | # 2.3 in the BLE spec) 983 | # 984 | # this disables notifications that were enabled by the `device_report_start_notify` 985 | # method. After this action completes, the `callback` in the enable call will no longer 986 | # be called 987 | def device_report_stop_notify(self): 988 | self.loop.run_until_complete(self.adevice_report_stop_notify()) 989 | 990 | # asynchronously read the "Control Characteristic" (sec. 3.1 in the BLE spec) 991 | async def acontrol_read(self): 992 | resp = await self.client.read_gatt_char(ControlCharacteristic.UUID) 993 | return ControlCharacteristic.from_bytes(resp) 994 | 995 | # asynchronously read the "Control Characteristic" (sec. 3.1 in the BLE spec) 996 | def control_read(self): 997 | return self.loop.run_until_complete(self.acontrol_read()) 998 | 999 | # asynchronously write the "Control Characteristic" (sec. 3.1 in the BLE spec) 1000 | async def acontrol_write(self, control_characteristic : ControlCharacteristic): 1001 | msg_bytes = control_characteristic.to_bytes() 1002 | await self.client.write_gatt_char(ControlCharacteristic.UUID, msg_bytes) 1003 | 1004 | # synchronously write the "Control Characteristic" (sec. 3.1 in the BLE spec) 1005 | def control_write(self, control_characteristic : ControlCharacteristic): 1006 | self.loop.run_until_complete(self.acontrol_write(control_characteristic)) 1007 | 1008 | # asynchronously subscribe to long-payload measurement notifications 1009 | async def along_payload_start_notify(self, callback): 1010 | await self.client.start_notify(LongPayloadCharacteristic.UUID, callback) 1011 | 1012 | # synchronously subscribe to long-payload measurement notifications 1013 | def long_payload_start_notify(self, callback): 1014 | self.loop.run_until_complete(self.along_payload_start_notify(callback)) 1015 | 1016 | async def along_payload_stop_notify(self): 1017 | await self.client.stop_notify(LongPayloadCharacteristic.UUID) 1018 | 1019 | def long_payload_stop_notify(self): 1020 | self.loop.run_until_complete(self.along_payload_stop_notify()) 1021 | 1022 | async def amedium_payload_start_notify(self, callback): 1023 | await self.client.start_notify(MediumPayloadCharacteristic.UUID, callback) 1024 | 1025 | def medium_payload_start_notify(self, callback): 1026 | self.loop.run_until_complete(self.amedium_payload_start_notify(callback)) 1027 | 1028 | async def amedium_payload_stop_notify(self): 1029 | await self.client.stop_notify(MediumPayloadCharacteristic.UUID) 1030 | 1031 | def medium_payload_stop_notify(self): 1032 | self.loop.run_until_complete(self.amedium_payload_stop_notify()) 1033 | 1034 | async def ashort_payload_start_notify(self, callback): 1035 | await self.client.start_notify(ShortPayloadCharacteristic.UUID, callback) 1036 | 1037 | def short_payload_start_notify(self, callback): 1038 | self.loop.run_until_complete(self.ashort_payload_start_notify(callback)) 1039 | 1040 | async def ashort_payload_stop_notify(self): 1041 | await self.client.stop_notify(ShortPayloadCharacteristic.UUID) 1042 | 1043 | def short_payload_stop_notify(self): 1044 | self.loop.run_until_complete(self.ashort_payload_stop_notify()) 1045 | 1046 | # asynchronously read the "Battery Characteristic" (sec. 4.1 in the BLE spec) 1047 | async def abattery_read(self): 1048 | resp = await self.client.read_gatt_char(BatteryCharacteristic.UUID) 1049 | return BatteryCharacteristic.from_bytes(resp) 1050 | 1051 | # synchronously read the "Battery Characteristic" (sec. 4.1 in the BLE spec) 1052 | def battery_read(self): 1053 | return self.loop.run_until_complete(self.abattery_read()) 1054 | 1055 | # asynchronously enable battery notifications from the "Battery Characteristic" (see 1056 | # sec. 4.1 in the BLE spec) 1057 | async def abattery_start_notify(self, callback): 1058 | await self.client.start_notify(BatteryCharacteristic.UUID, callback) 1059 | 1060 | # synchronously enable battery notifications from the "Battery Characteristic" (see 1061 | # sec. 4.1 in the BLE spec) 1062 | def battery_start_notify(self, callback): 1063 | self.loop.run_until_complete(self.abattery_start_notify(callback)) 1064 | 1065 | # high-level operations 1066 | 1067 | # asynchronously requests that the DOT identifies itself 1068 | # 1069 | # (from BLE spec sec. 2.2.): The sensor LED will fast blink 8 times and then 1070 | # a short pause in red, lasting for 10 seconds. 1071 | async def aidentify(self): 1072 | dc = await self.adevice_control_read() 1073 | dc.visit_index = 0x01 1074 | dc.identifying = 0x01 1075 | await self.adevice_control_write(dc) 1076 | 1077 | # synchronously requests that the DOT identifies itself 1078 | # 1079 | # (from BLE spec sec. 2.2.): The sensor LED will fast blink 8 times and then 1080 | # a short pause in red, lasting for 10 seconds. 1081 | def identify(self): 1082 | self.loop.run_until_complete(self.aidentify()) 1083 | 1084 | # asynchronously requests that the DOT powers itself off 1085 | async def apower_off(self): 1086 | dc = await self.adevice_control_read() 1087 | dc.visit_index = 0x02 1088 | dc.power_options = dc.power_options | 0x01 1089 | await self.adevice_control_write(dc) 1090 | 1091 | # synchronously requests that the DOT powers itself off 1092 | def power_off(self): 1093 | self.loop.run_until_complete(self.apower_off()) 1094 | 1095 | # asynchronosly requests that the DOT should power itself on when plugged into 1096 | # a USB (e.g. when charging) 1097 | async def aenable_power_on_by_usb_plug_in(self): 1098 | dc = await self.adevice_control_read() 1099 | dc.visit_index = 0x02 1100 | dc.poweroff = dc.poweroff | 0x02 1101 | await self.adevice_control_write(dc) 1102 | 1103 | # synchronosly requests that the DOT should power itself on when plugged into 1104 | # a USB (e.g. when charging) 1105 | def enable_power_on_by_usb_plug_in(self): 1106 | self.loop.run_until_complete(self.aenable_power_on_by_usb_plug_in()) 1107 | 1108 | # asynchronously requests that the DOT shouldn't power itself on when plugged into 1109 | # a USB (e.g. when charging) 1110 | async def adisable_power_on_by_usb_plug_in(self): 1111 | dc = await self.adevice_control_read() 1112 | dc.visit_index = 0x02 1113 | dc.poweroff = dc.poweroff & ~(0x02) 1114 | await self.adevice_control_write(dc) 1115 | 1116 | # synchronously requests that the DOT shouldn't power itself on when plugged into 1117 | # a USB (e.g. when charging) 1118 | def disable_power_on_by_usb_plug_in(self): 1119 | self.loop.run_until_complete(self.adisable_power_on_by_usb_plug_in()) 1120 | 1121 | # asynchronously sets the output rate of the DOT 1122 | # 1123 | # (BLE spec sec. 2.2): only values 1,4,10,12,15,20,30,60,120hz are permitted 1124 | async def aset_output_rate(self, rate): 1125 | assert rate in {1, 4, 10, 12, 15, 20, 30, 60, 120} 1126 | 1127 | dc = await self.adevice_control_read() 1128 | dc.visit_index = 0x10 1129 | dc.output_rate = rate 1130 | await self.adevice_control_write(dc) 1131 | 1132 | # synchronously sets the output rate of the DOT 1133 | # 1134 | # (BLE spec sec. 2.2): only values 1,4,10,12,15,20,30,60,120hz are permitted 1135 | def set_output_rate(self, rate): 1136 | self.loop.run_until_complete(self.aset_output_rate(rate)) 1137 | 1138 | # asynchronously resets the output rate of the DOT to its default value (60 hz) 1139 | async def areset_output_rate(self): 1140 | await self.aset_output_rate(60) # default, according to BLE spec 1141 | 1142 | # synchronously resets the output rate of the DOT to its default value (60 hz) 1143 | def reset_output_rate(self): 1144 | self.loop.run_until_complete(self.areset_output_rate()) 1145 | 1146 | # asynchronously sets the "Filter Profile Index" field in the Device Control Characteristic 1147 | # 1148 | # this sets how the DOT filters measurements? No idea. See sec. 2.2 in the BLE 1149 | # spec for a slightly better explanation 1150 | async def aset_filter_profile_index(self, idx): 1151 | assert idx in {0, 1} 1152 | 1153 | dc = await self.adevice_control_read() 1154 | dc.visit_index = 0x20 1155 | dc.filter_profile_index = idx 1156 | await self.adevice_control_write(dc) 1157 | 1158 | # synchronously sets the "Filter Profile Index" field in the Device Control Characteristic 1159 | # 1160 | # this sets how the DOT filters measurements? No idea. See sec. 2.2 in the BLE 1161 | # spec for a slightly better explanation 1162 | def set_filter_profile_index(self, idx): 1163 | self.loop.run_until_complete(self.aset_filter_profile_index(idx)) 1164 | 1165 | # asynchronously sets the "Filter Profile Index" of the DOT to "General" 1166 | # 1167 | # (from BLE spec sec. 2.2., table 8): "General" is the "Default for general human 1168 | # motions" 1169 | async def aset_filter_profile_to_general(self): 1170 | await self.aset_filter_profile_index(0) 1171 | 1172 | # synchronously sets the "Filter Profile Index" of the DOT to "General" 1173 | # 1174 | # (from BLE spec sec. 2.2., table 8): "General" is the "Default for general human 1175 | # motions" 1176 | def set_filter_profile_to_general(self): 1177 | self.loop.run_until_complete(self.aset_filter_profile_to_general()) 1178 | 1179 | # asynchronously sets the "Filter Profile Index" of the DOT to "Dynamic" 1180 | # 1181 | # (from BLE spec. sec. 2.2., table 8): "Dynamic" is "For fast and jerky human motions 1182 | # like sprinting" 1183 | async def aset_filter_profile_to_dynamic(self): 1184 | await self.aset_filter_profile_index(1) 1185 | 1186 | # synchronously sets the "Filter Profile Index" of the DOT to "Dynamic" 1187 | # 1188 | # (from BLE spec. sec. 2.2., table 8): "Dynamic" is "For fast and jerky human motions 1189 | # like sprinting" 1190 | def set_filter_profile_to_dynamic(self): 1191 | self.loop.run_until_complete(self.aset_filter_profile_to_dynamic()) 1192 | 1193 | # sychronously pump this DOT's message loop forever 1194 | def pump_forever(self): 1195 | self.loop.run_forever() 1196 | 1197 | 1198 | # a python `Callable` that is called whenever a notification is 1199 | # received by the bluetooth backend 1200 | class ResponseHandler: 1201 | def __init__(self): 1202 | self.i = 0 1203 | 1204 | def __call__(self, sender, data): 1205 | self.i += 1 1206 | 1207 | parsed = MediumPayloadCompleteEuler.parse(data) 1208 | print(f"i={self.i} t={parsed.timestamp.value} x={parsed.euler.x}, y={parsed.euler.y}, z={parsed.euler.z}") 1209 | 1210 | 1211 | # SECTION: high-level helper functions 1212 | # 1213 | # these are module-level functions that users can call without having 1214 | # to set up an `xdc.Dot` context etc. 1215 | 1216 | 1217 | # asynchronously returns `True` if the provided `bleak.backends.device.BLEDevice` 1218 | # is believed to be an XSens DOT sensor 1219 | async def ais_DOT(bledevice: bleak.BLEDevice) -> bool: 1220 | if bledevice.name and "xsens dot" in bledevice.name.lower(): 1221 | # spec: 1.2: Scanning and Filtering: tag name is "Xsens DOT" 1222 | # 1223 | # other devices in the wild *may* have a name collision with this, but it's 1224 | # unlikely 1225 | return True 1226 | elif bledevice.metadata.get("manufacturer_data") and bledevice.metadata["manufacturer_data"].get(2182): 1227 | # spec: 1.2: Scanning and Filtering: Bluetooth SIG identifier for XSens Technologies B.V. is 2182 (0x0886) 1228 | # 1229 | # this ambiguously identifies an XSens DOT. XSens might recycle its bluetooth 1230 | # ID for other products, though, so we need to check that the device actually 1231 | # responds to a DOT-like packet 1232 | try: 1233 | async with Dot(bledevice) as dot: 1234 | await dot.adevice_info_read() # typical request 1235 | return True 1236 | except asyncio.exceptions.TimeoutError as ex: 1237 | return False 1238 | 1239 | # synchronously returns `True` if the provided `bleak.backends.device.BLEDevice` is an 1240 | # XSens DOT 1241 | def is_DOT(bledevice: bleak.BLEDevice) -> bool: 1242 | loop = asyncio.new_event_loop() 1243 | return loop.run_until_complete(ais_DOT(bledevice)) 1244 | 1245 | # asynchronously returns a list of all (not just DOT) BLE devices that 1246 | # the host's bluetooth adaptor can see. Each element in the list is an 1247 | # instance of `bleak.backends.device.BLEDevice` 1248 | async def ascan_all(timeout: float = 5.0) -> list[bleak.BLEDevice]: 1249 | async with bleak.BleakScanner() as scanner: 1250 | return list(await scanner.discover(timeout=timeout)) 1251 | 1252 | # synchronously returns a list of all (not just DOT) BLE devices that the 1253 | # host's bluetooth adaptor can see. Each element in the list is an instance 1254 | # of `bleak.backends.device.BLEDevice` 1255 | def scan_all(timeout: float = 5.0) -> list[bleak.BLEDevice]: 1256 | loop = asyncio.new_event_loop() 1257 | return loop.run_until_complete(ascan_all(timeout)) 1258 | 1259 | # asynchronously returns a list of all XSens DOTs that the host's bluetooth 1260 | # adaptor can see. Each element in the list is an instance of 1261 | # `bleak.backends.device.BLEDevice` 1262 | async def ascan(timeout: float = 5.0) -> list[bleak.BLEDevice]: 1263 | return [d for d in await ascan_all(timeout) if await ais_DOT(d)] 1264 | 1265 | # synchronously returns a list of all XSens DOTs that the host's bluetooth 1266 | # adaptor can see. Each element in the list is an instance of 1267 | # `bleak.backends.device.BLEDevice` 1268 | def scan(timeout: float = 5.0) -> list[bleak.BLEDevice]: 1269 | loop = asyncio.new_event_loop() 1270 | return loop.run_until_complete(ascan(timeout)) 1271 | 1272 | # asynchronously returns a BLE device with the given identifier/address 1273 | # 1274 | # returns `None` if the device cannot be found (e.g. no connection, wrong 1275 | # address) 1276 | async def afind_by_address(device_identifier: str) -> typing.Optional[bleak.BLEDevice]: 1277 | async with bleak.BleakScanner() as scanner: 1278 | return await scanner.find_device_by_address(device_identifier, timeout=40) 1279 | 1280 | # synchronously returns a BLE device with the given identifier/address 1281 | # 1282 | # returns `None` if the device cannot be found (e.g. no connection, wrong 1283 | # address) 1284 | def find_by_address(device_identifier: str) -> typing.Optional[bleak.BLEDevice]: 1285 | loop = asyncio.new_event_loop() 1286 | return loop.run_until_complete(afind_by_address(device_identifier)) 1287 | 1288 | # asynchronously returns a BLE device with the given identifier/address if the 1289 | # device appears to be an XSens DOT 1290 | # 1291 | # effectively, the same as `afind_by_address` but with the extra stipulation that 1292 | # the given device must be a DOT 1293 | async def afind_dot_by_address(device_identifier: str) -> typing.Optional[bleak.BLEDevice]: 1294 | dev = await afind_by_address(device_identifier) 1295 | 1296 | if dev is None: 1297 | return None # device cannot be found 1298 | elif not await ais_DOT(dev): 1299 | return None # device exists but is not a DOT 1300 | else: 1301 | return dev 1302 | 1303 | # synchronously returns a BLE device with the given identifier/address, if the device 1304 | # appears to be an XSens DOT 1305 | # 1306 | # effectively, the same as `find_by_address`, but with the extra stipulation that 1307 | # the given device must be a DOT 1308 | def find_dot_by_address(device_identifier: str) -> typing.Optional[bleak.BLEDevice]: 1309 | loop = asyncio.new_event_loop() 1310 | return loop.run_until_complete(afind_dot_by_address(device_identifier)) 1311 | 1312 | 1313 | # low-level characteristic accessors (free functions) 1314 | 1315 | 1316 | # asynchronously returns the "Device Info Characteristic" for the given DOT device 1317 | # 1318 | # see: sec 2.1 Device Info Characteristic in DOT BLE spec 1319 | async def adevice_info_read(bledevice: bleak.BLEDevice) -> DeviceInfoCharacteristic: 1320 | async with Dot(bledevice) as dot: 1321 | return await dot.adevice_info_read() 1322 | 1323 | # synchronously returns the "Device Info Characteristic" for the given DOT device 1324 | # 1325 | # see: sec 2.1: Device Info Characteristic in DOT BLE spec 1326 | def device_info_read(bledevice: bleak.BLEDevice) -> DeviceInfoCharacteristic: 1327 | loop = asyncio.new_event_loop() 1328 | return loop.run_until_complete(adevice_info_read(bledevice)) 1329 | 1330 | # asynchronously returns the "Device Control Characteristic" for the given DOT device 1331 | # 1332 | # see: sec 2.2: Device Control Characteristic in DOT BLE spec 1333 | async def adevice_control_read(bledevice: bleak.BLEDevice) -> DeviceControlCharacteristic: 1334 | async with Dot(bledevice) as dot: 1335 | return await dot.adevice_control_read() 1336 | 1337 | # synchronously returns the "Device Control Characteristic" for the given DOT device 1338 | # 1339 | # see: sec 2.2: Device Control Characteristic in DOT BLE spec 1340 | def device_control_read(bledevice: bleak.BLEDevice) -> DeviceControlCharacteristic: 1341 | loop = asyncio.new_event_loop() 1342 | return loop.run_until_complete(adevice_control_read(bledevice)) 1343 | 1344 | # asynchronously write the provided DeviceControlCharacteristic to the provided 1345 | # DOT device 1346 | async def adevice_control_write(bledevice: bleak.BLEDevice, device_control_characteristic: DeviceControlCharacteristic): 1347 | async with Dot(bledevice) as dot: 1348 | await dot.adevice_control_write(device_control_characteristic) 1349 | 1350 | def device_control_write(bledevice: bleak.BLEDevice, device_control_characteristic: DeviceControlCharacteristic): 1351 | loop = asyncio.new_event_loop() 1352 | loop.run_until_complete(adevice_control_write(bledevice, device_control_characteristic)) 1353 | 1354 | # high-level operations (free functions) 1355 | 1356 | async def aidentify(bledevice: bleak.BLEDevice): 1357 | async with Dot(bledevice) as dot: 1358 | await dot.aidentify() 1359 | 1360 | def identify(bledevice: bleak.BLEDevice): 1361 | loop = asyncio.new_event_loop() 1362 | loop.run_until_complete(aidentify(bledevice)) 1363 | 1364 | async def apower_off(bledevice): 1365 | async with Dot(bledevice) as dot: 1366 | await dot.apower_off() 1367 | 1368 | def power_off(bledevice: bleak.BLEDevice): 1369 | loop = asyncio.new_event_loop() 1370 | loop.run_until_complete(apower_off(bledevice)) 1371 | 1372 | async def aenable_power_on_by_usb_plug_in(bledevice: bleak.BLEDevice): 1373 | async with Dot(bledevice) as dot: 1374 | await dot.aenable_power_on_by_usb_plug_in() 1375 | 1376 | def enable_power_on_by_usb_plug_in(bledevice: bleak.BLEDevice): 1377 | loop = asyncio.new_event_loop() 1378 | loop.run_until_complete(aenable_power_on_by_usb_plug_in(bledevice)) 1379 | 1380 | async def adisable_power_on_by_usb_plug_in(bledevice: bleak.BLEDevice): 1381 | async with Dot(bledevice) as dot: 1382 | await dot.adisable_power_on_by_usb_plug_in() 1383 | 1384 | def disable_power_on_by_usb_plug_in(bledevice: bleak.BLEDevice): 1385 | loop = asyncio.new_event_loop() 1386 | loop.run_until_complete(adisable_power_on_by_usb_plug_in(bledevice)) 1387 | 1388 | async def aset_output_rate(bledevice: bleak.BLEDevice, rate: int): 1389 | async with Dot(bledevice) as dot: 1390 | await dot.aset_output_rate(rate) 1391 | 1392 | def set_output_rate(bledevice: bleak.BLEDevice, rate: int): 1393 | loop = asyncio.new_event_loop() 1394 | loop.run_until_complete(aset_output_rate(bledevice, rate)) 1395 | 1396 | async def areset_output_rate(bledevice: bleak.BLEDevice): 1397 | async with Dot(bledevice) as dot: 1398 | await dot.areset_output_rate() 1399 | 1400 | def reset_output_rate(bledevice: bleak.BLEDevice): 1401 | loop = asyncio.new_event_loop() 1402 | loop.run_until_complete(areset_output_rate(bledevice)) 1403 | 1404 | async def aset_filter_profile_index(bledevice: bleak.BLEDevice, idx: int): 1405 | async with Dot(bledevice) as dot: 1406 | await dot.aset_filter_profile_index(idx) 1407 | 1408 | def set_filter_profile_index(bledevice: bleak.BLEDevice, idx: int): 1409 | loop = asyncio.new_event_loop() 1410 | loop.run_until_complete(aset_filter_profile_index(bledevice, idx)) 1411 | 1412 | 1413 | # SECTION: CLI API 1414 | # 1415 | # wraps various API calls with a command-line client 1416 | 1417 | def main(): 1418 | parser = argparse.ArgumentParser(description="Command line API for the XDC library") 1419 | subparsers = parser.add_subparsers(dest='command') 1420 | subparsers.add_parser("scan", description="scan for DOTs and print their address + name") 1421 | subparsers.add_parser("scan_all", description="scan for all visible BLE devices and print their address + name") 1422 | identify_parser = subparsers.add_parser("identify", description="identify the dot with the given address") 1423 | identify_parser.add_argument("address") 1424 | args = parser.parse_args() 1425 | 1426 | if args.command == "scan": 1427 | for dot in scan(): 1428 | print(f"{dot.address} {dot.name}") 1429 | elif args.command == "scan_all": 1430 | for ble_device in scan_all(): 1431 | print(f"{ble_device.address} {ble_device.name}") 1432 | elif args.command == "identify": 1433 | maybe_device = find_by_address(args.address) 1434 | if maybe_device: 1435 | identify(maybe_device) 1436 | else: 1437 | raise RuntimeError(f"No device with address {args.address} found - you can maybe try with a longer --timeout arg?") 1438 | 1439 | 1440 | if __name__ == '__main__': 1441 | main() 1442 | --------------------------------------------------------------------------------