From b52343ea16d878832e0db82603895310fd4142e8 Mon Sep 17 00:00:00 2001 From: Jayden Date: Mon, 6 Apr 2026 15:14:06 +0800 Subject: [PATCH] new script --- serial_mjpeg_http_streamer.py | 395 ++++++++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100755 serial_mjpeg_http_streamer.py diff --git a/serial_mjpeg_http_streamer.py b/serial_mjpeg_http_streamer.py new file mode 100755 index 0000000..26dc9aa --- /dev/null +++ b/serial_mjpeg_http_streamer.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +import argparse +import sys +import threading +import time +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +from typing import Optional + +import serial + + +JPEG_SOI = b"\xff\xd8" # Start Of Image +JPEG_EOI = b"\xff\xd9" # End Of Image + +BLANK_JPEG = ( + b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00" + b"\x01\x00\x00\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08" + b"\x07\x07\x07\t\t\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12" + b'\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a\x1c\x1c $.\x27 ",#' + b"\x1c\x1c(7telestreet.telestreet1c\x01\t\t\t\x0c\x0b\x0c" + b"\x18\r\r\x1821\x1c!222222222222222222222222222222222222" + b"22222222222222\xff\xc0\x00\x0b\x08\x00\x01\x00\x01\x01\x01" + b"\x11\x00\xff\xc4\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01" + b"\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06" + b"\x07\x08\t\n\x0b\xff\xc4\x00\xb5\x10\x00\x02\x01\x03\x03\x02" + b"\x04\x03\x05\x05\x04\x04\x00\x00\x01}\x01\x02\x03\x00\x04" + b'\x11\x05\x12!1A\x06\x13Qa\x07"q\x142\x81\x91\xa1\x08#B\xb1' + b"\xc1\x15R\xd1\xf0$3br\x82\t\n\x16\x17\x18\x19\x1a%&\x27()*" + b"456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz\x83\x84\x85\x86\x87" + b"\x88\x89\x8a\x92\x93\x94\x95\x96\x97\x98\x99\x9a\xa2\xa3\xa4" + b"\xa5\xa6\xa7\xa8\xa9\xaa\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba" + b"\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xd2\xd3\xd4\xd5\xd6\xd7" + b"\xd8\xd9\xda\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xf1\xf2" + b"\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xff\xda\x00\x08\x01\x01\x00" + b"\x00?\x00\xfb\xd5\xff\xd9" +) + +class FrameBuffer: + def __init__(self): + self._lock = threading.Lock() + self._frame: Optional[bytes] = None + self._status: str = "waiting for data" + + def update_frame(self, frame: bytes) -> None: + with self._lock: + self._frame = frame + self._status = "streaming" + + def set_status(self, status: str) -> None: + with self._lock: + self._status = status + + def get_frame(self) -> Optional[bytes]: + with self._lock: + return self._frame + + def get_status(self) -> str: + with self._lock: + return self._status + + +#Serial Helpers +def validate_serial_port(port: str, baudrate: int, timeout: float = 1.0) -> bool: + try: + test_ser = serial.Serial(port=port, baudrate=baudrate, timeout=timeout) + # Try to read a small amount of data to verify it's working + test_ser.read(1) + test_ser.close() + return True + except serial.SerialException as e: + print(f"[Validation] Failed to validate {port}: {e}", file=sys.stderr) + return False + except Exception as e: + print(f"[Validation] Unexpected error validating {port}: {e}", file=sys.stderr) + return False + + +def try_open_serial( + port: str, + baudrate: int, + timeout: float, + reconnect_delay: float, + frame_buffer: FrameBuffer, + verbose: bool = False, +) -> Optional[serial.Serial]: + + try: + ser = serial.Serial(port=port, baudrate=baudrate, timeout=timeout) + if verbose: + print(f"[Serial] Opened {port} at {baudrate} baud") + frame_buffer.set_status("connected") + return ser + except serial.SerialException as e: + if verbose: + print(f"[Serial] Could not open {port}: {e}") + frame_buffer.set_status("disconnected") + time.sleep(reconnect_delay) + return None + + +def serial_mjpeg_reader( + port: str, + baudrate: int, + timeout: float, + frame_buffer: FrameBuffer, + chunk_size: int, + max_buffer: int, + reconnect_delay: float, + verbose: bool = False, +) -> None: + + ser: Optional[serial.Serial] = None + buffer = bytearray() + + while True: + # Ensure serial is open + if ser is None or not ser.is_open: + frame_buffer.set_status("connecting") + ser = try_open_serial( + port, baudrate, timeout, reconnect_delay, frame_buffer, verbose + ) + if ser is None: + # Failed to open; try again in next loop iteration + continue + buffer.clear() + + try: + # Normal read loop + chunk = ser.read(chunk_size) + if not chunk: + # Timeout or no data; small sleep to avoid busy-wait + time.sleep(0.01) + continue + + buffer.extend(chunk) + + # Prevent unbounded buffer growth + if len(buffer) > max_buffer: + buffer[:] = buffer[-max_buffer // 2 :] + + while True: + start_idx = buffer.find(JPEG_SOI) + if start_idx == -1: + if len(buffer) > max_buffer: + buffer.clear() + break + + end_idx = buffer.find(JPEG_EOI, start_idx + 2) + if end_idx == -1: + # Have SOI, but no complete JPEG yet + if start_idx > 0: + del buffer[:start_idx] + break + + frame_data = buffer[start_idx : end_idx + 2] + del buffer[: end_idx + 2] + + frame_buffer.update_frame(bytes(frame_data)) + if verbose: + print(f"[Serial] Frame: {len(frame_data)} bytes") + + except (serial.SerialException, OSError) as e: + # Port disconnected or error occurred + print(f"[Serial] Error / disconnect: {e}", file=sys.stderr) + frame_buffer.set_status("disconnected") + try: + if ser and ser.is_open: + ser.close() + except Exception: + pass + ser = None + # Short pause before trying to reopen + time.sleep(reconnect_delay) + except Exception as e: + # Unexpected errors; log and retry + print(f"[Serial] Unexpected error: {e}", file=sys.stderr) + time.sleep(1) + +#HTTP Handler +class MJPEGRequestHandler(BaseHTTPRequestHandler): + # Set at server initialization + frame_buffer: FrameBuffer = None # type: ignore[assignment] + boundary: str = "frame" + frame_interval: float = 0.05 + verbose: bool = False + + def log_message(self, format: str, *args) -> None: + if self.verbose: + super().log_message(format, *args) + + def do_GET(self): + if self.path != self.server.stream_path: # type: ignore[attr-defined] + # Optionally expose a basic status page at "/" + if self.path == "/": + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + status = self.frame_buffer.get_status() + self.wfile.write( + f"Serial MJPEG HTTP Streamer\nStatus: {status}\n".encode("utf-8") + ) + return + + self.send_error(404, "Not Found") + return + + self.send_response(200) + self.send_header( + "Content-Type", f"multipart/x-mixed-replace; boundary={self.boundary}" + ) + self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.end_headers() + + if self.verbose: + print(f"[HTTP] Client connected: {self.client_address}") + + try: + while True: + frame = self.frame_buffer.get_frame() + status = self.frame_buffer.get_status() + + if frame is None or status != "streaming": + frame = BLANK_JPEG + + self.wfile.write(b"--" + self.boundary.encode("ascii") + b"\r\n") + self.wfile.write(b"Content-Type: image/jpeg\r\n") + self.wfile.write(f"Content-Length: {len(frame)}\r\n".encode("ascii")) + self.wfile.write(b"\r\n") + self.wfile.write(frame) + self.wfile.write(b"\r\n") + + time.sleep(self.frame_interval) + + except (BrokenPipeError, ConnectionResetError): + if self.verbose: + print(f"[HTTP] Client disconnected: {self.client_address}") + except Exception as e: + print(f"[HTTP] Streaming error: {e}", file=sys.stderr) + + +class MJPEGHTTPServer(ThreadingHTTPServer): + def __init__(self, server_address, RequestHandlerClass, stream_path: str): + super().__init__(server_address, RequestHandlerClass) + self.stream_path = stream_path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Stream an MJPEG serial feed over HTTP (auto-reconnect)." + ) + + # Serial options + parser.add_argument( + "--serial-port", + "-p", + required=True, + help="Serial port (e.g. COM3, /dev/ttyUSB0, /dev/ttyACM0)", + ) + parser.add_argument( + "--baudrate", + "-b", + type=int, + default=115200, + help="Serial baud rate (default: 115200)", + ) + parser.add_argument( + "--timeout", + type=float, + default=0.1, + help="Serial read timeout in seconds (default: 0.1)", + ) + parser.add_argument( + "--chunk-size", + type=int, + default=4096, + help="Bytes to read from serial per call (default: 4096)", + ) + parser.add_argument( + "--max-buffer", + type=int, + default=1_000_000, + help="Maximum serial buffer size in bytes (default: 1,000,000)", + ) + parser.add_argument( + "--reconnect-delay", + type=float, + default=2.0, + help="Seconds to wait before retrying serial open after an error (default: 2.0)", + ) + + # HTTP options + parser.add_argument( + "--http-host", + "-H", + default="0.0.0.0", + help="HTTP bind address (default: 0.0.0.0)", + ) + parser.add_argument( + "--http-port", "-P", type=int, default=8080, help="HTTP port (default: 8080)" + ) + parser.add_argument( + "--path", + default="/stream", + help="HTTP path for MJPEG stream (default: /stream)", + ) + parser.add_argument( + "--boundary", + default="frame", + help="Multipart boundary string (default: 'frame')", + ) + parser.add_argument( + "--fps", + type=float, + default=20.0, + help="Approximate frames per second to send (default: 20.0)", + ) + + # Misc + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable verbose logging" + ) + + return parser.parse_args() + + +def main(): + args = parse_args() + + # Validate serial port is accessible before starting + print(f"[Main] Validating serial port {args.serial_port}...") + if not validate_serial_port(args.serial_port, args.baudrate): + print( + f"[Main] Error: Serial port {args.serial_port} is not accessible.", + file=sys.stderr, + ) + print( + "[Main] Please check that the device is connected and you have permission to access it.", + file=sys.stderr, + ) + sys.exit(1) + print(f"[Main] Serial port {args.serial_port} validated successfully") + + frame_buffer = FrameBuffer() + frame_buffer.set_status("initializing") + + # Start serial reader in background; it handles reconnects itself + reader_thread = threading.Thread( + target=serial_mjpeg_reader, + args=( + args.serial_port, + args.baudrate, + args.timeout, + frame_buffer, + args.chunk_size, + args.max_buffer, + args.reconnect_delay, + args.verbose, + ), + daemon=True, + ) + reader_thread.start() + print( + f"[Main] Serial reader thread started for {args.serial_port} " + f"({args.baudrate} baud). Auto-reconnect enabled." + ) + + # Configure HTTP handler + MJPEGRequestHandler.frame_buffer = frame_buffer + MJPEGRequestHandler.boundary = args.boundary + MJPEGRequestHandler.frame_interval = 1.0 / max(args.fps, 0.1) + MJPEGRequestHandler.verbose = args.verbose + + server_address = (args.http_host, args.http_port) + httpd = MJPEGHTTPServer(server_address, MJPEGRequestHandler, args.path) + + print( + f"[Main] HTTP MJPEG server running on " + f"http://{args.http_host}:{args.http_port}{args.path}" + ) + print("[Main] Status endpoint at http://%s:%d/" % (args.http_host, args.http_port)) + print("[Main] Press Ctrl+C to quit") + + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\n[Main] Shutting down HTTP server...") + finally: + httpd.server_close() + print("[Main] Exiting") + + +if __name__ == "__main__": + main()