// useMixerWS — WebSocket hook for op-pedal mixer engine protocol // // Connects to pipedald's WebSocket endpoint and provides the mixer // command set (mixerGetState, mixerSetChannelVolume, etc.) matching // the PiPedalSocket message format used in PiPedalSocket.cpp handlers. // // Protocol: // Send: [{"message": "mixerGetState", "replyTo": N}] // Reply: [{"reply": N, "message": "mixerState"}, {…body…}] import { useCallback, useEffect, useRef, useState } from "react"; // ── Types ────────────────────────────────────────────────────────── export interface MixerChannelState { channelIndex: number; volume: number; // dB pan: number; // -1..1 mute: boolean; solo: boolean; type: string; // "Instrument" | "Mic" | "Line" label: string; hpEnabled: boolean; hpFrequency: number; } export interface MixerBusState { id: number; name: string; type: string; // "Master" | "Subgroup" | "Aux" | "FxReturn" volume: number; // dB mute: boolean; } export interface MixerRouteState { sourceId: number; targetBusId: number; level: number; // dB sourceType: string; // "channel" | "bus" } export interface MixerState { channels: MixerChannelState[]; buses: MixerBusState[]; routes: MixerRouteState[]; } export type WsStatus = | "disconnected" | "connecting" | "connected" | "error"; export interface MixerWsHandle { /** Current connection status. */ status: WsStatus; /** Last error message (cleared on reconnect). */ lastError: string | null; /** Send a mixer command and await the reply. */ send: (command: string, params?: unknown) => Promise; /** Manually connect (auto-called on mount; safe to call again after disconnect). */ connect: () => void; /** Disconnect manually. */ disconnect: () => void; } // ── Defaults ─────────────────────────────────────────────────────── const DEFAULT_WS_URL = "ws://192.168.0.245:8080/ws"; const MAX_RECONNECT_DELAY_MS = 30_000; const INITIAL_RECONNECT_DELAY_MS = 1_000; // ── Hook ─────────────────────────────────────────────────────────── export function useMixerWS(wsUrl: string = DEFAULT_WS_URL): MixerWsHandle { const [status, setStatus] = useState("disconnected"); const [lastError, setLastError] = useState(null); const wsRef = useRef(null); const replyMapRef = useRef void>>(new Map()); const nextReplyToRef = useRef(0); const reconnectTimerRef = useRef | null>(null); const reconnectAttemptRef = useRef(0); const mountedRef = useRef(true); const wsUrlRef = useRef(wsUrl); // Keep the URL ref current without triggering re-renders. useEffect(() => { wsUrlRef.current = wsUrl; }, [wsUrl]); /** Clean up WS and timers. */ const cleanup = useCallback(() => { // Clear reconnect timer. if (reconnectTimerRef.current !== null) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } // Close socket cleanly. const sock = wsRef.current; if (sock) { sock.onopen = null; sock.onclose = null; sock.onerror = null; sock.onmessage = null; if (sock.readyState === WebSocket.OPEN || sock.readyState === WebSocket.CONNECTING) { sock.close(); } wsRef.current = null; } // Reject all pending promises. const pending = replyMapRef.current; if (pending.size > 0) { const err = new Error("WebSocket disconnected"); for (const reject of pending.values()) { reject(err); } pending.clear(); } }, []); /** Open a new WebSocket connection. */ const connect = useCallback(() => { if (!mountedRef.current) return; if (wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING) { return; // already connected or connecting } cleanup(); setStatus("connecting"); setLastError(null); const url = wsUrlRef.current; let sock: WebSocket; try { sock = new WebSocket(url); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); setStatus("error"); setLastError(`Failed to create WebSocket: ${msg}`); return; } sock.onopen = () => { if (!mountedRef.current) { sock.close(); return; } wsRef.current = sock; reconnectAttemptRef.current = 0; setStatus("connected"); setLastError(null); }; sock.onclose = () => { if (!mountedRef.current) return; if (wsRef.current === sock) { wsRef.current = null; } setStatus("disconnected"); // Schedule reconnect with exponential backoff. scheduleReconnect(); }; sock.onerror = () => { // The onclose event will fire after onerror, so we don't set // status here to avoid a flash of "error" followed by "disconnected". setLastError("WebSocket connection error"); }; sock.onmessage = (event: MessageEvent) => { if (!mountedRef.current) return; let parsed: unknown; try { parsed = JSON.parse(event.data); } catch { // Ignore non-JSON messages. return; } if (!Array.isArray(parsed)) return; if (parsed.length < 1) return; const header = parsed[0] as Record; const body = parsed.length >= 2 ? parsed[1] : undefined; // If this is a reply to one of our requests, resolve the promise. if (typeof header.reply === "number") { const resolve = replyMapRef.current.get(header.reply); if (resolve) { replyMapRef.current.delete(header.reply); if (header.message === "error") { resolve(Promise.reject(new Error(String(body ?? "Unknown error")))); } else { resolve(body); } } } // Unprompted messages (state updates, etc.) are ignored at this level. // Subscribers can extend the hook later if they need push events. }; wsRef.current = sock; }, [cleanup]); /** Schedule a reconnection attempt with exponential backoff. */ const scheduleReconnect = useCallback(() => { if (!mountedRef.current) return; if (reconnectTimerRef.current !== null) return; // already scheduled const attempt = reconnectAttemptRef.current; const delay = Math.min( INITIAL_RECONNECT_DELAY_MS * Math.pow(2, attempt), MAX_RECONNECT_DELAY_MS ); reconnectAttemptRef.current = attempt + 1; reconnectTimerRef.current = setTimeout(() => { reconnectTimerRef.current = null; if (mountedRef.current) { connect(); } }, delay); }, [connect]); /** Send a command and wait for the response. */ const send = useCallback((command: string, params?: unknown): Promise => { return new Promise((resolve, reject) => { const sock = wsRef.current; if (!sock || sock.readyState !== WebSocket.OPEN) { reject(new Error("WebSocket not connected")); return; } const replyTo = ++nextReplyToRef.current; // Build message in PiPedalSocket format: [header, body?] const header: Record = { message: command, replyTo }; const msg = params !== undefined ? [header, params] : [header]; // Register the reply handler before sending (race-safe). replyMapRef.current.set(replyTo, (value: unknown) => { resolve(value); }); // Set a timeout for the reply. const timeout = setTimeout(() => { replyMapRef.current.delete(replyTo); reject(new Error(`Command "${command}" timed out after 10s`)); }, 10_000); // Wrap the resolve to clear the timeout. const originalResolve = replyMapRef.current.get(replyTo)!; replyMapRef.current.set(replyTo, (value: unknown) => { clearTimeout(timeout); originalResolve(value); }); try { sock.send(JSON.stringify(msg)); } catch (err: unknown) { clearTimeout(timeout); replyMapRef.current.delete(replyTo); reject(err instanceof Error ? err : new Error(String(err))); } }); }, []); const disconnect = useCallback(() => { cleanup(); setStatus("disconnected"); }, [cleanup]); // ── Lifecycle ────────────────────────────────────────────────── useEffect(() => { mountedRef.current = true; // Connect on mount. connect(); return () => { mountedRef.current = false; cleanup(); }; }, [connect, cleanup]); return { status, lastError, send, connect, disconnect }; }