aboutsummaryrefslogtreecommitdiffstats
path: root/deckit
diff options
context:
space:
mode:
Diffstat (limited to 'deckit')
-rw-r--r--deckit/obsclient.py99
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