Files
radar_frontend/web_viewer/app.py
2025-11-20 15:46:23 +03:00

359 lines
11 KiB
Python

"""
FastAPI Web Application for Beacon Tracker Video Streaming
This application reads JPEG frames from shared memory and streams them
to web browsers via Server-Sent Events (SSE) or WebSocket (H.264 stream).
"""
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
from fastapi.responses import HTMLResponse, StreamingResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from shared_memory_reader import SharedMemoryFrameReader
import asyncio
import time
import base64
import json
import os
from typing import Set
from contextlib import asynccontextmanager
# Global state
reader = None
reader_lock = asyncio.Lock()
last_frame_data = None
last_frame_header = None
frame_lock = asyncio.Lock()
# Video streaming state
active_websocket_clients: Set[WebSocket] = set()
streaming_task = None
streaming_active = False
PIPE_PATH = '/tmp/beacon_video_stream'
async def init_reader():
"""Initialize the shared memory reader"""
global reader
async with reader_lock:
if reader is None:
try:
reader = SharedMemoryFrameReader()
print("Connected to shared memory successfully")
except Exception as e:
print(f"Failed to connect to shared memory: {e}")
reader = None
async def cleanup_reader():
"""Clean up shared memory reader"""
global reader
async with reader_lock:
if reader:
reader.close()
reader = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown events"""
# Startup
await init_reader()
print("FastAPI application started")
yield
# Shutdown
await cleanup_reader()
print("FastAPI application shutdown")
# Create FastAPI app
app = FastAPI(
title="Beacon Tracker Video Stream",
description="High-performance video streaming with WebSocket and SSE support",
version="2.0",
lifespan=lifespan
)
# Mount static files if directory exists
if os.path.exists("static"):
app.mount("/static", StaticFiles(directory="static"), name="static")
else:
print("Warning: 'static' directory not found, static files will not be served")
# Templates
templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""Render the main page"""
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/stream")
async def stream_sse():
"""
Server-Sent Events stream endpoint
Continuously sends JPEG frames to the client as they become available.
"""
async def generate():
global reader
# Initialize reader for this stream
await init_reader()
# Check again after init
async with reader_lock:
local_reader = reader
if local_reader is None:
yield f"data: {json.dumps({'error': 'Failed to connect to shared memory'})}\n\n"
return
consecutive_failures = 0
max_failures = 100
while True:
try:
async with reader_lock:
if reader is None:
yield f"data: {json.dumps({'error': 'Reader is None'})}\n\n"
return
# Run blocking read in thread pool
result = await asyncio.to_thread(reader.read_frame)
if result:
header, jpeg_data = result
consecutive_failures = 0
# Encode JPEG as base64 for transmission
jpeg_base64 = base64.b64encode(jpeg_data).decode('utf-8')
# Create event data
event_data = {
'frame_number': header.frame_number,
'timestamp_us': header.timestamp_us,
'width': header.width,
'height': header.height,
'data_size': header.data_size,
'jpeg': jpeg_base64
}
# Send as SSE event
yield f"data: {json.dumps(event_data)}\n\n"
# Update global state
async with frame_lock:
global last_frame_data, last_frame_header
last_frame_data = jpeg_data
last_frame_header = header
else:
consecutive_failures += 1
if consecutive_failures >= max_failures:
yield f"data: {json.dumps({'error': 'No frames available'})}\n\n"
consecutive_failures = 0
# Small delay to prevent busy waiting
await asyncio.sleep(0.001) # 1ms - fast updates
except Exception as e:
print(f"Error in stream: {e}")
yield f"data: {json.dumps({'error': str(e)})}\n\n"
await asyncio.sleep(1)
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
@app.get("/status")
async def status():
"""Get the current status of the stream"""
await init_reader()
async with frame_lock:
if last_frame_header:
return {
'connected': True,
'last_frame': last_frame_header.frame_number,
'timestamp_us': last_frame_header.timestamp_us,
'resolution': f"{last_frame_header.width}x{last_frame_header.height}"
}
else:
return {
'connected': reader is not None,
'last_frame': None,
'message': 'Waiting for frames...'
}
@app.get("/latest_frame")
async def latest_frame():
"""Get the latest frame as a JPEG image"""
async with frame_lock:
if last_frame_data:
return Response(content=last_frame_data, media_type="image/jpeg")
else:
return Response(content="No frame available", status_code=404)
async def stream_video_from_pipe():
"""
Read MPEG-TS video stream from named pipe and broadcast via WebSocket.
This runs as a background task.
"""
global streaming_active
print(f"Starting video stream reader from: {PIPE_PATH}")
# Wait for pipe to be created by C++ application
max_wait_time = 30 # seconds
start_time = time.time()
while not os.path.exists(PIPE_PATH):
if time.time() - start_time > max_wait_time:
print(f"ERROR: Pipe {PIPE_PATH} not found after {max_wait_time}s")
return
print(f"Waiting for pipe {PIPE_PATH}...")
await asyncio.sleep(1)
print(f"Pipe found: {PIPE_PATH}")
try:
# Open the named pipe in binary read mode
print("Opening pipe for reading...")
# Use asyncio to read from pipe
streaming_active = True
# Open pipe in non-blocking mode
import fcntl
pipe_fd = os.open(PIPE_PATH, os.O_RDONLY | os.O_NONBLOCK)
print("Pipe opened successfully, starting stream...")
chunk_size = 32768 # 32KB chunks for MPEG-TS
while streaming_active and active_websocket_clients:
try:
# Read chunk from pipe
try:
data = os.read(pipe_fd, chunk_size)
except BlockingIOError:
# No data available, wait a bit
await asyncio.sleep(0.001)
continue
if not data:
print("No data from pipe, stream may have ended")
break
# Broadcast binary data to all connected WebSocket clients
disconnected_clients = set()
for client in active_websocket_clients.copy():
try:
await client.send_bytes(data)
except Exception as e:
print(f"Error sending to client: {e}")
disconnected_clients.add(client)
# Remove disconnected clients
active_websocket_clients.difference_update(disconnected_clients)
# Small delay to prevent overwhelming clients
await asyncio.sleep(0.001)
except Exception as e:
print(f"Error reading from pipe: {e}")
break
os.close(pipe_fd)
except FileNotFoundError:
print(f"ERROR: Pipe {PIPE_PATH} not found")
except Exception as e:
print(f"ERROR in video streaming: {e}")
import traceback
traceback.print_exc()
finally:
streaming_active = False
print("Video streaming stopped")
@app.websocket("/ws/video")
async def websocket_video_endpoint(websocket: WebSocket):
"""WebSocket endpoint for video streaming"""
global streaming_task, streaming_active
await websocket.accept()
print(f"Client connected to video stream: {id(websocket)}")
# Add client to active set
active_websocket_clients.add(websocket)
# Start streaming task if not already running
if not streaming_active:
streaming_task = asyncio.create_task(stream_video_from_pipe())
print("Started video streaming task")
try:
# Keep connection alive and handle client messages
while True:
# Wait for client messages (like ping/pong or control messages)
try:
data = await asyncio.wait_for(websocket.receive_text(), timeout=1.0)
# Handle client messages if needed
if data == "ping":
await websocket.send_text("pong")
except asyncio.TimeoutError:
# No message received, continue
continue
except WebSocketDisconnect:
print(f"Client disconnected from video stream: {id(websocket)}")
except Exception as e:
print(f"WebSocket error: {e}")
finally:
# Remove client from active set
active_websocket_clients.discard(websocket)
# Stop streaming if no more clients
if not active_websocket_clients:
streaming_active = False
print("No more clients, stopping stream")
@app.get("/health")
async def health():
"""Health check endpoint"""
return {
"status": "healthy",
"active_websocket_clients": len(active_websocket_clients),
"streaming_active": streaming_active,
"pipe_exists": os.path.exists(PIPE_PATH)
}
if __name__ == '__main__':
import uvicorn
print("Starting FastAPI server on http://0.0.0.0:5000")
print(f"Video stream will be available at ws://0.0.0.0:5000/ws/video")
uvicorn.run(
app,
host='0.0.0.0',
port=5000,
log_level='info',
access_log=True
)