├── .flake8 ├── .github ├── ci-requirements.txt └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── obswebsocket ├── __init__.py ├── base_classes.py ├── core.py └── exceptions.py ├── requirements.txt ├── samples ├── events.py ├── reconnect.py ├── switch_scenes.py └── switch_scenes_v4.py ├── setup.cfg ├── setup.py └── test_ci.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = build/* 3 | max-line-length = 200 4 | ignore = W503 5 | -------------------------------------------------------------------------------- /.github/ci-requirements.txt: -------------------------------------------------------------------------------- 1 | -r ../requirements.txt 2 | pytest 3 | flake8 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: python-ci 2 | on: [push] 3 | permissions: 4 | contents: read 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.7", "3.10"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Setup Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v4 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install -r .github/ci-requirements.txt 21 | - name: Test with pytest 22 | run: | 23 | pytest 24 | - name: Lint with flake8 25 | run: | 26 | flake8 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | 104 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Guillaume "Elektordi" Genty 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include requirements.txt 4 | include setup.cfg 5 | include setup.py 6 | recursive-include obswebsocket *.py 7 | recursive-include samples *.py 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # obs-websocket-py 2 | Python3 library to communicate with an [obs-websocket](https://github.com/Palakis/obs-websocket) server. 3 | 4 | _Licensed under the MIT License_ 5 | 6 | ## Project pages 7 | 8 | GitHub project: https://github.com/Elektordi/obs-websocket-py 9 | 10 | PyPI package: https://pypi.org/project/obs-websocket-py/ 11 | 12 | ## Installation 13 | 14 | Just run `pip3 install obs-websocket-py` in your Python venv or directly on your system. 15 | 16 | For manual install, git clone the github repo and copy the directory **obswebsocket** in your python project root. 17 | 18 | **Requires**: websocket-client (from pip) 19 | 20 | ## Usage 21 | 22 | See python scripts in the [samples](https://github.com/Elektordi/obs-websocket-py/tree/master/samples) directory. 23 | 24 | Or take a look at the documentation below: 25 | 26 | _Output of `pydoc obswebsocket.core.obsws`:_ 27 | 28 | ``` 29 | Help on class obsws in obswebsocket.core: 30 | 31 | obswebsocket.core.obsws = class obsws 32 | | Core class for using obs-websocket-py 33 | | 34 | | Simple usage: (v5 api) 35 | | >>> from obswebsocket import obsws, requests 36 | | >>> client = obsws("localhost", 4455, "secret") 37 | | >>> client.connect() 38 | | >>> client.call(requests.GetVersion()).getObsVersion() 39 | | '29.0.0' 40 | | >>> client.disconnect() 41 | | 42 | | Legacy usage: (v4 api) 43 | | >>> from obswebsocket import obsws, requests 44 | | >>> client = obsws("localhost", 4444, "secret", legacy=True) 45 | | >>> client.connect() 46 | | >>> client.call(requests.GetVersion()).getObsStudioVersion() 47 | | '25.0.0' 48 | | >>> client.disconnect() 49 | | 50 | | For advanced usage, including events callback, see the 'samples' directory. 51 | | 52 | | Methods defined here: 53 | | 54 | | __init__(self, host='localhost', port=4444, password='', legacy=None, timeout=60, authreconnect=0, on_connect=None, on_disconnect=None) 55 | | Construct a new obsws wrapper 56 | | 57 | | :param host: Hostname to connect to 58 | | :param port: TCP Port to connect to (Default is 4444) 59 | | :param password: Password for the websocket server (Leave this field empty if auth is not enabled) 60 | | :param legacy: Server is using old obs-websocket protocol (v4). Default is v5 (False) except if port is 4444. 61 | | :param timeout: How much seconds to wait for an answer after sending a request. 62 | | :param authreconnect: Try to reconnect if websocket is closed, value is number of seconds between attemps. 63 | | :param on_connect: Function to call after successful connect, with parameter (obsws) 64 | | :param on_disconnect: Function to call after successful disconnect, with parameter (obsws) 65 | | 66 | | call(self, obj) 67 | | Make a call to the OBS server through the Websocket. 68 | | 69 | | :param obj: Request (class from obswebsocket.requests module) to send 70 | | to the server. 71 | | :return: Request object populated with response data. 72 | | 73 | | connect(self) 74 | | Connect to the websocket server 75 | | 76 | | :return: Nothing 77 | | 78 | | disconnect(self) 79 | | Disconnect from websocket server 80 | | 81 | | :return: Nothing 82 | | 83 | | reconnect(self) 84 | | Restart the connection to the websocket server 85 | | 86 | | :return: Nothing 87 | | 88 | | register(self, func, event=None) 89 | | Register a new hook in the websocket client 90 | | 91 | | :param func: Callback function pointer for the hook 92 | | :param event: Event (class from obswebsocket.events module) to trigger 93 | | the hook on. Default is None, which means trigger on all events. 94 | | :return: Nothing 95 | | 96 | | unregister(self, func, event=None) 97 | | Unregister a new hook in the websocket client 98 | | 99 | | :param func: Callback function pointer for the hook 100 | | :param event: Event (class from obswebsocket.events module) which 101 | | triggered the hook on. Default is None, which means unregister this 102 | | function for all events. 103 | | :return: Nothing 104 | ``` 105 | 106 | ## Problems? 107 | 108 | Please check on [Github project issues](https://github.com/Elektordi/obs-websocket-py/issues), and if nobody else have experienced it before, you can [file a new issue](https://github.com/Elektordi/obs-websocket-py/issues/new). 109 | 110 | -------------------------------------------------------------------------------- /obswebsocket/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python library to communicate with an obs-websocket server. 3 | """ 4 | 5 | from .base_classes import Baseevents, Baserequests, ClassFactory 6 | 7 | events = ClassFactory(Baseevents) 8 | requests = ClassFactory(Baserequests) 9 | 10 | from .core import obsws # noqa: E402 11 | 12 | __all__ = ["obsws", "events", "requests"] 13 | 14 | VERSION = "1.0" 15 | -------------------------------------------------------------------------------- /obswebsocket/base_classes.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import re 3 | import warnings 4 | 5 | camelcase_to_kebabcase = re.compile(r'(?".format(self.name, self.datain) 19 | 20 | def __getattr__(self, item): 21 | if item.startswith("get"): 22 | def getter(): 23 | key1 = key = item[3:4].lower() + item[4:] 24 | if key not in self.datain: 25 | key = camelcase_to_kebabcase.sub('-', key).lower() 26 | if key not in self.datain: 27 | raise KeyError(key1) 28 | return self.datain[key] 29 | return getter 30 | raise AttributeError("'{}' object has no attribute '{}'".format(self.name, item)) 31 | 32 | 33 | class Baserequests: 34 | def __init__(self, *args, **kwargs): 35 | if args: 36 | warnings.warn("All obs-websocket-py requests now require keyword arguments (since version 1.0), check documentation!", UserWarning, stacklevel=2) 37 | self.name = type(self).__dict__.get('name') or "?" 38 | self.datain = {} 39 | self.dataout = {} 40 | for k in kwargs: 41 | self.dataout[k] = kwargs[k] 42 | self.status = None 43 | 44 | def data(self): 45 | payload = copy.copy(self.dataout) 46 | return payload 47 | 48 | def input(self, data, status): 49 | r = copy.copy(data) 50 | self.datain = r 51 | self.status = status 52 | 53 | def __repr__(self): 54 | if self.status is None: 55 | return u"<{} request ({}) waiting>".format(self.name, self.dataout) 56 | elif self.status: 57 | return u"<{} request ({}) called: success ({})>".format(self.name, self.dataout, self.datain) 58 | else: 59 | return u"<{} request ({}) called: failed ({})>".format(self.name, self.dataout, self.datain) 60 | 61 | def __getattr__(self, item): 62 | if item.startswith("get"): 63 | def getter(): 64 | key1 = key = item[3:4].lower() + item[4:] 65 | if key not in self.datain: 66 | key = camelcase_to_kebabcase.sub('-', key).lower() 67 | if key not in self.datain: 68 | raise KeyError(key1) 69 | return self.datain[key] 70 | return getter 71 | raise AttributeError("'{}' object has no attribute '{}'".format(self.name, item)) 72 | 73 | 74 | class ClassFactory: 75 | cache = {} 76 | 77 | def __init__(self, base_class): 78 | self.base_class = base_class 79 | 80 | def __getattr__(self, item): 81 | new_class = ClassFactory.cache.get((self.base_class, item)) 82 | if not new_class: 83 | new_class = type(item, (self.base_class,), {}) 84 | new_class.name = item 85 | ClassFactory.cache[(self.base_class, item)] = new_class 86 | return new_class 87 | -------------------------------------------------------------------------------- /obswebsocket/core.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import json 4 | import logging 5 | import socket 6 | import threading 7 | import websocket 8 | import time 9 | 10 | from . import exceptions 11 | from . import base_classes 12 | from . import events 13 | 14 | LOG = logging.getLogger(__name__) 15 | 16 | 17 | class obsws: 18 | """ 19 | Core class for using obs-websocket-py 20 | 21 | Simple usage: (v5 api) 22 | >>> from obswebsocket import obsws, requests 23 | >>> client = obsws("localhost", 4455, "secret") 24 | >>> client.connect() 25 | >>> client.call(requests.GetVersion()).getObsVersion() 26 | '29.0.0' 27 | >>> client.disconnect() 28 | 29 | Legacy usage: (v4 api) 30 | >>> from obswebsocket import obsws, requests 31 | >>> client = obsws("localhost", 4444, "secret", legacy=True) 32 | >>> client.connect() 33 | >>> client.call(requests.GetVersion()).getObsStudioVersion() 34 | '25.0.0' 35 | >>> client.disconnect() 36 | 37 | For advanced usage, including events callback, see the 'samples' directory. 38 | """ 39 | 40 | def __init__(self, host='localhost', port=4444, password='', legacy=None, timeout=60, authreconnect=0, on_connect=None, on_disconnect=None): 41 | """ 42 | Construct a new obsws wrapper 43 | 44 | :param host: Hostname to connect to 45 | :param port: TCP Port to connect to (Default is 4444) 46 | :param password: Password for the websocket server (Leave this field empty if auth is not enabled) 47 | :param legacy: Server is using old obs-websocket protocol (v4). Default is v5 (False) except if port is 4444. 48 | :param timeout: How much seconds to wait for an answer after sending a request. 49 | :param authreconnect: Try to reconnect if websocket is closed, value is number of seconds between attemps. 50 | :param on_connect: Function to call after successful connect, with parameter (obsws) 51 | :param on_disconnect: Function to call after successful disconnect, with parameter (obsws) 52 | """ 53 | self.host = host 54 | self.port = port 55 | self.password = password 56 | self.legacy = legacy 57 | self.timeout = timeout 58 | self.authreconnect = authreconnect 59 | self.on_connect = on_connect 60 | self.on_disconnect = on_disconnect 61 | 62 | if self.legacy is None: 63 | self.legacy = (self.port == 4444) 64 | 65 | self.id = 1 66 | self.thread_recv = None 67 | self.thread_reco = None 68 | self.ws = None 69 | self.eventmanager = EventManager() 70 | self.events = {} 71 | self.answers = {} 72 | self.server_version = None 73 | 74 | def connect(self): 75 | """ 76 | Connect to the websocket server 77 | 78 | :return: Nothing 79 | """ 80 | try: 81 | self.ws = websocket.WebSocket() 82 | url = "ws://{}:{}".format(self.host, self.port) 83 | LOG.info("Connecting to %s..." % (url)) 84 | self.ws.connect(url) 85 | LOG.info("Connected!") 86 | if self.legacy: 87 | self._auth_legacy() 88 | else: 89 | self._auth() 90 | 91 | if self.thread_recv is not None: 92 | self.thread_recv.running = False 93 | self.thread_recv = RecvThread(self) 94 | self.thread_recv.daemon = True 95 | self.thread_recv.start() 96 | if self.on_connect: 97 | self.on_connect(self) 98 | except socket.error as e: 99 | if self.authreconnect: 100 | if not self.thread_reco: 101 | LOG.warning("Connection failed, reconnecting in %s second(s)." % (self.authreconnect)) 102 | self.thread_reco = ReconnectThread(self) 103 | self.thread_reco.daemon = True 104 | self.thread_reco.start() 105 | else: 106 | LOG.warning("Connection failed, but reconnect timer already running.") 107 | else: 108 | raise exceptions.ConnectionFailure(str(e)) 109 | 110 | def reconnect(self): 111 | """ 112 | Restart the connection to the websocket server 113 | 114 | :return: Nothing 115 | """ 116 | self.disconnect() 117 | self.connect() 118 | 119 | def disconnect(self): 120 | """ 121 | Disconnect from websocket server 122 | 123 | :return: Nothing 124 | """ 125 | if self.thread_recv and self.thread_recv.running: 126 | if self.on_disconnect: 127 | self.on_disconnect(self) 128 | self.thread_recv.running = False 129 | if not self.ws.connected: 130 | return 131 | LOG.info("Disconnecting...") 132 | try: 133 | self.ws.close() 134 | except socket.error: 135 | pass 136 | self.thread_recv.join() 137 | self.thread_recv = None 138 | 139 | def _auth(self): 140 | message = self.ws.recv() 141 | LOG.debug("Got Hello message: {}".format(message)) 142 | result = json.loads(message) 143 | 144 | if result.get('op') != 0: 145 | raise exceptions.ConnectionFailure(result.get('error', "Invalid Hello message.")) 146 | self.server_version = result['d'].get('obsWebSocketVersion') 147 | 148 | if result['d'].get('authentication'): 149 | auth = self._build_auth_string(result['d']['authentication']['salt'], result['d']['authentication']['challenge']) 150 | else: 151 | auth = '' 152 | 153 | payload = { 154 | "op": 1, 155 | "d": { 156 | "rpcVersion": 1, 157 | "authentication": auth, 158 | "eventSubscriptions": 1023 # EventSubscription::All 159 | } 160 | } 161 | LOG.debug("Sending Identify message: {}".format(json.dumps(payload))) 162 | self.ws.send(json.dumps(payload)) 163 | 164 | message = self.ws.recv() 165 | if not message: 166 | raise exceptions.ConnectionFailure("Empty response to Identify, password may be inconnect.") 167 | LOG.debug("Got Identified message: {}".format(message)) 168 | result = json.loads(message) 169 | if result.get('op') != 2: 170 | raise exceptions.ConnectionFailure(result.get('error', "Invalid Identified message.")) 171 | if result['d'].get('negotiatedRpcVersion') != 1: 172 | raise exceptions.ConnectionFailure(result.get('error', "Invalid RPC version negotiated.")) 173 | 174 | def _auth_legacy(self): 175 | auth_payload = json.dumps({ 176 | "request-type": "GetAuthRequired", 177 | "message-id": str(self.id), 178 | }) 179 | LOG.debug("Sending initial message: {}".format(auth_payload)) 180 | self.id += 1 181 | self.ws.send(auth_payload) 182 | message = self.ws.recv() 183 | LOG.debug("Got initial response: {}".format(message)) 184 | result = json.loads(message) 185 | 186 | if result.get('status') != 'ok': 187 | raise exceptions.ConnectionFailure(result.get('error', "Invalid initial response.")) 188 | 189 | if result.get('authRequired'): 190 | auth = self._build_auth_string(result['salt'], result['challenge']) 191 | auth_payload = json.dumps({ 192 | "request-type": "Authenticate", 193 | "message-id": str(self.id), 194 | "auth": auth, 195 | }) 196 | LOG.debug("Sending auth message: {}".format(auth_payload)) 197 | self.id += 1 198 | self.ws.send(auth_payload) 199 | message = self.ws.recv() 200 | LOG.debug("Got auth response: {}".format(message)) 201 | result = json.loads(message) 202 | if result.get('status') != 'ok': 203 | raise exceptions.ConnectionFailure(result.get('error', "Invalid auth response.")) 204 | pass 205 | 206 | def _build_auth_string(self, salt, challenge): 207 | secret = base64.b64encode( 208 | hashlib.sha256( 209 | (self.password + salt).encode('utf-8') 210 | ).digest() 211 | ) 212 | auth = base64.b64encode( 213 | hashlib.sha256( 214 | secret + challenge.encode('utf-8') 215 | ).digest() 216 | ).decode('utf-8') 217 | return auth 218 | 219 | def call(self, obj): 220 | """ 221 | Make a call to the OBS server through the Websocket. 222 | 223 | :param obj: Request (class from obswebsocket.requests module) to send 224 | to the server. 225 | :return: Request object populated with response data. 226 | """ 227 | if not isinstance(obj, base_classes.Baserequests): 228 | raise exceptions.ObjectError("Call parameter is not a request object") 229 | data = obj.data() 230 | 231 | message_id = str(self.id) 232 | self.id += 1 233 | event = threading.Event() 234 | self.events[message_id] = event 235 | 236 | if self.legacy: 237 | payload = { 238 | "message-id": message_id, 239 | "request-type": obj.name 240 | } 241 | payload.update(data) 242 | else: 243 | payload = { 244 | "op": 6, 245 | "d": { 246 | "requestId": message_id, 247 | "requestType": obj.name, 248 | "requestData": data 249 | } 250 | } 251 | LOG.debug("Sending message id {}: {}".format(message_id, json.dumps(payload))) 252 | self.ws.send(json.dumps(payload)) 253 | 254 | event.wait(self.timeout) 255 | self.events.pop(message_id) 256 | 257 | if message_id in self.answers: 258 | r = self.answers.pop(message_id) 259 | if self.legacy: 260 | obj.input(r, r['status'] == 'ok') 261 | else: 262 | obj.input(r.get('responseData', {}), r['requestStatus']['result']) 263 | return obj 264 | raise exceptions.MessageTimeout("No answer for message {}".format(message_id)) 265 | 266 | def register(self, func, event=None): 267 | """ 268 | Register a new hook in the websocket client 269 | 270 | :param func: Callback function pointer for the hook 271 | :param event: Event (class from obswebsocket.events module) to trigger 272 | the hook on. Default is None, which means trigger on all events. 273 | :return: Nothing 274 | """ 275 | self.eventmanager.register(func, event) 276 | 277 | def unregister(self, func, event=None): 278 | """ 279 | Unregister a new hook in the websocket client 280 | 281 | :param func: Callback function pointer for the hook 282 | :param event: Event (class from obswebsocket.events module) which 283 | triggered the hook on. Default is None, which means unregister this 284 | function for all events. 285 | :return: Nothing 286 | """ 287 | self.eventmanager.unregister(func, event) 288 | 289 | 290 | class RecvThread(threading.Thread): 291 | 292 | def __init__(self, core): 293 | self.core = core 294 | self.ws = core.ws 295 | self.running = True 296 | threading.Thread.__init__(self) 297 | 298 | def run(self): 299 | while self.running: 300 | message = "" 301 | try: 302 | message = self.ws.recv() 303 | 304 | # recv() can return an empty string (Issue #6) 305 | if not message: 306 | continue 307 | 308 | result = json.loads(message) 309 | if self.core.legacy: 310 | if 'update-type' in result: 311 | LOG.debug("Got event: {}".format(result)) 312 | obj = self.build_event(result) 313 | self.core.eventmanager.trigger(obj) 314 | elif 'message-id' in result: 315 | LOG.debug("Got answer for id {}: {}".format(result['message-id'], result)) 316 | if result['message-id'] in self.core.events: 317 | self.core.answers[result['message-id']] = result 318 | self.core.events[result['message-id']].set() 319 | else: 320 | LOG.warning("Drop message with unknow message-id: {}".format(result)) 321 | else: 322 | LOG.warning("Unknown message: {}".format(result)) 323 | else: 324 | if result['op'] == 5: # Event 325 | LOG.debug("Got event: {}".format(result)) 326 | obj = self.build_event(result['d']) 327 | self.core.eventmanager.trigger(obj) 328 | elif result['op'] == 7: # RequestResponse 329 | LOG.debug("Got answer for id {}: {}".format(result['d']['requestId'], result)) 330 | if result['d']['requestId'] in self.core.events: 331 | self.core.answers[result['d']['requestId']] = result['d'] 332 | self.core.events[result['d']['requestId']].set() 333 | else: 334 | LOG.warning("Unknown message: {}".format(result)) 335 | 336 | except websocket.WebSocketConnectionClosedException: 337 | if self.running: 338 | if self.core.authreconnect: 339 | LOG.warning("Connection lost, attempting to reconnect...") 340 | self.core.reconnect() 341 | else: 342 | LOG.warning("Connection lost!") 343 | self.core.disconnect() 344 | break 345 | except OSError as e: 346 | if self.running: 347 | raise e 348 | except (ValueError, exceptions.ObjectError) as e: 349 | LOG.warning("Invalid message: {} ({})".format(message, e)) 350 | # end while 351 | LOG.debug("RecvThread ended.") 352 | 353 | def build_event(self, data): 354 | if self.core.legacy: 355 | name = data["update-type"] 356 | else: 357 | name = data["eventType"] 358 | try: 359 | obj = getattr(events, name)() 360 | except AttributeError: 361 | raise exceptions.ObjectError("Invalid event {}".format(name)) 362 | if self.core.legacy: 363 | obj.input(data) 364 | else: 365 | obj.input(data.get("eventData", {})) 366 | return obj 367 | 368 | 369 | class ReconnectThread(threading.Thread): 370 | 371 | def __init__(self, core): 372 | self.core = core 373 | threading.Thread.__init__(self) 374 | 375 | def run(self): 376 | time.sleep(self.core.authreconnect) 377 | self.core.thread_reco = None 378 | self.core.reconnect() 379 | 380 | 381 | class EventManager: 382 | 383 | def __init__(self): 384 | self.functions = [] 385 | 386 | def register(self, callback, trigger): 387 | self.functions.append((callback, trigger)) 388 | 389 | def unregister(self, callback, trigger): 390 | for c, t in self.functions: 391 | if (c == callback) and (trigger is None or t == trigger): 392 | self.functions.remove((c, t)) 393 | 394 | def trigger(self, data): 395 | for callback, trigger in self.functions: 396 | if trigger is None or isinstance(data, trigger): 397 | callback(data) 398 | -------------------------------------------------------------------------------- /obswebsocket/exceptions.py: -------------------------------------------------------------------------------- 1 | class ConnectionFailure(Exception): 2 | pass 3 | 4 | 5 | class MessageTimeout(Exception): 6 | pass 7 | 8 | 9 | class ObjectError(Exception): 10 | pass 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | websocket-client 2 | -------------------------------------------------------------------------------- /samples/events.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import time 5 | 6 | import logging 7 | logging.basicConfig(level=logging.DEBUG) 8 | 9 | sys.path.append('../') 10 | from obswebsocket import obsws, events # noqa: E402 11 | 12 | host = "localhost" 13 | port = 4455 14 | password = "secret" 15 | 16 | 17 | def on_event(message): 18 | print("Got message: {}".format(message)) 19 | 20 | 21 | def on_switch(message): 22 | print("You changed the scene to {}".format(message.getSceneName())) 23 | 24 | 25 | ws = obsws(host, port, password) 26 | ws.register(on_event) 27 | ws.register(on_switch, events.SwitchScenes) 28 | ws.register(on_switch, events.CurrentProgramSceneChanged) 29 | ws.connect() 30 | 31 | try: 32 | print("OK") 33 | time.sleep(10) 34 | print("END") 35 | 36 | except KeyboardInterrupt: 37 | pass 38 | 39 | ws.disconnect() 40 | -------------------------------------------------------------------------------- /samples/reconnect.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import time 5 | 6 | import logging 7 | logging.basicConfig(level=logging.DEBUG) 8 | 9 | sys.path.append('../') 10 | from obswebsocket import obsws # noqa: E402 11 | 12 | host = "localhost" 13 | port = 4455 14 | password = "secret" 15 | 16 | 17 | def on_connect(obs): 18 | print("on_connect({})".format(obs)) 19 | 20 | 21 | def on_disconnect(obs): 22 | print("on_disconnect({})".format(obs)) 23 | 24 | 25 | ws = obsws(host, port, password, authreconnect=1, on_connect=on_connect, on_disconnect=on_disconnect) 26 | ws.connect() 27 | 28 | try: 29 | print("Running. Now try to start/quit obs multiple times!") 30 | time.sleep(30) 31 | print("End of test.") 32 | 33 | except KeyboardInterrupt: 34 | pass 35 | 36 | ws.disconnect() 37 | -------------------------------------------------------------------------------- /samples/switch_scenes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import time 5 | 6 | import logging 7 | logging.basicConfig(level=logging.DEBUG) 8 | 9 | sys.path.append('../') 10 | from obswebsocket import obsws, requests # noqa: E402 11 | 12 | host = "localhost" 13 | port = 4455 14 | password = "secret" 15 | 16 | ws = obsws(host, port, password) 17 | ws.connect() 18 | 19 | try: 20 | scenes = ws.call(requests.GetSceneList()) 21 | for s in scenes.getScenes(): 22 | name = s['sceneName'] 23 | print("Switching to {}".format(name)) 24 | ws.call(requests.SetCurrentProgramScene(sceneName=name)) 25 | time.sleep(2) 26 | 27 | print("End of list") 28 | 29 | except KeyboardInterrupt: 30 | pass 31 | 32 | ws.disconnect() 33 | -------------------------------------------------------------------------------- /samples/switch_scenes_v4.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import time 5 | 6 | import logging 7 | logging.basicConfig(level=logging.DEBUG) 8 | 9 | sys.path.append('../') 10 | from obswebsocket import obsws, requests # noqa: E402 11 | 12 | host = "localhost" 13 | port = 4444 14 | password = "secret" 15 | 16 | ws = obsws(host, port, password, legacy=True) 17 | ws.connect() 18 | 19 | try: 20 | scenes = ws.call(requests.GetSceneList()) 21 | for s in scenes.getScenes(): 22 | name = s.get('name') 23 | print("Switching to {}".format(name)) 24 | ws.call(requests.SetCurrentScene(**{'scene-name': name})) 25 | time.sleep(2) 26 | 27 | print("End of list") 28 | 29 | except KeyboardInterrupt: 30 | pass 31 | 32 | ws.disconnect() 33 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from distutils.core import setup 4 | from obswebsocket import VERSION 5 | 6 | # Convert README from Markdown to reStructuredText 7 | description = "Please take a look at README.md" 8 | try: 9 | description = open('README.md', 'rt').read() 10 | import pypandoc 11 | description = pypandoc.convert_text(description, 'rst', 'gfm') 12 | except ImportError: 13 | # If not possible, leave it in Markdown... 14 | print("Cannot find pypandoc, not generating README!") 15 | 16 | requirements = open('requirements.txt', 'rt').readlines() 17 | requirements = [x.strip() for x in requirements if x] 18 | 19 | setup( 20 | name='obs-websocket-py', 21 | packages=['obswebsocket'], 22 | license='MIT', 23 | version=VERSION, 24 | description='Python library to communicate with an obs-websocket server.', 25 | long_description=description, 26 | author='Guillaume "Elektordi" Genty', 27 | author_email='elektordi@elektordi.net', 28 | url='https://github.com/Elektordi/obs-websocket-py', 29 | keywords=['obs', 'obs-studio', 'websocket'], 30 | classifiers=[ 31 | 'License :: OSI Approved :: MIT License', 32 | 'Environment :: Plugins', 33 | 'Intended Audience :: Developers', 34 | 'Topic :: Software Development :: Libraries', 35 | 36 | 'Development Status :: 4 - Beta', 37 | 38 | 'Programming Language :: Python :: 3', 39 | 'Programming Language :: Python :: 3.7', 40 | 'Programming Language :: Python :: 3.8', 41 | 'Programming Language :: Python :: 3.9', 42 | 'Programming Language :: Python :: 3.10', 43 | ], 44 | install_requires=requirements, 45 | ) 46 | -------------------------------------------------------------------------------- /test_ci.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, Mock 2 | from queue import Queue, Empty 3 | import time 4 | 5 | from obswebsocket import obsws, requests, events 6 | 7 | 8 | def fake_send(data): 9 | if data == '{"request-type": "GetAuthRequired", "message-id": "1"}': 10 | fake_recv.queue.put('{"status":"ok"}') 11 | elif data == '{"message-id": "2", "request-type": "FakeRequest"}': 12 | fake_recv.queue.put('{"message-id": "2", "status":"ok"}') 13 | elif data == '{"op": 1, "d": {"rpcVersion": 1, "authentication": "", "eventSubscriptions": 1023}}': 14 | fake_recv.queue.put('{"op": 2, "d": {"negotiatedRpcVersion": 1 }}') 15 | elif data == '{"op": 6, "d": {"requestId": "1", "requestType": "FakeRequest", "requestData": {}}}': 16 | fake_recv.queue.put('{"op": 7, "d": {"requestId": "1", "requestType": "FakeRequest", "requestStatus": {"result": true, "code": 100}}}') 17 | else: 18 | raise Exception(data) 19 | 20 | 21 | def fake_recv(): 22 | try: 23 | return fake_recv.queue.get(timeout=1) 24 | except Empty: 25 | return "" 26 | fake_recv.queue = Queue() # noqa: E305 27 | 28 | 29 | def test_request_legacy(): 30 | ws = obsws("127.0.0.1", 4444, "", legacy=True) 31 | with patch('websocket.WebSocket') as mock: 32 | mockws = mock.return_value 33 | mockws.send = Mock(wraps=fake_send) 34 | mockws.recv = Mock(wraps=fake_recv) 35 | 36 | ws.connect() 37 | mockws.connect.assert_called_once_with("ws://127.0.0.1:4444") 38 | assert ws.thread_recv.running 39 | 40 | r = ws.call(requests.FakeRequest()) 41 | assert r.name == "FakeRequest" 42 | assert r.status 43 | 44 | ws.disconnect() 45 | assert not ws.thread_recv 46 | 47 | 48 | def test_event_legacy(): 49 | ws = obsws("127.0.0.1", 4444, "", legacy=True) 50 | with patch('websocket.WebSocket') as mock: 51 | mockws = mock.return_value 52 | mockws.send = Mock(wraps=fake_send) 53 | mockws.recv = Mock(wraps=fake_recv) 54 | 55 | ws.connect() 56 | mockws.connect.assert_called_once_with("ws://127.0.0.1:4444") 57 | assert ws.thread_recv.running 58 | 59 | def on_fake_event(message): 60 | assert message.name == "FakeEvent" 61 | assert message.getFakeKey() == "fakeValue" 62 | on_fake_event.ok = True 63 | 64 | on_fake_event.ok = False 65 | ws.register(on_fake_event, events.FakeEvent) 66 | fake_recv.queue.put('{"update-type": "FakeEvent", "fakeKey":"fakeValue"}') 67 | time.sleep(1) 68 | assert on_fake_event.ok 69 | 70 | ws.disconnect() 71 | assert not ws.thread_recv 72 | 73 | 74 | def test_request(): 75 | ws = obsws("127.0.0.1", 4455, "") 76 | with patch('websocket.WebSocket') as mock: 77 | mockws = mock.return_value 78 | mockws.send = Mock(wraps=fake_send) 79 | mockws.recv = Mock(wraps=fake_recv) 80 | 81 | fake_recv.queue.put('{"op": 0, "d": { "obsWebSocketVersion": "fake", "rpcVersion": 1 }}') 82 | ws.connect() 83 | mockws.connect.assert_called_once_with("ws://127.0.0.1:4455") 84 | assert ws.thread_recv.running 85 | 86 | r = ws.call(requests.FakeRequest()) 87 | assert r.name == "FakeRequest" 88 | assert r.status 89 | 90 | ws.disconnect() 91 | assert not ws.thread_recv 92 | 93 | 94 | def test_event(): 95 | ws = obsws("127.0.0.1", 4455, "") 96 | with patch('websocket.WebSocket') as mock: 97 | mockws = mock.return_value 98 | mockws.send = Mock(wraps=fake_send) 99 | mockws.recv = Mock(wraps=fake_recv) 100 | 101 | fake_recv.queue.put('{"op": 0, "d": { "obsWebSocketVersion": "fake", "rpcVersion": 1 }}') 102 | ws.connect() 103 | mockws.connect.assert_called_once_with("ws://127.0.0.1:4455") 104 | assert ws.thread_recv.running 105 | 106 | def on_fake_event(message): 107 | assert message.name == "FakeEvent" 108 | assert message.getFakeKey() == "fakeValue" 109 | on_fake_event.ok = True 110 | 111 | on_fake_event.ok = False 112 | ws.register(on_fake_event, events.FakeEvent) 113 | fake_recv.queue.put('{"op": 5, "d": { "eventType": "FakeEvent", "eventIntent": 1, "eventData": { "fakeKey": "fakeValue" }}}') 114 | time.sleep(1) 115 | assert on_fake_event.ok 116 | 117 | ws.disconnect() 118 | assert not ws.thread_recv 119 | --------------------------------------------------------------------------------