forked from genewildish/Mainline
feat(integration): Complete feature rewrite with pipeline architecture, effects system, and display improvements
Major changes: - Pipeline architecture with capability-based dependency resolution - Effects plugin system with performance monitoring - Display abstraction with multiple backends (terminal, null, websocket) - Camera system for viewport scrolling - Sensor framework for real-time input - Command-and-control system via ntfy - WebSocket display backend for browser clients - Comprehensive test suite and documentation Issue #48: ADR for preset scripting language included This commit consolidates 110 individual commits into a single feature integration that can be reviewed and tested before further refinement.
This commit is contained in:
464
engine/display/backends/websocket.py
Normal file
464
engine/display/backends/websocket.py
Normal file
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user