├── .gitignore ├── LICENSE ├── README.md ├── conda ├── setup_spheropy_dev_env.py └── setup_spheropy_env.py ├── setup.py ├── spheropy.pyproj ├── spheropy.sln ├── spheropy ├── __init__.py └── spheropy.py ├── tests ├── auto_reconnect_test.py ├── bluetooth_info_test.py ├── collision_test.py ├── configure_locator_test.py ├── get_locator_info_test.py ├── get_power_state_test.py ├── get_rgb_led_test.py ├── get_version_info_test.py ├── ping_test.py ├── power_state_change_test.py ├── roll_test.py ├── self_level_test.py ├── set_back_led_test.py ├── set_heading_test.py ├── set_rgb_led_test.py └── test_utils.py └── winble ├── setup.py ├── winble.cpp ├── winble.vcxproj ├── winble.vcxproj.filters └── winble.vcxproj.user /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | .*-env 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # vscode 108 | .vscode/ 109 | 110 | #vs 111 | .vs/ 112 | 113 | # temp dirs 114 | .tmp 115 | /Debug 116 | /winble/Debug 117 | /winble/x64/Debug 118 | /x64/Debug 119 | /winble/Win32/Debug 120 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 irvinec 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SpheroPy 2 | An unofficial Sphero Python SDK to programmatically control Sphero robots. 3 | 4 | # Project Status 5 | **Now Available on PyPi!** 6 | 7 | **Early Alpha**\ 8 | Many commands have been implemented and we now have early alpha releases.\ 9 | Be aware that releases are frequent and breaking changes can happen at this stage of development. 10 | 11 | # Supported Platforms 12 | SpheroPy is supported and tested on Windows and Linux.\ 13 | SpheroPy is theoretically supported on Mac, but has not been tested on Mac. 14 | 15 | # Supported Devices 16 | SpheroPy currently supports and has been tested with: 17 | * Sphero 1.0 18 | * Sphero SPRK+ 19 | 20 | SpheroPy theoretically supports but has not been tested with: 21 | * Sphero 2.0 22 | * Sphero SPRK 23 | * BB8 24 | 25 | Support for additional devices is desired and planned. If your device is not supported now, please check back in the future. 26 | 27 | # Dependencies 28 | SpheroPy requires Python 3.6 or greater. 29 | 30 | SpheroPy needs a low-level bluetooth interface provider in order to talk to Sphero devices.\ 31 | You can choose to optionally install a bluetooth interface provider along with SpheroPy (see install).\ 32 | SpheroPy has optional depedencies on: 33 | - **pybluez** 34 | - For bluetooth support. Useful for talking to first-gen Sphero devices that don't implement BLE. 35 | - [pybluez github](https://github.com/pybluez/pybluez) 36 | - **pygatt** 37 | - For bluetooth LE support. Supported on linux. Also, supported on any platform with a BGAPI supported adapter. 38 | - Example of BGAPI adapter: https://www.silabs.com/products/wireless/bluetooth/bluetooth-low-energy-modules/bled112-bluetooth-smart-dongle 39 | - [pygatt github](https://github.com/peplin/pygatt) 40 | - **winble** 41 | - For bluetooth LE support on Windows. Winble is a native bluetooth LE library for Windows. Requires VS2017 to build from source, but wheel distribution is available. 42 | - [winble github](https://github.com/irvinec/SpheroPy/tree/master/winble) 43 | 44 | # Install 45 | To install SpheroPy:\ 46 | ```pip install spheropy``` 47 | 48 | To update SpheroPy:\ 49 | ```pip install --upgrade spheropy``` 50 | 51 | To install with optional bluetooth interface dependency:\ 52 | ```pip install spheropy[]```\ 53 | Replace `` with **pybluez**, **pygatt**, or **winble** (see Dependencies). 54 | 55 | # Examples 56 | See files in the [tests](https://github.com/irvinec/SpheroPy/tree/master/tests) directory for examples on how to use the APIs. 57 | 58 | # License 59 | This software is made available under the MIT License. 60 | See the license file for more details. 61 | -------------------------------------------------------------------------------- /conda/setup_spheropy_dev_env.py: -------------------------------------------------------------------------------- 1 | """Setup a conda environment for spheropy development.""" 2 | # Keep this file in sync with setup_spheropy_dev_env.py 3 | 4 | import os, sys 5 | import subprocess 6 | 7 | def main(): 8 | print('Setting up SpheroPy development conda environment.') 9 | if not is_conda_available(): 10 | print('\"conda\" is not available in this context. Are you sure you have minconda or anaconda installed and are running this from a conda environment?') 11 | 12 | add_conda_forge() 13 | install_deps() 14 | # Give python access to bluetooth 15 | if is_running_on_linux(): 16 | subprocess.check_call(['sudo', 'apt-get', 'install', '-y', 'libcap2-bin']) 17 | subprocess.check_call("sudo setcap 'cap_net_raw,cap_net_admin+eip' `which python3.6`", shell=True) 18 | 19 | print('Done setting up environment.') 20 | print("Please install SpheroPy from source using 'pip install -e .'") 21 | 22 | def is_conda_available(): 23 | try: 24 | subprocess.check_call( 25 | ['conda', '--help'], 26 | stderr=subprocess.DEVNULL, 27 | stdout=subprocess.DEVNULL 28 | ) 29 | return True 30 | except subprocess.CalledProcessError: 31 | return False 32 | 33 | def install_deps(): 34 | subprocess.check_call(['conda', 'install', '--yes', 35 | 'python=3.6', 36 | 'pylint', 37 | 'git', 38 | 'pexpect'] 39 | ) 40 | subprocess.check_call(['pip', 'install', 41 | # install pybluez. If running on windows we need to install irvinec's fork that has a patch for windows. 42 | 'git+https://github.com/irvinec/pybluez' if is_running_on_windows() else 'git+https://github.com/pybluez/pybluez', 43 | # install pygatt 44 | 'git+https://github.com/peplin/pygatt'] 45 | ) 46 | 47 | def add_conda_forge(): 48 | subprocess.check_call(['conda', 'config', '--add', 'channels', 'conda-forge']) 49 | 50 | def is_running_on_windows(): 51 | return os.name == 'nt' 52 | 53 | def is_running_on_linux(): 54 | return os.name == 'posix' 55 | 56 | def is_running_on_mac(): 57 | return os.name == 'mac' 58 | 59 | if __name__ == '__main__': main() -------------------------------------------------------------------------------- /conda/setup_spheropy_env.py: -------------------------------------------------------------------------------- 1 | """Setup a conda environment for working with spheropy.""" 2 | # Keep this file in sync with setup_spheropy_env.py 3 | 4 | import os, sys 5 | import subprocess 6 | 7 | def main(): 8 | print('Setting up SpheroPy conda environment.') 9 | if not is_conda_available(): 10 | print('\"conda\" is not available in this context. Are you sure you have minconda or anaconda installed and are running this from a conda environment?') 11 | 12 | add_conda_forge() 13 | install_deps() 14 | install_spheropy() 15 | # Give python access to bluetooth 16 | if is_running_on_linux(): 17 | subprocess.check_call(['sudo', 'apt-get', 'install', '-y', 'libcap2-bin']) 18 | subprocess.check_call("sudo setcap 'cap_net_raw,cap_net_admin+eip' `which python3.6`", shell=True) 19 | 20 | print('Done setting up environment.') 21 | 22 | def is_conda_available(): 23 | try: 24 | subprocess.check_call( 25 | ['conda', '--help'], 26 | stderr=subprocess.DEVNULL, 27 | stdout=subprocess.DEVNULL 28 | ) 29 | return True 30 | except subprocess.CalledProcessError: 31 | return False 32 | 33 | def install_deps(): 34 | subprocess.check_call(['conda', 'install', '--yes', 35 | 'python=3.6', 36 | 'pylint', 37 | 'git', 38 | 'pexpect'] 39 | ) 40 | subprocess.check_call(['pip', 'install', 41 | # install pybluez. If running on windows we need to install irvinec's fork that has a patch for windows. 42 | 'git+https://github.com/irvinec/pybluez' if is_running_on_windows() else 'git+https://github.com/pybluez/pybluez', 43 | # install pygatt 44 | 'git+https://github.com/peplin/pygatt'] 45 | ) 46 | 47 | def install_spheropy(): 48 | subprocess.check_call(['pip', 'install', 'git+https://github.com/irvinec/SpheroPy']) 49 | 50 | def add_conda_forge(): 51 | subprocess.check_call(['conda', 'config', '--add', 'channels', 'conda-forge']) 52 | 53 | def is_running_on_windows(): 54 | return os.name == 'nt' 55 | 56 | def is_running_on_linux(): 57 | return os.name == 'posix' 58 | 59 | def is_running_on_mac(): 60 | return os.name == 'mac' 61 | 62 | if __name__ == '__main__': main() -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | from setuptools import setup 3 | import sys 4 | 5 | __version__ = '0.0.4' 6 | 7 | ext_modules = [] 8 | 9 | extras_require = { 10 | 'winble': ['winble'], 11 | 'pygatt': ['pygatt'], 12 | 'pybluez': ['pybluez'] 13 | } 14 | 15 | install_requires = [] 16 | 17 | if sys.version_info < (3,6): 18 | sys.exit('Sorry, Python >= 3.6 is required') 19 | 20 | setup( 21 | name='SpheroPy', 22 | version=__version__, 23 | author='Casey Irvine', 24 | author_email='caseyi@outlook.com', 25 | packages=['spheropy'], 26 | url='https://github.com/irvinec/SpheroPy', 27 | license='LICENSE', 28 | description='Control Sphero devices.', 29 | long_description=open('README.md').read(), 30 | install_requires=install_requires, 31 | extras_require=extras_require, 32 | zip_safe = False 33 | ) -------------------------------------------------------------------------------- /spheropy.pyproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Debug 5 | 2.0 6 | {215b07e3-70ff-4dda-b96b-334b12af6167} 7 | 8 | tests\set_rgb_led_test.py 9 | spheropy 10 | . 11 | . 12 | {888888a0-9f3d-457c-b088-3a5042f75d52} 13 | Standard Python launcher 14 | CondaEnv|CondaEnv|spheropy-env 15 | False 16 | True 17 | --ble 18 | -i 19 | 20 | 21 | 22 | 23 | 24 | 25 | 10.0 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | Code 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | winble 64 | {c67c4629-4d48-44f8-a213-e4b49c107d3f} 65 | True 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /spheropy.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.28307.329 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{888888A0-9F3D-457C-B088-3A5042F75D52}") = "spheropy", "spheropy.pyproj", "{215B07E3-70FF-4DDA-B96B-334B12AF6167}" 7 | EndProject 8 | Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "winble", "winble\winble.vcxproj", "{C67C4629-4D48-44F8-A213-E4B49C107D3F}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Debug|x64 = Debug|x64 14 | Debug|x86 = Debug|x86 15 | Release|Any CPU = Release|Any CPU 16 | Release|x64 = Release|x64 17 | Release|x86 = Release|x86 18 | EndGlobalSection 19 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 20 | {215B07E3-70FF-4DDA-B96B-334B12AF6167}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {215B07E3-70FF-4DDA-B96B-334B12AF6167}.Debug|x64.ActiveCfg = Debug|Any CPU 22 | {215B07E3-70FF-4DDA-B96B-334B12AF6167}.Debug|x86.ActiveCfg = Debug|Any CPU 23 | {215B07E3-70FF-4DDA-B96B-334B12AF6167}.Release|Any CPU.ActiveCfg = Release|Any CPU 24 | {215B07E3-70FF-4DDA-B96B-334B12AF6167}.Release|x64.ActiveCfg = Release|Any CPU 25 | {215B07E3-70FF-4DDA-B96B-334B12AF6167}.Release|x86.ActiveCfg = Release|Any CPU 26 | {C67C4629-4D48-44F8-A213-E4B49C107D3F}.Debug|Any CPU.ActiveCfg = Debug|Win32 27 | {C67C4629-4D48-44F8-A213-E4B49C107D3F}.Debug|x64.ActiveCfg = Debug|x64 28 | {C67C4629-4D48-44F8-A213-E4B49C107D3F}.Debug|x64.Build.0 = Debug|x64 29 | {C67C4629-4D48-44F8-A213-E4B49C107D3F}.Debug|x86.ActiveCfg = Debug|Win32 30 | {C67C4629-4D48-44F8-A213-E4B49C107D3F}.Debug|x86.Build.0 = Debug|Win32 31 | {C67C4629-4D48-44F8-A213-E4B49C107D3F}.Release|Any CPU.ActiveCfg = Release|Win32 32 | {C67C4629-4D48-44F8-A213-E4B49C107D3F}.Release|x64.ActiveCfg = Release|x64 33 | {C67C4629-4D48-44F8-A213-E4B49C107D3F}.Release|x64.Build.0 = Release|x64 34 | {C67C4629-4D48-44F8-A213-E4B49C107D3F}.Release|x86.ActiveCfg = Release|Win32 35 | {C67C4629-4D48-44F8-A213-E4B49C107D3F}.Release|x86.Build.0 = Release|Win32 36 | EndGlobalSection 37 | GlobalSection(SolutionProperties) = preSolution 38 | HideSolutionNode = FALSE 39 | EndGlobalSection 40 | GlobalSection(ExtensibilityGlobals) = postSolution 41 | SolutionGuid = {448FAC0D-959E-4466-A99D-A1FC9EE2C926} 42 | EndGlobalSection 43 | EndGlobal 44 | -------------------------------------------------------------------------------- /spheropy/__init__.py: -------------------------------------------------------------------------------- 1 | from spheropy.spheropy import * 2 | -------------------------------------------------------------------------------- /spheropy/spheropy.py: -------------------------------------------------------------------------------- 1 | """spheropy 2 | 3 | Interact with Sphero devices. 4 | """ 5 | import os 6 | import sys 7 | import uuid 8 | import threading 9 | import struct 10 | import queue 11 | import enum 12 | from collections import namedtuple 13 | 14 | 15 | USE_PYBLUEZ = True 16 | try: 17 | import bluetooth # pybluez 18 | HAS_PYBLUEZ = True 19 | except Exception: 20 | HAS_PYBLUEZ = False 21 | 22 | USE_PYGATT = True 23 | try: 24 | import pygatt 25 | HAS_PYGATT = True 26 | except Exception: 27 | HAS_PYGATT = False 28 | 29 | USE_WINBLE = True 30 | try: 31 | import winble 32 | HAS_WINBLE = True 33 | except Exception: 34 | HAS_WINBLE = False 35 | 36 | # TODO: Need more parameter validation on functions and throughout. 37 | 38 | 39 | class RollMode(enum.Enum): 40 | NORMAL = enum.auto() 41 | IN_PLACE_ROTATE = enum.auto() 42 | FAST_ROTATE = enum.auto() 43 | 44 | # region Sphero 45 | 46 | 47 | class Sphero(object): 48 | """The main class that is used for interacting with a Sphero device.""" 49 | 50 | # region Sphero public members 51 | 52 | def __init__(self, default_response_timeout_in_seconds=0.5): 53 | self.on_collision = [] 54 | self.on_power_state_change = [] 55 | self.on_self_level_complete = [] 56 | 57 | self._bluetooth_interface = None 58 | self._default_response_timeout_in_seconds = default_response_timeout_in_seconds 59 | self._command_sequence_number = 0x00 60 | 61 | # Message processing members 62 | self._commands_waiting_for_response = {} 63 | # TODO: Consider passing in a max size for the queue. 64 | self._message_receive_queue = queue.Queue() 65 | self._message_processing_thread = None 66 | 67 | async def connect(self, 68 | search_name=None, 69 | address=None, 70 | port=None, 71 | bluetooth_interface=None, 72 | use_ble=False, 73 | num_retry_attempts=1): 74 | """Connects to the Sphero. 75 | 76 | Must be called before calling any other methods. 77 | 78 | Args: 79 | search_name (str): 80 | The partial name of the device to connect to. 81 | Must be specified if address is not. 82 | address (str): 83 | The bluetooth address of the device. 84 | Must be specified if search_name is not. 85 | port (str or int): 86 | Can have different meaning for different bluetooth interfaces. 87 | Can be the bluetooth port, or COM port. 88 | bluetooth_interface (BluetoothInterfaceBase): 89 | A custom bluetooth interface to use instead of the defaults. 90 | use_ble (bool): 91 | Indicates that BLE protocol should be used. 92 | num_retry_attempts (int): 93 | The number of times to try to connect. 94 | Defaults to 1. 95 | """ 96 | # Create the bluetooth interface 97 | global HAS_PYBLUEZ 98 | global USE_PYBLUEZ 99 | global HAS_PYGATT 100 | global USE_PYGATT 101 | global HAS_WINBLE 102 | global USE_WINBLE 103 | if bluetooth_interface is None: 104 | if use_ble: 105 | if (HAS_PYGATT and USE_PYGATT) or (HAS_WINBLE and USE_WINBLE): 106 | self._bluetooth_interface = BleInterface( 107 | search_name=search_name, address=address, port=port) 108 | else: 109 | raise RuntimeError( 110 | 'Could not import a bluetooth LE Library.') 111 | else: 112 | if HAS_PYBLUEZ and USE_PYBLUEZ: 113 | self._bluetooth_interface = BluetoothInterface( 114 | search_name=search_name, address=address, port=port) 115 | else: 116 | raise RuntimeError( 117 | 'Could not import a bluetooth (non-BLE) library.') 118 | else: 119 | self._bluetooth_interface = bluetooth_interface 120 | 121 | self._bluetooth_interface.data_received_handler = self._handle_data_received 122 | self._bluetooth_interface.connect( 123 | num_retry_attempts=num_retry_attempts) 124 | print('Connected to Sphero.') 125 | 126 | def disconnect(self): 127 | """Disconnect from the Sphero. 128 | """ 129 | if self._bluetooth_interface: 130 | self._bluetooth_interface.disconnect() 131 | 132 | async def ping(self, 133 | wait_for_response=True, 134 | reset_inactivity_timeout=True, 135 | response_timeout_in_seconds=None): 136 | """Sends a ping to the Sphero. 137 | 138 | The Ping command is used to verify both a solid data link with the client 139 | and that Sphero is awake and dispatching commands. 140 | 141 | Args: 142 | wait_for_response (bool, True): 143 | If True, will wait for a response from the Sphero 144 | reset_inactivity_timeout (bool, True): 145 | If True, will reset the inactivity timer on the Sphero. 146 | response_timeout_in_seconds (float, None): 147 | The amount of time to wait for a response. 148 | If not specified or None, uses the default timeout 149 | passed in the constructor of this Sphero. 150 | """ 151 | command = _create_ping_command(self._get_and_increment_command_sequence_number(), 152 | wait_for_response=wait_for_response, 153 | reset_inactivity_timeout=reset_inactivity_timeout) 154 | 155 | await self._send_command(command, 156 | response_timeout_in_seconds) 157 | 158 | async def get_version_info(self, 159 | reset_inactivity_timeout=True, 160 | response_timeout_in_seconds=None): 161 | """Get the version info for various software and hardware components of the Sphero. 162 | 163 | The get version info command returns a whole slew of software and hardware information. 164 | It’s useful if your Client Application requires a minimum version number 165 | of some resource within the Sphero. 166 | 167 | Args: 168 | reset_inactivity_timeout (bool, True): 169 | If True, will reset the inactivity timer on the Sphero. 170 | response_timeout_in_seconds (float, None): 171 | The amount of time to wait for a response. 172 | If not specified or None, uses the default timeout 173 | passed in the constructor of this Sphero. 174 | 175 | Returns: 176 | VersionInfo namedtuple. 177 | 178 | record_version (int): 179 | model_number (int): 180 | hardware_version (int): 181 | main_sphero_app_version (int): 182 | main_sphero_app_revision (int): 183 | bootloader_version (int): 184 | orb_basic_version (int): 185 | macro_executive_version (int): 186 | firmware_api_major_revision (int): 187 | firmware_api_minor_revision (int): 188 | """ 189 | command = _create_get_version_command(self._get_and_increment_command_sequence_number(), 190 | wait_for_response=True, 191 | reset_inactivity_timeout=reset_inactivity_timeout) 192 | 193 | response_packet = await self._send_command(command, 194 | response_timeout_in_seconds) 195 | 196 | return _parse_version_info(response_packet.data) 197 | 198 | async def set_device_name(self, 199 | device_name, 200 | wait_for_response=True, 201 | reset_inactivity_timeout=True, 202 | response_timeout_in_seconds=None): 203 | """Sets the Sphero's internal name. 204 | 205 | This formerly reprogrammed the Bluetooth module to advertise with a different name, 206 | but this is no longer the case. 207 | This assigned name is held internally and returned in get_bluetooth_info. 208 | Names are clipped at 48 characters in length to support UTF-8 sequences. 209 | You can send something longer but the extra will be discarded. 210 | This field defaults to the Bluetooth advertising name. 211 | 212 | Args: 213 | device_name (string): The desired name of the device/Sphero. 214 | wait_for_response (bool, True): 215 | If True, will wait for a response from the Sphero 216 | reset_inactivity_timeout (bool, True): 217 | If True, will reset the inactivity timer on the Sphero. 218 | response_timeout_in_seconds (float, None): 219 | The amount of time to wait for a response. 220 | If not specified or None, uses the default timeout 221 | passed in the constructor of this Sphero. 222 | """ 223 | command = _create_set_device_name_command(device_name=device_name, 224 | sequence_number=self._get_and_increment_command_sequence_number(), 225 | wait_for_response=wait_for_response, 226 | reset_inactivity_timeout=reset_inactivity_timeout) 227 | 228 | await self._send_command(command, 229 | response_timeout_in_seconds) 230 | 231 | async def get_bluetooth_info(self, 232 | reset_inactivity_timeout=True, 233 | response_timeout_in_seconds=None): 234 | """Gets bluetooth related info from the Sphero. 235 | 236 | Args: 237 | reset_inactivity_timeout (bool, True): 238 | If True, will reset the inactivity timer on the Sphero. 239 | response_timeout_in_seconds (float, None): 240 | The amount of time to wait for a response. 241 | If not specified or None, uses the default timeout 242 | passed in the constructor of this Sphero object. 243 | 244 | Returns: 245 | BluetoothInfo namedtuple. 246 | 247 | name (str): 248 | bluetooth_address (str): 249 | id_colors (str): 250 | """ 251 | command = _create_get_bluetooth_info_command(sequence_number=self._get_and_increment_command_sequence_number(), 252 | wait_for_response=True, 253 | reset_inactivity_timeout=reset_inactivity_timeout) 254 | 255 | response_packet = await self._send_command(command, 256 | response_timeout_in_seconds) 257 | 258 | return _parse_bluetooth_info(response_packet.data) 259 | 260 | async def set_auto_reconnect(self, 261 | should_enable_auto_reconnect, 262 | seconds_after_boot, 263 | wait_for_response=True, 264 | reset_inactivity_timeout=True, 265 | response_timeout_in_seconds=None): 266 | """Sets the auto reconnect policy of the Sphero. 267 | 268 | This configures the control of the Bluetooth module in its attempt 269 | to automatically reconnect with the last mobile Apple device. 270 | This is a courtesy behavior since the Apple Bluetooth stack doesn't 271 | initiate automatic reconnection on its own. 272 | 273 | Args: 274 | should_enable_auto_reconnect (bool): 275 | True to enable auto reconnect, False to disable it. 276 | seconds_after_boot (int): 277 | The number of seconds after power-up 278 | in which to enable auto reconnect mode. 279 | For example, if seconds_after_boot = 30, 280 | then the module will attempt to reconnect 281 | 30 seconds after waking up. 282 | wait_for_response (bool, True): 283 | If True, will wait for a response from the Sphero 284 | reset_inactivity_timeout (bool, True): 285 | If True, will reset the inactivity timer on the Sphero. 286 | response_timeout_in_seconds (float, None): 287 | The amount of time to wait for a response. 288 | If not specified or None, uses the default timeout 289 | passed in the constructor of this Sphero. 290 | """ 291 | command = _create_set_auto_reconnect_command(should_enable_auto_reconnect=should_enable_auto_reconnect, 292 | seconds_after_boot=seconds_after_boot, 293 | sequence_number=self._get_and_increment_command_sequence_number(), 294 | wait_for_response=wait_for_response, 295 | reset_inactivity_timeout=reset_inactivity_timeout) 296 | 297 | await self._send_command(command, 298 | response_timeout_in_seconds) 299 | 300 | async def get_auto_reconnect(self, 301 | reset_inactivity_timeout=True, 302 | response_timeout_in_seconds=None): 303 | """Gets the auto reconnect settings for the Sphero. 304 | 305 | Args: 306 | reset_inactivity_timeout (bool, True): 307 | If True, will reset the inactivity timer on the Sphero. 308 | response_timeout_in_seconds (float, None): 309 | The amount of time to wait for a response. 310 | If not specified or None, uses the default timeout 311 | passed in the constructor of this Sphero. 312 | 313 | Returns: 314 | AutoReconnectInfo namedtuple. 315 | 316 | record_version (int): 317 | battery_state (int): 318 | battery_voltage (int): 319 | total_number_of_recharges (int): 320 | seconds_awake_since_last_recharge (int): 321 | """ 322 | command = _create_get_auto_reconnect_command(sequence_number=self._get_and_increment_command_sequence_number(), 323 | wait_for_response=True, 324 | reset_inactivity_timeout=reset_inactivity_timeout) 325 | 326 | response_packet = await self._send_command(command, 327 | response_timeout_in_seconds=response_timeout_in_seconds) 328 | 329 | return _parse_auto_reconnect_info(response_packet.data) 330 | 331 | BATTERY_STATE_CHARGING = 0x01 332 | BATTERY_STATE_OK = 0x02 333 | BATTERY_STATE_LOW = 0x03 334 | BATTERY_STATE_CRITICAL = 0x04 335 | 336 | async def get_power_state(self, 337 | reset_inactivity_timeout=True, 338 | response_timeout_in_seconds=None): 339 | """Gets the current power state of the Sphero. 340 | 341 | Args: 342 | reset_inactivity_timeout (bool, True): 343 | If True, will reset the inactivity timer on the Sphero. 344 | response_timeout_in_seconds (float, None): 345 | The amount of time to wait for a response. 346 | If not specified or None, uses the default timeout 347 | passed in the constructor of this Sphero. 348 | 349 | Returns: 350 | PowerState namedtuple. 351 | 352 | record_version (int): 353 | The record version code. 354 | 355 | battery_state (int): 356 | The current charge state of the battery. 357 | 1 = Battery Charging = Sphero.BATTERY_STATE_CHARGING. 358 | 2 = Battery OK = Sphero.BATTERY_STATE_OK. 359 | 3 = Battery Low = Sphero.BATTERY_STATE_LOW. 360 | 4 = Battery Critical = Sphero.BATTERY_STATE_CRITICAL. 361 | 362 | battery_volage (int): 363 | Current battery voltage in 1/100 of a volt. 364 | Unsigned 16-bit value. 365 | 0x02EF would be 7.51 volts. 366 | 367 | total_number_of_recharges (int): 368 | Number of battery recharges in the lifetime 369 | of this Sphero. 370 | Unsigned 16-bit value. 371 | 372 | seconds_awake_since_last_recharge (int): 373 | Seconds awake since last recharge. 374 | Unsigned 16-bit value. 375 | """ 376 | command = _create_get_power_state_command(sequence_number=self._get_and_increment_command_sequence_number(), 377 | wait_for_response=True, 378 | reset_inactivity_timeout=reset_inactivity_timeout) 379 | 380 | response_packet = await self._send_command(command, 381 | response_timeout_in_seconds=response_timeout_in_seconds) 382 | 383 | return _parse_power_state(response_packet.data) 384 | 385 | # TODO: rename to something better if possible 386 | # maybe enable_power_notifications 387 | async def set_power_notification(self, 388 | should_enable, 389 | wait_for_response=True, 390 | reset_inactivity_timeout=True, 391 | response_timeout_in_seconds=None): 392 | """Enables/Disables receiving power notification from the Sphero. 393 | 394 | Enables/Disables the Sphero to asynchronously notify the client 395 | periodically with the power state or 396 | immediately when the power manager detects a state change. 397 | Timed notifications arrive every 10 seconds until they are explicitly disabled 398 | or Sphero is unpaired. 399 | This setting is volatile and therefore not retained across sleep cycles. 400 | 401 | Args: 402 | should_enable (bool): 403 | If True, power notifications will be enabled. 404 | If False, power notifications will be disabled. 405 | wait_for_response (bool, True): 406 | If True, will wait for a response from the Sphero 407 | reset_inactivity_timeout (bool, True): 408 | If True, will reset the inactivity timer on the Sphero. 409 | response_timeout_in_seconds (float, None): 410 | The amount of time to wait for a response. 411 | If not specified or None, uses the default timeout 412 | passed in the constructor of this Sphero. 413 | """ 414 | command = _create_set_power_notification_command(should_enable, 415 | sequence_number=self._get_and_increment_command_sequence_number(), 416 | wait_for_response=wait_for_response, 417 | reset_inactivity_timeout=reset_inactivity_timeout) 418 | 419 | await self._send_command(command, 420 | response_timeout_in_seconds=response_timeout_in_seconds) 421 | 422 | async def set_heading(self, 423 | heading, 424 | wait_for_response=True, 425 | reset_inactivity_timeout=True, 426 | response_timeout_in_seconds=None): 427 | """Sets the heading of the Sphero. 428 | 429 | This allows the client to adjust the orientation of the Sphero 430 | by commanding a new reference heading in degrees, 431 | which ranges from 0 to 359. 432 | You will see the ball respond immediately to this command 433 | if stabilization is enabled. 434 | 435 | In firmware version 3.10 and later this also clears 436 | the maximum value counters for the rate gyro, 437 | effectively re-enabling the generation of an async message 438 | alerting the client to this event. 439 | 440 | Args: 441 | heading (int): 442 | The desired heading in degrees. 443 | In range [0, 359]. 444 | wait_for_response (bool, True): 445 | If True, will wait for a response from the Sphero 446 | reset_inactivity_timeout (bool, True): 447 | If True, will reset the inactivity timer on the Sphero. 448 | response_timeout_in_seconds (float, None): 449 | The amount of time to wait for a response. 450 | If not specified or None, uses the default timeout 451 | passed in the constructor of this Sphero. 452 | """ 453 | command = _create_set_heading_command(heading=heading, 454 | sequence_number=self._get_and_increment_command_sequence_number(), 455 | wait_for_response=wait_for_response, 456 | reset_inactivity_timeout=reset_inactivity_timeout) 457 | 458 | await self._send_command(command, 459 | response_timeout_in_seconds=response_timeout_in_seconds) 460 | 461 | async def set_stabilization(self, 462 | stabilization, 463 | wait_for_response=True, 464 | reset_inactivity_timeout=True, 465 | response_timeout_in_seconds=None): 466 | """Turns the stabilization of the Sphero on or off. 467 | 468 | This turns on or off the internal stabilization of the Sphero, 469 | in which the IMU is used to match the ball's orientation to 470 | its various set points. 471 | Stabilization is enabled by default when the Sphero powers up. 472 | You will want to disable stabilization when using Sphero 473 | as an external input controller or even to save battery power 474 | during testing that doesn't involve movement (orbBasic, etc.). 475 | This can also be useful for aiming the Sphero. 476 | 477 | Args: 478 | stabilization (bool): 479 | True to turn on. 480 | False to turn off. 481 | wait_for_response (bool, True): 482 | If True, will wait for a response from the Sphero 483 | reset_inactivity_timeout (bool, True): 484 | If True, will reset the inactivity timer on the Sphero. 485 | response_timeout_in_seconds (float, None): 486 | The amount of time to wait for a response. 487 | If not specified or None, uses the default timeout 488 | passed in the constructor of this Sphero. 489 | """ 490 | command = _create_set_stabilization_command(stabilization=stabilization, 491 | sequence_number=self._get_and_increment_command_sequence_number(), 492 | wait_for_response=wait_for_response, 493 | reset_inactivity_timeout=reset_inactivity_timeout) 494 | 495 | await self._send_command(command, 496 | response_timeout_in_seconds=response_timeout_in_seconds) 497 | 498 | async def self_level(self, 499 | start=True, 500 | use_original_heading=True, 501 | sleep=False, 502 | turn_on_control_system=True, 503 | angle_limit=0, 504 | timeout=0, 505 | true_time=0, 506 | wait_for_response=True, 507 | reset_inactivity_timeout=True, 508 | response_timeout_in_seconds=None): 509 | """Attempts to level the Sphero within angle limits. 510 | 511 | Attempts to achieve a horizontal orientation where pitch 512 | and roll angles are less than the provided angle limit. 513 | After both angle limits are satisfied, sleep, heading, 514 | and control system settings are applied. 515 | 516 | Notifies on_self_level_complete callbacks when done 517 | with a SelfLevelResult. 518 | 519 | Args: 520 | start (bool, True): 521 | If True, starts the self level routine. 522 | If False, cancels the self level routine. 523 | use_original_heading (bool, True): 524 | If True, it will adjust the sphero to 525 | the heading it started the routine with. 526 | If False, it will not adjust the heading. 527 | sleep (bool, False): 528 | If True, will put the Sphero to sleep. 529 | turn_on_control_system (bool, True): 530 | If True, it will turn on the control system when done. 531 | If False, it will leave the control system off. 532 | angle_limit (int, 0): 533 | The max angle error tolerance in degrees. 534 | Valid range is [0, 90]. 535 | 0 will use the system default value. 536 | timeout (int, 0): 537 | The max seconds to run the routine. 538 | Valid range is [0, 255]. 539 | 0 will use the system default value. 540 | true_time (int, 0): 541 | The time in centiseconds (10 milliseconds) 542 | that the sphero must maintain its angle accuracy 543 | after routine is complete. 544 | If the angle limit is violated in this time, 545 | the routine will start again. 546 | Valid range is [0, 255]. 547 | 0 will use the system default. 548 | wait_for_response (bool, True): 549 | If True, will wait for a response from the Sphero 550 | reset_inactivity_timeout (bool, True): 551 | If True, will reset the inactivity timer on the Sphero. 552 | response_timeout_in_seconds (float, None): 553 | The amount of time to wait for a response. 554 | If not specified or None, uses the default timeout 555 | passed in the constructor of this Sphero. 556 | """ 557 | command = _create_self_level_command(start, 558 | use_original_heading, 559 | sleep, 560 | turn_on_control_system, 561 | angle_limit, 562 | timeout, 563 | true_time, 564 | self._get_and_increment_command_sequence_number(), 565 | wait_for_response, reset_inactivity_timeout) 566 | 567 | await self._send_command(command, response_timeout_in_seconds) 568 | 569 | async def configure_collision_detection(self, 570 | turn_on_collision_detection, 571 | x_t, x_speed, 572 | y_t, y_speed, 573 | collision_dead_time, 574 | wait_for_response=True, 575 | reset_inactivity_timeout=True, 576 | response_timeout_in_seconds=None): 577 | """Configure the Sphero's collision detection. 578 | 579 | Sphero contains a powerful analysis function to filter 580 | accelerometer data in order to detect collisions. 581 | Because this is a great example of a high-level concept 582 | that humans excel and – but robots do not – a number of 583 | parameters control the behavior. 584 | When a collision is detected an asynchronous message 585 | is generated to the client. 586 | 587 | Args: 588 | turn_on_collision_detection (bool): 589 | x_t: 590 | x_speed: 591 | y_t: 592 | y_speed: 593 | collision_dead_time: 594 | wait_for_response (bool, True): 595 | If True, will wait for a response from the Sphero 596 | reset_inactivity_timeout (bool, True): 597 | If True, will reset the inactivity timer on the Sphero. 598 | response_timeout_in_seconds (float, None): 599 | The amount of time to wait for a response. 600 | If not specified or None, uses the default timeout 601 | passed in the constructor of this Sphero. 602 | """ 603 | command = _create_configure_collision_detection_command(turn_on_collision_detection=turn_on_collision_detection, 604 | x_t=x_t, x_speed=x_speed, 605 | y_t=y_t, y_speed=y_speed, 606 | collision_dead_time=collision_dead_time, 607 | sequence_number=self._get_and_increment_command_sequence_number(), 608 | wait_for_response=wait_for_response, 609 | reset_inactivity_timeout=reset_inactivity_timeout) 610 | 611 | await self._send_command(command, 612 | response_timeout_in_seconds) 613 | 614 | async def configure_locator(self, 615 | enable_auto_yaw_tare_correction=True, 616 | pos_x=0, 617 | pos_y=0, 618 | yaw_tare=0, 619 | wait_for_response=True, 620 | reset_inactivity_timeout=True, 621 | response_timeout_in_seconds=None): 622 | """ 623 | """ 624 | command = _create_configure_locator_command(enable_auto_yaw_tare_correction=enable_auto_yaw_tare_correction, 625 | pos_x=pos_x, pos_y=pos_y, 626 | yaw_tare=yaw_tare, 627 | sequence_number=self._get_and_increment_command_sequence_number(), 628 | wait_for_response=wait_for_response, 629 | reset_inactivity_timeout=reset_inactivity_timeout) 630 | 631 | await self._send_command(command, 632 | response_timeout_in_seconds) 633 | 634 | async def get_locator_info(self, 635 | reset_inactivity_timeout=True, 636 | response_timeout_in_seconds=None): 637 | """Gets the Sphero's locator info. 638 | 639 | Sphero locator info includes: 640 | current position (X,Y), 641 | component velocities 642 | and speed over ground 643 | 644 | The position is a signed value in centimeters. 645 | The component velocities are signed cm/sec. 646 | The SOG is unsigned cm/sec. 647 | 648 | Args: 649 | reset_inactivity_timeout (bool, True): 650 | If True, will reset the inactivity timer on the Sphero. 651 | response_timeout_in_seconds (float, None): 652 | The amount of time to wait for a response. 653 | If not specified or None, uses the default timeout 654 | passed in the constructor of this Sphero. 655 | 656 | Returns: 657 | A LocatorInfo namedtuple. 658 | pos_x (int): 659 | X position in centimeters. 660 | pos_y (int): 661 | Y position in centimeters. 662 | vel_x (int): 663 | X component velocity in cm/sec. 664 | vel_y (int): 665 | Y component velocity in cm/sec. 666 | speed_over_ground (int): 667 | The speed over ground in unsigned cm/sec. 668 | """ 669 | command = _create_read_locator_command(sequence_number=self._get_and_increment_command_sequence_number(), 670 | wait_for_response=True, 671 | reset_inactivity_timeout=reset_inactivity_timeout) 672 | response_packet = await self._send_command(command, response_timeout_in_seconds) 673 | return _parse_locator_info(response_packet.data) 674 | 675 | async def set_rgb_led(self, 676 | red=0, 677 | green=0, 678 | blue=0, 679 | save_as_user_led_color=False, 680 | wait_for_response=True, 681 | reset_inactivity_timeout=True, 682 | response_timeout_in_seconds=None): 683 | """Sets the main LED RGB color of the Sphero. 684 | 685 | The composite value is stored as the "application LED color" 686 | and immediately driven to the LED 687 | (if not overridden by a macro or orbBasic operation). 688 | If save_as_user_led_color is True, the value is also saved 689 | as the "user LED color" which persists across power cycles 690 | and is rendered in the gap between an application connecting 691 | and sending this command. 692 | 693 | Args: 694 | red (int): 695 | The red channel value. 696 | Valid range is [0, 255]. 697 | green (int): 698 | The green channel value. 699 | Valid range is [0, 255]. 700 | blue (int): 701 | The blue channel value. 702 | Valid range is [0, 255]. 703 | save_as_user_led_color (bool, False): 704 | If True, the color will be saved as the user color 705 | and will persist across power cycles. 706 | wait_for_response (bool, True): 707 | If True, will wait for a response from the Sphero 708 | reset_inactivity_timeout (bool, True): 709 | If True, will reset the inactivity timer on the Sphero. 710 | response_timeout_in_seconds (float, None): 711 | The amount of time to wait for a response. 712 | If not specified or None, uses the default timeout 713 | passed in the constructor of this Sphero object. 714 | """ 715 | command = _create_set_rgb_led_command(red, 716 | green, 717 | blue, 718 | save_as_user_led_color, 719 | sequence_number=self._get_and_increment_command_sequence_number(), 720 | wait_for_response=wait_for_response, 721 | reset_inactivity_timeout=reset_inactivity_timeout) 722 | 723 | await self._send_command(command, 724 | response_timeout_in_seconds=response_timeout_in_seconds) 725 | 726 | async def get_rgb_led(self, 727 | reset_inactivity_timeout=True, 728 | response_timeout_in_seconds=None): 729 | """Retrieves the user LED color in RGB. 730 | 731 | The user LED color is the color that persisists across reboots. 732 | This may or may not be the active RGB LED color. 733 | The user LED color is set by calling set_rgb_led 734 | with save_as_user_led_color=True. 735 | 736 | Args: 737 | reset_inactivity_timeout (bool, True): 738 | If True, will reset the inactivity timer on the Sphero. 739 | response_timeout_in_seconds (float, None): 740 | The amount of time to wait for a response. 741 | If not specified or None, uses the default timeout 742 | passed in the constructor of this Sphero object. 743 | 744 | Returns: 745 | The user LED color as a list in the form 746 | [red, green, blue]. 747 | """ 748 | command = _create_get_rgb_led_command(sequence_number=self._get_and_increment_command_sequence_number(), 749 | # must wait for the response to get the result. 750 | wait_for_response=True, 751 | reset_inactivity_timeout=reset_inactivity_timeout) 752 | 753 | response_packet = await self._send_command(command, 754 | response_timeout_in_seconds) 755 | 756 | return response_packet.data 757 | 758 | async def set_back_led(self, 759 | brightness, 760 | wait_for_response=True, 761 | reset_inactivity_timeout=True, 762 | response_timeout_in_seconds=None): 763 | """Sets the brightness for the back LED. 764 | 765 | This allows you to control the brightness of the back LED. 766 | The value does not persist across power cycles. 767 | 768 | Args: 769 | brightness (int): 770 | The brightness of the back LED. 771 | wait_for_response (bool, True): 772 | If True, will wait for a response from the Sphero 773 | reset_inactivity_timeout (bool, True): 774 | If True, will reset the inactivity timer on the Sphero. 775 | response_timeout_in_seconds (float, None): 776 | The amount of time to wait for a response. 777 | If not specified or None, uses the default timeout 778 | passed in the constructor of this Sphero object. 779 | """ 780 | command = _create_set_back_led_output_command(brightness=brightness, 781 | sequence_number=self._get_and_increment_command_sequence_number(), 782 | wait_for_response=wait_for_response, 783 | reset_inactivity_timeout=reset_inactivity_timeout) 784 | 785 | await self._send_command(command, response_timeout_in_seconds) 786 | 787 | async def roll(self, 788 | speed, 789 | heading_in_degrees, 790 | mode=RollMode.NORMAL, 791 | wait_for_response=True, 792 | reset_inactivity_timeout=True, 793 | response_timeout_in_seconds=None): 794 | """Sends the roll command to the Sphero with given heading and speed. 795 | 796 | This commands Sphero to roll along the provided vector 797 | determined by heading_in_degrees and speed. 798 | The heading is relative to the last calibrated direction. 799 | 800 | The heading follows the 360 degrees on a circle, relative to the Sphero: 801 | * 0 is straight ahead. 802 | * 90 is to the right. 803 | * 180 is back. 804 | * 270 is to the left. 805 | 806 | Args: 807 | speed (int): 808 | The relative speed with which to roll the Sphero. 809 | The valid range is [0, 255]. 810 | heading_in_degrees (int): 811 | The relative heading in degrees. 812 | The valid range is [0, 359] 813 | mode (spheropy.RollMode): 814 | Indicates the mode to use when rolling. 815 | wait_for_response (bool, True): 816 | If True, will wait for a response from the Sphero 817 | reset_inactivity_timeout (bool, True): 818 | If True, will reset the inactivity timer on the Sphero. 819 | response_timeout_in_seconds (float, None): 820 | The amount of time to wait for a response. 821 | If not specified or None, uses the default timeout 822 | passed in the constructor of this Sphero object. 823 | """ 824 | command = _create_roll_command(speed, 825 | heading_in_degrees, 826 | mode, 827 | sequence_number=self._get_and_increment_command_sequence_number(), 828 | wait_for_response=wait_for_response, 829 | reset_inactivity_timeout=reset_inactivity_timeout) 830 | 831 | await self._send_command(command, response_timeout_in_seconds) 832 | 833 | # endregion Sphero public members 834 | 835 | # region Sphero private members 836 | 837 | async def _send_command(self, 838 | command, 839 | response_timeout_in_seconds): 840 | """ 841 | """ 842 | response_event = threading.Event() 843 | response_packet = None 844 | if command.wait_for_response: 845 | # define a generic response handler 846 | # TODO: might need the ability to pass a custom handler 847 | def handle_response(received_response_packet): 848 | nonlocal response_packet 849 | response_packet = received_response_packet 850 | nonlocal response_event 851 | response_event.set() 852 | 853 | # Register the response handler for this commands sequence number 854 | assert command.sequence_number not in self._commands_waiting_for_response, f'A response handler was already registered for the sequence number {command.sequence_number}' 855 | self._commands_waiting_for_response[command.sequence_number] = handle_response 856 | 857 | self._bluetooth_interface.send(command.bytes) 858 | 859 | # Wait for the response if necessary 860 | if command.wait_for_response: 861 | if response_timeout_in_seconds is None: 862 | response_timeout_in_seconds = self._default_response_timeout_in_seconds 863 | 864 | timed_out = not response_event.wait(response_timeout_in_seconds) 865 | del self._commands_waiting_for_response[command.sequence_number] 866 | response_event.clear() 867 | if timed_out: 868 | raise CommandTimedOutError() 869 | 870 | return response_packet 871 | 872 | def _handle_data_received(self, received_data): 873 | self._message_receive_queue.put(received_data) 874 | if self._message_processing_thread is None or not self._message_processing_thread.is_alive(): 875 | self._message_processing_thread = threading.Thread(target=_process_messages, 876 | args=[self._message_receive_queue, 877 | self._commands_waiting_for_response, 878 | self.on_collision, 879 | self.on_power_state_change, 880 | self.on_self_level_complete]) 881 | self._message_processing_thread.start() 882 | 883 | def _get_and_increment_command_sequence_number(self): 884 | result = self._command_sequence_number 885 | self._command_sequence_number += 1 886 | 887 | # Check if we have overflowed our sequence number byte. 888 | # If we have, start the sequence back at 0. 889 | if self._command_sequence_number > 0xFF: 890 | self._command_sequence_number = 0x00 891 | 892 | return result 893 | 894 | 895 | # endregion Sphero private members 896 | 897 | # region Message processing 898 | 899 | 900 | def _process_messages(message_queue, 901 | commands_waiting_for_response, 902 | on_collision_callbacks, 903 | on_power_state_change_callbacks, 904 | on_self_level_complete_callbacks): 905 | """Processes received messages.""" 906 | message = [] 907 | # Keep going as long as there is a message in the queue, 908 | # or if we are still processing or looking for more data 909 | # in message. 910 | while (not message_queue.empty()) or message: 911 | response_packet = None 912 | message_part = message_queue.get() 913 | if message_part is None: 914 | return 915 | 916 | message.extend(message_part) 917 | response_packet = _parse_message(message) 918 | if response_packet is not None: 919 | if response_packet.is_async: 920 | _handle_async_response(response_packet, 921 | on_collision_callbacks, 922 | on_power_state_change_callbacks, 923 | on_self_level_complete_callbacks) 924 | else: 925 | _handle_sync_response(response_packet, 926 | commands_waiting_for_response) 927 | 928 | # Remove the packet we just handled 929 | del message[:response_packet.packet_length] 930 | 931 | message_queue.task_done() 932 | 933 | 934 | def _parse_message(message): 935 | while len(message) >= _MIN_PACKET_LENGTH: 936 | response_packet = _ResponsePacket(message) 937 | if response_packet.status == _ResponsePacketStatus.VALID: 938 | # we have a valid response to handle 939 | # break out of the inner while loop to handle 940 | # the response. 941 | return response_packet 942 | elif response_packet.status == _ResponsePacketStatus.NOT_ENOUGH_BUFFER: 943 | # Return and wait to get more data. 944 | return None 945 | else: 946 | # There is an error in the packet format. 947 | # Remove all the bytes until the next SOP1 byte. 948 | del message[: message.index(_ResponsePacket._START_OF_PACKET_1)] 949 | continue 950 | 951 | return None 952 | 953 | 954 | def _handle_async_response(response_packet, 955 | on_collision_callbacks, 956 | on_power_state_change_callbacks, 957 | on_self_level_complete_callbacks): 958 | """ 959 | """ 960 | if response_packet.id_code is _ID_CODE_COLLISION_DETECTED: 961 | collision_info = _parse_collision_info(response_packet.data) 962 | for func in on_collision_callbacks: 963 | _call_callback(func, [collision_info]) 964 | elif response_packet.id_code is _ID_CODE_POWER_NOTIFICATION: 965 | power_state = response_packet.data[0] 966 | for func in on_power_state_change_callbacks: 967 | _call_callback(func, [power_state]) 968 | elif response_packet.id_code is _ID_CODE_SELF_LEVEL_COMPLETE: 969 | result = _parse_self_level_result(response_packet.data) 970 | for func in on_self_level_complete_callbacks: 971 | _call_callback(func, [result]) 972 | 973 | 974 | def _call_callback(callback, args): 975 | # Schedule the callback on its own thread. 976 | # TODO: there is probably a more asyncio way of doing this, but do 977 | # we care? 978 | # Maybe we can run the function on the main thread's event loop? 979 | # TODO: Refactor kicking off callback in seperate thread to a function 980 | callback_thread = threading.Thread(target=callback, args=args) 981 | callback_thread.daemon = True 982 | callback_thread.start() 983 | 984 | 985 | def _handle_sync_response(response_packet, 986 | commands_waiting_for_response): 987 | """ 988 | """ 989 | # for ACK/synchronous responses we only need to call the registered 990 | # callback. 991 | sequence_number = response_packet.sequence_number 992 | if sequence_number in commands_waiting_for_response: 993 | # TODO: check to make sure handler is callable before invoking. 994 | commands_waiting_for_response[sequence_number](response_packet) 995 | # NOTE: it is up to the callback/waiting function to remove the 996 | # handler. 997 | 998 | # endregion 999 | # endregion Sphero 1000 | 1001 | # region Public Exceptions 1002 | 1003 | 1004 | class SpheroError(Exception): 1005 | """ 1006 | """ 1007 | pass 1008 | 1009 | 1010 | class CommandTimedOutError(SpheroError): 1011 | """Exception thrown when a command times out.""" 1012 | 1013 | def __init__(self, message="Command timeout reached."): 1014 | super().__init__(message) 1015 | 1016 | # endregion 1017 | 1018 | # region Bluetooth Interfaces 1019 | 1020 | 1021 | class BluetoothInterfaceBase(object): 1022 | """Base class for Bluetooth Interfaces 1023 | 1024 | Args: 1025 | search_name (str): 1026 | The name to use when searching for Sphero device. 1027 | Finds any device that starts with search_name. 1028 | Defaults to DEFAULT_SEARCH_NAME. 1029 | Only used if address is not specified. 1030 | address (str): 1031 | The bluetooth address of the device. 1032 | If not specified, search_name should be specified. 1033 | port (str or int): 1034 | Can have different meaning for subclasses. 1035 | Can be the bluetooth port, or COM port. 1036 | Defaults to DEFAULT_PORT 1037 | """ 1038 | DEFAULT_SEARCH_NAME = None 1039 | DEFAULT_PORT = None 1040 | 1041 | def __init__(self, search_name=None, address=None, port=None): 1042 | super().__init__() 1043 | self.data_received_handler = None 1044 | self._search_name = self.DEFAULT_SEARCH_NAME if search_name is None else search_name 1045 | self._port = self.DEFAULT_PORT if port is None else port 1046 | self._address = address 1047 | 1048 | def connect(self, num_retry_attempts=1): 1049 | """Connects to the sphero device. 1050 | 1051 | Args: 1052 | num_retry_attempts (int): 1053 | The number of times to try to connect. 1054 | Defaults to 1. 1055 | """ 1056 | pass 1057 | 1058 | def send(self, data): 1059 | """Sends raw data to the device. 1060 | 1061 | Args: 1062 | data (list): 1063 | The raw data to send as list of bytes. 1064 | """ 1065 | pass 1066 | 1067 | def disconnect(self): 1068 | """Disconnects from the device.""" 1069 | pass 1070 | 1071 | 1072 | class BluetoothInterface(BluetoothInterfaceBase): 1073 | """Legacy Bluetooth Interface""" 1074 | 1075 | DEFAULT_SEARCH_NAME = 'Sphero' 1076 | DEFAULT_PORT = 1 1077 | 1078 | def __init__(self, search_name=None, address=None, port=None): 1079 | super().__init__(search_name, address, port) 1080 | self._sock = None 1081 | 1082 | # setup thread for receiving responses 1083 | self._class_destroy_event = threading.Event() 1084 | self._receive_thread = threading.Thread( 1085 | target=self._receive_thread_run) 1086 | self._receive_thread.daemon = True 1087 | self._receive_thread.start() 1088 | 1089 | def connect(self, num_retry_attempts=1): 1090 | super().connect(num_retry_attempts) 1091 | is_connected = False 1092 | for _ in range(num_retry_attempts): 1093 | if self._address is None: 1094 | self._address = self._find_device(self._search_name) 1095 | 1096 | if self._address is not None: 1097 | self._sock = bluetooth.BluetoothSocket(bluetooth.RFCOMM) 1098 | self._sock.connect((self._address, self._port)) 1099 | is_connected = True 1100 | break 1101 | 1102 | if self._address is None: 1103 | raise RuntimeError( 1104 | f'Could not find device with name {self._search_name} after {num_retry_attempts} tries.') 1105 | elif not is_connected: 1106 | raise RuntimeError( 1107 | f'Count not connect to device {self._address} after {num_retry_attempts} tries.') 1108 | 1109 | def send(self, data): 1110 | if self._sock is not None: 1111 | self._sock.send(data) 1112 | 1113 | def disconnect(self): 1114 | if self._sock is not None: 1115 | self._sock.close() 1116 | 1117 | def _receive_thread_run(self): 1118 | """Checks for received data and calls handler. 1119 | 1120 | Used to create background thread to listen 1121 | for data received from device. 1122 | """ 1123 | while not self._class_destroy_event.is_set(): 1124 | if self._sock is not None: 1125 | data = self._sock.recv(1024) 1126 | if data is not None and len(data) > 0: 1127 | if self.data_received_handler is not None: 1128 | if callable(self.data_received_handler): 1129 | self.data_received_handler(data) 1130 | else: 1131 | raise ValueError( 1132 | 'data_received_handler is not callable.') 1133 | 1134 | @staticmethod 1135 | def _find_device(search_name): 1136 | found_device_address = None 1137 | nearby_devices = bluetooth.discover_devices(lookup_names=True) 1138 | if nearby_devices: 1139 | for address, name in nearby_devices: 1140 | if name.startswith(search_name): 1141 | found_device_address = address 1142 | print( 1143 | f'Found device named: {name} at {found_device_address}') 1144 | break 1145 | 1146 | return found_device_address 1147 | 1148 | 1149 | class BleInterface(BluetoothInterfaceBase): 1150 | """Bluetooth Low Energy (BLE) Interface""" 1151 | 1152 | _BLE_SERVICE = uuid.UUID("22bb746f-2bb0-7554-2d6f-726568705327") 1153 | _BLE_SERVICE_WAKE = uuid.UUID("22bb746f-2bbf-7554-2d6f-726568705327") 1154 | _BLE_SERVICE_TX_POWER = uuid.UUID("22bb746f-2bb2-7554-2d6f-726568705327") 1155 | _BLE_SERVICE_ANTI_DOS = uuid.UUID("22bb746f-2bbd-7554-2d6f-726568705327") 1156 | _ROBOT_SERVICE = uuid.UUID('22BB746F-2BA0-7554-2D6F-726568705327') 1157 | _ROBOT_SERVICE_CONTROL = uuid.UUID('22BB746F-2BA1-7554-2D6F-726568705327') 1158 | _ROBOT_SERVICE_RESPONSE = uuid.UUID('22BB746F-2BA6-7554-2D6F-726568705327') 1159 | 1160 | _ANTI_DOS_MESSAGE = '011i3' 1161 | _TX_POWER_VALUE = 7 1162 | 1163 | DEFAULT_SEARCH_NAME = 'SK' 1164 | DEFAULT_PORT = None 1165 | 1166 | BleAdapterType = enum.Enum('BleAdapterType', 'PYGATT WINBLE') 1167 | 1168 | def __init__(self, search_name=None, address=None, port=None): 1169 | super().__init__(search_name, address, port) 1170 | self._adapter = None 1171 | self._adapter_type = None 1172 | self._device = None 1173 | 1174 | def connect(self, num_retry_attempts=1): 1175 | super().connect(num_retry_attempts) 1176 | for _ in range(num_retry_attempts): 1177 | if self._address is None: 1178 | if not self._find_adapter(): 1179 | continue 1180 | 1181 | if not self._find_device(): 1182 | continue 1183 | 1184 | if self._address is not None: 1185 | self._connect() 1186 | 1187 | self._turn_on_dev_mode() 1188 | self._subscribe() 1189 | 1190 | is_connected = True 1191 | break 1192 | 1193 | if self._address is None: 1194 | raise RuntimeError( 1195 | f'Could not find device with name {self._search_name} after {num_retry_attempts} tries.') 1196 | elif not is_connected: 1197 | raise RuntimeError( 1198 | f'Count not connect to device {self._address} after {num_retry_attempts} tries.') 1199 | 1200 | def _connect(self): 1201 | if self._adapter_type is BleInterface.BleAdapterType.PYGATT: 1202 | self._device = self._adapter.connect(address=self._address, 1203 | address_type=pygatt.BLEAddressType.random) 1204 | elif self._adapter_type is BleInterface.BleAdapterType.WINBLE: 1205 | self._device = self._adapter.connect(self._address) 1206 | 1207 | def _subscribe(self): 1208 | if self._adapter_type == BleInterface.BleAdapterType.PYGATT: 1209 | self._device.subscribe( 1210 | self._ROBOT_SERVICE_RESPONSE, self._pygatt_response_callback) 1211 | elif self._adapter_type is BleInterface.BleAdapterType.WINBLE: 1212 | self._device.subscribe( 1213 | self._ROBOT_SERVICE_RESPONSE.bytes, self._winble_response_callback) 1214 | 1215 | def send(self, data): 1216 | super().send(data) 1217 | if self._device is not None: 1218 | # TODO: need to understand how ble ack works 1219 | # so we know if we should set the wait_for_response param. 1220 | self._char_write(self._ROBOT_SERVICE_CONTROL, data) 1221 | 1222 | def disconnect(self): 1223 | super().disconnect() 1224 | if self._device is not None: 1225 | self._device.disconnect() 1226 | 1227 | def _pygatt_response_callback(self, characteristic_handle, value): 1228 | """Callback for when data is received from device. 1229 | 1230 | Calls registered data received handler. 1231 | Specific to PyGatt. 1232 | """ 1233 | if self.data_received_handler is not None: 1234 | if callable(self.data_received_handler): 1235 | self.data_received_handler(value) 1236 | else: 1237 | raise ValueError('data_received_handler is not callable.') 1238 | 1239 | def _winble_response_callback(self, value): 1240 | """Callback for when data is received from device. 1241 | 1242 | Calls registered data received handler. 1243 | Specific to WinBle. 1244 | """ 1245 | if self.data_received_handler is not None: 1246 | if callable(self.data_received_handler): 1247 | self.data_received_handler(value) 1248 | else: 1249 | raise ValueError('data_received_handler is not callable.') 1250 | 1251 | def _turn_on_dev_mode(self): 1252 | """Turns on 'dev mode' for the Sphero. 1253 | 1254 | This is necessary to start sending the raw commands to the Sphero 1255 | and to receive data from the Sphero. 1256 | """ 1257 | if self._device is not None: 1258 | self._char_write(self._BLE_SERVICE_ANTI_DOS, 1259 | [ord(c) for c in self._ANTI_DOS_MESSAGE]) 1260 | self._char_write(self._BLE_SERVICE_TX_POWER, 1261 | [self._TX_POWER_VALUE]) 1262 | # Sending 0x01 to the wake service wakes the sphero. 1263 | self._char_write(self._BLE_SERVICE_WAKE, [0x01]) 1264 | 1265 | def _char_write(self, charId, data): 1266 | if self._adapter_type == BleInterface.BleAdapterType.PYGATT: 1267 | self._device.char_write(charId, bytes(data)) 1268 | elif self._adapter_type == BleInterface.BleAdapterType.WINBLE: 1269 | self._device.char_write(charId.bytes, bytes(data)) 1270 | 1271 | def _find_adapter(self): 1272 | """ 1273 | """ 1274 | adapter = None 1275 | adapter_type = None 1276 | found_adapter = False 1277 | 1278 | # Try pygatt BGAPI for all platforms first. 1279 | global HAS_PYGATT 1280 | global USE_PYGATT 1281 | if HAS_PYGATT and USE_PYGATT: 1282 | try: 1283 | adapter = pygatt.BGAPIBackend(serial_port=self._port) 1284 | adapter.start() 1285 | adapter_type = BleInterface.BleAdapterType.PYGATT 1286 | found_adapter = True 1287 | except pygatt.exceptions.NotConnectedError: 1288 | pass 1289 | 1290 | # If we couldn't find the adapter, 1291 | # Try a platform specific adapter. 1292 | if not found_adapter: 1293 | global HAS_WINBLE 1294 | global USE_WINBLE 1295 | if _is_windows() and HAS_WINBLE and USE_WINBLE: 1296 | try: 1297 | adapter = winble.WinBleAdapter() 1298 | adapter.start() 1299 | adapter_type = BleInterface.BleAdapterType.WINBLE 1300 | found_adapter = True 1301 | except Exception: 1302 | pass 1303 | elif _is_linux() and HAS_PYGATT and USE_PYGATT: 1304 | try: 1305 | adapter = pygatt.backends.GATTToolBackend() 1306 | adapter.start() 1307 | adapter_type = BleInterface.BleAdapterType.PYGATT 1308 | found_adapter = True 1309 | except pygatt.exceptions.NotConnectedError: 1310 | pass 1311 | 1312 | if found_adapter: 1313 | self._adapter = adapter 1314 | self._adapter_type = adapter_type 1315 | 1316 | return found_adapter 1317 | 1318 | def _find_device(self): 1319 | """Looks for a matching nearby device.""" 1320 | found_device = False 1321 | nearby_devices = None 1322 | try: 1323 | nearby_devices = self._adapter.scan() 1324 | except Exception: 1325 | pass 1326 | 1327 | if nearby_devices is not None: 1328 | for device in nearby_devices: 1329 | name = device['name'] 1330 | if name is not None and name.startswith(self._search_name): 1331 | self._address = device['address'] 1332 | print(f'Found device named: {name} at {self._address}') 1333 | found_device = True 1334 | break 1335 | 1336 | return found_device 1337 | 1338 | # endregion 1339 | 1340 | 1341 | # Minimum length of a valid packet 1342 | _MIN_PACKET_LENGTH = 6 1343 | 1344 | # TODO: where to put these 1345 | _ID_CODE_POWER_NOTIFICATION = 0x01 1346 | _ID_CODE_COLLISION_DETECTED = 0x07 1347 | _ID_CODE_SELF_LEVEL_COMPLETE = 0x0B 1348 | # TODO: Fill the rest as needed 1349 | 1350 | # region Data Tuples and Parsers 1351 | VersionInfo = namedtuple("VersionInfo", 1352 | ["record_version", 1353 | "model_number", 1354 | "hardware_version", 1355 | "main_sphero_app_version", 1356 | "main_sphero_app_revision", 1357 | "bootloader_version", 1358 | "orb_basic_version", 1359 | "macro_executive_version", 1360 | "firmware_api_major_revision", 1361 | "firmware_api_minor_revision"]) 1362 | 1363 | 1364 | def _parse_version_info(data): 1365 | return VersionInfo(data[0] if data else None, 1366 | data[1] if len(data) > 1 else None, 1367 | data[2] if len(data) > 2 else None, 1368 | data[3] if len(data) > 3 else None, 1369 | data[4] if len(data) > 4 else None, 1370 | data[5] if len(data) > 5 else None, 1371 | data[6] if len(data) > 6 else None, 1372 | data[7] if len(data) > 7 else None, 1373 | data[8] if len(data) > 8 else None, 1374 | data[9] if len(data) > 9 else None) 1375 | 1376 | 1377 | BluetoothInfo = namedtuple("BluetoothInfo", 1378 | ["name", 1379 | "bluetooth_address", 1380 | "id_colors"]) 1381 | 1382 | 1383 | def _parse_bluetooth_info(data): 1384 | """ 1385 | """ 1386 | # Combine the bytes as a char string and then strip off extra bytes. 1387 | name = ''.join(chr(i) for i in data[:16]).partition('\0')[0] 1388 | return BluetoothInfo(name, 1389 | ''.join(chr(i) for i in data[16:28]), 1390 | ''.join(chr(i) for i in data[29:])) 1391 | 1392 | 1393 | AutoReconnectInfo = namedtuple("AutoReconnectInfo", 1394 | ["is_enabled", 1395 | "seconds_after_boot"]) 1396 | 1397 | 1398 | def _parse_auto_reconnect_info(data): 1399 | """ 1400 | """ 1401 | if len(data) is not 2: 1402 | raise ValueError( 1403 | "data is not 2 bytes long. Actual length: {}".format(len(data))) 1404 | 1405 | return AutoReconnectInfo(data[0] is not 0, 1406 | data[1]) 1407 | 1408 | 1409 | PowerState = namedtuple("PowerState", 1410 | ["record_version", 1411 | "battery_state", 1412 | "battery_voltage", 1413 | "total_number_of_recharges", 1414 | "seconds_awake_since_last_recharge"]) 1415 | 1416 | 1417 | def _parse_power_state(data): 1418 | """ 1419 | """ 1420 | return PowerState(data[0], 1421 | data[1], 1422 | _pack_bytes(data[2:4]), 1423 | _pack_bytes(data[4:6]), 1424 | _pack_bytes(data[6:8])) 1425 | 1426 | 1427 | LocatorInfo = namedtuple("LocatorInfo", 1428 | ["pos_x", 1429 | "pos_y", 1430 | "vel_x", 1431 | "vel_y", 1432 | "speed_over_ground"]) 1433 | 1434 | 1435 | def _parse_locator_info(data): 1436 | """ 1437 | """ 1438 | return LocatorInfo(_pack_bytes_signed(data[0:2]), 1439 | _pack_bytes_signed(data[2:4]), 1440 | _pack_bytes_signed(data[4:6]), 1441 | _pack_bytes_signed(data[6:8]), 1442 | _pack_bytes(data[8:10])) 1443 | 1444 | 1445 | CollisionInfo = namedtuple("CollisionInfo", 1446 | ["x_impact", 1447 | "y_impact", 1448 | "z_impact", 1449 | "axis", 1450 | "x_magnitude", 1451 | "y_magnitude", 1452 | "speed", 1453 | "timestamp"]) 1454 | 1455 | 1456 | def _parse_collision_info(data): 1457 | """ 1458 | """ 1459 | 1460 | if len(data) is not 0x10: 1461 | raise ValueError( 1462 | "data is not 16 bytes long. Actual length: {}".format(len(data))) 1463 | 1464 | return CollisionInfo(_pack_bytes_signed(data[0:2]), 1465 | _pack_bytes_signed(data[2:4]), 1466 | _pack_bytes_signed(data[4:6]), 1467 | data[6], 1468 | _pack_bytes(data[7:9]), 1469 | _pack_bytes(data[9:11]), 1470 | data[11], 1471 | _pack_bytes(data[12:16])) 1472 | 1473 | 1474 | class SelfLevelResult(enum.Enum): 1475 | TIMED_OUT = 0x1 1476 | SENSOR_ERROR = 0x2 1477 | DISABLED = 0x3 1478 | ABORTED = 0x4 1479 | CHARGER_NOT_FOUND = 0x5 1480 | SUCCESS = 0x6 1481 | 1482 | 1483 | def _parse_self_level_result(data): 1484 | return SelfLevelResult(data[0]) 1485 | 1486 | # endregion 1487 | 1488 | 1489 | # region Command Factory Methods 1490 | _DEVICE_ID_CORE = 0x00 1491 | 1492 | _COMMAND_ID_PING = 0x01 1493 | 1494 | 1495 | def _create_ping_command(sequence_number, 1496 | wait_for_response, 1497 | reset_inactivity_timeout): 1498 | """ 1499 | """ 1500 | return _ClientCommandPacket(device_id=_DEVICE_ID_CORE, 1501 | command_id=_COMMAND_ID_PING, 1502 | sequence_number=sequence_number, 1503 | data=None, 1504 | wait_for_response=wait_for_response, 1505 | reset_inactivity_timeout=reset_inactivity_timeout) 1506 | 1507 | 1508 | _COMMAND_ID_GET_VERSION = 0x02 1509 | 1510 | 1511 | def _create_get_version_command(sequence_number, 1512 | wait_for_response, 1513 | reset_inactivity_timeout): 1514 | """ 1515 | """ 1516 | return _ClientCommandPacket(device_id=_DEVICE_ID_CORE, 1517 | command_id=_COMMAND_ID_GET_VERSION, 1518 | sequence_number=sequence_number, 1519 | data=None, 1520 | wait_for_response=wait_for_response, 1521 | reset_inactivity_timeout=reset_inactivity_timeout) 1522 | 1523 | 1524 | _COMMAND_ID_SET_DEVICE_NAME = 0x10 1525 | 1526 | 1527 | def _create_set_device_name_command(device_name, 1528 | sequence_number, 1529 | wait_for_response, 1530 | reset_inactivity_timeout): 1531 | """ 1532 | """ 1533 | return _ClientCommandPacket(device_id=_DEVICE_ID_CORE, 1534 | command_id=_COMMAND_ID_SET_DEVICE_NAME, 1535 | sequence_number=sequence_number, 1536 | data=[ord(i) for i in device_name], 1537 | wait_for_response=wait_for_response, 1538 | reset_inactivity_timeout=reset_inactivity_timeout) 1539 | 1540 | 1541 | _COMMAND_ID_GET_BLUETOOTH_INFO = 0x11 1542 | 1543 | 1544 | def _create_get_bluetooth_info_command(sequence_number, 1545 | wait_for_response, 1546 | reset_inactivity_timeout): 1547 | """ 1548 | """ 1549 | return _ClientCommandPacket(device_id=_DEVICE_ID_CORE, 1550 | command_id=_COMMAND_ID_GET_BLUETOOTH_INFO, 1551 | sequence_number=sequence_number, 1552 | data=None, 1553 | wait_for_response=wait_for_response, 1554 | reset_inactivity_timeout=reset_inactivity_timeout) 1555 | 1556 | 1557 | _COMMAND_ID_SET_AUTO_RECONNECT = 0x12 1558 | 1559 | 1560 | def _create_set_auto_reconnect_command(should_enable_auto_reconnect, 1561 | seconds_after_boot, 1562 | sequence_number, 1563 | wait_for_response, 1564 | reset_inactivity_timeout): 1565 | """ 1566 | """ 1567 | return _ClientCommandPacket(device_id=_DEVICE_ID_CORE, 1568 | command_id=_COMMAND_ID_SET_AUTO_RECONNECT, 1569 | sequence_number=sequence_number, 1570 | data=[0x01 if should_enable_auto_reconnect else 0x00, 1571 | seconds_after_boot], 1572 | wait_for_response=wait_for_response, 1573 | reset_inactivity_timeout=reset_inactivity_timeout) 1574 | 1575 | 1576 | _COMMAND_ID_GET_AUTO_RECONNECT = 0x13 1577 | 1578 | 1579 | def _create_get_auto_reconnect_command(sequence_number, 1580 | wait_for_response, 1581 | reset_inactivity_timeout): 1582 | """ 1583 | """ 1584 | return _ClientCommandPacket(device_id=_DEVICE_ID_CORE, 1585 | command_id=_COMMAND_ID_GET_AUTO_RECONNECT, 1586 | sequence_number=sequence_number, 1587 | data=None, 1588 | wait_for_response=wait_for_response, 1589 | reset_inactivity_timeout=reset_inactivity_timeout) 1590 | 1591 | 1592 | _COMMAND_ID_GET_POWER_STATE = 0x20 1593 | 1594 | 1595 | def _create_get_power_state_command(sequence_number, 1596 | wait_for_response, 1597 | reset_inactivity_timeout): 1598 | """ 1599 | """ 1600 | return _ClientCommandPacket(device_id=_DEVICE_ID_CORE, 1601 | command_id=_COMMAND_ID_GET_POWER_STATE, 1602 | sequence_number=sequence_number, 1603 | data=None, 1604 | wait_for_response=wait_for_response, 1605 | reset_inactivity_timeout=reset_inactivity_timeout) 1606 | 1607 | 1608 | _COMMAND_ID_SET_POWER_NOTIFICATION = 0x21 1609 | 1610 | 1611 | def _create_set_power_notification_command(should_enable, 1612 | sequence_number, 1613 | wait_for_response, 1614 | reset_inactivity_timeout): 1615 | """ 1616 | """ 1617 | return _ClientCommandPacket(device_id=_DEVICE_ID_CORE, 1618 | command_id=_COMMAND_ID_SET_POWER_NOTIFICATION, 1619 | sequence_number=sequence_number, 1620 | data=[0x01 if should_enable else 0x00], 1621 | wait_for_response=wait_for_response, 1622 | reset_inactivity_timeout=reset_inactivity_timeout) 1623 | 1624 | 1625 | _DEVICE_ID_SPHERO = 0x02 1626 | 1627 | _COMMAND_ID_SET_HEADING = 0x01 1628 | 1629 | 1630 | def _create_set_heading_command(heading, 1631 | sequence_number, 1632 | wait_for_response, 1633 | reset_inactivity_timeout): 1634 | """ 1635 | """ 1636 | return _ClientCommandPacket(device_id=_DEVICE_ID_SPHERO, 1637 | command_id=_COMMAND_ID_SET_HEADING, 1638 | sequence_number=sequence_number, 1639 | data=[_get_byte_at_index(heading, 1), 1640 | _get_byte_at_index(heading, 0)], 1641 | wait_for_response=wait_for_response, 1642 | reset_inactivity_timeout=reset_inactivity_timeout) 1643 | 1644 | 1645 | _COMMAND_ID_SET_STABILIZATION = 0x02 1646 | 1647 | 1648 | def _create_set_stabilization_command(stabilization, 1649 | sequence_number, 1650 | wait_for_response, 1651 | reset_inactivity_timeout): 1652 | """ 1653 | """ 1654 | return _ClientCommandPacket(device_id=_DEVICE_ID_SPHERO, 1655 | command_id=_COMMAND_ID_SET_STABILIZATION, 1656 | sequence_number=sequence_number, 1657 | data=[0x01 if stabilization else 0x00], 1658 | wait_for_response=wait_for_response, 1659 | reset_inactivity_timeout=reset_inactivity_timeout) 1660 | 1661 | 1662 | _COMMAND_ID_SELF_LEVEL = 0x09 1663 | 1664 | 1665 | def _create_self_level_command(start, 1666 | use_original_heading, 1667 | sleep, 1668 | turn_on_control_system, 1669 | angle_limit, 1670 | timeout, 1671 | true_time, 1672 | sequence_number, 1673 | wait_for_response, 1674 | reset_inactivity_timeout): 1675 | """ 1676 | """ 1677 | options = ((1 if start else 0) | (1 if use_original_heading else 0) >> 1 | ( 1678 | 1 if sleep else 0) >> 2 | (1 if turn_on_control_system else 0) >> 3) 1679 | return _ClientCommandPacket(device_id=_DEVICE_ID_SPHERO, 1680 | command_id=_COMMAND_ID_SELF_LEVEL, 1681 | sequence_number=sequence_number, 1682 | data=[options, angle_limit, 1683 | timeout, true_time], 1684 | wait_for_response=wait_for_response, 1685 | reset_inactivity_timeout=reset_inactivity_timeout) 1686 | 1687 | 1688 | _COMMAND_ID_CONFIGURE_COLLISION_DETECTION = 0x12 1689 | 1690 | 1691 | def _create_configure_collision_detection_command(turn_on_collision_detection, 1692 | x_t, 1693 | x_speed, 1694 | y_t, 1695 | y_speed, 1696 | collision_dead_time, 1697 | sequence_number, 1698 | wait_for_response, 1699 | reset_inactivity_timeout): 1700 | """ 1701 | """ 1702 | return _ClientCommandPacket(device_id=_DEVICE_ID_SPHERO, 1703 | command_id=_COMMAND_ID_CONFIGURE_COLLISION_DETECTION, 1704 | sequence_number=sequence_number, 1705 | data=[0x01 if turn_on_collision_detection else 0x00, 1706 | x_t, x_speed, 1707 | y_t, y_speed, 1708 | collision_dead_time], 1709 | wait_for_response=wait_for_response, 1710 | reset_inactivity_timeout=reset_inactivity_timeout) 1711 | 1712 | 1713 | _COMMAND_ID_CONFIGURE_LOCATOR = 0x13 1714 | 1715 | 1716 | def _create_configure_locator_command(enable_auto_yaw_tare_correction, 1717 | pos_x, 1718 | pos_y, 1719 | yaw_tare, 1720 | sequence_number, 1721 | wait_for_response, 1722 | reset_inactivity_timeout): 1723 | """ 1724 | """ 1725 | return _ClientCommandPacket(device_id=_DEVICE_ID_SPHERO, 1726 | command_id=_COMMAND_ID_CONFIGURE_LOCATOR, 1727 | sequence_number=sequence_number, 1728 | data=[0x80 if enable_auto_yaw_tare_correction else 0, 1729 | _get_byte_at_index(pos_x, 0), 1730 | _get_byte_at_index(pos_x, 1), 1731 | _get_byte_at_index(pos_y, 0), 1732 | _get_byte_at_index(pos_y, 1), 1733 | _get_byte_at_index(yaw_tare, 0), 1734 | _get_byte_at_index(yaw_tare, 0)], 1735 | wait_for_response=wait_for_response, 1736 | reset_inactivity_timeout=reset_inactivity_timeout) 1737 | 1738 | 1739 | _COMMAND_ID_READ_LOCATOR = 0x15 1740 | 1741 | 1742 | def _create_read_locator_command(sequence_number, 1743 | wait_for_response, 1744 | reset_inactivity_timeout): 1745 | """ 1746 | """ 1747 | return _ClientCommandPacket(device_id=_DEVICE_ID_SPHERO, 1748 | command_id=_COMMAND_ID_READ_LOCATOR, 1749 | sequence_number=sequence_number, 1750 | data=None, 1751 | wait_for_response=wait_for_response, 1752 | reset_inactivity_timeout=reset_inactivity_timeout) 1753 | 1754 | 1755 | _COMMAND_ID_SET_RGB_LED = 0x20 1756 | 1757 | 1758 | def _create_set_rgb_led_command(red, 1759 | green, 1760 | blue, 1761 | save_as_user_led_color, 1762 | sequence_number, 1763 | wait_for_response, 1764 | reset_inactivity_timeout): 1765 | """ 1766 | """ 1767 | if red < 0 or red > 0xFF: 1768 | raise ValueError() 1769 | 1770 | if green < 0 or green > 0xFF: 1771 | raise ValueError() 1772 | 1773 | if blue < 0 or blue > 0xFF: 1774 | raise ValueError() 1775 | 1776 | return _ClientCommandPacket(device_id=_DEVICE_ID_SPHERO, 1777 | command_id=_COMMAND_ID_SET_RGB_LED, 1778 | sequence_number=sequence_number, 1779 | data=[red, green, blue, 1780 | 1 if save_as_user_led_color else 0], 1781 | wait_for_response=wait_for_response, 1782 | reset_inactivity_timeout=reset_inactivity_timeout) 1783 | 1784 | 1785 | _COMMAND_ID_GET_RGB_LED = 0x22 1786 | 1787 | 1788 | def _create_get_rgb_led_command(sequence_number, 1789 | wait_for_response, 1790 | reset_inactivity_timeout): 1791 | """ 1792 | """ 1793 | return _ClientCommandPacket(device_id=_DEVICE_ID_SPHERO, 1794 | command_id=_COMMAND_ID_GET_RGB_LED, 1795 | sequence_number=sequence_number, 1796 | data=[], 1797 | wait_for_response=wait_for_response, 1798 | reset_inactivity_timeout=reset_inactivity_timeout) 1799 | 1800 | 1801 | _COMMAND_ID_SET_BACK_LED_OUTPUT = 0x21 1802 | 1803 | 1804 | def _create_set_back_led_output_command(brightness, 1805 | sequence_number, 1806 | wait_for_response, 1807 | reset_inactivity_timeout): 1808 | """ 1809 | """ 1810 | return _ClientCommandPacket(device_id=_DEVICE_ID_SPHERO, 1811 | command_id=_COMMAND_ID_SET_BACK_LED_OUTPUT, 1812 | sequence_number=sequence_number, 1813 | data=[brightness], 1814 | wait_for_response=wait_for_response, 1815 | reset_inactivity_timeout=reset_inactivity_timeout) 1816 | 1817 | 1818 | _COMMAND_ID_ROLL = 0x30 1819 | 1820 | 1821 | def _create_roll_command(speed, 1822 | heading_in_degrees, 1823 | mode, 1824 | sequence_number, 1825 | wait_for_response, 1826 | reset_inactivity_timeout): 1827 | """ 1828 | """ 1829 | if heading_in_degrees < 0 or heading_in_degrees > 359: 1830 | raise ValueError( 1831 | f'heading_in_degrees must be in the range [0, 359]. heading was {heading_in_degrees}') 1832 | 1833 | state = 0 1834 | if mode == RollMode.IN_PLACE_ROTATE: 1835 | speed = 0 1836 | state = 1 1837 | elif mode == RollMode.FAST_ROTATE: 1838 | state = 2 1839 | elif mode == RollMode.NORMAL: 1840 | if speed > 0: 1841 | state = 1 1842 | else: 1843 | state = 0 1844 | else: 1845 | raise ValueError('Unknown RollMode.') 1846 | 1847 | return _ClientCommandPacket(device_id=_DEVICE_ID_SPHERO, 1848 | command_id=_COMMAND_ID_ROLL, 1849 | sequence_number=sequence_number, 1850 | data=[speed, 1851 | _get_byte_at_index( 1852 | heading_in_degrees, 1), 1853 | _get_byte_at_index( 1854 | heading_in_degrees, 0), 1855 | state], 1856 | wait_for_response=wait_for_response, 1857 | reset_inactivity_timeout=reset_inactivity_timeout) 1858 | 1859 | # endregion 1860 | 1861 | # region Private Package Classes 1862 | 1863 | 1864 | class _ClientCommandPacket(object): 1865 | """Represents a command packet sent from the client to a Sphero. 1866 | """ 1867 | _START_OF_PACKET_1 = 0xFF 1868 | _START_OF_PACKET_2_BASE = 0xFC 1869 | _START_OF_PACKET_2_ANSWER_MASK = 0x01 1870 | _START_OF_PACKET_2_RESET_INACTIVITY_TIMEOUT_MASK = 0x02 1871 | 1872 | def __init__(self, 1873 | device_id, 1874 | command_id, 1875 | sequence_number=0x00, 1876 | data=None, 1877 | wait_for_response=True, 1878 | reset_inactivity_timeout=True): 1879 | 1880 | if data is None: 1881 | data = [] 1882 | 1883 | self._wait_for_response = wait_for_response 1884 | 1885 | start_of_packet_2 = self._START_OF_PACKET_2_BASE 1886 | if wait_for_response: 1887 | start_of_packet_2 |= self._START_OF_PACKET_2_ANSWER_MASK 1888 | 1889 | if reset_inactivity_timeout: 1890 | start_of_packet_2 |= self._START_OF_PACKET_2_RESET_INACTIVITY_TIMEOUT_MASK 1891 | 1892 | self._packet = [self._START_OF_PACKET_1, 1893 | start_of_packet_2, 1894 | device_id, 1895 | command_id, 1896 | sequence_number, 1897 | min(len(data) + 1, 0xFF), ] 1898 | 1899 | self._packet.extend(data) 1900 | self._packet.append(_compute_checksum(self._packet)) 1901 | 1902 | @property 1903 | def bytes(self): 1904 | """Get the ClientCommandPacket as a bytes object. 1905 | 1906 | Used to send the packet to the Sphero. 1907 | 1908 | Returns: 1909 | The ClientCommandPacket as bytes. 1910 | """ 1911 | return bytes(self._packet) 1912 | 1913 | @property 1914 | def sequence_number(self): 1915 | """ 1916 | """ 1917 | return self._packet[4] 1918 | 1919 | @property 1920 | def wait_for_response(self): 1921 | """ 1922 | """ 1923 | return self._wait_for_response 1924 | 1925 | 1926 | class _ResponsePacketStatus(enum.Enum): 1927 | VALID = enum.auto() 1928 | NOT_ENOUGH_BUFFER = enum.auto() 1929 | INVALID_DATA = enum.auto() 1930 | INCORRECT_LENGTH = enum.auto() 1931 | 1932 | 1933 | class _ResponsePacket(object): 1934 | """Represents a response packet from a Sphero to the client 1935 | 1936 | Will try to parse buffer provided to constructor as a packet 1937 | 1938 | Args: 1939 | buffer (list): the raw byte buffer to 1940 | try and parse as a packet 1941 | """ 1942 | _START_OF_PACKET_1_INDEX = 0 1943 | _START_OF_PACKET_2_INDEX = 1 1944 | 1945 | # async response value indexes 1946 | _ID_CODE_INDEX = 2 1947 | _DATA_LENGTH_MSB_INDEX = 3 1948 | _DATA_LENGTH_LSB_INDEX = 4 1949 | 1950 | # simple response value indexes 1951 | _MESSAGE_RESPONSE_CODE_INDEX = 2 1952 | _SEQUENCE_NUMBER_INDEX = 3 1953 | _DATA_LENGTH_INDEX = 4 1954 | 1955 | _DATA_START_INDEX = 5 1956 | 1957 | # 1 becuase data length includes checksum which is always present 1958 | _MIN_DATA_LENGTH = 1 1959 | 1960 | _START_OF_PACKET_1 = 0xFF 1961 | _START_OF_PACKET_2_SYNC = 0xFF 1962 | _START_OF_PACKET_2_ASYNC = 0xFE 1963 | 1964 | def __init__(self, buffer): 1965 | assert len( 1966 | buffer) >= _MIN_PACKET_LENGTH, "Buffer is less than the minimum packet length" 1967 | self.status = _ResponsePacketStatus.VALID 1968 | self._message_response_code = 0x00 1969 | self._sequence_number = 0x00 1970 | self._id_code = 0x00 1971 | 1972 | self._start_of_packet_byte_1 = buffer[self._START_OF_PACKET_1_INDEX] 1973 | if self._start_of_packet_byte_1 != self._START_OF_PACKET_1: 1974 | self.status = _ResponsePacketStatus.INVALID_DATA 1975 | return 1976 | 1977 | self._start_of_packet_byte_2 = buffer[self._START_OF_PACKET_2_INDEX] 1978 | 1979 | self._is_async = self._start_of_packet_byte_2 is self._START_OF_PACKET_2_ASYNC 1980 | if self._is_async: 1981 | self._id_code = buffer[self._ID_CODE_INDEX] 1982 | self._data_length = _pack_bytes([buffer[self._DATA_LENGTH_MSB_INDEX], 1983 | buffer[self._DATA_LENGTH_LSB_INDEX]]) 1984 | else: 1985 | self._message_response_code = buffer[self._MESSAGE_RESPONSE_CODE_INDEX] 1986 | self._sequence_number = buffer[self._SEQUENCE_NUMBER_INDEX] 1987 | self._data_length = buffer[self._DATA_LENGTH_INDEX] 1988 | 1989 | if self._data_length < self._MIN_DATA_LENGTH: 1990 | self.status = _ResponsePacketStatus.INCORRECT_LENGTH 1991 | return 1992 | 1993 | if self._data_length + _MIN_PACKET_LENGTH - 1 > len(buffer): 1994 | self.status = _ResponsePacketStatus.NOT_ENOUGH_BUFFER 1995 | return 1996 | 1997 | checksum_index = self._checksum_index 1998 | self._data = buffer[self._DATA_START_INDEX:checksum_index] 1999 | self._checksum = buffer[checksum_index] 2000 | 2001 | if not self._is_data_length_valid: 2002 | self.status = _ResponsePacketStatus.INCORRECT_LENGTH 2003 | return 2004 | 2005 | if self._checksum is not _compute_checksum(buffer[:checksum_index]): 2006 | self.status = _ResponsePacketStatus.INVALID_DATA 2007 | return 2008 | 2009 | @property 2010 | def is_async(self): 2011 | """ 2012 | """ 2013 | return self._is_async 2014 | 2015 | @property 2016 | def data(self): 2017 | """ 2018 | """ 2019 | return self._data 2020 | 2021 | @property 2022 | def id_code(self): 2023 | """ 2024 | """ 2025 | return self._id_code 2026 | 2027 | @property 2028 | def message_response(self): 2029 | """ 2030 | """ 2031 | return self._message_response_code 2032 | 2033 | @property 2034 | def sequence_number(self): 2035 | """ 2036 | """ 2037 | return self._sequence_number 2038 | 2039 | @property 2040 | def packet_length(self): 2041 | """ 2042 | """ 2043 | return self._data_length + 5 2044 | 2045 | @property 2046 | def _checksum_index(self): 2047 | """ 2048 | """ 2049 | return self._data_length + 4 2050 | 2051 | @property 2052 | def _is_data_length_valid(self): 2053 | """ 2054 | """ 2055 | # data_length includes the length of data and the checksum 2056 | return len(self._data) is self._data_length - 1 2057 | 2058 | # endregion 2059 | 2060 | # region Private Utility Methods 2061 | 2062 | 2063 | def _compute_checksum(packet): 2064 | """Computes the checksum byte of a packet. 2065 | 2066 | Packet must not contain a checksum already 2067 | 2068 | Args: 2069 | packet (list): 2070 | List of bytes for a packet. 2071 | packet must not contain a checksum 2072 | as the last element 2073 | 2074 | Returns: 2075 | The computed checksum byte. 2076 | """ 2077 | # checksum is the sum of the bytes 2078 | # from device id to the end of the data 2079 | # mod (%) 256 and bit negated (~) (1's compliment) 2080 | # and (&) with 0xFF to make sure it is a byte. 2081 | return ~(sum(packet[2:]) % 0x100) & 0xFF 2082 | 2083 | 2084 | def _get_byte_at_index(value, index): 2085 | """ 2086 | """ 2087 | # NOTE: We could also use int.to_bytes to just convert 2088 | # value into a byte array. 2089 | return value >> index * 8 & 0xFF 2090 | 2091 | 2092 | def _pack_bytes(byte_list): 2093 | """Packs a list of bytes to be a single unsigned int. 2094 | 2095 | The MSB is the leftmost byte (index 0). 2096 | The LSB is the rightmost byte (index -1 or len(byte_list) - 1). 2097 | Big Endian order. 2098 | Each value in byte_list is assumed to be a byte (range [0, 255]). 2099 | This assumption is not validated in this function. 2100 | 2101 | Args: 2102 | byte_list (list): 2103 | 2104 | Returns: 2105 | The number resulting from the packed bytes. 2106 | """ 2107 | return int.from_bytes(byte_list, 'big', signed=False) 2108 | 2109 | 2110 | def _pack_bytes_signed(byte_list): 2111 | """Packs a list of bytes to be a single signed int. 2112 | 2113 | The MSB is the leftmost byte (index 0). 2114 | The LSB is the rightmost byte (index -1 or len(byte_list) - 1). 2115 | Big Endian order. 2116 | Each value in byte_list is assumed to be a byte (range [0, 255]). 2117 | This assumption is not validated in this function. 2118 | 2119 | Args: 2120 | byte_list (list): 2121 | 2122 | Returns: 2123 | The number resulting from the packed bytes. 2124 | """ 2125 | return int.from_bytes(byte_list, 'big', signed=True) 2126 | 2127 | 2128 | def _is_windows(): 2129 | """ 2130 | """ 2131 | return os.name == 'nt' 2132 | 2133 | 2134 | def _is_linux(): 2135 | """ 2136 | """ 2137 | return os.name == 'posix' 2138 | 2139 | # endregion 2140 | -------------------------------------------------------------------------------- /tests/auto_reconnect_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import asyncio 5 | from test_utils import parse_args 6 | import spheropy 7 | 8 | 9 | async def main(): 10 | script_args = parse_args() 11 | sphero = spheropy.Sphero() 12 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 13 | 14 | original_auto_reconnect_setting = await sphero.get_auto_reconnect() 15 | 16 | print("Original Auto Reconnect Setting:") 17 | print("Is Enabled: {}".format(original_auto_reconnect_setting.is_enabled)) 18 | print("Seconds After Boot: {}".format( 19 | original_auto_reconnect_setting.seconds_after_boot)) 20 | print("") 21 | 22 | print("Trying to enable auto reconnect with:") 23 | print("Is Enabled: True") 24 | print("Seconds After Boot: 20") 25 | await sphero.set_auto_reconnect(True, 20) 26 | print("Completed set auto reconnect.") 27 | print("") 28 | 29 | new_auto_reconnect_info = await sphero.get_auto_reconnect() 30 | 31 | print("New Auto Reconnect Setting:") 32 | print("Is Enabled: {}".format(new_auto_reconnect_info.is_enabled)) 33 | print("Seconds After Boot: {}".format( 34 | new_auto_reconnect_info.seconds_after_boot)) 35 | print("") 36 | 37 | # Restore the original setting 38 | await sphero.set_auto_reconnect( 39 | original_auto_reconnect_setting.is_enabled, 40 | original_auto_reconnect_setting.seconds_after_boot) 41 | 42 | if __name__ == "__main__": 43 | main_loop = asyncio.get_event_loop() 44 | main_loop.run_until_complete(main()) 45 | -------------------------------------------------------------------------------- /tests/bluetooth_info_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import asyncio 5 | from test_utils import parse_args 6 | import spheropy 7 | 8 | 9 | async def main(): 10 | script_args = parse_args() 11 | sphero = spheropy.Sphero() 12 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 13 | 14 | original_bluetooth_info = await sphero.get_bluetooth_info() 15 | 16 | print("Original Bluetooth Info:") 17 | print("Name: {}".format(original_bluetooth_info.name)) 18 | print("Bluetooth Address: {}".format( 19 | original_bluetooth_info.bluetooth_address)) 20 | print("ID Colors: {}".format(original_bluetooth_info.id_colors)) 21 | print("") 22 | 23 | print("Trying to set device name to SwervySwerve.") 24 | await sphero.set_device_name("SwervySwerve") 25 | print("Completed set device name.") 26 | print("") 27 | 28 | new_bluetooth_info = await sphero.get_bluetooth_info() 29 | 30 | print("New Bluetooth Info:") 31 | print("Name: {}".format(new_bluetooth_info.name)) 32 | print("Bluetooth Address: {}".format(new_bluetooth_info.bluetooth_address)) 33 | print("ID Colors: {}".format(new_bluetooth_info.id_colors)) 34 | 35 | # Restore the original setting 36 | await sphero.set_device_name(original_bluetooth_info.name) 37 | 38 | if __name__ == "__main__": 39 | main_loop = asyncio.get_event_loop() 40 | main_loop.run_until_complete(main()) 41 | -------------------------------------------------------------------------------- /tests/collision_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import asyncio 5 | import time 6 | from test_utils import parse_args 7 | import spheropy 8 | 9 | 10 | async def main(): 11 | script_args = parse_args() 12 | sphero = spheropy.Sphero() 13 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 14 | 15 | await sphero.configure_collision_detection( 16 | True, 17 | 45, 110, 18 | 45, 110, 19 | 20) 20 | 21 | collision_detected = False 22 | 23 | def handle_collision(collision_data): 24 | nonlocal collision_detected 25 | collision_detected = True 26 | event_loop = asyncio.new_event_loop() 27 | event_loop.run_until_complete(sphero.roll(0, 0)) 28 | event_loop.run_until_complete(sphero.set_rgb_led(red=0xFF)) 29 | 30 | print("Collision Data:") 31 | print("X Impact: {}".format(collision_data.x_impact)) 32 | print("Y Impact: {}".format(collision_data.y_impact)) 33 | print("Z Impact: {}".format(collision_data.z_impact)) 34 | print("Axis: {}".format(collision_data.axis)) 35 | print("X Magnitude: {}".format(collision_data.x_magnitude)) 36 | print("Y Magnitude: {}".format(collision_data.y_magnitude)) 37 | print("Speed: {}".format(collision_data.speed)) 38 | print("Timestamp: {}".format(collision_data.timestamp)) 39 | time.sleep(4) 40 | 41 | sphero.on_collision.append(handle_collision) 42 | 43 | await sphero.set_rgb_led(green=0xFF) 44 | await sphero.roll(127, 0) 45 | await asyncio.sleep(10) 46 | if not collision_detected: 47 | print("FAIL: collision not detected.") 48 | await sphero.roll(0, 0) 49 | await sphero.set_rgb_led(blue=0xFF) 50 | await asyncio.sleep(4) 51 | 52 | if __name__ == "__main__": 53 | main_loop = asyncio.get_event_loop() 54 | main_loop.run_until_complete(main()) 55 | -------------------------------------------------------------------------------- /tests/configure_locator_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import asyncio 5 | import time 6 | from test_utils import parse_args 7 | import spheropy 8 | 9 | 10 | async def main(): 11 | script_args = parse_args() 12 | sphero = spheropy.Sphero() 13 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 14 | 15 | locator_info = await sphero.get_locator_info() 16 | 17 | print('Original LocatorInfo:') 18 | print('X Pos: {}'.format(locator_info.pos_x)) 19 | print('Y Pos: {}'.format(locator_info.pos_y)) 20 | print('X Velocity: {}'.format(locator_info.vel_x)) 21 | print('Y Velocity: {}'.format(locator_info.vel_y)) 22 | print('Speed Over Ground: {}'.format(locator_info.speed_over_ground)) 23 | 24 | print('Simulating aiming the Sphero') 25 | # Turn on the aiming LED 26 | await sphero.set_back_led(0xFF) 27 | # Turn off auto-correction for yaw tare 28 | await sphero.configure_locator(False) 29 | time.sleep(1) 30 | # Adjust the heading by 90 degrees 31 | await sphero.set_heading(90) 32 | time.sleep(1) 33 | # Turn on auto-correction for yay tare 34 | await sphero.configure_locator(True) 35 | await sphero.set_back_led(0) 36 | 37 | locator_info = await sphero.get_locator_info() 38 | 39 | print('New LocatorInfo:') 40 | print('X Pos: {}'.format(locator_info.pos_x)) 41 | print('Y Pos: {}'.format(locator_info.pos_y)) 42 | print('X Velocity: {}'.format(locator_info.vel_x)) 43 | print('Y Velocity: {}'.format(locator_info.vel_y)) 44 | print('Speed Over Ground: {}'.format(locator_info.speed_over_ground)) 45 | 46 | if __name__ == "__main__": 47 | main_loop = asyncio.get_event_loop() 48 | main_loop.run_until_complete(main()) 49 | -------------------------------------------------------------------------------- /tests/get_locator_info_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import asyncio 5 | import time 6 | from test_utils import parse_args 7 | import spheropy 8 | 9 | 10 | async def main(): 11 | script_args = parse_args() 12 | sphero = spheropy.Sphero() 13 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 14 | 15 | locator_info = await sphero.get_locator_info() 16 | 17 | print('LocatorInfo:') 18 | print('X Pos: {}'.format(locator_info.pos_x)) 19 | print('Y Pos: {}'.format(locator_info.pos_y)) 20 | print('X Velocity: {}'.format(locator_info.vel_x)) 21 | print('Y Velocity: {}'.format(locator_info.vel_y)) 22 | print('Speed Over Ground: {}'.format(locator_info.speed_over_ground)) 23 | 24 | if __name__ == "__main__": 25 | main_loop = asyncio.get_event_loop() 26 | main_loop.run_until_complete(main()) 27 | -------------------------------------------------------------------------------- /tests/get_power_state_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import asyncio 5 | from test_utils import parse_args 6 | import spheropy 7 | 8 | 9 | async def main(): 10 | script_args = parse_args() 11 | sphero = spheropy.Sphero() 12 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 13 | 14 | power_state = await sphero.get_power_state() 15 | 16 | print("Power State:") 17 | print("Record Version: {}".format(power_state.record_version)) 18 | 19 | battery_state_string = "Unknown" 20 | if power_state.battery_state is spheropy.Sphero.BATTERY_STATE_CHARGING: 21 | battery_state_string = "Charging" 22 | elif power_state.battery_state is spheropy.Sphero.BATTERY_STATE_OK: 23 | battery_state_string = "OK" 24 | elif power_state.battery_state is spheropy.Sphero.BATTERY_STATE_LOW: 25 | battery_state_string = "Low" 26 | elif power_state.battery_state is spheropy.Sphero.BATTERY_STATE_CRITICAL: 27 | battery_state_string = "Critical" 28 | 29 | print("Battery State: {}".format(battery_state_string)) 30 | print("Battery Voltage: {}".format(power_state.battery_voltage)) 31 | print("Lifetime Number of Recharges: {}".format( 32 | power_state.total_number_of_recharges)) 33 | print("Seconds Since Last Recharge: {}".format( 34 | power_state.seconds_awake_since_last_recharge)) 35 | 36 | if __name__ == "__main__": 37 | main_loop = asyncio.get_event_loop() 38 | main_loop.run_until_complete(main()) 39 | -------------------------------------------------------------------------------- /tests/get_rgb_led_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import asyncio 5 | import time 6 | from test_utils import parse_args 7 | import spheropy 8 | 9 | 10 | async def main(): 11 | script_args = parse_args() 12 | sphero = spheropy.Sphero() 13 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 14 | 15 | original_user_led_color = await sphero.get_rgb_led() 16 | 17 | await sphero.set_rgb_led(red=0xFF, save_as_user_led_color=True) 18 | time.sleep(2) 19 | user_led_color = await sphero.get_rgb_led() 20 | if user_led_color != [0xFF, 0x00, 0x00]: 21 | print("FAIL: User LED color is not red=0xFF, green=0x00, blue=0x00 as expected. Actual = {}" 22 | .format(user_led_color)) 23 | 24 | await sphero.set_rgb_led(green=0xFF, save_as_user_led_color=True) 25 | time.sleep(2) 26 | user_led_color = await sphero.get_rgb_led() 27 | if user_led_color != [0x00, 0xFF, 0x00]: 28 | print("FAIL: User LED color is not red=0x00, green=0xFF, blue=0x00 as expected. Actual = {}" 29 | .format(user_led_color)) 30 | 31 | await sphero.set_rgb_led(blue=0xFF, save_as_user_led_color=True) 32 | time.sleep(2) 33 | user_led_color = await sphero.get_rgb_led() 34 | if user_led_color != [0x00, 0x00, 0xFF]: 35 | print("FAIL: User LED color is not red=0x00, green=0x00, blue=0xFF as expected. Actual = {}" 36 | .format(user_led_color)) 37 | 38 | await sphero.set_rgb_led(red=0xFF, blue=0xFF, save_as_user_led_color=True) 39 | time.sleep(2) 40 | user_led_color = await sphero.get_rgb_led() 41 | if user_led_color != [0xFF, 0x00, 0xFF]: 42 | print("FAIL: User LED color is not red=0xFF, green=0x00, blue=0xFF as expected. Actual = {}" 43 | .format(user_led_color)) 44 | 45 | # Restore the original setting 46 | await sphero.set_rgb_led( 47 | original_user_led_color[0], 48 | original_user_led_color[1], 49 | original_user_led_color[2], 50 | save_as_user_led_color=True) 51 | time.sleep(2) 52 | 53 | if __name__ == "__main__": 54 | main_loop = asyncio.get_event_loop() 55 | main_loop.run_until_complete(main()) 56 | -------------------------------------------------------------------------------- /tests/get_version_info_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import asyncio 5 | from test_utils import parse_args 6 | import spheropy 7 | 8 | 9 | async def main(): 10 | script_args = parse_args() 11 | sphero = spheropy.Sphero() 12 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 13 | 14 | version_info = await sphero.get_version_info() 15 | 16 | print("Version Info:") 17 | print("Record Version: {}".format(version_info.record_version)) 18 | print("Model Number: {}".format(version_info.model_number)) 19 | print("Hardware Version: {}".format(version_info.hardware_version)) 20 | print("Main Sphero Application Version: {}" 21 | .format(version_info.main_sphero_app_version)) 22 | print("Main Sphero Application Revision: {}" 23 | .format(version_info.main_sphero_app_revision)) 24 | print("Bootloader Version: {}".format(version_info.bootloader_version)) 25 | print("OrbBasic Version: {}".format(version_info.orb_basic_version)) 26 | print("Macro Executive Version: {}" 27 | .format(version_info.macro_executive_version)) 28 | print("Firmware API Major Version: {}" 29 | .format(version_info.firmware_api_major_revision)) 30 | print("Firmware API Minor Version: {}" 31 | .format(version_info.firmware_api_major_revision)) 32 | 33 | if __name__ == "__main__": 34 | main_loop = asyncio.get_event_loop() 35 | main_loop.run_until_complete(main()) 36 | -------------------------------------------------------------------------------- /tests/ping_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import asyncio 5 | import spheropy 6 | from test_utils import parse_args 7 | 8 | 9 | async def main(): 10 | script_args = parse_args() 11 | sphero = spheropy.Sphero() 12 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 13 | 14 | # Ping the sphero and wait for a response. 15 | # Do this a few times to validate 16 | # sequence_number handling. 17 | await sphero.ping() 18 | await sphero.ping() 19 | await sphero.ping() 20 | 21 | # Ping the Sphero a few times, 22 | # but don't wait until all have been started. 23 | await asyncio.gather( 24 | sphero.ping(), 25 | sphero.ping(), 26 | sphero.ping() 27 | ) 28 | 29 | # Ping the sphero but don't request/wait for a response 30 | await sphero.ping(wait_for_response=False) 31 | 32 | # Don't reset the inactivity timeout 33 | await sphero.ping(wait_for_response=False, reset_inactivity_timeout=False) 34 | 35 | if __name__ == "__main__": 36 | main_loop = asyncio.get_event_loop() 37 | main_loop.run_until_complete(main()) 38 | -------------------------------------------------------------------------------- /tests/power_state_change_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import asyncio 5 | import time 6 | from test_utils import parse_args 7 | import spheropy 8 | 9 | 10 | async def main(): 11 | script_args = parse_args() 12 | sphero = spheropy.Sphero() 13 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 14 | 15 | await sphero.set_power_notification(True) 16 | 17 | power_state_change_detected = False 18 | 19 | def handle_power_state_change(power_state): 20 | nonlocal power_state_change_detected 21 | power_state_change_detected = True 22 | print("Power State Changed To: {}".format(power_state)) 23 | 24 | sphero.on_power_state_change.append(handle_power_state_change) 25 | 26 | await sphero.set_rgb_led(green=0xFF) 27 | for _ in range(3): 28 | await sphero.ping() 29 | await asyncio.sleep(10) 30 | if power_state_change_detected: 31 | break 32 | 33 | if not power_state_change_detected: 34 | print("FAIL: No Power State Change Detected") 35 | 36 | if __name__ == "__main__": 37 | main_loop = asyncio.get_event_loop() 38 | main_loop.run_until_complete(main()) 39 | -------------------------------------------------------------------------------- /tests/roll_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import asyncio 5 | import time 6 | from test_utils import parse_args 7 | import spheropy 8 | 9 | 10 | async def main(): 11 | script_args = parse_args() 12 | sphero = spheropy.Sphero() 13 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 14 | 15 | await sphero.roll(127, 0, wait_for_response=False) 16 | time.sleep(0.5) 17 | await sphero.roll(127, 90) 18 | time.sleep(0.5) 19 | await sphero.roll(127, 180) 20 | time.sleep(0.5) 21 | await sphero.roll(127, 270) 22 | time.sleep(0.5) 23 | 24 | await sphero.roll(16, 0) 25 | time.sleep(0.5) 26 | await sphero.roll(255, 180) 27 | time.sleep(0.5) 28 | await sphero.roll(0, 0) 29 | 30 | if __name__ == "__main__": 31 | main_loop = asyncio.get_event_loop() 32 | main_loop.run_until_complete(main()) 33 | -------------------------------------------------------------------------------- /tests/self_level_test.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | from test_utils import parse_args 4 | import spheropy 5 | 6 | 7 | async def main(): 8 | script_args = parse_args() 9 | sphero = spheropy.Sphero() 10 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 11 | 12 | self_level_result_received = False 13 | 14 | def handle_complete(result): 15 | nonlocal self_level_result_received 16 | self_level_result_received = True 17 | print(f'Result: {result}') 18 | 19 | sphero.on_self_level_complete.append(handle_complete) 20 | 21 | await sphero.self_level() 22 | await asyncio.sleep(5) 23 | if not self_level_result_received: 24 | print("FAIL: Result not received.") 25 | 26 | 27 | if __name__ == "__main__": 28 | main_loop = asyncio.get_event_loop() 29 | main_loop.run_until_complete(main()) 30 | -------------------------------------------------------------------------------- /tests/set_back_led_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import asyncio 5 | import time 6 | import spheropy 7 | from test_utils import parse_args 8 | 9 | 10 | async def main(): 11 | script_args = parse_args() 12 | sphero = spheropy.Sphero() 13 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 14 | 15 | await sphero.set_back_led(255) 16 | time.sleep(2) 17 | await sphero.set_back_led(0) 18 | 19 | if __name__ == "__main__": 20 | main_loop = asyncio.get_event_loop() 21 | main_loop.run_until_complete(main()) 22 | -------------------------------------------------------------------------------- /tests/set_heading_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import asyncio 5 | import time 6 | from test_utils import parse_args 7 | import spheropy 8 | 9 | 10 | async def main(): 11 | script_args = parse_args() 12 | sphero = spheropy.Sphero() 13 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 14 | 15 | await sphero.set_heading(0) 16 | await sphero.set_heading(90) 17 | await sphero.set_heading(180) 18 | await sphero.set_heading(0) 19 | 20 | if __name__ == "__main__": 21 | main_loop = asyncio.get_event_loop() 22 | main_loop.run_until_complete(main()) 23 | -------------------------------------------------------------------------------- /tests/set_rgb_led_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import asyncio 5 | import time 6 | import spheropy 7 | from test_utils import parse_args 8 | 9 | 10 | async def main(): 11 | script_args = parse_args() 12 | sphero = spheropy.Sphero() 13 | await sphero.connect(num_retry_attempts=3, use_ble=script_args.use_ble) 14 | 15 | await sphero.set_rgb_led(red=0xFF) 16 | time.sleep(2) 17 | await sphero.set_rgb_led(green=0xFF) 18 | time.sleep(2) 19 | await sphero.set_rgb_led(blue=0xFF) 20 | time.sleep(2) 21 | await sphero.set_rgb_led(red=0xFF, blue=0xFF) 22 | time.sleep(2) 23 | await sphero.set_rgb_led(0xFF, 0xFF, 0xFF) 24 | time.sleep(2) 25 | 26 | if __name__ == "__main__": 27 | main_loop = asyncio.get_event_loop() 28 | main_loop.run_until_complete(main()) 29 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | """ 3 | 4 | import argparse 5 | 6 | 7 | def parse_args(): 8 | parser = argparse.ArgumentParser() 9 | parser.add_argument( 10 | '--ble', dest='use_ble', action='store_true', 11 | help='Specify that the test should use Bluetooth Low Energy (BLE).' 12 | ) 13 | return parser.parse_args() 14 | -------------------------------------------------------------------------------- /winble/setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | from setuptools import setup, Extension 3 | from setuptools.command.build_ext import build_ext 4 | import sys 5 | 6 | # See https://github.com/pybind/python_example/blob/master/setup.py 7 | 8 | __version__ = '0.0.1' 9 | 10 | class get_pybind_include(object): 11 | """Helper class to determine the pybind11 include path 12 | The purpose of this class is to postpone importing pybind11 13 | until it is actually installed, so that the ``get_include()`` 14 | method can be invoked. """ 15 | 16 | def __init__(self, user=False): 17 | self.user = user 18 | 19 | def __str__(self): 20 | import pybind11 21 | return pybind11.get_include(self.user) 22 | 23 | 24 | class BuildExt(build_ext): 25 | """A custom build extension for adding compiler-specific options.""" 26 | c_opts = { 27 | 'msvc': ['/EHsc', '/std:c++17', '/await', '/permissive-'], 28 | 'unix': [], 29 | } 30 | 31 | if sys.platform == 'darwin': 32 | c_opts['unix'] += ['-stdlib=libc++', '-mmacosx-version-min=10.7'] 33 | 34 | def build_extensions(self): 35 | ct = self.compiler.compiler_type 36 | opts = self.c_opts.get(ct, []) 37 | if ct == 'unix': 38 | opts.append(f'-DVERSION_INFO="{self.distribution.get_version()}"') 39 | opts.append('-std=c++17') 40 | if self.compiler.has_flag('-fvisibility=hidden'): 41 | opts.append('-fvisibility=hidden') 42 | elif ct == 'msvc': 43 | opts.append(f'/DVERSION_INFO=\\"{self.distribution.get_version()}\\"') 44 | for ext in self.extensions: 45 | # TODO: we should probably append opts to existing extra_compile_args 46 | ext.extra_compile_args = opts 47 | build_ext.build_extensions(self) 48 | 49 | winble_module = Extension( 50 | 'winble', 51 | sources=['winble.cpp'], 52 | include_dirs=[ 53 | # Path to pybind11 headers 54 | get_pybind_include(), 55 | get_pybind_include(user=True) 56 | ], 57 | language='c++', 58 | ) 59 | 60 | ext_modules = [winble_module] 61 | 62 | extras_require = { 63 | } 64 | 65 | install_requires = ['pybind11>=2.2.3'] 66 | 67 | setup( 68 | name='WinBle', 69 | version=__version__, 70 | author='Casey Irvine', 71 | author_email='caseyi@outlook.com', 72 | url='https://github.com/irvinec/SpheroPy', 73 | description='Native Bluetooth LE support on Windows for SpheroPy.', 74 | install_requires=install_requires, 75 | extras_require=extras_require, 76 | ext_modules=ext_modules, 77 | cmdclass={'build_ext': BuildExt}, 78 | zip_safe = False 79 | ) -------------------------------------------------------------------------------- /winble/winble.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | 12 | // Link to umbrella lib. 13 | #pragma comment(lib, "windowsapp") 14 | 15 | using namespace winrt; 16 | using namespace winrt::Windows::Devices::Enumeration; 17 | using namespace winrt::Windows::Devices::Bluetooth; 18 | using namespace winrt::Windows::Devices::Bluetooth::GenericAttributeProfile; 19 | using namespace winrt::Windows::Security::Cryptography; 20 | using namespace winrt::Windows::Storage::Streams; 21 | namespace py = pybind11; 22 | 23 | const std::vector g_requestedProperties 24 | { 25 | L"System.Devices.Aep.DeviceAddress", 26 | L"System.Devices.Aep.IsConnected", 27 | L"System.Devices.Aep.Bluetooth.Le.AddressType" 28 | }; 29 | 30 | std::string GetDeviceAddress(const DeviceInformation& deviceInfo) 31 | { 32 | return to_string(unbox_value(deviceInfo.Properties().Lookup(L"System.Devices.Aep.DeviceAddress"))); 33 | } 34 | 35 | uint64_t MacAddressStringToUint(std::string macAddress) 36 | { 37 | // Remove colons if there are any 38 | macAddress.erase(std::remove(macAddress.begin(), macAddress.end(), ':'), macAddress.end()); 39 | 40 | // Convert to uint64_t 41 | return strtoul(macAddress.c_str(), NULL, 16); 42 | } 43 | 44 | DeviceWatcher CreateDeviceWatcher() 45 | { 46 | // We need to init the thread apartment before creating the watcher 47 | init_apartment(); 48 | return DeviceInformation::CreateWatcher( 49 | //Windows::Devices::Bluetooth::BluetoothLEDevice::GetDeviceSelectorFromPairingState(false), 50 | BluetoothLEDevice::GetDeviceSelectorFromConnectionStatus(BluetoothConnectionStatus::Disconnected), 51 | g_requestedProperties, 52 | DeviceInformationKind::AssociationEndpoint 53 | ); 54 | } 55 | 56 | guid BytesToGuid(const std::string& guidBytes) 57 | { 58 | std::vector guidVector(guidBytes.begin(), guidBytes.end()); 59 | DataWriter guidWriter; 60 | guidWriter.WriteBytes(guidVector); 61 | DataReader guidReader = DataReader::FromBuffer(guidWriter.DetachBuffer()); 62 | guid result{ guidReader.ReadGuid() }; 63 | std::reverse(std::begin(result.Data4), std::end(result.Data4)); 64 | return result; 65 | } 66 | 67 | std::vector GetCharacteristics(const BluetoothLEDevice& device) 68 | { 69 | std::vector allCharacteristics; 70 | 71 | GattDeviceServicesResult getServicesResult 72 | { 73 | device.GetGattServicesAsync(BluetoothCacheMode::Uncached).get() 74 | }; 75 | 76 | if (getServicesResult.Status() != GattCommunicationStatus::Success) 77 | { 78 | throw std::exception(); 79 | } 80 | 81 | auto services = getServicesResult.Services(); 82 | for (const auto& service : services) 83 | { 84 | GattCharacteristicsResult characteristicsResult 85 | { 86 | service.GetCharacteristicsAsync(BluetoothCacheMode::Uncached).get() 87 | }; 88 | 89 | if (characteristicsResult.Status() != GattCommunicationStatus::Success) 90 | { 91 | throw std::exception(); 92 | } 93 | 94 | auto characterisitcs = characteristicsResult.Characteristics(); 95 | allCharacteristics.insert(allCharacteristics.end(), begin(characterisitcs), end(characterisitcs)); 96 | } 97 | 98 | return allCharacteristics; 99 | } 100 | 101 | struct WinBleDevice 102 | { 103 | WinBleDevice(BluetoothLEDevice&& device) 104 | : m_device{ std::move(device) } 105 | // NOTE: Getting all characteristics only seems to work 106 | // in the constructor. 107 | // This probably has something to do with needing to be on the UI thread. 108 | , m_characteristics{ GetCharacteristics(m_device) } 109 | { 110 | } 111 | 112 | void WriteToCharacteristic(std::string characteristicId, std::string data) 113 | { 114 | DataWriter writer; 115 | std::vector dataBytes(data.cbegin(), data.cend()); 116 | writer.WriteBytes(dataBytes); 117 | 118 | guid characteristicGuid{ BytesToGuid(characteristicId) }; 119 | auto characteristicItr = std::find_if(m_characteristics.begin(), m_characteristics.end(), 120 | [&characteristicGuid](const GattCharacteristic& characteristic) 121 | { 122 | return characteristicGuid == characteristic.Uuid(); 123 | } 124 | ); 125 | 126 | if (characteristicItr == m_characteristics.end()) 127 | { 128 | throw std::exception(); 129 | } 130 | 131 | // Assume for now that we only got one characteristic. 132 | GattCommunicationStatus result 133 | { 134 | characteristicItr->WriteValueAsync(writer.DetachBuffer()).get() 135 | }; 136 | 137 | if (result != GattCommunicationStatus::Success) 138 | { 139 | throw std::exception("WinBleDevice: Error sending data to bluetooth device.", static_cast(result)); 140 | } 141 | } 142 | 143 | void Subscribe(std::string characteristicId, const std::function& eventHandler) 144 | { 145 | if (!eventHandler) { return; } 146 | 147 | guid characteristicGuid{ BytesToGuid(characteristicId) }; 148 | auto characteristicItr = std::find_if(m_characteristics.begin(), m_characteristics.end(), 149 | [&characteristicGuid](const GattCharacteristic& characteristic) 150 | { 151 | return characteristicGuid == characteristic.Uuid(); 152 | } 153 | ); 154 | 155 | if (characteristicItr == m_characteristics.end()) 156 | { 157 | throw std::exception(); 158 | } 159 | 160 | // Tell the characteristic we want to receive notifications. 161 | GattCommunicationStatus status 162 | { 163 | characteristicItr->WriteClientCharacteristicConfigurationDescriptorAsync( 164 | GattClientCharacteristicConfigurationDescriptorValue::Notify 165 | ).get() 166 | }; 167 | 168 | if (status != GattCommunicationStatus::Success) 169 | { 170 | throw std::exception(); 171 | } 172 | 173 | event_token valueChangedEventToken = characteristicItr->ValueChanged( 174 | [eventHandler]( 175 | GattCharacteristic, 176 | GattValueChangedEventArgs valueChangedEventArgs 177 | ) 178 | { 179 | // Use https://github.com/Microsoft/Windows-universal-samples/blob/master/Samples/BluetoothLE/cs/Scenario2_Client.xaml.cs 180 | // As an example. 181 | com_array data; 182 | CryptographicBuffer::CopyToByteArray(valueChangedEventArgs.CharacteristicValue(), data); 183 | std::string dataAsString{ data.begin(), data.end() }; 184 | py::bytes dataAsPyType{ dataAsString }; 185 | eventHandler(dataAsPyType); 186 | } 187 | ); 188 | } 189 | 190 | void Disconnect() 191 | { 192 | m_device.Close(); 193 | } 194 | 195 | private: 196 | BluetoothLEDevice m_device; 197 | // In-memory cache of characteristics from the device. 198 | std::vector m_characteristics; 199 | }; 200 | 201 | struct WinBleAdapter 202 | { 203 | WinBleAdapter() 204 | : m_deviceWatcher{ CreateDeviceWatcher() } 205 | { 206 | init_apartment(); 207 | } 208 | 209 | ~WinBleAdapter() 210 | { 211 | m_deviceWatcher.Stop(); 212 | uninit_apartment(); 213 | } 214 | 215 | void Start() 216 | { 217 | // Do nothing if the watcher is already started. 218 | if (m_deviceWatcher.Status() == Windows::Devices::Enumeration::DeviceWatcherStatus::Started) 219 | { 220 | return; 221 | } 222 | 223 | // Register event handlers before starting the watcher. 224 | // Added, Updated and Removed are required to get all nearby devices 225 | m_addedEventToken = m_deviceWatcher.Added( 226 | [this]( 227 | const Windows::Devices::Enumeration::DeviceWatcher&, 228 | const Windows::Devices::Enumeration::DeviceInformation& deviceInfo 229 | ) 230 | { 231 | m_nearbyDevices.insert(deviceInfo); 232 | } 233 | ); 234 | 235 | m_updatedEventToken = m_deviceWatcher.Updated( 236 | [this]( 237 | const Windows::Devices::Enumeration::DeviceWatcher&, 238 | const Windows::Devices::Enumeration::DeviceInformationUpdate& deviceInfoUpdate 239 | ) 240 | { 241 | auto foundDeviceInfoItr = std::find_if(m_nearbyDevices.cbegin(), m_nearbyDevices.cend(), 242 | [&deviceInfoUpdate]( 243 | const Windows::Devices::Enumeration::DeviceInformation& deviceInfo 244 | ) -> bool 245 | { 246 | return deviceInfo.Id() == deviceInfoUpdate.Id(); 247 | } 248 | ); 249 | 250 | if (foundDeviceInfoItr != m_nearbyDevices.cend()) 251 | { 252 | foundDeviceInfoItr->Update(deviceInfoUpdate); 253 | } 254 | } 255 | ); 256 | 257 | m_removedEventToken = m_deviceWatcher.Removed( 258 | [this]( 259 | const Windows::Devices::Enumeration::DeviceWatcher&, 260 | const Windows::Devices::Enumeration::DeviceInformationUpdate& deviceInfoUpdate 261 | ) 262 | { 263 | m_nearbyDevices.erase( 264 | std::find_if(m_nearbyDevices.begin(), m_nearbyDevices.end(), 265 | [&deviceInfoUpdate]( 266 | const Windows::Devices::Enumeration::DeviceInformation& deviceInfo 267 | ) -> bool 268 | { 269 | return deviceInfo.Id() == deviceInfoUpdate.Id(); 270 | } 271 | ) 272 | ); 273 | } 274 | ); 275 | 276 | m_deviceEunumerationCompletedEventToken = m_deviceWatcher.EnumerationCompleted( 277 | [this]( 278 | const Windows::Devices::Enumeration::DeviceWatcher& deviceWatcher, 279 | const Windows::Foundation::IInspectable& 280 | ) 281 | { 282 | m_enumerationCompletedEvent.notify_all(); 283 | } 284 | ); 285 | 286 | m_deviceWatcher.Start(); 287 | } 288 | 289 | py::list Scan() 290 | { 291 | // Wait for device enumeration to complete. 292 | std::mutex m; 293 | std::unique_lock lock{ m }; 294 | /*auto now = std::chrono::system_clock::now(); 295 | using namespace std::chrono_literals; 296 | enumerationCompletedEvent.wait_until(lock, now + 20s);*/ 297 | m_enumerationCompletedEvent.wait(lock); 298 | 299 | // TODO: lock for m_nearbyDevices 300 | py::list devices; 301 | for (const auto& deviceInfo : m_nearbyDevices) 302 | { 303 | py::dict device; 304 | device["name"] = to_string(deviceInfo.Name()); 305 | device["address"] = GetDeviceAddress(deviceInfo); 306 | devices.append(device); 307 | } 308 | 309 | return devices; 310 | } 311 | 312 | WinBleDevice Connect(std::string address) 313 | { 314 | auto deviceInfoItr = std::find_if(m_nearbyDevices.cbegin(), m_nearbyDevices.cend(), 315 | [&address](const Windows::Devices::Enumeration::DeviceInformation& deviceInfo) -> bool 316 | { 317 | return GetDeviceAddress(deviceInfo) == address; 318 | } 319 | ); 320 | 321 | if (deviceInfoItr != m_nearbyDevices.cend()) 322 | { 323 | Windows::Devices::Bluetooth::BluetoothLEDevice device 324 | { 325 | Windows::Devices::Bluetooth::BluetoothLEDevice::FromIdAsync(deviceInfoItr->Id()).get() 326 | }; 327 | 328 | return { std::move(device) }; 329 | } 330 | else 331 | { 332 | Windows::Devices::Bluetooth::BluetoothLEDevice device 333 | { 334 | Windows::Devices::Bluetooth::BluetoothLEDevice::FromBluetoothAddressAsync(MacAddressStringToUint(address)).get() 335 | }; 336 | 337 | return { std::move(device) }; 338 | } 339 | } 340 | 341 | private: 342 | Windows::Devices::Enumeration::DeviceWatcher m_deviceWatcher; 343 | std::unordered_set m_nearbyDevices; 344 | std::condition_variable m_enumerationCompletedEvent; 345 | event_token m_addedEventToken; 346 | event_token m_updatedEventToken; 347 | event_token m_removedEventToken; 348 | event_token m_deviceEunumerationCompletedEventToken; 349 | }; 350 | 351 | PYBIND11_MODULE(winble, m) 352 | { 353 | py::class_(m, "WinBleAdapter") 354 | .def(py::init<>()) 355 | .def("start", &WinBleAdapter::Start) 356 | .def("scan", &WinBleAdapter::Scan) 357 | .def("connect", &WinBleAdapter::Connect); 358 | 359 | py::class_(m, "WinBleDevice") 360 | .def("char_write", &WinBleDevice::WriteToCharacteristic) 361 | .def("subscribe", &WinBleDevice::Subscribe) 362 | .def("disconnect", &WinBleDevice::Disconnect); 363 | 364 | m.doc() = "Windows BLE Library"; 365 | 366 | #ifdef VERSION_INFO 367 | m.attr("__version__") = VERSION_INFO; 368 | #else 369 | m.attr("__version__") = "dev"; 370 | #endif 371 | } 372 | 373 | -------------------------------------------------------------------------------- /winble/winble.vcxproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | Debug 6 | Win32 7 | 8 | 9 | Release 10 | Win32 11 | 12 | 13 | Debug 14 | x64 15 | 16 | 17 | Release 18 | x64 19 | 20 | 21 | 22 | 23 | 24 | 25 | {C67C4629-4D48-44F8-A213-E4B49C107D3F} 26 | winble 27 | 3.6 28 | 10.0 29 | 30 | 31 | 32 | DynamicLibrary 33 | true 34 | v142 35 | Unicode 36 | 37 | 38 | DynamicLibrary 39 | false 40 | v142 41 | true 42 | Unicode 43 | 44 | 45 | DynamicLibrary 46 | true 47 | v142 48 | Unicode 49 | 50 | 51 | DynamicLibrary 52 | false 53 | v142 54 | true 55 | Unicode 56 | 57 | 58 | RegistryView.Registry32 59 | RegistryView.Registry64 60 | $(PythonVersion)-32 61 | $(PythonVersion) 62 | $([MSBuild]::GetRegistryValueFromView('HKEY_CURRENT_USER\SOFTWARE\Python\PythonCore\$(PythonTag)\InstallPath', null, null, $(RegistryView))) 63 | $([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\Python\PythonCore\$(PythonTag)\InstallPath', null, null, $(RegistryView))) 64 | $([MSBuild]::GetRegistryValueFromView('HKEY_CURRENT_USER\SOFTWARE\Python\PythonCore\$(PythonTag)\InstallPath', 'ExecutablePath', null, $(RegistryView))) 65 | $([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\Python\PythonCore\$(PythonTag)\InstallPath', 'ExecutablePath', null, $(RegistryView))) 66 | $(PythonHome)python.exe 67 | $([MSBuild]::GetRegistryValueFromView('HKEY_CURRENT_USER\SOFTWARE\Python\PythonCore\$(PythonTag)\InstalledFeatures', 'dev', null, $(RegistryView))) 68 | $([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\Python\PythonCore\$(PythonTag)\InstalledFeatures', 'dev', null, $(RegistryView))) 69 | $([MSBuild]::GetRegistryValueFromView('HKEY_CURRENT_USER\SOFTWARE\Python\PythonCore\$(PythonTag)\InstalledFeatures', 'core_pdb', null, $(RegistryView))) 70 | $([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\Python\PythonCore\$(PythonTag)\InstalledFeatures', 'core_pdb', null, $(RegistryView))) 71 | $([MSBuild]::GetRegistryValueFromView('HKEY_CURRENT_USER\SOFTWARE\Python\PythonCore\$(PythonTag)\InstalledFeatures', 'core_d', null, $(RegistryView))) 72 | $([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\Python\PythonCore\$(PythonTag)\InstalledFeatures', 'core_d', null, $(RegistryView))) 73 | _d 74 | $([System.IO.Path]::GetDirectoryName($(PythonExe)))\python$(PythonDebugSuffix).exe 75 | $(PythonExe) 76 | 77 | 78 | 79 | WindowsLocalDebugger 80 | PythonDebugLaunchProvider 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | $(ProjectName) 101 | .pyd 102 | $(PythonDExe) 103 | -i -c "print('>>> import winble'); import winble" 104 | PYTHONPATH=$(OutDir) 105 | $(DefaultDebuggerFlavor) 106 | $(ProjectDir)$(Platform)\$(Configuration)\ 107 | 108 | 109 | $(ProjectName) 110 | .pyd 111 | $(PythonExe) 112 | -i -c "print('>>> import winble'); import winble" 113 | PYTHONPATH=$(OutDir) 114 | $(DefaultDebuggerFlavor) 115 | $(ProjectDir)$(Platform)\$(Configuration)\ 116 | 117 | 118 | $(ProjectName) 119 | .pyd 120 | $(PythonDExe) 121 | -i -c "print('>>> import winble'); import winble" 122 | PYTHONPATH=$(OutDir) 123 | $(DefaultDebuggerFlavor) 124 | $(ProjectDir)$(Platform)\$(Configuration)\ 125 | 126 | 127 | $(ProjectName) 128 | .pyd 129 | $(PythonExe) 130 | -i -c "print('>>> import winble'); import winble" 131 | PYTHONPATH=$(OutDir) 132 | $(DefaultDebuggerFlavor) 133 | $(ProjectDir)$(Platform)\$(Configuration)\ 134 | 135 | 136 | 137 | Level3 138 | Disabled 139 | MultiThreadedDLL 140 | $(PythonHome)Include;%(AdditionalIncludeDirectories) 141 | true 142 | stdcpp17 143 | /await /permissive- %(AdditionalOptions) 144 | true 145 | 146 | 147 | true 148 | $(PythonHome)libs;%(AdditionalLibraryDirectories) 149 | 150 | 151 | 152 | 153 | Level3 154 | Disabled 155 | MultiThreadedDLL 156 | C:\Users\Casey\Miniconda3\include;C:\Users\Casey\Miniconda3\envs\spheropy-env\include;$(PythonHome)Include;%(AdditionalIncludeDirectories) 157 | true 158 | stdcpp17 159 | /await /permissive- %(AdditionalOptions) 160 | true 161 | 162 | 163 | true 164 | C:\Users\Casey\Miniconda3\envs\spheropy-env\libs;C:\Users\Casey\Miniconda3\libs;$(PythonHome)libs;%(AdditionalLibraryDirectories) 165 | 166 | 167 | 168 | 169 | Level3 170 | MaxSpeed 171 | true 172 | true 173 | MultiThreadedDLL 174 | $(PythonHome)Include;%(AdditionalIncludeDirectories) 175 | true 176 | stdcpp17 177 | /await /permissive- %(AdditionalOptions) 178 | true 179 | 180 | 181 | true 182 | true 183 | true 184 | libucrt.lib;%(IgnoreSpecificDefaultLibraries) 185 | ucrt.lib;%(AdditionalDependencies) 186 | $(PythonHome)libs;%(AdditionalLibraryDirectories) 187 | 188 | 189 | 190 | 191 | Level3 192 | MaxSpeed 193 | true 194 | true 195 | MultiThreadedDLL 196 | C:\Users\Casey\Miniconda3\include;C:\Users\Casey\Miniconda3\envs\spheropy-env\include;$(PythonHome)Include;%(AdditionalIncludeDirectories) 197 | true 198 | stdcpp17 199 | /await /permissive- %(AdditionalOptions) 200 | true 201 | 202 | 203 | true 204 | true 205 | true 206 | libucrt.lib;%(IgnoreSpecificDefaultLibraries) 207 | ucrt.lib;%(AdditionalDependencies) 208 | C:\Users\Casey\Miniconda3\envs\spheropy-env\libs;C:\Users\Casey\Miniconda3\libs;$(PythonHome)libs;%(AdditionalLibraryDirectories) 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | -------------------------------------------------------------------------------- /winble/winble.vcxproj.filters: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | {4FC737F1-C7A5-4376-A066-2A32D752A2FF} 6 | cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx 7 | 8 | 9 | {93995380-89BD-4b04-88EB-625FBE52EBFB} 10 | h;hh;hpp;hxx;hm;inl;inc;xsd 11 | 12 | 13 | {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} 14 | rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms 15 | 16 | 17 | 18 | 19 | Source Files 20 | 21 | 22 | -------------------------------------------------------------------------------- /winble/winble.vcxproj.user: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | --------------------------------------------------------------------------------