""" WebSocket display backend - broadcasts frame buffer to connected web clients. Supports streaming protocols: - Full frame (JSON) - default for compatibility - Binary streaming - efficient binary protocol - Diff streaming - only sends changed lines TODO: Transform to a true streaming backend with: - Proper WebSocket message streaming (currently sends full buffer each frame) - Connection pooling and backpressure handling - Binary protocol for efficiency (instead of JSON) - Client management with proper async handling - Mark for deprecation if replaced by a new streaming implementation Current implementation: Simple broadcast of text frames to all connected clients. """ import asyncio import base64 import json import threading import time from enum import IntFlag from engine.display.streaming import ( MessageType, compress_frame, compute_diff, encode_binary_message, encode_diff_message, ) class StreamingMode(IntFlag): """Streaming modes for WebSocket display.""" JSON = 0x01 # Full JSON frames (default, compatible) BINARY = 0x02 # Binary compression DIFF = 0x04 # Differential updates try: import websockets except ImportError: websockets = None def get_monitor(): """Get the performance monitor.""" try: from engine.effects.performance import get_monitor as _get_monitor return _get_monitor() except Exception: return None class WebSocketDisplay: """WebSocket display backend - broadcasts to HTML Canvas clients.""" width: int = 80 height: int = 24 def __init__( self, host: str = "0.0.0.0", port: int = 8765, http_port: int = 8766, streaming_mode: StreamingMode = StreamingMode.JSON, ): self.host = host self.port = port self.http_port = http_port self.width = 80 self.height = 24 self._clients: set = set() self._server_running = False self._http_running = False self._server_thread: threading.Thread | None = None self._http_thread: threading.Thread | None = None self._available = True self._max_clients = 10 self._client_connected_callback = None self._client_disconnected_callback = None self._command_callback = None self._controller = None # Reference to UI panel or pipeline controller self._frame_delay = 0.0 self._httpd = None # HTTP server instance # Streaming configuration self._streaming_mode = streaming_mode self._last_buffer: list[str] = [] self._client_capabilities: dict = {} # Track client capabilities try: import websockets as _ws self._available = _ws is not None except ImportError: self._available = False def is_available(self) -> bool: """Check if WebSocket support is available.""" return self._available def init(self, width: int, height: int, reuse: bool = False) -> None: """Initialize display with dimensions and start server. Args: width: Terminal width in characters height: Terminal height in rows reuse: If True, skip starting servers (assume already running) """ self.width = width self.height = height if not reuse or not self._server_running: self.start_server() self.start_http_server() def show(self, buffer: list[str], border: bool = False) -> None: """Broadcast buffer to all connected clients using streaming protocol.""" t0 = time.perf_counter() # Get metrics for border display fps = 0.0 frame_time = 0.0 monitor = get_monitor() if monitor: stats = monitor.get_stats() avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0 frame_count = stats.get("frame_count", 0) if stats else 0 if avg_ms and frame_count > 0: fps = 1000.0 / avg_ms frame_time = avg_ms # Apply border if requested if border: from engine.display import render_border buffer = render_border(buffer, self.width, self.height, fps, frame_time) if not self._clients: self._last_buffer = buffer return # Send to each client based on their capabilities disconnected = set() for client in list(self._clients): try: client_id = id(client) client_mode = self._client_capabilities.get( client_id, StreamingMode.JSON ) if client_mode & StreamingMode.DIFF: self._send_diff_frame(client, buffer) elif client_mode & StreamingMode.BINARY: self._send_binary_frame(client, buffer) else: self._send_json_frame(client, buffer) except Exception: disconnected.add(client) for client in disconnected: self._clients.discard(client) if self._client_disconnected_callback: self._client_disconnected_callback(client) self._last_buffer = buffer elapsed_ms = (time.perf_counter() - t0) * 1000 if monitor: chars_in = sum(len(line) for line in buffer) monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in) def _send_json_frame(self, client, buffer: list[str]) -> None: """Send frame as JSON.""" frame_data = { "type": "frame", "width": self.width, "height": self.height, "lines": buffer, } message = json.dumps(frame_data) asyncio.run(client.send(message)) def _send_binary_frame(self, client, buffer: list[str]) -> None: """Send frame as compressed binary.""" compressed = compress_frame(buffer) message = encode_binary_message( MessageType.FULL_FRAME, self.width, self.height, compressed ) encoded = base64.b64encode(message).decode("utf-8") asyncio.run(client.send(encoded)) def _send_diff_frame(self, client, buffer: list[str]) -> None: """Send frame as diff.""" diff = compute_diff(self._last_buffer, buffer) if not diff.changed_lines: return diff_payload = encode_diff_message(diff) message = encode_binary_message( MessageType.DIFF_FRAME, self.width, self.height, diff_payload ) encoded = base64.b64encode(message).decode("utf-8") asyncio.run(client.send(encoded)) def set_streaming_mode(self, mode: StreamingMode) -> None: """Set the default streaming mode for new clients.""" self._streaming_mode = mode def get_streaming_mode(self) -> StreamingMode: """Get the current streaming mode.""" return self._streaming_mode def clear(self) -> None: """Broadcast clear command to all clients.""" if self._clients: clear_data = {"type": "clear"} message = json.dumps(clear_data) for client in list(self._clients): try: asyncio.run(client.send(message)) except Exception: pass def cleanup(self) -> None: """Stop the servers.""" self.stop_server() self.stop_http_server() async def _websocket_handler(self, websocket): """Handle WebSocket connections.""" if len(self._clients) >= self._max_clients: await websocket.close() return self._clients.add(websocket) if self._client_connected_callback: self._client_connected_callback(websocket) try: async for message in websocket: try: data = json.loads(message) msg_type = data.get("type") if msg_type == "resize": self.width = data.get("width", 80) self.height = data.get("height", 24) elif msg_type == "command" and self._command_callback: # Forward commands to the pipeline controller command = data.get("command", {}) self._command_callback(command) elif msg_type == "state_request": # Send current state snapshot state = self._get_state_snapshot() if state: response = {"type": "state", "state": state} await websocket.send(json.dumps(response)) except json.JSONDecodeError: pass except Exception: pass finally: self._clients.discard(websocket) if self._client_disconnected_callback: self._client_disconnected_callback(websocket) async def _run_websocket_server(self): """Run the WebSocket server.""" if not websockets: return async with websockets.serve(self._websocket_handler, self.host, self.port): while self._server_running: await asyncio.sleep(0.1) async def _run_http_server(self): """Run simple HTTP server for the client.""" import os from http.server import HTTPServer, SimpleHTTPRequestHandler # Find the project root by locating 'engine' directory in the path websocket_file = os.path.abspath(__file__) parts = websocket_file.split(os.sep) if "engine" in parts: engine_idx = parts.index("engine") project_root = os.sep.join(parts[:engine_idx]) client_dir = os.path.join(project_root, "client") else: # Fallback: go up 4 levels from websocket.py # websocket.py: .../engine/display/backends/websocket.py # We need: .../client client_dir = os.path.join( os.path.dirname( os.path.dirname(os.path.dirname(os.path.dirname(__file__))) ), "client", ) class Handler(SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=client_dir, **kwargs) def log_message(self, format, *args): pass httpd = HTTPServer((self.host, self.http_port), Handler) # Store reference for shutdown self._httpd = httpd # Serve requests continuously httpd.serve_forever() def _run_async(self, coro): """Run coroutine in background.""" try: asyncio.run(coro) except Exception as e: print(f"WebSocket async error: {e}") def start_server(self): """Start the WebSocket server in a background thread.""" if not self._available: return if self._server_thread is not None: return self._server_running = True self._server_thread = threading.Thread( target=self._run_async, args=(self._run_websocket_server(),), daemon=True ) self._server_thread.start() def stop_server(self): """Stop the WebSocket server.""" self._server_running = False self._server_thread = None def start_http_server(self): """Start the HTTP server in a background thread.""" if not self._available: return if self._http_thread is not None: return self._http_running = True self._http_running = True self._http_thread = threading.Thread( target=self._run_async, args=(self._run_http_server(),), daemon=True ) self._http_thread.start() def stop_http_server(self): """Stop the HTTP server.""" self._http_running = False if hasattr(self, "_httpd") and self._httpd: self._httpd.shutdown() self._http_thread = None def client_count(self) -> int: """Return number of connected clients.""" return len(self._clients) def get_ws_port(self) -> int: """Return WebSocket port.""" return self.port def get_http_port(self) -> int: """Return HTTP port.""" return self.http_port def set_frame_delay(self, delay: float) -> None: """Set delay between frames in seconds.""" self._frame_delay = delay def get_frame_delay(self) -> float: """Get delay between frames.""" return self._frame_delay def set_client_connected_callback(self, callback) -> None: """Set callback for client connections.""" self._client_connected_callback = callback def set_client_disconnected_callback(self, callback) -> None: """Set callback for client disconnections.""" self._client_disconnected_callback = callback def set_command_callback(self, callback) -> None: """Set callback for incoming command messages from clients.""" self._command_callback = callback def set_controller(self, controller) -> None: """Set controller (UI panel or pipeline) for state queries and command execution.""" self._controller = controller def broadcast_state(self, state: dict) -> None: """Broadcast state update to all connected clients. Args: state: Dictionary containing state data to send to clients """ if not self._clients: return message = json.dumps({"type": "state", "state": state}) disconnected = set() for client in list(self._clients): try: asyncio.run(client.send(message)) except Exception: disconnected.add(client) for client in disconnected: self._clients.discard(client) if self._client_disconnected_callback: self._client_disconnected_callback(client) def _get_state_snapshot(self) -> dict | None: """Get current state snapshot from controller.""" if not self._controller: return None try: # Expect controller to have methods we need state = {} # Get stages info if UIPanel if hasattr(self._controller, "stages"): state["stages"] = { name: { "enabled": ctrl.enabled, "params": ctrl.params, "selected": ctrl.selected, } for name, ctrl in self._controller.stages.items() } # Get current preset if hasattr(self._controller, "_current_preset"): state["preset"] = self._controller._current_preset if hasattr(self._controller, "_presets"): state["presets"] = self._controller._presets # Get selected stage if hasattr(self._controller, "selected_stage"): state["selected_stage"] = self._controller.selected_stage return state except Exception: return None def get_dimensions(self) -> tuple[int, int]: """Get current dimensions. Returns: (width, height) in character cells """ return (self.width, self.height)