mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-05 15:17:37 +03:00
got the display
This commit is contained in:
parent
58232d2483
commit
c869ca46ed
@ -7,7 +7,7 @@ services:
|
||||
tmpfs:
|
||||
- /tmp
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "8090:8090"
|
||||
stdin_open: true
|
||||
tty: true
|
||||
stop_signal: SIGKILL
|
||||
@ -16,6 +16,7 @@ services:
|
||||
- -c
|
||||
- |
|
||||
set -x;
|
||||
set -e;
|
||||
source /root/esp/esp-idf/export.sh
|
||||
|
||||
cd /tmp
|
||||
@ -26,8 +27,8 @@ services:
|
||||
dd if="partitions.bin" bs=1 count=$(stat -c%s "partitions.bin") seek=$((16#8000)) conv=notrunc of=./flash.bin
|
||||
dd if="firmware.bin" bs=1 count=$(stat -c%s "firmware.bin") seek=$((16#10000)) conv=notrunc of=./flash.bin
|
||||
|
||||
qemu-system-riscv32 \
|
||||
-nographic \
|
||||
-M esp32c3 \
|
||||
-drive \
|
||||
file=flash.bin,if=mtd,format=raw
|
||||
# TODO: why pip install doesn't work in Dockerfile?
|
||||
python3 -m pip install websockets --break-system-packages
|
||||
|
||||
cp /app/scripts/emulation/web_ui.html .
|
||||
python3 /app/scripts/emulation/web_server.py
|
||||
|
||||
242
scripts/emulation/web_server.py
Normal file
242
scripts/emulation/web_server.py
Normal file
@ -0,0 +1,242 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from typing import Set
|
||||
|
||||
import websockets
|
||||
from websockets.http11 import Response
|
||||
from websockets.server import WebSocketServerProtocol
|
||||
|
||||
|
||||
"""
|
||||
This script spawns a QEMU process, then forward its stdout and stderr to WebSocket clients.
|
||||
"""
|
||||
|
||||
|
||||
# WebSocket clients
|
||||
connected_clients: Set[WebSocketServerProtocol] = set()
|
||||
|
||||
# QEMU process
|
||||
qemu_process: asyncio.subprocess.Process | None = None
|
||||
|
||||
def process_message(message: str, for_ui: bool) -> str:
|
||||
if message.startswith("$$DATA:DISPLAY:"):
|
||||
if for_ui:
|
||||
return message
|
||||
else:
|
||||
return f"[DISPLAY DATA]"
|
||||
return message
|
||||
|
||||
|
||||
async def broadcast_message(msg_type: str, data: str):
|
||||
"""Broadcast a message to all connected WebSocket clients."""
|
||||
if not connected_clients:
|
||||
return
|
||||
|
||||
message = json.dumps({"type": msg_type, "data": data})
|
||||
|
||||
# Send to all clients, remove disconnected ones
|
||||
disconnected = set()
|
||||
for client in connected_clients:
|
||||
try:
|
||||
await client.send(message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
disconnected.add(client)
|
||||
|
||||
connected_clients.difference_update(disconnected)
|
||||
|
||||
|
||||
async def read_stream(stream: asyncio.StreamReader, stream_type: str):
|
||||
"""Read from a stream line by line and broadcast to clients."""
|
||||
buffer = b""
|
||||
|
||||
while True:
|
||||
try:
|
||||
chunk = await stream.read(1024)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
buffer += chunk
|
||||
|
||||
# Process complete lines
|
||||
while b"\n" in buffer:
|
||||
line, buffer = buffer.split(b"\n", 1)
|
||||
try:
|
||||
decoded_line = line.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
decoded_line = line.decode("latin-1", errors="replace")
|
||||
|
||||
# Forward to parent process
|
||||
if stream_type == "stdout":
|
||||
print(process_message(decoded_line, for_ui=False), flush=True)
|
||||
else:
|
||||
print(process_message(decoded_line, for_ui=False), file=sys.stderr, flush=True)
|
||||
|
||||
# Broadcast to WebSocket clients
|
||||
await broadcast_message(stream_type, process_message(decoded_line, for_ui=True))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error reading {stream_type}: {e}", file=sys.stderr)
|
||||
break
|
||||
|
||||
# Process remaining buffer
|
||||
if buffer:
|
||||
try:
|
||||
decoded_line = buffer.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
decoded_line = buffer.decode("latin-1", errors="replace")
|
||||
|
||||
if stream_type == "stdout":
|
||||
print(decoded_line, flush=True)
|
||||
else:
|
||||
print(decoded_line, file=sys.stderr, flush=True)
|
||||
|
||||
await broadcast_message(stream_type, decoded_line)
|
||||
|
||||
|
||||
async def spawn_qemu():
|
||||
"""Spawn the QEMU process and capture its output."""
|
||||
global qemu_process
|
||||
|
||||
# Build the command
|
||||
cmd = [
|
||||
"qemu-system-riscv32",
|
||||
"-nographic",
|
||||
"-M", "esp32c3",
|
||||
"-drive", "file=flash.bin,if=mtd,format=raw"
|
||||
]
|
||||
|
||||
# Get working directory from environment or use /tmp
|
||||
work_dir = os.getcwd()
|
||||
|
||||
print(f"Starting QEMU in {work_dir}...", flush=True)
|
||||
print(f"Command: {' '.join(cmd)}", flush=True)
|
||||
|
||||
try:
|
||||
qemu_process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
cwd=work_dir,
|
||||
env=os.environ.copy()
|
||||
)
|
||||
|
||||
# Read stdout and stderr concurrently
|
||||
await asyncio.gather(
|
||||
read_stream(qemu_process.stdout, "stdout"),
|
||||
read_stream(qemu_process.stderr, "stderr")
|
||||
)
|
||||
|
||||
# Wait for process to complete
|
||||
await qemu_process.wait()
|
||||
print(f"QEMU process exited with code {qemu_process.returncode}", flush=True)
|
||||
|
||||
except FileNotFoundError:
|
||||
print("Error: qemu-system-riscv32 not found. Make sure it's in PATH.", file=sys.stderr)
|
||||
await broadcast_message("stderr", "Error: qemu-system-riscv32 not found")
|
||||
except Exception as e:
|
||||
print(f"Error spawning QEMU: {e}", file=sys.stderr)
|
||||
await broadcast_message("stderr", f"Error spawning QEMU: {e}")
|
||||
|
||||
|
||||
async def websocket_handler(websocket: WebSocketServerProtocol):
|
||||
"""Handle a WebSocket connection."""
|
||||
connected_clients.add(websocket)
|
||||
print(f"Client connected. Total clients: {len(connected_clients)}", flush=True)
|
||||
|
||||
try:
|
||||
# Send a welcome message
|
||||
await websocket.send(json.dumps({
|
||||
"type": "info",
|
||||
"data": "Connected to CrossPoint emulator"
|
||||
}))
|
||||
|
||||
# Handle incoming messages (for stdin forwarding)
|
||||
async for message in websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
if data.get("type") == "stdin" and qemu_process and qemu_process.stdin:
|
||||
input_data = data.get("data", "")
|
||||
qemu_process.stdin.write((input_data + "\n").encode())
|
||||
await qemu_process.stdin.drain()
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Error handling client message: {e}", file=sys.stderr)
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
pass
|
||||
finally:
|
||||
connected_clients.discard(websocket)
|
||||
print(f"Client disconnected. Total clients: {len(connected_clients)}", flush=True)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point."""
|
||||
host = os.environ.get("HOST", "0.0.0.0")
|
||||
port = int(os.environ.get("PORT", "8090"))
|
||||
|
||||
print(f"Starting WebSocket server on {host}:{port}", flush=True)
|
||||
|
||||
# Start WebSocket server
|
||||
async with websockets.serve(
|
||||
websocket_handler, host, port,
|
||||
process_request=process_request,
|
||||
origins=None,
|
||||
):
|
||||
print(f"WebSocket server running on ws://{host}:{port}/ws", flush=True)
|
||||
print(f"Web UI available at http://{host}:{port}/", flush=True)
|
||||
|
||||
# Spawn QEMU process
|
||||
await spawn_qemu()
|
||||
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
"""Handle shutdown signals."""
|
||||
print("\nShutting down...", flush=True)
|
||||
if qemu_process:
|
||||
qemu_process.terminate()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def process_request(connection, request):
|
||||
"""Handle HTTP requests for serving static files."""
|
||||
path = request.path
|
||||
|
||||
if path == "/" or path == "/web_ui.html":
|
||||
# Serve the web_ui.html file
|
||||
html_path = Path(__file__).parent / "web_ui.html"
|
||||
try:
|
||||
content = html_path.read_bytes()
|
||||
return Response(
|
||||
HTTPStatus.OK,
|
||||
"OK",
|
||||
websockets.Headers([
|
||||
("Content-Type", "text/html; charset=utf-8"),
|
||||
("Content-Length", str(len(content))),
|
||||
]),
|
||||
content
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return Response(HTTPStatus.NOT_FOUND, "Not Found", websockets.Headers(), b"web_ui.html not found")
|
||||
|
||||
if path == "/ws":
|
||||
# Return None to continue with WebSocket handshake
|
||||
return None
|
||||
|
||||
# Return 404 for other paths
|
||||
return Response(HTTPStatus.NOT_FOUND, "Not Found", websockets.Headers(), b"Not found")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Set up signal handlers
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Run the main loop
|
||||
asyncio.run(main())
|
||||
120
scripts/emulation/web_ui.html
Normal file
120
scripts/emulation/web_ui.html
Normal file
@ -0,0 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CrossPoint Emulator</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: monospace;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="screen" width="480" height="800" style="border:1px solid #555;"></canvas>
|
||||
|
||||
<div id="output"></div>
|
||||
|
||||
<script>
|
||||
const output = document.getElementById('output');
|
||||
const screen = document.getElementById('screen');
|
||||
const ctx = screen.getContext('2d');
|
||||
|
||||
let ws = null;
|
||||
|
||||
function appendLog(type, message) {
|
||||
const line = document.createElement('div');
|
||||
line.className = type;
|
||||
line.textContent = message;
|
||||
output.appendChild(line);
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function drawScreen(b64Data) {
|
||||
// b64Data is a base64-encoded 1-bit per pixel framebuffer
|
||||
// Source buffer is 800x480, with 100 bytes per row (800/8 = 100)
|
||||
// We rotate 90 degrees clockwise to display as 480x800
|
||||
const binaryString = atob(b64Data);
|
||||
const srcWidth = 800;
|
||||
const srcHeight = 480;
|
||||
const srcWidthBytes = srcWidth / 8; // 100 bytes per row
|
||||
|
||||
// After 90° clockwise rotation: new width = srcHeight, new height = srcWidth
|
||||
const dstWidth = srcHeight; // 480
|
||||
const dstHeight = srcWidth; // 800
|
||||
|
||||
const imageData = ctx.createImageData(dstWidth, dstHeight);
|
||||
const pixels = imageData.data;
|
||||
|
||||
for (let srcY = 0; srcY < srcHeight; srcY++) {
|
||||
for (let xByte = 0; xByte < srcWidthBytes; xByte++) {
|
||||
const byteIndex = srcY * srcWidthBytes + xByte;
|
||||
const byte = binaryString.charCodeAt(byteIndex);
|
||||
|
||||
// Each byte contains 8 pixels (MSB first)
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
const srcX = xByte * 8 + bit;
|
||||
|
||||
// 90° clockwise rotation: (srcX, srcY) -> (srcHeight - 1 - srcY, srcX)
|
||||
const dstX = srcHeight - 1 - srcY;
|
||||
const dstY = srcX;
|
||||
|
||||
const pixelIndex = (dstY * dstWidth + dstX) * 4;
|
||||
|
||||
// Bit 1 = white (0xFF), Bit 0 = black (0x00)
|
||||
const isWhite = (byte >> (7 - bit)) & 1;
|
||||
const color = isWhite ? 255 : 0;
|
||||
|
||||
pixels[pixelIndex] = color; // R
|
||||
pixels[pixelIndex + 1] = color; // G
|
||||
pixels[pixelIndex + 2] = color; // B
|
||||
pixels[pixelIndex + 3] = 255; // A
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
|
||||
appendLog('info', `Connecting to ${wsUrl}...`);
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
appendLog('info', 'Connected');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.data.startsWith('$$DATA:DISPLAY:')) {
|
||||
// Parse the display data: $$DATA:DISPLAY:{"mode":X,"buffer":"..."}$$
|
||||
const jsonStart = msg.data.indexOf('{');
|
||||
const jsonEnd = msg.data.lastIndexOf('}');
|
||||
if (jsonStart !== -1 && jsonEnd !== -1) {
|
||||
const displayData = JSON.parse(msg.data.substring(jsonStart, jsonEnd + 1));
|
||||
drawScreen(displayData.buffer);
|
||||
}
|
||||
} else {
|
||||
appendLog(msg.type, msg.data);
|
||||
}
|
||||
} catch (e) {
|
||||
appendLog('stdout', event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
appendLog('info', 'Disconnected');
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
setTimeout(connect, 100);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user