"""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