diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-09 18:15:48 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-09 18:15:48 +0100 |
| commit | 9275f5fe05629574bffb39c7b5ea8eaaa4867e3a (patch) | |
| tree | 3f27821a6e7b44c7710cd2706b1251195ef9522a /deckit | |
| parent | 70deef7bc561f377eda231cf2b20e7e605b72623 (diff) | |
| download | deck-it-9275f5fe05629574bffb39c7b5ea8eaaa4867e3a.tar.gz deck-it-9275f5fe05629574bffb39c7b5ea8eaaa4867e3a.zip | |
feat: obs-websocket v5 client
Diffstat (limited to 'deckit')
| -rw-r--r-- | deckit/obsclient.py | 99 |
1 files changed, 99 insertions, 0 deletions
diff --git a/deckit/obsclient.py b/deckit/obsclient.py new file mode 100644 index 0000000..2a8fd8c --- /dev/null +++ b/deckit/obsclient.py @@ -0,0 +1,99 @@ +"""A minimal obs-websocket v5 client. + +OBS speaks WebSocket + JSON. On connect it sends Hello; if auth is required we +answer with Identify carrying the protocol's two-step SHA-256 proof, then wait +for Identified. Requests are JSON op 6 and their replies are matched by id. +""" + +import base64 +import hashlib +import json +import threading + +from .module import ModuleError + + +def auth_string(password: str, salt: str, challenge: str) -> str: + secret = base64.b64encode(hashlib.sha256((password + salt).encode()).digest()).decode() + return base64.b64encode(hashlib.sha256((secret + challenge).encode()).digest()).decode() + + +class ObsClient: + def __init__(self, host="127.0.0.1", port=4455, password="", on_event=None): + self.host, self.port, self.password = host, port, password + self.on_event = on_event + self.ws = None + self.connected = False + self._lock = threading.Lock() + self._pending = {} + self._seq = 0 + self._stop = threading.Event() + self._thread = None + + def connect(self): + import websocket # only needed when actually talking to OBS + + self.ws = websocket.create_connection(f"ws://{self.host}:{self.port}", + timeout=5, suppress_origin=True) + hello = json.loads(self.ws.recv()) + auth = {} + if hello.get("d", {}).get("authentication"): + a = hello["d"]["authentication"] + auth["authentication"] = auth_string(self.password, a["salt"], a["challenge"]) + self.ws.send(json.dumps({"op": 1, "d": {"rpcVersion": 1, **auth, "eventSubscriptions": 0xFFFFFFFF}})) + if json.loads(self.ws.recv()).get("op") != 2: + raise ModuleError("OBS did not identify") + self.connected = True + self._stop.clear() + self._thread = threading.Thread(target=self._recv_loop, daemon=True) + self._thread.start() + + def _recv_loop(self): + while not self._stop.is_set(): + try: + msg = json.loads(self.ws.recv()) + except Exception: + break + op, d = msg.get("op"), msg.get("d", {}) + if op == 5: + if self.on_event: + self.on_event(d.get("eventType"), d.get("eventData", {})) + elif op == 7: + req = self._pending.get(d.get("requestId")) + if req: + req[1] = d + req[0].set() + self.connected = False + + def request(self, request_type: str, data: dict | None = None, timeout=5) -> dict: + if not self.ws or not self.connected: + raise ModuleError("OBS is not connected") + with self._lock: + self._seq += 1 + rid = f"deck-{self._seq}" + event = threading.Event() + self._pending[rid] = [event, None] + try: + self.ws.send(json.dumps({"op": 6, "d": {"requestType": request_type, "requestId": rid, + "requestData": data or {}}})) + except Exception as e: + self._pending.pop(rid, None) + raise ModuleError(f"OBS send failed: {e}") + if not event.wait(timeout): + self._pending.pop(rid, None) + raise ModuleError("OBS did not answer") + entry = self._pending.pop(rid, None) + resp = (entry[1] if entry else None) or {} + if not resp.get("requestStatus", {}).get("result"): + raise ModuleError(f"OBS refused: {resp.get('requestStatus', {}).get('code')}") + return resp.get("responseData", {}) + + def close(self): + self._stop.set() + if self.ws: + try: + self.ws.close() + except Exception: + pass + self.ws = None + self.connected = False |
