url = $url ?? (string) config('rig.obs_ws.url'); $this->password = $password ?? (string) config('rig.obs_ws.password'); $this->timeoutMs = $timeoutMs; } /** * Broadcast a CustomEvent through OBS WS to every connected client. * `_type` is added to eventData so the mini client's `onCustom(typeFilter)` works. */ public function broadcast(string $type, array $payload = []): void { $eventData = array_merge(['_type' => $type], $payload); $this->sendRequest('BroadcastCustomEvent', ['eventData' => $eventData]); } public function sendRequest(string $requestType, array $requestData = []): array { $loop = Loop::get(); $connector = new PawlConnector($loop); $result = null; $error = null; $identified = false; $requestId = 'php-' . bin2hex(random_bytes(6)); $promise = $connector($this->url); $promise->then(function (WebSocket $conn) use (&$result, &$error, &$identified, $requestId, $requestType, $requestData) { $conn->on('message', function (MessageInterface $msg) use ($conn, &$result, &$error, &$identified, $requestId, $requestType, $requestData) { $payload = json_decode((string) $msg, true); if (!is_array($payload) || !isset($payload['op'])) return; try { switch ($payload['op']) { case 0: // Hello $auth = null; if (isset($payload['d']['authentication'])) { if ($this->password === '') { $error = new Exception('OBS WS server requires password but none configured'); $conn->close(); return; } $auth = $this->authString( $payload['d']['authentication']['salt'], $payload['d']['authentication']['challenge'] ); } $identify = [ 'op' => 1, 'd' => [ 'rpcVersion' => 1, 'authentication' => $auth, 'eventSubscriptions' => 0, ], ]; // null auth would serialize as JSON null; OBS WS expects the key absent when no auth. if ($auth === null) unset($identify['d']['authentication']); $conn->send(json_encode($identify)); break; case 2: // Identified $identified = true; $conn->send(json_encode([ 'op' => 6, 'd' => [ 'requestType' => $requestType, 'requestId' => $requestId, 'requestData' => (object) $requestData, ], ])); break; case 7: // RequestResponse if (($payload['d']['requestId'] ?? null) !== $requestId) return; $status = $payload['d']['requestStatus'] ?? []; if (!empty($status['result'])) { $result = $payload['d']['responseData'] ?? []; } else { $error = new Exception( 'OBS WS request failed: ' . ($status['comment'] ?? 'unknown') ); } $conn->close(); break; } } catch (Throwable $e) { $error = $e; $conn->close(); } }); $conn->on('close', function () { Loop::stop(); }); $conn->on('error', function ($e) use (&$error) { $error = $e instanceof Throwable ? $e : new Exception((string) $e); Loop::stop(); }); }, function ($e) use (&$error) { $error = $e instanceof Throwable ? $e : new Exception((string) $e); Loop::stop(); }); // Hard timeout guard — Loop::stop fires no matter what. $timer = $loop->addTimer($this->timeoutMs / 1000, function () use (&$error) { $error = $error ?: new Exception('OBS WS request timed out'); Loop::stop(); }); $loop->run(); $loop->cancelTimer($timer); if ($error) throw $error; if (!$identified) throw new Exception('OBS WS never reached Identified'); return $result ?? []; } private function authString(string $salt, string $challenge): string { $secret = base64_encode(hash('sha256', $this->password . $salt, true)); return base64_encode(hash('sha256', $secret . $challenge, true)); } }