├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── Pipfile ├── README.md ├── buttplug ├── __init__.py ├── client │ ├── __init__.py │ ├── client.py │ ├── connector.py │ └── websocket_connector.py ├── core │ ├── __init__.py │ ├── enums.py │ ├── errors.py │ └── messages.py └── utils │ ├── __init__.py │ └── eventhandler.py ├── docs ├── Makefile ├── _static │ └── js │ │ └── matomo.js ├── client.rst ├── conf.py ├── device.rst ├── enums.rst ├── errors.rst ├── eventhandler.rst ├── index.md ├── index.rst ├── make.bat └── requirements.txt ├── examples └── example.py ├── pytest.ini ├── requirements.txt ├── runtime.txt ├── setup.py └── tests ├── __init__.py ├── test_client_device.py └── test_messages.py /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | *egg-info* 3 | build 4 | dist 5 | docs/_build -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.3.0 (2022-08-06) 2 | 3 | ## Breaking Changes 4 | 5 | - device_removed_handlers now correctly receive the removed ButtplugClientDevice rather than its integer id. 6 | 7 | # 0.2.1 (2021-12-12) 8 | 9 | ## Bug Fixes 10 | 11 | - Change print statements to logging calls so we don't interrupt other libraries. 12 | - Update to websockets 10 for security issues. 13 | 14 | # 0.2.0 (2020-05-10) 15 | 16 | ## Bug Fixes 17 | 18 | - DeviceRemoved event no longer tries to use non-existent dict method 19 | - Fixed wrong enum naming 20 | - Client now actually sends client name 21 | 22 | # 0.1.0 (2019-09-07) 23 | 24 | ## Features 25 | 26 | - Add support for request logs/log handling 27 | - Actually raise exceptions on errors 28 | - Lots of documentation additions 29 | - Adjust naming to match other libraries 30 | (ButtplugDeviceClient.allowed_messages) or python idioms (Exceptions 31 | end in Error) 32 | 33 | # 0.0.2 (2019-09-05) 34 | 35 | ## Features 36 | 37 | - Possibly the most minimal implementation of a Buttplug Client possible 38 | - Supports the handshake/enumeration messages and the basic generic 39 | messages. That's it. 40 | - Still needs documentation, rest of features, etc... but is very 41 | basically usable, assuming you are as committed to python 3.7 as I 42 | am. 43 | 44 | # 0.0.1 (2019-04-13) 45 | 46 | ## Features 47 | 48 | - Squatting the name on PyPi 49 | - Get it? 50 | - Squatting 51 | - And the project is named buttplug 52 | - Get it? 53 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our 7 | project and our community a harassment-free experience for everyone, 8 | regardless of age (see addendum), body size, disability, ethnicity, 9 | gender identity and expression, level of experience, nationality, 10 | personal appearance, race, religion, or sexual identity and 11 | orientation. 12 | 13 | ## Our Standards 14 | 15 | Examples of behavior that contributes to creating a positive 16 | environment include: 17 | 18 | * Using welcoming and inclusive language 19 | * Being respectful of differing viewpoints and experiences 20 | * Gracefully accepting constructive criticism 21 | * Focusing on what is best for the community 22 | * Showing empathy towards other community members 23 | 24 | Examples of unacceptable behavior by participants include: 25 | 26 | * The use of unnecessary sexualized language or imagery and unwelcome 27 | sexual attention or advances 28 | * Trolling, insulting/derogatory comments, and personal or political 29 | attacks 30 | * Public or private harassment 31 | * Publishing others' private information, such as a physical or 32 | electronic address, without explicit permission 33 | * Other conduct which could reasonably be considered inappropriate in 34 | a professional setting 35 | 36 | ## Our Responsibilities 37 | 38 | Project maintainers are responsible for clarifying the standards of 39 | acceptable behavior and are expected to take appropriate and fair 40 | corrective action in response to any instances of unacceptable 41 | behavior. 42 | 43 | Project maintainers have the right and responsibility to remove, edit, 44 | or reject comments, commits, code, wiki edits, issues, and other 45 | contributions that are not aligned to this Code of Conduct, or to ban 46 | temporarily or permanently any contributor for other behaviors that 47 | they deem inappropriate, threatening, offensive, or harmful. 48 | 49 | ## Scope 50 | 51 | This Code of Conduct applies both within project spaces and in public 52 | spaces when an individual is representing the project or its 53 | community. Examples of representing a project or community include 54 | using an official project e-mail address, posting via an official 55 | social media account, or acting as an appointed representative at an 56 | online or offline event. Representation of a project may be further 57 | defined and clarified by project maintainers. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior 62 | May be reported by contacting the project team at 63 | admin@metafetish.com. All complaints will be reviewed and investigated 64 | and will result in a response that is deemed necessary and appropriate 65 | to the circumstances. The project team is obligated to maintain 66 | confidentiality with regard to the reporter of an incident. Further 67 | details of specific enforcement policies may be posted separately. 68 | 69 | Project maintainers who do not follow or enforce the Code of Conduct 70 | in good faith may face temporary or permanent repercussions as 71 | determined by other members of the project's leadership. 72 | 73 | ## Addendum for Metafetish Related Projects 74 | 75 | While the project team seeks to welcome all contributors and 76 | participants, due to the sexual nature of projects related to the 77 | Metafetish organization, all contributors and participants should be 78 | of provable legal age. 79 | 80 | Working on projects related to the Metafetish organization may at 81 | times require sexual situations to be discussed in project forums, be 82 | they message boards, social media, chat systems, or other means not 83 | listed here. These discussions should pertain only to usage of the 84 | projects they involve, and should only include required details to 85 | express some sort of issue or feature request. If the discussion 86 | includes material that others may find objectionable for some reason, 87 | the discussions should be prepended with content warnings. 88 | 89 | While usage stories about projects are appreciated, unless they 90 | express some issue with specific usage, we ask that they be kept to 91 | outside forums, where they would be appropriate. There are many 92 | sub-reddits, fetlife groups, and forum instances available for a 93 | multitude of interests where usage can be discussed. If a proper venue 94 | is not known, please contact project maintainers, as they may be able 95 | to point to one. 96 | 97 | If any contributor or project member feels that these considerations 98 | have not been met, they should contact the project maintainers at 99 | admin@metafetish.com. 100 | 101 | # Moderation 102 | 103 | These are the policies for upholding our community's standards of 104 | conduct. If you feel that a thread needs moderation, please contact 105 | the [Metafetish moderation team](mailto:admin@metafetish.com). 106 | 107 | 1. Remarks that violate the standard of conduct, including hateful, 108 | hurtful, oppressive, or exclusionary remarks, are not allowed. 109 | (Cursing is allowed, but never targeting another user, and never in 110 | a hateful manner.) 111 | 2. Remarks that moderators find inappropriate, whether listed in the 112 | code of conduct or not, are also not allowed. 113 | 3. Moderators will first respond to such remarks with a warning. 114 | 4. If the warning is unheeded, the user will be "kicked," i.e., kicked 115 | out of the communication channel to cool off. 116 | 5. If the user comes back and continues to make trouble, they will be 117 | banned, i.e., indefinitely excluded. 118 | 6. Moderators may choose at their discretion to un-ban the user if it 119 | was a first offense and they offer the offended party a genuine 120 | apology. 121 | 7. If a moderator bans someone and you think it was unjustified, 122 | please take it up with that moderator, or with a different 123 | moderator, **in private**. Complaints about bans in-channel are not 124 | allowed. 125 | 8. Moderators are held to a higher standard than other community 126 | members. If a moderator creates an inappropriate situation, they 127 | should expect less leeway than others. 128 | 129 | In this community we strive to go the extra step to look out for each 130 | other. Don't just aim to be technically unimpeachable, try to be your 131 | best self. In particular, avoid flirting with offensive or sensitive 132 | issues, particularly if they're off-topic; this all too often leads to 133 | unnecessary fights, hurt feelings, and damaged trust; worse, it can 134 | drive people away from the community entirely. 135 | 136 | And if someone takes issue with something you said or did, resist the 137 | urge to be defensive. Just stop doing what it was they complained 138 | about and apologize. Even if you feel you were misinterpreted or 139 | unfairly accused, chances are good there was something you could've 140 | communicated better — remember that it's your responsibility to make 141 | your fellow community members comfortable. Everyone wants to get along 142 | and we are all here first and foremost because we want to talk about 143 | cool technology. You will find that people will be eager to assume 144 | good intent and forgive as long as you earn their trust. 145 | 146 | The enforcement policies listed above apply to all official Metafetish 147 | venues; including all Slack channels and their related bridged IRC 148 | channels, repositories and their respective issue trackers/wikis/etc, 149 | message boards, and social media networks. For other projects adopting 150 | this Code of Conduct, please contact the maintainers of those projects 151 | for enforcement. If you wish to use this code of conduct for your own 152 | project, consider explicitly mentioning your moderation policy or 153 | making a copy with your own moderation policy so as to avoid 154 | confusion. 155 | 156 | # Attribution 157 | 158 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 159 | available at [http://contributor-covenant.org/version/1/4][version] 160 | 161 | [homepage]: http://contributor-covenant.org 162 | [version]: http://contributor-covenant.org/version/1/4/ 163 | 164 | The Moderation portion of this Code of Conduct is adapted from 165 | the [Rust Language Code of Conduct](https://www.rust-lang.org/en-US/conduct.html). -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in contributing to the Metafetish 4 | Organization repo! We're looking forward to working with you to make 5 | this project better. 6 | 7 | ## Code Of Conduct 8 | 9 | First off, you'll want to check out our [Code of Conduct](CODE_OF_CONDUCT.md), which 10 | should be alongside this document. It outlines the rules and 11 | expectations for interaction with our project. 12 | 13 | ## For All Community 14 | 15 | This section contains information for everyone involved with the 16 | projects, whether developing or simply interested in using and 17 | recommending improvements. 18 | 19 | ### Communication 20 | 21 | There are a couple of different ways in which you can interact with 22 | other members of Metafetish projects. 23 | 24 | - [We have a Slack instance](https://metafetish.slack.com). Note that an invitation is required 25 | to join the slack instance. Please email admin@metafetish.com for an 26 | invite, and include the email address you'd like to use to log in. 27 | - [We have message boards](http://metafetish.club). We have Discourse forums available that 28 | cover most of our projects. For anything that doesn't fit into one 29 | of the categories, there's the General forum. 30 | 31 | ### Anonymous Accounts 32 | 33 | Due to the sensitive nature of Metafetish projects, some community 34 | members prefer to use anonymous accounts, on message boards as well as 35 | for contributing to code repos. We understand the need for this, and 36 | try to be as accepting of that as possible without letting it 37 | interfere with project progress. 38 | 39 | Note that vetting by project leads will still need to occur before 40 | administration rights after given to any account on a project resource 41 | (forums, repos, etc), anonymous or otherwise. 42 | 43 | ### Filing feature requests 44 | 45 | If there are features you'd like in a project, you may request them by: 46 | 47 | - If you have a github account, filing a github issue on the project. 48 | - Otherwise, make a post on the message board in the appropriate 49 | category, or on the General category if a proper category does not 50 | exist. 51 | 52 | Please be specific in your feature request. We will ask followup 53 | questions for clarification, but the more information we have, the 54 | better. 55 | 56 | ### Filing bug reports 57 | 58 | If you find a problem in a project, please do not hesitate to tell us. 59 | 60 | - If you have a github account, filing a github issue on the project. 61 | - Otherwise, make a post on the message board in the appropriate 62 | category, or on the General category if a proper category does not 63 | exist. 64 | 65 | In the issue or post, you should let us know: 66 | 67 | - The software you are using that has the bug 68 | - The version of the software 69 | - The operating system version of the computer you using the software 70 | on. 71 | - The steps you took to get to the problem. 72 | - Whether the problem is repeatable. 73 | 74 | Someone should hopefully follow up on your problem soon. 75 | 76 | ## For Developer Community 77 | 78 | This section contains information mainly related to helping in 79 | development of Metafetish projects. 80 | 81 | ### Getting up and running on a project 82 | 83 | In many cases, if you are trying to start developing a new project, 84 | information about compiling and using the project will be in the 85 | README. If these instructions are missing or incomplete, you can ask 86 | in the [proper category on the message boards]( http://metafetish.club), or file an issue on 87 | the project if you have a github account. You can also contact us on 88 | Slack if you have an account there. 89 | 90 | Note that some of our projects are rather complicated, and span 91 | multiple repositories and/or technologies. We do our best to keep 92 | things up to date, but there may be times where we've missed updating 93 | documentation. If something seems wrong, or isn't working for you, ask 94 | us using one of the above methods. 95 | 96 | ### Continuous Integration 97 | 98 | In as many cases as possible, we have added continuous integration 99 | services to run build checks on our software projects. These will 100 | normally be [Travis](http://travis-ci.org) for macOS and linux builds (or platform 101 | independent builds, like Node with no native requirements), 102 | and [Appveyor](http://appveyor.com) for Windows builds. CI Badges are usually added to 103 | the README. 104 | 105 | ### Git/Github Workflow 106 | 107 | This section goes over our git workflow. We realize that git can be 108 | quite complicated and has a steep learning curve. We have done as much 109 | as we can to make sure Github makes this easy for contributors. If you 110 | are new to git, or if you do not understand some part of this section, 111 | please let us know when you make a pull request, and we'll help out. 112 | If you are not sure how to make a pull request on github, 113 | contact [admin@metafetish.com](mailto:admin@metafetish.com) and a project lead will help. 114 | 115 | As of this writing, Metafetish projects are maintained on 116 | the [Metafetish Organization on Github](http://github.com/metafetish). 'master' branches on 117 | Metafetish projects are kept as [Github protected branches](https://help.github.com/articles/about-protected-branches/), with 118 | the following settings. 119 | 120 | - All of the following rules apply to both users and administrators. 121 | - No direct pushes to 'master'. All changes must be via Pull Request 122 | (PR). 123 | - No force pushes to 'master'. All rewrites must be done on feature 124 | branches. 125 | - PRs must be off the end of the 'master' branch to merge to master. 126 | Github will enforce this in PRs. 127 | - PRs must pass CI to merge. Due to the hardware focus of many 128 | Metafetish projects, tests may be difficult to write in languages 129 | without proper mocking utilities. Therefore, Code Coverage increase 130 | is nice, but not required. 131 | - PRs should have a reviewer if possible, but this is not enforced. 132 | 133 | Metafetish organization projects maintain a 'rebase-only' workflow to 134 | master when possible, where all branches will be a fast-forward merge. 135 | Github PRs should manage this themselves, and will display an error if 136 | this is not possible. Project management will be happy to work with 137 | you to resolve the issue. 138 | 139 | In order to reduce workload of contributors, repo dependencies should 140 | be brought in by using the [git subtree method](https://developer.atlassian.com/blog/2015/05/the-power-of-git-subtree/) instead of git 141 | submodules. As this will require upkeep and documentation, please 142 | discuss possible repo inclusion with project leads before submitting 143 | pull requests with subtree merges. 144 | 145 | ### Project Management 146 | 147 | For project management, we usually use either [Trello](http://trello.com) 148 | or [ZenHub](http://zenhub.io), depending on the level of integration needed with the 149 | source code repo itself. More information about this is usually 150 | included in the README for the specific project. 151 | 152 | ### Documentation 153 | 154 | Non-code documentation for projects is usually done in one of two 155 | formats: 156 | 157 | - Markdown, for all README and contributor facing files. 158 | - org-mode, for large documentation sets and manuals. 159 | 160 | As there is currently only one project lead using org-mode (but they 161 | write most of the documentation), conversation from org-mode to 162 | markdown can happen on request. Similarly, markdown versions of 163 | org-mode documents may be checked in to documentation repos as needed. 164 | 165 | Large manuals are usually managed using the [gitbook](https://github.com/GitbookIO/gitbook) format. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Buttplug is covered under the following BSD 3-Clause License 2 | 3 | Copyright (c) 2017-2018, Nonpolynomial Labs, LLC 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | 31 | -- 32 | 33 | WebsocketListener (https://github.com/vtortola/WebSocketListener) is 34 | covered undr the following MIT License: 35 | 36 | The MIT License (MIT) 37 | 38 | Copyright (c) 2014 vtortola 39 | 40 | Permission is hereby granted, free of charge, to any person obtaining 41 | a copy of this software and associated documentation files (the 42 | "Software"), to deal in the Software without restriction, including 43 | without limitation the rights to use, copy, modify, merge, publish, 44 | distribute, sublicense, and/or sell copies of the Software, and to 45 | permit persons to whom the Software is furnished to do so, subject to 46 | the following conditions: 47 | 48 | The above copyright notice and this permission notice shall be 49 | included in all copies or substantial portions of the Software. 50 | 51 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 52 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 53 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 54 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 55 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 56 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 57 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -- 58 | 59 | -- 60 | 61 | SharpDX (https://github.com/sharpdx/SharpDX/) is covered under the 62 | following MIT License: 63 | 64 | Copyright (c) 2010-2014 SharpDX - Alexandre Mutel 65 | 66 | Permission is hereby granted, free of charge, to any person obtaining a copy 67 | of this software and associated documentation files (the "Software"), to deal 68 | in the Software without restriction, including without limitation the rights 69 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 70 | copies of the Software, and to permit persons to whom the Software is 71 | furnished to do so, subject to the following conditions: 72 | 73 | The above copyright notice and this permission notice shall be included in 74 | all copies or substantial portions of the Software. 75 | 76 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 77 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 78 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 79 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 80 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 81 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 82 | THE SOFTWARE. 83 | 84 | -- 85 | 86 | Newtonsoft.JSON (http://newtonsoft.com/json) is covered under the 87 | following MIT License: 88 | 89 | The MIT License (MIT) 90 | 91 | Copyright (c) 2007 James Newton-King 92 | 93 | Permission is hereby granted, free of charge, to any person obtaining 94 | a copy of this software and associated documentation files (the 95 | "Software"), to deal in the Software without restriction, including 96 | without limitation the rights to use, copy, modify, merge, publish, 97 | distribute, sublicense, and/or sell copies of the Software, and to 98 | permit persons to whom the Software is furnished to do so, subject to 99 | the following conditions: 100 | 101 | The above copyright notice and this permission notice shall be 102 | included in all copies or substantial portions of the Software. 103 | 104 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 105 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 106 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 107 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 108 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 109 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 110 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 111 | 112 | -- 113 | 114 | NLog (http://nlog-project.org) is covered under the following BSD 115 | License: 116 | 117 | Copyright (c) 2004-2016 Jaroslaw Kowalski , Kim 118 | Christensen, Julian Verdurmen 119 | 120 | All rights reserved. 121 | 122 | Redistribution and use in source and binary forms, with or without 123 | modification, are permitted provided that the following conditions 124 | are met: 125 | 126 | * Redistributions of source code must retain the above copyright notice, 127 | this list of conditions and the following disclaimer. 128 | 129 | * Redistributions in binary form must reproduce the above copyright notice, 130 | this list of conditions and the following disclaimer in the documentation 131 | and/or other materials provided with the distribution. 132 | 133 | * Neither the name of Jaroslaw Kowalski nor the names of its 134 | contributors may be used to endorse or promote products derived from this 135 | software without specific prior written permission. 136 | 137 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 138 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 139 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 140 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 141 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 142 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 143 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 144 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 145 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 146 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF 147 | THE POSSIBILITY OF SUCH DAMAGE. 148 | 149 | -- 150 | 151 | XUnit (http://xunit.github.io) is covered under the following Apache License: 152 | 153 | Copyright (c) .NET Foundation and Contributors 154 | All Rights Reserved 155 | 156 | Licensed under the Apache License, Version 2.0 (the "License"); 157 | you may not use this file except in compliance with the License. 158 | You may obtain a copy of the License at 159 | 160 | http://www.apache.org/licenses/LICENSE-2.0 161 | 162 | Unless required by applicable law or agreed to in writing, software 163 | distributed under the License is distributed on an "AS IS" BASIS, 164 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 165 | See the License for the specific language governing permissions and 166 | limitations under the License. 167 | 168 | -- 169 | 170 | SharpRaven (https://github.com/getsentry/raven-csharp/) is covered 171 | under the following BSD 3-Clause License: 172 | 173 | Copyright (c) 2013 The Sentry Team and individual contributors. 174 | All rights reserved. 175 | 176 | Redistribution and use in source and binary forms, with or without 177 | modification, are permitted provided that the following conditions are 178 | met: 179 | 180 | 1. Redistributions of source code must retain the above copyright 181 | notice, this list of conditions and the following disclaimer. 182 | 183 | 2. Redistributions in binary form must reproduce the above 184 | copyright notice, this list of conditions and the following 185 | disclaimer in the documentation and/or other materials provided 186 | with the distribution. 187 | 188 | 3. Neither the name of the Sentry nor the names of its 189 | contributors may be used to endorse or promote products derived 190 | from this software without specific prior written permission. 191 | 192 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 193 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 194 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 195 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 196 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 197 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 198 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 199 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 200 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 201 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 202 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 203 | 204 | -- 205 | 206 | OpenCover (https://github.com/OpenCover/opencover/) is covered under 207 | the following MIT License: 208 | 209 | OpenCover is released under the following MIT compatible software 210 | licence this does not apply to any other software, be that source 211 | code, compiled libraries or tools, that OpenCover may rely on or use 212 | and that that software will continue to retain whatever licence they 213 | were released under. OpenCover Licence 214 | 215 | Copyright (c) 2011-2012 Shaun Wilde 216 | 217 | Permission is hereby granted, free of charge, to any person obtaining 218 | a copy of this software and associated documentation files (the 219 | "Software"), to deal in the Software without restriction, including 220 | without limitation the rights to use, copy, modify, merge, publish, 221 | distribute, sublicense, and/or sell copies of the Software, and to 222 | permit persons to whom the Software is furnished to do so, subject to 223 | the following conditions: 224 | 225 | The above copyright notice and this permission notice shall be 226 | included in all copies or substantial portions of the Software. 227 | 228 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 229 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 230 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 231 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 232 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 233 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 234 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 235 | 236 | -- 237 | 238 | JetBrains Annotations is covered under the following Apache License: 239 | 240 | Copyright 2007-2011 JetBrains s.r.o. 241 | 242 | Licensed under the Apache License, Version 2.0 (the "License"); 243 | you may not use this file except in compliance with the License. 244 | You may obtain a copy of the License at 245 | 246 | http://www.apache.org/licenses/LICENSE-2.0 247 | 248 | Unless required by applicable law or agreed to in writing, software 249 | distributed under the License is distributed on an "AS IS" BASIS, 250 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 251 | See the License for the specific language governing permissions and 252 | limitations under the License. 253 | 254 | -- 255 | 256 | NJsonSchema (https://github.com/RSuter/NJsonSchema) is covered under the 257 | following MIT License: 258 | 259 | The MIT License (MIT) 260 | 261 | Copyright (c) 2016 Rico Suter 262 | 263 | Permission is hereby granted, free of charge, to any person obtaining 264 | a copy of this software and associated documentation files (the 265 | "Software"), to deal in the Software without restriction, including 266 | without limitation the rights to use, copy, modify, merge, publish, 267 | distribute, sublicense, and/or sell copies of the Software, and to 268 | permit persons to whom the Software is furnished to do so, subject to 269 | the following conditions: 270 | 271 | The above copyright notice and this permission notice shall be 272 | included in all copies or substantial portions of the Software. 273 | 274 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 275 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 276 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 277 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 278 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 279 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 280 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 281 | 282 | -- 283 | 284 | BouncyCastle (http://www.bouncycastle.org) is covered under the following 285 | adapted MIT X11 License: 286 | 287 | LICENSE 288 | 289 | Copyright (c) 2000 - 2017 The Legion of the Bouncy Castle Inc. 290 | (http://www.bouncycastle.org) 291 | 292 | Permission is hereby granted, free of charge, to any person obtaining a copy 293 | of this software and associated documentation files (the "Software"), to deal 294 | in the Software without restriction, including without limitation the rights 295 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 296 | copies of the Software, and to permit persons to whom the Software is 297 | furnished to do so, subject to the following conditions: 298 | 299 | The above copyright notice and this permission notice shall be included in 300 | all copies or substantial portions of the Software. 301 | 302 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 303 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 304 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 305 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 306 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 307 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 308 | THE SOFTWARE. 309 | 310 | -- 311 | 312 | EasyHook (https://easyhook.github.io/) is covered under the following MIT License: 313 | 314 | Copyright (c) 2009 Christoph Husse & Copyright (c) 2012 Justin Stenning 315 | 316 | Permission is hereby granted, free of charge, to any person obtaining a copy 317 | of this software and associated documentation files (the "Software"), to deal 318 | in the Software without restriction, including without limitation the rights 319 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 320 | copies of the Software, and to permit persons to whom the Software is 321 | furnished to do so, subject to the following conditions: 322 | 323 | The above copyright notice and this permission notice shall be included in 324 | all copies or substantial portions of the Software. 325 | 326 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 327 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 328 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 329 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 330 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 331 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 332 | THE SOFTWARE. -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | websockets = "==10.1" 10 | 11 | [requires] 12 | python_version = "3.8" 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # buttplug-py (DEPRECATED) 2 | 3 | [![PyPi version](https://img.shields.io/pypi/v/buttplug)](http://pypi.org/project/buttplug) 4 | [![Python version](https://img.shields.io/pypi/pyversions/buttplug)](http://pypi.org/project/buttplug) 5 | 6 | [![Patreon donate button](https://img.shields.io/badge/patreon-donate-yellow.svg)](https://www.patreon.com/qdot) 7 | [![Discord](https://img.shields.io/discord/353303527587708932.svg?logo=discord)](https://discord.buttplug.io) 8 | [![Twitter](https://img.shields.io/twitter/follow/buttplugio.svg?style=social&logo=twitter)](https://twitter.com/buttplugio) 9 | 10 | ### THIS VERSION OF BUTTPLUG-PY IS DEPRECATED IN FAVOR OF [COMMUNITY MAINTAINED LIBRARIES](https://github.com/buttplugio/awesome-buttplug#python). 11 | 12 | The community maintained library brings in support for Buttplug Spec v3, as well as many nice to have features. It is recommended to use that instead of this library. 13 | 14 | ## Old Intro 15 | 16 | Buttplug-py is a python implementation of the Core and Client portions of the Buttplug Sex Toy 17 | Control Protocol. It allows users to write applications that can connect to Buttplug Servers, such 18 | as the [Intiface Desktop Application](https://github.com/intiface/intiface-desktop) or [Intiface Rust CLI](https://github.com/intiface/intiface-cli-rs). 19 | 20 | For more information on the Buttplug project, check out the project website at 21 | [https://buttplug.io](https://buttplug.io). 22 | 23 | ## Table Of Contents 24 | 25 | - [Support The Project](#support-the-project) 26 | - [Documentation](#documentation) 27 | - [Examples](#examples) 28 | - [License](#license) 29 | 30 | ## Support The Project 31 | 32 | If you find this project helpful, you can [support us via Patreon](http://patreon.com/qdot) or 33 | [Github Sponsors](http://github.com/sponsors/qdot)! Every donation helps us afford more hardware to 34 | reverse, document, and write code for! 35 | 36 | ## Documentation 37 | 38 | Library and API Documentation for buttplug-py is available at 39 | 40 | https://buttplug-py.docs.buttplug.io 41 | 42 | Other recommended reading includes 43 | 44 | - [The Buttplug Protocol Spec](https://buttplug-spec.docs.buttplug.io) 45 | - [The Buttplug Developer Guide](https://buttplug-developer-guide.docs.buttplug.io) 46 | 47 | ## Examples 48 | 49 | Example code is available in the examples/ directory. Examples are heavily commented to hopefully 50 | make usage of the library clearer. 51 | 52 | ## License 53 | 54 | Buttplug is BSD 3-Clause licensed. More information is available in the LICENSE file. 55 | -------------------------------------------------------------------------------- /buttplug/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.3.0" 2 | name = "buttplug" 3 | -------------------------------------------------------------------------------- /buttplug/client/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import ButtplugClient, ButtplugClientDevice 2 | from .connector import ButtplugClientConnector, ButtplugClientConnectorError 3 | from .websocket_connector import ButtplugClientWebsocketConnector 4 | 5 | __all__ = ["ButtplugClient", "ButtplugClientConnector", 6 | "ButtplugClientWebsocketConnector", "ButtplugClientDevice", 7 | "ButtplugClientConnectorError"] 8 | -------------------------------------------------------------------------------- /buttplug/client/client.py: -------------------------------------------------------------------------------- 1 | # Buttplug Python 2 | # Client Module 3 | # Copyright 2019 Nonpolynomial 4 | # 3-Clause BSD Licensed 5 | 6 | from .connector import (ButtplugClientConnector, 7 | ButtplugClientConnectorObserver, 8 | ButtplugClientConnectorError) 9 | from ..core import (ButtplugMessage, StartScanning, StopScanning, Ok, 10 | RequestServerInfo, Error, ServerInfo, 11 | ButtplugMessageError, RequestLog, DeviceAdded, 12 | DeviceList, DeviceRemoved, ScanningFinished, DeviceInfo, 13 | MessageAttributes, VibrateCmd, SpeedSubcommand, 14 | RequestDeviceList, RotateSubcommand, LinearSubcommand, 15 | RotateCmd, LinearCmd, StopDeviceCmd, ButtplugErrorCode, 16 | ButtplugError, Log, ButtplugDeviceError, 17 | ButtplugHandshakeError, ButtplugPingError, 18 | ButtplugUnknownError) 19 | from ..utils import EventHandler 20 | from typing import Dict, List, Tuple, Union 21 | from asyncio import Future, get_event_loop 22 | import logging, sys 23 | 24 | 25 | class ButtplugClient(ButtplugClientConnectorObserver): 26 | """Used to connect to Buttplug Servers. 27 | 28 | Attributes: 29 | 30 | name (string): 31 | name of the client, which the server can use to show 32 | with connection status. 33 | 34 | devices (Dict[int, ButtplugClientDevice]): 35 | dict of devices currently connected to the Buttplug Server, indexed 36 | by their server-provisioned numerical index. 37 | 38 | device_added_handler (buttplug.utils.EventHandler): 39 | Takes functions of the format f(a: ButtplugClientDevice) -> void. 40 | Calls handlers whenever a new device is found by the Buttplug 41 | Server. 42 | 43 | device_removed_handler (buttplug.utils.EventHandler): 44 | Takes functions of the format f(a: ButtplugClientDevice) -> void. 45 | Calls handlers whenever a device has disconnected from the Buttplug 46 | server. 47 | 48 | scanning_finished_handler (buttplug.utils.EventHandler): 49 | Takes functions of the format f() -> void. Calls handlers whenever 50 | the server has finished scanning for devices. 51 | 52 | log_handler (buttplug.utils.EventHandler): 53 | Takes functions of the format f(a: Log) -> void. Calls handlers 54 | whenever a new log message is received. 55 | """ 56 | def __init__(self, name: str): 57 | self.name: str = name 58 | self.connector: ButtplugClientConnector = None 59 | self.devices: Dict[int, ButtplugClientDevice] = {} 60 | self.scanning_finished_handler: EventHandler = EventHandler(self) 61 | self.device_added_handler: EventHandler = EventHandler(self) 62 | self.device_removed_handler: EventHandler = EventHandler(self) 63 | self.log_handler: EventHandler = EventHandler(self) 64 | self._msg_tasks: Dict[int, Future] = {} 65 | self._msg_counter: int = 1 66 | 67 | async def connect(self, connector): 68 | """Connects to a Buttplug Server, using the connector passed to it. 69 | 70 | Asynchronous function that connects to a Buttplug Server. 71 | 72 | Args: 73 | connector (ButtplugConnector): 74 | Connector to use to contact the server. 75 | 76 | Returns: 77 | void: 78 | Should just return on successful connect. 79 | 80 | Raises: 81 | buttplug.client.ButtplugClientConnectorError: 82 | On failed connect. Check message for context. 83 | """ 84 | self.connector = connector 85 | self.connector.add_observer(self) 86 | await self.connector.connect() 87 | await self._init() 88 | 89 | async def _init(self): 90 | initmsg = RequestServerInfo(self.name) 91 | msg: ServerInfo = await self._send_message_expect_reply(initmsg, 92 | ServerInfo) 93 | logging.info("Connected to server: " + msg.server_name) 94 | dl: DeviceList = await self._send_message_expect_reply(RequestDeviceList(), 95 | DeviceList) 96 | self._handle_device_list(dl) 97 | 98 | def _handle_device_list(self, dl: DeviceList): 99 | for dev in dl.devices: 100 | self.devices[dev.device_index] = ButtplugClientDevice(self, dev) 101 | self.device_added_handler(self.devices[dev.device_index]) 102 | 103 | async def disconnect(self): 104 | """Disconnect from the remote server. 105 | """ 106 | if not self.connector.connected: 107 | return 108 | await self.connector.disconnect() 109 | 110 | async def start_scanning(self): 111 | """Request that the server starts scanning for devices. 112 | """ 113 | await self._send_message_expect_ok(StartScanning()) 114 | 115 | async def stop_scanning(self): 116 | """Request that the server stops scanning for devices. 117 | """ 118 | await self._send_message_expect_ok(StopScanning()) 119 | 120 | async def request_log(self, log_level: str): 121 | """Request that the server sends logs at the requested level or higher to the 122 | client. 123 | 124 | To stop logs from being sent, call request_log again with the "Off" 125 | level. 126 | 127 | Args: 128 | log_level (string): 129 | Log level to receive. Send "Off" to stop logs from being sent. 130 | """ 131 | await self._send_message_expect_ok(RequestLog(log_level)) 132 | 133 | async def _send_message(self, msg: ButtplugMessage): 134 | msg.id = self._msg_counter 135 | self._msg_counter += 1 136 | await self.connector.send(msg) 137 | 138 | async def _parse_message(self, msg: ButtplugMessage): 139 | if isinstance(msg, DeviceAdded): 140 | da: DeviceAdded = msg 141 | self.devices[da.device_index] = ButtplugClientDevice(self, da) 142 | self.device_added_handler(self.devices[da.device_index]) 143 | elif isinstance(msg, DeviceRemoved): 144 | dr: DeviceRemoved = msg 145 | removed_device: ButtplugClientDevice = self.devices[dr.device_index] 146 | self.devices.pop(dr.device_index) 147 | self.device_removed_handler(removed_device) 148 | elif isinstance(msg, ScanningFinished): 149 | self.scanning_finished_handler() 150 | elif isinstance(msg, Log): 151 | self.log_handler(Log) 152 | 153 | # What kinda typing should expectedClass be here? Could we make this a 154 | # generic function? 155 | async def _send_message_expect_reply(self, 156 | msg: ButtplugMessage, 157 | expectedClass) -> ButtplugMessage: 158 | if not self.connector.connected: 159 | raise ButtplugClientConnectorError("Client not connected to server") 160 | f = get_event_loop().create_future() 161 | await self._send_message(msg) 162 | self._msg_tasks[msg.id] = f 163 | retmsg = await f 164 | if not isinstance(retmsg, expectedClass): 165 | if isinstance(retmsg, Error): 166 | # This will always throw 167 | self._throw_error_msg_exception(retmsg) 168 | raise ButtplugMessageError("Unexpected message" + retmsg) 169 | return retmsg 170 | 171 | async def _send_message_expect_ok(self, msg: ButtplugMessage) -> None: 172 | await self._send_message_expect_reply(msg, Ok) 173 | 174 | async def _handle_message(self, msg: ButtplugMessage): 175 | if msg.id in self._msg_tasks.keys(): 176 | self._msg_tasks[msg.id].set_result(msg) 177 | return 178 | await self._parse_message(msg) 179 | 180 | def _throw_error_msg_exception(self, msg: Error): 181 | if msg.error_code == ButtplugErrorCode.ERROR_UNKNOWN: 182 | raise ButtplugUnknownError(msg) 183 | elif msg.error_code == ButtplugErrorCode.ERROR_DEVICE: 184 | raise ButtplugDeviceError(msg) 185 | elif msg.error_code == ButtplugErrorCode.ERROR_MSG: 186 | raise ButtplugMessageError(msg) 187 | elif msg.error_code == ButtplugErrorCode.ERROR_PING: 188 | raise ButtplugPingError(msg) 189 | elif msg.error_code == ButtplugErrorCode.ERROR_INIT: 190 | raise ButtplugHandshakeError(msg) 191 | raise ButtplugError(msg) 192 | 193 | 194 | class ButtplugClientDevice(object): 195 | """Represents a device that is connected to the Buttplug Server. 196 | 197 | Attributes: 198 | 199 | name (string): 200 | Name of the device 201 | 202 | allowed_messages (Dict[str, MessageAttributes]): 203 | Dictionary that matches message names to attributes. For instance, 204 | if a device can vibrate, it will have a dictionary entry for 205 | "VibrateCmd", as well as a MessageAttribute for "FeatureCount" that 206 | says how many vibrators are in the device. 207 | """ 208 | def __init__(self, client: ButtplugClient, device_msg: Union[DeviceInfo, 209 | DeviceAdded]): 210 | self._client = client 211 | if isinstance(device_msg, DeviceInfo): 212 | device_info: DeviceInfo = device_msg 213 | self.name = device_info.device_name 214 | self._index = device_info.device_index 215 | self.allowed_messages: Dict[str, MessageAttributes] = {} 216 | for (msg_name, attrs) in device_info.device_messages.items(): 217 | self.allowed_messages[msg_name] = MessageAttributes(attrs.get("FeatureCount")) 218 | elif isinstance(device_msg, DeviceAdded): 219 | device_info: DeviceAdded = device_msg 220 | self.name = device_info.device_name 221 | self._index = device_info.device_index 222 | self.allowed_messages: Dict[str, MessageAttributes] = {} 223 | logging.debug(device_info.device_messages) 224 | for (msg_name, attrs) in device_info.device_messages.items(): 225 | self.allowed_messages[msg_name] = MessageAttributes(attrs.get("FeatureCount")) 226 | else: 227 | raise ButtplugDeviceError( 228 | "Cannot create device from message {}".format(device_msg.__name__)) 229 | 230 | async def send_vibrate_cmd(self, speeds: Union[float, 231 | List[float], 232 | Dict[int, float]]): 233 | """Tell the server to make a device vibrate at a certain speed. 0.0 for speed 234 | or using send_stop_device_cmd will stop the hardware from vibrating. 235 | 236 | Args: 237 | speeds (Union[float, List[float], Dict[int, float]]): 238 | Speed, or speeds, to set the vibrators to, assuming the 239 | hardware supports vibration. Range is from 0.0 <= x <= 1.0. 240 | 241 | Types accepted: 242 | 243 | - a single float, which all vibration motors will be set to 244 | 245 | - a list of floats, mapping to the motor indexes in the 246 | hardware, i.e. [0.5, 1.0] will set motor 0 to 0.5, motor 1 to 247 | 1. 248 | 249 | - a dict of int to float, which maps motor index to speed. i.e. 250 | { 0: 0.5, 1: 1.0 } will set motor 0 to 0.5, motor 1 to 1. 251 | 252 | """ 253 | if "VibrateCmd" not in self.allowed_messages.keys(): 254 | raise ButtplugDeviceError("VibrateCmd not supported by device") 255 | speeds_obj = [] 256 | if isinstance(speeds, (float, int)): 257 | speeds_obj = [SpeedSubcommand(0, speeds)] 258 | elif isinstance(speeds, list): 259 | speeds_obj = [SpeedSubcommand(x, speed) 260 | for x, speed in enumerate(speeds)] 261 | elif isinstance(speeds, dict): 262 | speeds_obj = [SpeedSubcommand(x, speed) 263 | for x, speed in speeds.items()] 264 | 265 | msg = VibrateCmd(self._index, 266 | speeds_obj) 267 | await self._client._send_message_expect_ok(msg) 268 | 269 | async def send_rotate_cmd(self, rotations: Union[Tuple[float, bool], 270 | List[Tuple[float, bool]], 271 | Dict[int, Tuple[float, bool]]]): 272 | """Tell the server to make a device rotate at a certain speed. 0.0 for speed or 273 | using send_stop_device_cmd will stop the hardware from rotating. 274 | 275 | Args: 276 | rotations (Union[Tuple[float, bool], List[Tuple[float, bool]], Dict[int, Tuple[float, bool]]]): 277 | Rotation speed(s) and directions, to set the hardware to, 278 | assuming the hardware supports rotation.. Range is from 0.0 <= 279 | x <= 1.0 for speeds. For bool, True is clockwise direction, 280 | False is counterclockwise. 281 | 282 | Types accepted: 283 | 284 | - a Tuple of [float, bool], which all rotators will be set to 285 | 286 | - a list of Tuple[float, bool], mapping to the rotator indexes 287 | in the hardware, i.e. [(0.5, False), (1.0, True)] will set 288 | motor 0 to 50% speed going counterclockwise, motor 1 to 100% 289 | speed going clockwise. 290 | 291 | - a dict of int to Tuple[float, bool], mapping rotator indexes 292 | in the hardware, i.e. { 0: (0.5, False), 1: (1.0, True)} will 293 | set motor 0 to 50% speed going counterclockwise, motor 1 to 294 | 100% speed going clockwise. 295 | 296 | """ 297 | if "RotateCmd" not in self.allowed_messages.keys(): 298 | raise ButtplugDeviceError("RotateCmd not supported by device") 299 | rotations_obj = [] 300 | if isinstance(rotations, tuple): 301 | rotations_obj = [RotateSubcommand(0, rotations[0], rotations[1])] 302 | elif isinstance(rotations, list): 303 | rotations_obj = [RotateSubcommand(x, rot[0], rot[1]) 304 | for x, rot in enumerate(rotations)] 305 | elif isinstance(rotations, dict): 306 | rotations_obj = [RotateSubcommand(x, rot[0], rot[1]) 307 | for x, rot in rotations.items()] 308 | 309 | msg = RotateCmd(self._index, 310 | rotations_obj) 311 | await self._client._send_message_expect_ok(msg) 312 | 313 | async def send_linear_cmd(self, linear: Union[Tuple[int, float], 314 | List[Tuple[int, float]], 315 | Dict[int, Tuple[int, float]]]): 316 | """Tell the server to make a device stroke (move linearly) at a certain speed. 317 | Use StopDeviceCmd to stop the device from moving. 318 | 319 | Args: 320 | linear (Union[Tuple[int, float], List[Tuple[int, float]], Dict[int, Tuple[int, float]]]): 321 | 322 | Linear position(s) and movement duration(s), to set the 323 | hardware to, assuming the hardware supports linear movement. 324 | Position range is from 0.0 <= x <= 1.0. Duration is in 325 | milliseconds, 1000ms = 1s. 326 | 327 | Types accepted: 328 | 329 | - a Tuple of [int, float], which all linear hardware is set to. 330 | 331 | - a list of Tuple[int, float], mapping to the linear indexes in 332 | the hardware, i.e. [(1000, 0.9), (500, 0.1)] will set linear 333 | movement 0 to 90% position and move to it over 1s, while 334 | linear movement 1 will move to 10% position over 0.5s 335 | 336 | - a dict of Tuple[int, float], mapping to the linear indexes in 337 | the hardware, i.e. {0: (1000, 0.9), 1: (500, 0.1)} will set 338 | linear movement 0 to 90% position and move to it over 1s, 339 | while linear movement 1 will move to 10% position over 0.5s 340 | 341 | """ 342 | if "LinearCmd" not in self.allowed_messages.keys(): 343 | raise ButtplugDeviceError("LinearCmd not supported by device") 344 | linear_obj = [] 345 | if isinstance(linear, tuple): 346 | linear_obj = [LinearSubcommand(0, linear[0], linear[1])] 347 | elif isinstance(linear, list): 348 | linear_obj = [LinearSubcommand(x, l[0], l[1]) 349 | for x, l in enumerate(linear)] 350 | elif isinstance(linear, dict): 351 | linear_obj = [LinearSubcommand(x, l[0], l[1]) 352 | for x, l in linear.items()] 353 | 354 | msg = LinearCmd(self._index, 355 | linear_obj) 356 | await self._client._send_message_expect_ok(msg) 357 | 358 | async def send_stop_device_cmd(self): 359 | """Tell the server to stop whatever device movements may be happening. 360 | """ 361 | await self._client._send_message_expect_ok(StopDeviceCmd(self._index)) 362 | -------------------------------------------------------------------------------- /buttplug/client/connector.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from ..core.messages import ButtplugMessage 3 | from typing import List 4 | from ..core.errors import ButtplugError 5 | 6 | 7 | class ButtplugClientConnectorError(ButtplugError): 8 | """Raised when connector has connection issues. 9 | 10 | Attributes: 11 | 12 | message (str): Describes the nature of the exception 13 | """ 14 | pass 15 | 16 | 17 | class ButtplugClientConnectorObserver(object): 18 | @abstractmethod 19 | async def handle_message(self, msg: ButtplugMessage): 20 | pass 21 | 22 | 23 | class ButtplugClientConnector(object): 24 | def __init__(self): 25 | self._observers: List[ButtplugClientConnectorObserver] = list() 26 | self._connected: bool = False 27 | 28 | @abstractmethod 29 | async def connect(self): 30 | pass 31 | 32 | @abstractmethod 33 | async def disconnect(self): 34 | pass 35 | 36 | @abstractmethod 37 | async def send(self, msg: ButtplugMessage): 38 | pass 39 | 40 | @property 41 | def connected(self): 42 | return self._connected 43 | 44 | def add_observer(self, obs: ButtplugClientConnectorObserver): 45 | self._observers.append(obs) 46 | 47 | def remove_observer(self, obs: ButtplugClientConnectorObserver): 48 | self._observers.remove(obs) 49 | 50 | async def _notify_observers(self, msg: ButtplugMessage): 51 | for obs in self._observers: 52 | await obs._handle_message(msg) 53 | -------------------------------------------------------------------------------- /buttplug/client/websocket_connector.py: -------------------------------------------------------------------------------- 1 | from .connector import ButtplugClientConnector, ButtplugClientConnectorError 2 | from ..core.messages import ButtplugMessage 3 | import websockets 4 | import asyncio 5 | import json 6 | from typing import Optional 7 | import logging 8 | 9 | 10 | class ButtplugClientWebsocketConnector(ButtplugClientConnector): 11 | 12 | def __init__(self, addr: str): 13 | super().__init__() 14 | self.addr: str = addr 15 | self.ws: Optional[websockets.WebSocketClientProtocol] 16 | 17 | async def connect(self): 18 | try: 19 | self.ws = await websockets.connect(self.addr) 20 | except ConnectionRefusedError as e: 21 | raise ButtplugClientConnectorError(e) 22 | self._connected = True 23 | asyncio.create_task(self._consumer_handler()) 24 | 25 | async def _consumer_handler(self): 26 | # Guessing that this fails out once the websocket disconnects? 27 | while True: 28 | try: 29 | message = await self.ws.recv() 30 | except Exception as e: 31 | logging.error("Exiting read loop") 32 | logging.error(e) 33 | break 34 | msg_array = json.loads(message) 35 | for msg in msg_array: 36 | bp_msg = ButtplugMessage.from_dict(msg) 37 | logging.debug(bp_msg) 38 | await self._notify_observers(bp_msg) 39 | 40 | async def send(self, msg: ButtplugMessage): 41 | msg_str = msg.as_json() 42 | msg_str = "[" + msg_str + "]" 43 | logging.debug(msg_str) 44 | await self.ws.send(msg_str) 45 | 46 | async def disconnect(self): 47 | await self.ws.close() 48 | self._connected = False 49 | -------------------------------------------------------------------------------- /buttplug/core/__init__.py: -------------------------------------------------------------------------------- 1 | from .errors import (ButtplugError, ButtplugDeviceError, 2 | ButtplugHandshakeError, 3 | ButtplugMessageError, ButtplugPingError, 4 | ButtplugUnknownError) 5 | from .messages import (ButtplugMessage, Ok, Error, Test, Log, 6 | RequestServerInfo, ServerInfo, StartScanning, 7 | StopScanning, DeviceAdded, MessageAttributes, 8 | DeviceList, DeviceRemoved, DeviceInfo, RequestLog, 9 | ScanningFinished, VibrateCmd, SpeedSubcommand, 10 | RotateCmd, LinearCmd, RotateSubcommand, 11 | LinearSubcommand, RequestDeviceList, StopDeviceCmd) 12 | from .enums import ButtplugErrorCode, ButtplugLogLevel 13 | -------------------------------------------------------------------------------- /buttplug/core/enums.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | 4 | class ButtplugErrorCode(IntEnum): 5 | ERROR_UNKNOWN = 0 6 | ERROR_INIT = 1 7 | ERROR_PING = 2 8 | ERROR_MSG = 3 9 | ERROR_DEVICE = 4 10 | 11 | 12 | class ButtplugLogLevel(object): 13 | off: str = "Off" 14 | fatal: str = "Fatal" 15 | error: str = "Error" 16 | warn: str = "Warn" 17 | info: str = "Info" 18 | debug: str = "Debug" 19 | trace: str = "Trace" 20 | -------------------------------------------------------------------------------- /buttplug/core/errors.py: -------------------------------------------------------------------------------- 1 | class ButtplugError(Exception): 2 | """Base Class for Buttplug Errors. 3 | 4 | Attributes: 5 | 6 | message (str): Describes the nature of the exception 7 | """ 8 | def __init__(self, message: str): 9 | self.message = message 10 | 11 | 12 | class ButtplugHandshakeError(ButtplugError): 13 | """Error thrown when errors happen during initial connection 14 | 15 | Attributes: 16 | 17 | message (str): Describes the nature of the exception 18 | """ 19 | pass 20 | 21 | 22 | class ButtplugDeviceError(ButtplugError): 23 | """Error thrown when errors happen during device operations, including 24 | discovery, sending commands, etc. 25 | 26 | Attributes: 27 | 28 | message (str): Describes the nature of the exception 29 | """ 30 | pass 31 | 32 | 33 | class ButtplugMessageError(ButtplugError): 34 | """Error thrown when a message is incomplete or incorrectly formed. 35 | 36 | Attributes: 37 | 38 | message (str): Describes the nature of the exception 39 | """ 40 | pass 41 | 42 | 43 | class ButtplugPingError(ButtplugError): 44 | """Error thrown when a ping timeout request from the server is not met. 45 | 46 | Attributes: 47 | 48 | message (str): Describes the nature of the exception 49 | """ 50 | pass 51 | 52 | 53 | class ButtplugUnknownError(ButtplugError): 54 | """Unknown error, see message for more info. 55 | 56 | Attributes: 57 | 58 | message (str): Describes the nature of the exception 59 | """ 60 | pass 61 | -------------------------------------------------------------------------------- /buttplug/core/messages.py: -------------------------------------------------------------------------------- 1 | # TODO Maybe use marshmallow? 2 | 3 | from dataclasses import dataclass 4 | import json 5 | import sys 6 | from typing import Dict, List, Any 7 | from .enums import ButtplugErrorCode 8 | 9 | class ButtplugMessageEncoder(json.JSONEncoder): 10 | """Used for serializing ButtplugMessage types into Buttplug protocol JSON line 11 | format. 12 | 13 | """ 14 | def pascal_case(self, cc_string): 15 | return ''.join(x.title() for x in cc_string.split('_')) 16 | 17 | def build_obj_dict(self, obj): 18 | # Build camel case versions of our internal variables 19 | return dict((self.pascal_case(key), value) 20 | for (key, value) in obj.__dict__.items()) 21 | 22 | def default(self, obj): 23 | # Helper classes should drop their names 24 | if isinstance(obj, (MessageAttributes, DeviceInfo, 25 | SpeedSubcommand, LinearSubcommand, 26 | RotateSubcommand)): 27 | return self.build_obj_dict(obj) 28 | return {type(obj).__name__: self.build_obj_dict(obj)} 29 | 30 | 31 | # ButtplugMessage isn't a dataclass, because we usually set id later than 32 | # message construction, and don't want to require it in constructors 33 | class ButtplugMessage(object): 34 | SYSTEM_ID = 0 35 | DEFAULT_ID = 1 36 | 37 | def __init__(self): 38 | self.id = ButtplugMessage.DEFAULT_ID 39 | 40 | def as_json(self): 41 | return ButtplugMessageEncoder().encode(self) 42 | 43 | @staticmethod 44 | def from_json(json_str: str): 45 | d = json.loads(json_str) 46 | return ButtplugMessage.from_dict(d) 47 | 48 | @staticmethod 49 | def from_dict(msg_dict: dict): 50 | classname = list(msg_dict.keys())[0] 51 | cls = getattr(sys.modules[__name__], classname) 52 | d = list(msg_dict.values())[0] 53 | msg = cls.from_dict(d) 54 | msg.id = d["Id"] 55 | return msg 56 | 57 | 58 | @dataclass 59 | class ButtplugDeviceMessage(ButtplugMessage): 60 | device_index: int 61 | 62 | 63 | class ButtplugOutgoingOnlyMessage(object): 64 | pass 65 | 66 | 67 | @dataclass 68 | class Ok(ButtplugOutgoingOnlyMessage, ButtplugMessage): 69 | @staticmethod 70 | def from_dict(d: dict) -> "Ok": 71 | return Ok() 72 | 73 | 74 | @dataclass 75 | class Error(ButtplugOutgoingOnlyMessage, ButtplugMessage): 76 | error_message: str 77 | error_code: int 78 | 79 | @staticmethod 80 | def from_dict(d: dict) -> "Error": 81 | return Error(d['ErrorMessage'], d['ErrorCode']) 82 | 83 | 84 | @dataclass 85 | class Test(ButtplugMessage): 86 | test_string: str 87 | 88 | @staticmethod 89 | def from_dict(d: dict) -> "Test": 90 | return Test(d['TestString']) 91 | 92 | 93 | @dataclass 94 | class RequestServerInfo(ButtplugMessage): 95 | client_name: str 96 | message_version: int = 1 97 | 98 | @staticmethod 99 | def from_dict(d: dict) -> "RequestServerInfo": 100 | return RequestServerInfo(d['ClientName'], d['MessageVersion']) 101 | 102 | 103 | @dataclass 104 | class ServerInfo(ButtplugMessage): 105 | server_name: str 106 | major_version: int 107 | minor_version: int 108 | build_version: int 109 | message_version: int = 1 110 | max_ping_time: int = 0 111 | 112 | @staticmethod 113 | def from_dict(d: dict) -> "ServerInfo": 114 | return ServerInfo(d['ServerName'], d['MajorVersion'], 115 | d['MinorVersion'], d['BuildVersion'], 116 | d['MessageVersion'], d['MaxPingTime']) 117 | 118 | 119 | @dataclass 120 | class RequestDeviceList(ButtplugMessage): 121 | pass 122 | 123 | 124 | class MessageAttributes: 125 | def __init__(self, count: int = None): 126 | if count is not None: 127 | self.feature_count = count 128 | 129 | @staticmethod 130 | def from_dict(d: dict) -> "MessageAttributes": 131 | return MessageAttributes(d["FeatureCount"]) 132 | 133 | 134 | @dataclass 135 | class DeviceInfo: 136 | device_name: str 137 | device_index: int 138 | # TODO Make this use MessageAttributes, currently just a dict because serialization was broken. 139 | device_messages: Dict[str, Dict[str, Any]] 140 | 141 | @staticmethod 142 | def from_dict(d: dict) -> "DeviceInfo": 143 | attrs = dict([(k, v) for k, v in d["DeviceMessages"].items()]) 144 | return DeviceInfo(d["DeviceName"], d["DeviceIndex"], attrs) 145 | 146 | 147 | @dataclass 148 | class DeviceList(ButtplugMessage, ButtplugOutgoingOnlyMessage): 149 | devices: List[DeviceInfo] 150 | 151 | @staticmethod 152 | def from_dict(d: dict) -> "DeviceList": 153 | return DeviceList([DeviceInfo(x["DeviceName"], 154 | x["DeviceIndex"], 155 | x["DeviceMessages"]) 156 | for x in d["Devices"]]) 157 | 158 | 159 | # TODO Make this just be a DeviceInfo, currently own class because serialization was broken. 160 | @dataclass 161 | class DeviceAdded(ButtplugMessage, ButtplugOutgoingOnlyMessage): 162 | device_name: str 163 | device_index: int 164 | # TODO Make this use MessageAttributes, currently just a dict because serialization was broken. 165 | device_messages: Dict[str, Dict[str, Any]] 166 | 167 | @staticmethod 168 | def from_dict(d: dict) -> "DeviceAdded": 169 | attrs = dict([(k, v) for k, v in d["DeviceMessages"].items()]) 170 | return DeviceAdded(d["DeviceName"], d["DeviceIndex"], attrs) 171 | 172 | 173 | @dataclass 174 | class DeviceRemoved(ButtplugMessage, ButtplugOutgoingOnlyMessage): 175 | device_index: int 176 | 177 | @staticmethod 178 | def from_dict(d: dict) -> "DeviceRemoved": 179 | return DeviceRemoved(d["DeviceIndex"]) 180 | 181 | 182 | @dataclass 183 | class StartScanning(ButtplugMessage): 184 | @staticmethod 185 | def from_dict(d: dict) -> "StartScanning": 186 | return StartScanning() 187 | 188 | 189 | @dataclass 190 | class StopScanning(ButtplugMessage): 191 | @staticmethod 192 | def from_dict(d: dict) -> "StopScanning": 193 | return StopScanning() 194 | 195 | 196 | @dataclass 197 | class ScanningFinished(ButtplugMessage, ButtplugOutgoingOnlyMessage): 198 | @staticmethod 199 | def from_dict(d: dict) -> "ScanningFinished": 200 | return ScanningFinished() 201 | 202 | 203 | @dataclass 204 | class RequestLog(ButtplugMessage): 205 | log_level: str 206 | 207 | @staticmethod 208 | def from_dict(d: dict) -> "RequestLog": 209 | return RequestLog(d["LogLevel"]) 210 | 211 | 212 | @dataclass 213 | class Log(ButtplugMessage, ButtplugOutgoingOnlyMessage): 214 | log_level: str 215 | log_message: str 216 | 217 | @staticmethod 218 | def from_dict(d: dict) -> "Log": 219 | return Log(d["LogLevel"], d["LogMessage"]) 220 | 221 | 222 | @dataclass 223 | class Ping(ButtplugMessage): 224 | @staticmethod 225 | def from_dict(d: dict) -> "Ping": 226 | return Ping() 227 | 228 | 229 | @dataclass 230 | class FleshlightLaunchFW12Cmd(ButtplugDeviceMessage): 231 | position: int 232 | speed: int 233 | 234 | 235 | @dataclass 236 | class LovenseCmd(ButtplugDeviceMessage): 237 | command: str 238 | 239 | 240 | @dataclass 241 | class KiirooCmd(ButtplugDeviceMessage): 242 | command: str 243 | 244 | 245 | @dataclass 246 | class VorzeA10CycloneCmd(ButtplugMessage): 247 | speed: int 248 | clockwise: bool 249 | 250 | 251 | @dataclass 252 | class SpeedSubcommand: 253 | index: int 254 | speed: float 255 | 256 | 257 | @dataclass 258 | class VibrateCmd(ButtplugDeviceMessage): 259 | speeds: List[SpeedSubcommand] 260 | 261 | @staticmethod 262 | def from_dict(d: dict) -> "VibrateCmd": 263 | speeds = [] 264 | for cmd in d["Speeds"]: 265 | speeds.append(SpeedSubcommand(cmd["Index"], cmd["Speed"])) 266 | return VibrateCmd(d["DeviceIndex"], speeds) 267 | 268 | 269 | @dataclass 270 | class RotateSubcommand: 271 | index: int 272 | speed: float 273 | clockwise: bool 274 | 275 | 276 | @dataclass 277 | class RotateCmd(ButtplugDeviceMessage): 278 | rotations: List[RotateSubcommand] 279 | 280 | @staticmethod 281 | def from_dict(d: dict) -> "RotateCmd": 282 | rotations = [] 283 | for cmd in d["Rotations"]: 284 | rotations.append(RotateSubcommand(cmd["Index"], cmd["Speed"], 285 | cmd["Clockwise"])) 286 | return RotateCmd(d["DeviceIndex"], rotations) 287 | 288 | 289 | @dataclass 290 | class LinearSubcommand: 291 | index: int 292 | duration: int 293 | position: float 294 | 295 | 296 | @dataclass 297 | class LinearCmd(ButtplugDeviceMessage): 298 | vectors: List[LinearSubcommand] 299 | 300 | @staticmethod 301 | def from_dict(d: dict) -> "LinearCmd": 302 | vectors = [] 303 | for cmd in d["Vectors"]: 304 | vectors.append(LinearSubcommand(cmd["Index"], cmd["Duration"], 305 | cmd["Position"])) 306 | return LinearCmd(d["DeviceIndex"], vectors) 307 | 308 | 309 | class StopDeviceCmd(ButtplugDeviceMessage): 310 | pass 311 | 312 | 313 | class StopAllDevices(ButtplugMessage): 314 | pass 315 | -------------------------------------------------------------------------------- /buttplug/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .eventhandler import EventHandler 2 | 3 | __all__ = ["EventHandler"] 4 | -------------------------------------------------------------------------------- /buttplug/utils/eventhandler.py: -------------------------------------------------------------------------------- 1 | # Taken from https://bitbucket.org/marcusva/python-utils/ 2 | # 3 | # Original license is public domain, don't want to bring the whole package in, 4 | # and it's not really updated anyways. 5 | 6 | 7 | class EventHandler(object): 8 | """A simple event handling class, which manages callbacks to be 9 | executed. 10 | """ 11 | def __init__(self, sender): 12 | self.callbacks = [] 13 | self.sender = sender 14 | 15 | def __call__(self, *args): 16 | """Executes all callbacks. 17 | 18 | Executes all connected callbacks in the order of addition, 19 | passing the sender of the EventHandler as first argument and the 20 | optional args as second, third, ... argument to them. 21 | """ 22 | return [callback(self.sender, *args) for callback in self.callbacks] 23 | 24 | def __iadd__(self, callback): 25 | """Adds a callback to the EventHandler.""" 26 | self.add(callback) 27 | return self 28 | 29 | def __isub__(self, callback): 30 | """Removes a callback from the EventHandler.""" 31 | self.remove(callback) 32 | return self 33 | 34 | def __len__(self): 35 | """Gets the amount of callbacks connected to the EventHandler.""" 36 | return len(self.callbacks) 37 | 38 | def __getitem__(self, index): 39 | return self.callbacks[index] 40 | 41 | def __setitem__(self, index, value): 42 | self.callbacks[index] = value 43 | 44 | def __delitem__(self, index): 45 | del self.callbacks[index] 46 | 47 | def add(self, callback): 48 | """Adds a callback to the EventHandler.""" 49 | if not callable(callback): 50 | raise TypeError("callback must be callable") 51 | self.callbacks.append(callback) 52 | 53 | def remove(self, callback): 54 | """Removes a callback from the EventHandler.""" 55 | self.callbacks.remove(callback) 56 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/js/matomo.js: -------------------------------------------------------------------------------- 1 | var _paq = window._paq || []; 2 | /* tracker methods like "setCustomDimension" should be called before "trackPageView" */ 3 | _paq.push(["setCookieDomain", "*.buttplug-py.docs.buttplug.io"]); 4 | _paq.push(['trackPageView']); 5 | _paq.push(['enableLinkTracking']); 6 | (function() { 7 | var u="https://matomo.nonpolynomial.com/"; 8 | _paq.push(['setTrackerUrl', u+'matomo.php']); 9 | _paq.push(['setSiteId', '15']); 10 | var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; 11 | g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s); 12 | })(); 13 | -------------------------------------------------------------------------------- /docs/client.rst: -------------------------------------------------------------------------------- 1 | Client 2 | ====== 3 | 4 | .. autoclass:: buttplug.client.ButtplugClient 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('..')) 16 | from buttplug import __version__ 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'buttplug-py' 21 | copyright = '2019, Nonpolynomial' 22 | author = 'Nonpolynomial' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = __version__ 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | "sphinx.ext.autodoc", 35 | "sphinx.ext.napoleon", 36 | "m2r", 37 | ] 38 | 39 | # Add any paths that contain templates here, relative to this directory. 40 | templates_path = ['_templates'] 41 | 42 | # List of patterns, relative to source directory, that match files and 43 | # directories to ignore when looking for source files. 44 | # This pattern also affects html_static_path and html_extra_path. 45 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 46 | 47 | 48 | # -- Options for HTML output ------------------------------------------------- 49 | 50 | # The theme to use for HTML and HTML Help pages. See the documentation for 51 | # a list of builtin themes. 52 | # 53 | html_theme = 'pyramid' 54 | 55 | # Add any paths that contain custom static files (such as style sheets) here, 56 | # relative to this directory. They are copied after the builtin static files, 57 | # so a file named "default.css" will overwrite the builtin "default.css". 58 | html_static_path = ['_static'] 59 | html_js_files = [ 60 | 'js/matomo.js', 61 | ] 62 | 63 | html_sidebars = {'**': ['globaltoc.html', 'relations.html', 'sourcelink.html', 'searchbox.html']} 64 | source_suffix = ['.rst', '.md'] 65 | -------------------------------------------------------------------------------- /docs/device.rst: -------------------------------------------------------------------------------- 1 | Device 2 | ====== 3 | 4 | .. autoclass:: buttplug.client.ButtplugClientDevice 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/enums.rst: -------------------------------------------------------------------------------- 1 | Enums 2 | ===== 3 | 4 | .. autoclass:: buttplug.core.ButtplugErrorCode 5 | :members: 6 | 7 | .. autoclass:: buttplug.core.ButtplugLogLevel 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/errors.rst: -------------------------------------------------------------------------------- 1 | Errors 2 | ====== 3 | 4 | .. automodule:: buttplug.core.errors 5 | :members: 6 | 7 | .. autoclass:: buttplug.client.ButtplugClientConnectorError 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/eventhandler.rst: -------------------------------------------------------------------------------- 1 | Event Handler 2 | ============= 3 | 4 | .. autoclass:: buttplug.utils.EventHandler 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ## Introduction 2 | 3 | buttplug-py is a Python 3 implementation of the client portion of the 4 | Buttplug Intimate Hardware Protocol. For more information on the 5 | protocol, see the project website at 6 | 7 | https://buttplug.io 8 | 9 | You may also be interested in the Buttplug Spec at 10 | 11 | https://buttplug-spec.docs.buttplug.io 12 | 13 | and the Buttplug Developer Guide, at 14 | 15 | https://buttplug-developer-guide.docs.buttplug.io 16 | 17 | ## What Client Only Means 18 | 19 | buttplug-py is only an implementation of the client side of the 20 | Buttplug Protocol. Programs written with this client cannot directly 21 | access hardware, and will be required to connect to a Buttplug Server, 22 | such as Intiface Desktop in order to access hardware. You can find 23 | more information on Intiface Desktop at 24 | 25 | https://intiface.com/desktop 26 | 27 | ## Python Notes 28 | 29 | Before discussing the basics of using buttplug-py, we'll cover a few 30 | things to consider when implementing applications with it. 31 | 32 | - buttplug-py is HEAVILY Python 3.7. asyncio, dataclasses, typings, 33 | all that fun stuff. I (qDot, the author) have no plans on 34 | backporting, because I love these features and just plain don't 35 | wanna. 36 | - If someone else wants to backport for < 3.7 (but still >= 3 because 37 | come on 2.7 EOLs in like 3 months), please feel free to get in 38 | touch. I'm just not gonna do it myself. 39 | - At the moment, only the Client and ClientDevice classes are 40 | documented and meant to be used. Most of the protocol messages are 41 | available in code, but if you go that direction, you're on your own. 42 | - In order to make it look similar to the other implementations of the 43 | Buttplug protocol (such as 44 | [C#](https://github.com/buttplugio/buttplug-cshar) and 45 | [Typescript/Javascript](https://github.com/buttplugio/buttplug-js)), 46 | there is a faux-event system in buttplug-py. It's basically a way to 47 | attach callbacks to a list to be called at a certain time. Examples 48 | of this will be shown in the Usage section below. 49 | 50 | Event Handling looks similar to C#, with the ability to use the +=/-= 51 | operators on EventHandler types to add/remove handlers. See the 52 | example code below for demonstration of how event hookup works. 53 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | buttplug-py: Buttplug Protocol Client for Python >= 3.7 2 | ======================================================= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | Home 9 | errors 10 | enums 11 | client 12 | device 13 | eventhandler 14 | 15 | 16 | .. mdinclude:: ./index.md 17 | 18 | 19 | Code Example 20 | ============ 21 | 22 | Also `available on github `_ 23 | 24 | .. literalinclude:: ../examples/example.py 25 | 26 | Indices and tables 27 | ================== 28 | 29 | * :ref:`genindex` 30 | * :ref:`modindex` 31 | * :ref:`search` 32 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | m2r==0.2.1 2 | recommonmark==0.6.0 3 | Sphinx==2.2.0 4 | -------------------------------------------------------------------------------- /examples/example.py: -------------------------------------------------------------------------------- 1 | # buttplug-py example code 2 | # 3 | # Buttplug Clients are fairly simple things, in charge of the following 4 | # tasks: 5 | # 6 | # - Connect to a Buttplug Server and Identify Itself 7 | # - Enumerate Devices 8 | # - Control Found Devices 9 | # 10 | # That's about it, really. 11 | # 12 | # This is a program that connects to a server, scans for devices, and runs 13 | # commands on them when they are found. It'll be copiously commented so you 14 | # have some idea of what's going on and can maybe make something yourself. 15 | # 16 | # NOTE: We'll be talking about this in terms of execution flow, so you'll want 17 | # to start at the bottom and work your way up. 18 | 19 | # These are really the only things you actually need out of the library. The 20 | # Client and ClientDevice classes wrap all of the functionality you'll need to 21 | # talk to servers and access toys. 22 | from buttplug.client import (ButtplugClientWebsocketConnector, ButtplugClient, 23 | ButtplugClientDevice, ButtplugClientConnectorError) 24 | from buttplug.core import ButtplugLogLevel 25 | import asyncio 26 | import logging 27 | import sys 28 | 29 | async def cancel_me(): 30 | logging.debug('cancel_me(): before sleep') 31 | 32 | try: 33 | await asyncio.sleep(3600) 34 | except asyncio.CancelledError: 35 | pass 36 | 37 | 38 | async def device_added_task(dev: ButtplugClientDevice): 39 | # Ok, so we got a new device in! Neat! 40 | # 41 | # First off, we'll print the name of the devices. 42 | 43 | logging.info("Device Added: {}".format(dev.name)) 44 | 45 | # Once we've done that, we can send some commands to the device, depending 46 | # on what it can do. As of the current version I'm writing this for 47 | # (v0.0.3), all the client can send to devices are generic messages. 48 | # Specifically: 49 | # 50 | # - VibrateCmd 51 | # - RotateCmd 52 | # - LinearCmd 53 | # 54 | # However, this is good enough to still do a lot of stuff. 55 | # 56 | # These capabilities are held in the "messages" member of the 57 | # ButtplugClientDevice. 58 | 59 | if "VibrateCmd" in dev.allowed_messages.keys(): 60 | # If we see that "VibrateCmd" is an allowed message, it means the 61 | # device can vibrate. We can call send_vibrate_cmd on the device and 62 | # it'll tell the server to make the device start vibrating. 63 | await dev.send_vibrate_cmd(0.5) 64 | # We let it vibrate at 50% speed for 1 second, then we stop it. 65 | await asyncio.sleep(1) 66 | # We can use send_stop_device_cmd to stop the device from vibrating, as 67 | # well as anything else it's doing. If the device was vibrating AND 68 | # rotating, we could use send_vibrate_cmd(0) to just stop the 69 | # vibration. 70 | await dev.send_stop_device_cmd() 71 | if "LinearCmd" in dev.allowed_messages.keys(): 72 | # If we see that "LinearCmd" is an allowed message, it means the device 73 | # can move back and forth. We can call send_linear_cmd on the device 74 | # and it'll tell the server to make the device move to 90% of the 75 | # maximum position over 1 second (1000ms). 76 | await dev.send_linear_cmd((1000, 0.9)) 77 | # We wait 1 second for the move, then we move it back to the 0% 78 | # position. 79 | await asyncio.sleep(1) 80 | await dev.send_linear_cmd((1000, 0)) 81 | 82 | 83 | def device_added(emitter, dev: ButtplugClientDevice): 84 | asyncio.create_task(device_added_task(dev)) 85 | 86 | def device_removed(emitter, dev: ButtplugClientDevice): 87 | logging.info("Device removed: ", dev) 88 | 89 | async def main(): 90 | # And now we're in the main function. 91 | # 92 | # First, we'll need to set up a client object. This is our conduit to the 93 | # server. 94 | # 95 | # We create a Client object, passing it the name we want for the client. 96 | # Names are shown in things like the Intiface Desktop Server GUI. 97 | 98 | client = ButtplugClient("Test Client") 99 | 100 | # Now we have a client called "Test Client", but it's not connected to 101 | # anything yet. We can fix that by creating a connector. Connectors 102 | # allow clients to talk to servers through different methods, including: 103 | # 104 | # - Websockets 105 | # - IPC (Not currently available in Python) 106 | # - WebRTC (Not currently available in Python) 107 | # - TCP/UDP (Not currently available in Python) 108 | # 109 | # For now, all we've implemented in python is a Websocket connector, so 110 | # we'll use that. 111 | 112 | connector = ButtplugClientWebsocketConnector("ws://127.0.0.1:12345") 113 | 114 | # This connector will connect to Intiface Desktop on the local machine, 115 | # using the default port for insecure websockets. 116 | # 117 | # There's one more step before we connect to a client, and that's 118 | # setting up an event handler. 119 | 120 | client.device_added_handler += device_added 121 | client.device_removed_handler += device_removed 122 | 123 | # Whenever we connect to a client, we'll instantly get a list of devices 124 | # already connected (yes, this sometimes happens, mostly due to windows 125 | # weirdness). We'll want to make sure we know about those. 126 | # 127 | # Finally, we connect. 128 | 129 | try: 130 | await client.connect(connector) 131 | except ButtplugClientConnectorError as e: 132 | logging.error("Could not connect to server, exiting: {}".format(e.message)) 133 | return 134 | 135 | # If this succeeds, we'll be connected. If not, we'll probably have some 136 | # sort of exception thrown of type ButtplugClientConnectorException 137 | # 138 | # Let's receive log messages, since they're a handy way to find out what 139 | # the server is doing. We can choose the level from the ButtplugLogLevel 140 | # object. 141 | 142 | # await client.request_log(ButtplugLogLevel.info) 143 | 144 | # Now we move on to looking for devices. 145 | 146 | await client.start_scanning() 147 | 148 | # This will tell the server to start scanning for devices, and returns 149 | # while it's scanning. If we get any new devices, the device_added_task 150 | # function that we assigned as an event handler earlier will be called. 151 | # 152 | # Since everything interesting happens after devices have connected, now 153 | # all we have to do here is wait. So we do, asynchronously, so other things 154 | # can continue running. Now that you've made it this far, go look at what 155 | # the device_added_task does. 156 | 157 | task = asyncio.create_task(cancel_me()) 158 | try: 159 | await task 160 | except asyncio.CancelledError: 161 | pass 162 | 163 | # Ok so someone hit Ctrl-C or something and we've broken out of our task 164 | # wait. Let's tell the server to stop scanning. 165 | await client.stop_scanning() 166 | 167 | # Now that we've done that, we just disconnect and we're done! 168 | await client.disconnect() 169 | logging.info("Disconnected, quitting") 170 | 171 | # Here we are. The beginning. We'll spin up an asyncio event loop that runs the 172 | # main function. Remember that if you don't want to make your whole program 173 | # async (because, for instance, it's already written in a non-async way), you 174 | # can always create a thread for the asyncio loop to run in, and do some sort 175 | # of communication in/out of that thread to the rest of your program. 176 | # 177 | # But first, set up logging 178 | logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) 179 | asyncio.run(main(), debug=True) 180 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_classes = -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | websockets==10.1 2 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | 3.7 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from buttplug import __version__ 3 | 4 | # read the contents of your README file 5 | from os import path 6 | this_directory = path.abspath(path.dirname(__file__)) 7 | with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: 8 | long_description = f.read() 9 | 10 | setup(name="buttplug", 11 | version=__version__, 12 | author="Nonpolynomial", 13 | author_email="kyle@nonpolynomial.com", 14 | description="Python implementation of the Buttplug Intimate Hardware Control Protocol.", 15 | long_description=long_description, 16 | long_description_content_type='text/markdown', 17 | url="https://github.com/buttplugio/buttplug-py", 18 | classifiers=[ 19 | "Programming Language :: Python :: 3.7", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Development Status :: 4 - Beta", 23 | "License :: OSI Approved :: BSD License", 24 | "Operating System :: OS Independent", 25 | ], 26 | install_requires=['websockets>=10', ], 27 | packages=find_packages(exclude=["docs", "*.tests", "*.tests.*", 28 | "tests.*", "tests"])) 29 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buttplugio/buttplug-py/cd7f629dfaabaca2512a237aaec189b90a611afc/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_client_device.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import pytest 3 | import logging 4 | from buttplug.core import (ButtplugMessage, Ok, Error, ButtplugErrorCode, 5 | Test, DeviceAdded, MessageAttributes, DeviceRemoved, 6 | DeviceInfo, DeviceList, VibrateCmd, SpeedSubcommand, 7 | RotateCmd, RotateSubcommand, LinearCmd, 8 | LinearSubcommand) 9 | from buttplug.client import ButtplugClientDevice 10 | 11 | 12 | class DummyClient(object): 13 | def __init__(self): 14 | self.last_message: ButtplugMessage = None 15 | 16 | async def _send_message_expect_ok(self, msg: ButtplugMessage): 17 | logging.debug("Got message") 18 | self.last_message = msg 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_device_vibrate_single_argument(): 23 | client = DummyClient() 24 | dev = ButtplugClientDevice(client, DeviceInfo("Test Vibration Device", 25 | 0, 26 | {"VibrateCmd": 27 | {"FeatureCount": 1}})) 28 | await dev.send_vibrate_cmd(1.0) 29 | assert client.last_message == VibrateCmd(0, [SpeedSubcommand(0, 1.0)]) 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_device_vibrate_list(): 34 | client = DummyClient() 35 | dev = ButtplugClientDevice(client, DeviceInfo("Test Vibration Device", 36 | 0, 37 | {"VibrateCmd": 38 | {"FeatureCount": 1}})) 39 | await dev.send_vibrate_cmd([1.0]) 40 | assert client.last_message == VibrateCmd(0, [SpeedSubcommand(0, 1.0)]) 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_device_vibrate_dict(): 45 | client = DummyClient() 46 | dev = ButtplugClientDevice(client, DeviceInfo("Test Vibration Device", 47 | 0, 48 | {"VibrateCmd": 49 | {"FeatureCount": 1}})) 50 | await dev.send_vibrate_cmd({0: 1.0}) 51 | assert client.last_message == VibrateCmd(0, [SpeedSubcommand(0, 1.0)]) 52 | 53 | 54 | @pytest.mark.asyncio 55 | async def test_device_rotate_single_argument(): 56 | client = DummyClient() 57 | dev = ButtplugClientDevice(client, DeviceInfo("Test Rotation Device", 58 | 0, 59 | {"RotateCmd": 60 | {"FeatureCount": 1}})) 61 | await dev.send_rotate_cmd((1.0, True)) 62 | assert client.last_message == RotateCmd(0, [RotateSubcommand(0, 1.0, True)]) 63 | 64 | 65 | @pytest.mark.asyncio 66 | async def test_device_rotate_list(): 67 | client = DummyClient() 68 | dev = ButtplugClientDevice(client, DeviceInfo("Test Rotation Device", 69 | 0, 70 | {"RotateCmd": 71 | {"FeatureCount": 1}})) 72 | await dev.send_rotate_cmd([(1.0, True)]) 73 | assert client.last_message == RotateCmd(0, [RotateSubcommand(0, 1.0, True)]) 74 | 75 | 76 | @pytest.mark.asyncio 77 | async def test_device_rotate_dict(): 78 | client = DummyClient() 79 | dev = ButtplugClientDevice(client, DeviceInfo("Test Rotation Device", 80 | 0, 81 | {"RotateCmd": 82 | {"FeatureCount": 1}})) 83 | await dev.send_rotate_cmd({0: (1.0, True)}) 84 | assert client.last_message == RotateCmd(0, [RotateSubcommand(0, 1.0, True)]) 85 | 86 | 87 | @pytest.mark.asyncio 88 | async def test_device_linear_single_argument(): 89 | client = DummyClient() 90 | dev = ButtplugClientDevice(client, DeviceInfo("Test Rotation Device", 91 | 0, 92 | {"LinearCmd": 93 | {"FeatureCount": 1}})) 94 | await dev.send_linear_cmd((1000, 1.0)) 95 | assert client.last_message == LinearCmd(0, [LinearSubcommand(0, 1000, 1.0)]) 96 | 97 | 98 | @pytest.mark.asyncio 99 | async def test_device_linear_list(): 100 | client = DummyClient() 101 | dev = ButtplugClientDevice(client, DeviceInfo("Test Rotation Device", 102 | 0, 103 | {"LinearCmd": 104 | {"FeatureCount": 1}})) 105 | await dev.send_linear_cmd([(1000, 1.0)]) 106 | assert client.last_message == LinearCmd(0, [LinearSubcommand(0, 1000, 1.0)]) 107 | 108 | 109 | @pytest.mark.asyncio 110 | async def test_device_linear_dict(): 111 | client = DummyClient() 112 | dev = ButtplugClientDevice(client, DeviceInfo("Test Rotation Device", 113 | 0, 114 | {"LinearCmd": 115 | {"FeatureCount": 1}})) 116 | await dev.send_linear_cmd({0: (1000, 1.0)}) 117 | assert client.last_message == LinearCmd(0, [LinearSubcommand(0, 1000, 1.0)]) 118 | -------------------------------------------------------------------------------- /tests/test_messages.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from buttplug.core import (ButtplugMessage, Ok, Error, ButtplugErrorCode, 3 | Test, DeviceAdded, MessageAttributes, DeviceRemoved, 4 | DeviceInfo, DeviceList, VibrateCmd, SpeedSubcommand, 5 | RotateCmd, RotateSubcommand, LinearCmd, 6 | LinearSubcommand) 7 | 8 | 9 | class TestMessages(unittest.TestCase): 10 | 11 | def run_msg_test(self, msg_obj, msg_json): 12 | msg_obj.id = ButtplugMessage.DEFAULT_ID 13 | assert msg_obj.as_json() == msg_json 14 | assert ButtplugMessage.from_json(msg_json) == msg_obj 15 | 16 | def test_message_ok(self): 17 | ok = Ok() 18 | json_msg = "{\"Ok\": {\"Id\": 1}}" 19 | self.run_msg_test(ok, json_msg) 20 | 21 | def test_message_error(self): 22 | error = Error("Test", ButtplugErrorCode.ERROR_MSG) 23 | json_msg = "{\"Error\": {\"ErrorMessage\": \"Test\", \"ErrorCode\": 3, \"Id\": 1}}" 24 | self.run_msg_test(error, json_msg) 25 | 26 | def test_message_test(self): 27 | test = Test("Test") 28 | json_msg = "{\"Test\": {\"TestString\": \"Test\", \"Id\": 1}}" 29 | self.run_msg_test(test, json_msg) 30 | 31 | def test_device_added(self): 32 | device_added = DeviceAdded("Test Device", 33 | 1, 34 | {"VibrateCmd": {"FeatureCount": 1}}) 35 | json_msg = "{\"DeviceAdded\": {\"DeviceName\": \"Test Device\", \"DeviceIndex\": 1, \"DeviceMessages\": {\"VibrateCmd\": {\"FeatureCount\": 1}}, \"Id\": 1}}" 36 | self.run_msg_test(device_added, json_msg) 37 | 38 | def test_device_removed(self): 39 | device_removed = DeviceRemoved(1) 40 | json_msg = "{\"DeviceRemoved\": {\"DeviceIndex\": 1, \"Id\": 1}}" 41 | self.run_msg_test(device_removed, json_msg) 42 | 43 | def test_device_list(self): 44 | device_list = DeviceList([DeviceInfo("TestDevice1", 45 | 0, 46 | {"SingleMotorVibrateCmd": {}, 47 | "VibrateCmd": {"FeatureCount": 2}, 48 | "StopDeviceCmd": {}, 49 | }), 50 | DeviceInfo("TestDevice2", 51 | 1, 52 | {"FleshlightLaunchFW12Cmd": {}, 53 | "LinearCmd": {"FeatureCount": 1}, 54 | "StopDeviceCmd": {}})]) 55 | json_msg = "{\"DeviceList\": {\"Devices\": [{\"DeviceName\": \"TestDevice1\", \"DeviceIndex\": 0, \"DeviceMessages\": {\"SingleMotorVibrateCmd\": {}, \"VibrateCmd\": {\"FeatureCount\": 2}, \"StopDeviceCmd\": {}}}, {\"DeviceName\": \"TestDevice2\", \"DeviceIndex\": 1, \"DeviceMessages\": {\"FleshlightLaunchFW12Cmd\": {}, \"LinearCmd\": {\"FeatureCount\": 1}, \"StopDeviceCmd\": {}}}], \"Id\": 1}}" 56 | self.run_msg_test(device_list, json_msg) 57 | 58 | def test_vibrate_cmd(self): 59 | vibrate_cmd = VibrateCmd(0, [SpeedSubcommand(0, 0), 60 | SpeedSubcommand(1, 0.5)]) 61 | json_msg = "{\"VibrateCmd\": {\"DeviceIndex\": 0, \"Speeds\": [{\"Index\": 0, \"Speed\": 0}, {\"Index\": 1, \"Speed\": 0.5}], \"Id\": 1}}" 62 | self.run_msg_test(vibrate_cmd, json_msg) 63 | 64 | def test_rotate_cmd(self): 65 | rotate_cmd = RotateCmd(0, [RotateSubcommand(0, 0, False), 66 | RotateSubcommand(1, 0.5, True)]) 67 | json_msg = "{\"RotateCmd\": {\"DeviceIndex\": 0, \"Rotations\": [{\"Index\": 0, \"Speed\": 0, \"Clockwise\": false}, {\"Index\": 1, \"Speed\": 0.5, \"Clockwise\": true}], \"Id\": 1}}" 68 | self.run_msg_test(rotate_cmd, json_msg) 69 | 70 | def test_linear_cmd(self): 71 | linear_cmd = LinearCmd(0, [LinearSubcommand(0, 100, 1.0), 72 | LinearSubcommand(1, 500, 0.5)]) 73 | json_msg = "{\"LinearCmd\": {\"DeviceIndex\": 0, \"Vectors\": [{\"Index\": 0, \"Duration\": 100, \"Position\": 1.0}, {\"Index\": 1, \"Duration\": 500, \"Position\": 0.5}], \"Id\": 1}}" 74 | self.run_msg_test(linear_cmd, json_msg) 75 | --------------------------------------------------------------------------------