#! /usr/bin/env python # -*- coding: utf-8 -*- # Copyright (c) 2024 Realtek Semiconductor Corp. # SPDX-License-Identifier: Apache-2.0 import socket import json import time import threading import base64 from typing import Optional, Dict, Any import serial from serial.serialutil import SerialException, SerialTimeoutException from datetime import datetime class RemoteSerial: def __init__(self, logger, remote_server: str, remote_port: int, port: str, baudrate: int = 9600): """ Initialize remote serial proxy :param logger: logger object (external input, for unified log format) :param remote_server: remote serial server IP :param remote_port: remote serial server port :param port: remote serial port name (e.g. "COM3", "/dev/ttyUSB0") :param baudrate: serial baud rate """ self.logger = logger self.remote_server = remote_server self.remote_port = remote_port self.port = port self.baudrate = baudrate # Core state variables self.tcp_socket: Optional[socket.socket] = None self.receive_buffer = b"" # Binary receive buffer self.receive_thread: Optional[threading.Thread] = None self.is_open = False # Whether serial is open (TCP connected + serial command succeeds) self.response_event = threading.Event() # Command response synchronization event self.last_response: Dict[str, Any] = {} # Last command response # Initialize logger self.logger.debug( f"[RemoteSerial][{self.port}] Initialize remote serial proxy - " f"Server: {self.remote_server}:{self.remote_port}, baudrate: {self.baudrate}" ) # 1. Establish TCP connection (just connect, not start the receive thread) self._connect_tcp() self.receive_thread = threading.Thread( target=self._receive_loop, daemon=True, name=f"RemoteSerialRecv-{self.port}" ) self.receive_thread.start() self.logger.debug(f"[RemoteSerial][{self.port}] Receive thread started: {self.receive_thread.name}") def _connect_tcp(self) -> bool: """ Establish TCP connection with remote serial server :return: Returns True if connected successfully, raises SerialException on failure """ self.logger.debug(f"[RemoteSerial][{self.port}] Trying TCP connection: {self.remote_server}:{self.remote_port}") try: self.tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # self.tcp_socket.settimeout(10) # TCP connect timeout 10s self.tcp_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) self.logger.debug(f"[RemoteSerial][{self.port}] TCP_NODELAY set") self.tcp_socket.connect((self.remote_server, self.remote_port)) self.logger.debug(f"[RemoteSerial][{self.port}] TCP connection succeeded") return True except socket.timeout: raise SerialException(f"[RemoteSerial][{self.port}] TCP connection timed out (10s)- {self.remote_server}:{self.remote_port}") except ConnectionRefusedError: raise SerialException(f"[RemoteSerial][{self.port}] TCP connection refused - server not started or wrong port") except Exception as e: raise SerialException(f"[RemoteSerial][{self.port}] TCP connection failed: {str(e)}") def _receive_loop(self): """ Core logic of receive thread: continuously read TCP data, parse into buffer or trigger response """ self.logger.debug(f"[RemoteSerial][{self.port}] Receive thread running") buffer = "" while self.tcp_socket: try: # Read TCP data (as text, as server sends JSON+newline-delimited) data = self.tcp_socket.recv(4096).decode('utf-8', errors='strict') if not data: self.logger.error(f"[RemoteSerial][{self.port}] Server closed connection, recv returned empty data") raise ConnectionAbortedError("Server closed connection") buffer += data # Split by newline for complete messages (handle sticky packets) while '\n' in buffer: msg_str, buffer = buffer.split('\n', 1) msg_str = msg_str.strip() if not msg_str: continue self._parse_message(msg_str) except ConnectionResetError as e: self.logger.error(f"[RemoteSerial][{self.port}] Connection reset: {str(e)}") break except ConnectionAbortedError as e: self.logger.error(f"[RemoteSerial][{self.port}] Receive thread exception: {str(e)}") break except Exception as e: self.logger.error(f"[RemoteSerial][{self.port}] Receive thread exception: {str(e)}", exc_info=True) break self.is_open = False if self.tcp_socket: self.tcp_socket.close() self.logger.info(f"[RemoteSerial][{self.port}] Receive thread exited") def _parse_message(self, msg_str: str): """ Parse JSON message from server :param msg_str: Complete JSON string (without newline) """ try: msg = json.loads(msg_str) msg_type = msg.get("type") if msg_type == "command_response": self.last_response = msg self.logger.debug( f"[RemoteSerial][{self.port}] Received command response - " f"Success: {msg.get('success')}, message: {msg.get('message', '无')}" ) self.response_event.set() elif msg_type == "serial_data": if msg.get("port") != self.port: self.logger.warning(f"[RemoteSerial][{self.port}] Received data from other port (ignored): {msg.get('port')}") return base64_data = msg.get("data", "") if not base64_data: self.logger.warning(f"[RemoteSerial][{self.port}] Serial data empty (Base64)") return try: raw_data = base64.b64decode(base64_data, validate=True) self.receive_buffer += raw_data except base64.binascii.Error as e: self.logger.error(f"[RemoteSerial][{self.port}] Base64 decode failed: {str(e)}, data: {base64_data[:100]}...") else: self.logger.warning(f"[RemoteSerial][{self.port}] Unknown message type: {msg_type}, message: {msg_str[:200]}...") except json.JSONDecodeError as e: self.logger.error(f"[RemoteSerial][{self.port}] JSON parse failed: {str(e)}, raw: {msg_str[:200]}...") def _send_command(self, cmd: Dict[str, Any], timeout: float = 5.0) -> Dict[str, Any]: """ Send command to remote server and wait for response :param cmd: command dict (will be converted to JSON) :param timeout: response timeout (seconds) :return: server response dict """ if not self.is_open or not self.tcp_socket: raise SerialException(f"[RemoteSerial][{self.port}] Send command failed: serial not open") self.response_event.clear() self.last_response = {} cmd_str = json.dumps(cmd) + "\n" try: self.logger.debug(f"[RemoteSerial][{self.port}] Sending command (timeout {timeout}s): {cmd_str.strip()[:300]}...") self.tcp_socket.sendall(cmd_str.encode('utf-8')) # Wait for response or timeout if self.response_event.wait(timeout): return self.last_response else: raise SerialTimeoutException( f"[RemoteSerial][{self.port}] Command response timeout ({timeout}s) - Command: {cmd.get('type')}" ) except Exception as e: raise SerialException(f"[RemoteSerial][{self.port}] Send command exception: {str(e)}") def validate(self, password): try: validate_cmd = { "type": "validate", "password": password } self.is_open = True resp = self._send_command(validate_cmd, timeout=10.0) if not resp.get("success", False): self.logger.debug(f"[RemoteSerial][{self.port}] Validate failed") raise SerialException(f"[RemoteSerial][{self.port}] Remote serial validate failed: Wrong password") self.is_open = False self.logger.debug(f"[RemoteSerial][{self.port}] Remote serial port validate successfully") except Exception as e: self.close() raise SerialException(f"[RemoteSerial][{self.port}] Validate serial failed: {str(e)}") def open(self): if self.is_open: self.logger.debug(f"[RemoteSerial][{self.port}] Serial already opened, skip") return self.logger.debug(f"[RemoteSerial][{self.port}] Trying to open serial") try: self.is_open = True open_cmd = { "type": "open_port", "port": self.port, "options": { "baudRate": self.baudrate, "dataBits": 8, "stopBits": 1, "parity": "none", "timeout": 0.1 } } resp = self._send_command(open_cmd, timeout=10.0) if not resp.get("success", False): self.logger.debug(f"[RemoteSerial][{self.port}] Open failed") self.is_open = False err_msg = resp.get("message", "Unknown error") raise SerialException(f"[RemoteSerial][{self.port}] Remote serial open failed: {err_msg}") # set is_open and start receive thread self.is_open = True self.logger.debug(f"[RemoteSerial][{self.port}] Remote serial port opened successfully (baudrate: {self.baudrate})") except Exception as e: self.close() raise SerialException(f"[RemoteSerial][{self.port}] Open serial failed: {str(e)}") def close(self): """ Close remote serial: send close_port command + cleanup TCP and thread """ self.logger.debug(f"[RemoteSerial][{self.port}] Start closing remote serial") # 1. Mark state as closed (end receive thread loop) if not self.is_open: self.logger.debug(f"[RemoteSerial][{self.port}] Serial already closed, skip") return # 2. Send close serial command (try even if TCP error) try: if self.tcp_socket: close_cmd = {"type": "close_port", "port": self.port} resp = self._send_command(close_cmd, timeout=3.0) if resp.get("success", False): self.logger.debug(f"[RemoteSerial][{self.port}] Remote serial close command succeeded") else: self.logger.warning(f"[RemoteSerial][{self.port}] Remote serial close command failed: {resp.get('message', 'No response')}") except Exception as e: self.logger.error(f"[RemoteSerial][{self.port}] Send close command exception: {str(e)}") self.is_open = False self.receive_buffer = b"" self.logger.debug(f"[RemoteSerial][{self.port}] Remote serial closed") def write(self, data: bytes): """ Write binary data to remote serial (Base64 encode first) :param data: binary data to send """ if not self.is_open: raise SerialException(f"[RemoteSerial][{self.port}] Write failed: serial not open") if not data: self.logger.debug(f"[RemoteSerial][{self.port}] Write empty data (ignored)") return try: base64_data = base64.b64encode(data).decode('utf-8') write_cmd = { "type": "write_data", "port": self.port, "data": base64_data } self.logger.debug( f"[RemoteSerial][{self.port}] Write data - " f"Raw length: {len(data)}B, Base64 length: {len(base64_data)}B" ) resp = self._send_command(write_cmd, timeout=10.0) if not resp.get("success", False): raise SerialException(f"[RemoteSerial][{self.port}] Write data failed: {resp.get('message', 'Unknown error')}") self.logger.debug(f"[RemoteSerial][{self.port}] Write data succeeded") except Exception as e: raise SerialException(f"[RemoteSerial][{self.port}] Write exception: {str(e)}") def read(self, size: int = 1) -> bytes: """ Read specified length of binary data from receive buffer :param size: length to read (bytes) :return: binary data read (returns actual length if less than size) """ if not self.is_open: raise SerialException(f"[RemoteSerial][{self.port}] Read failed: serial not open") # Get data from buffer read_data = self.receive_buffer[:size] self.receive_buffer = self.receive_buffer[size:] self.logger.debug( f"[RemoteSerial][{self.port}] Read data - " f"Requested: {size}B, Read: {len(read_data)}B, Buffer left: {len(self.receive_buffer)}B" ) return read_data def inWaiting(self) -> int: """ Return number of bytes waiting in receive buffer :return: buffer length """ if not self.is_open: raise SerialException(f"[RemoteSerial][{self.port}] Read failed: serial not open") waiting = len(self.receive_buffer) return waiting def flushInput(self): """ Clear receive buffer (local only, no server interaction) """ old_len = len(self.receive_buffer) self.receive_buffer = b"" self.logger.debug(f"[RemoteSerial][{self.port}] Cleared receive buffer - old length: {old_len}B") def flushOutput(self): """ Flush output buffer (no local output buffer for remote serial, just for serial.Serial API compatibility) """ self.logger.debug(f"[RemoteSerial][{self.port}] flushOutput: No action required for remote serial (no local output buffer)") # ------------------------------ # Simulate serial.Serial's DTR/RTS properties # ------------------------------ @property def dtr(self): self.logger.debug(f"[RemoteSerial][{self.port}] Get DTR: not supported, return False") return False @dtr.setter def dtr(self, value): self.logger.debug(f"[RemoteSerial][{self.port}] Set DTR: not supported, ignored value={value}") @property def rts(self): self.logger.debug(f"[RemoteSerial][{self.port}] Get RTS: not supported, return False") return False @rts.setter def rts(self, value): self.logger.debug(f"[RemoteSerial][{self.port}] Set RTS: not supported, ignored value={value}") # ------------------------------ # Context manager support (with statement) # ------------------------------ def __enter__(self): self.open() return self def __exit__(self, exc_type, exc_val, exc_tb): self.close() self.logger.info(f"[RemoteSerial][{self.port}] __exit__ close") if exc_type: self.logger.error(f"[RemoteSerial][{self.port}] Context manager exception: {exc_type.__name__}: {exc_val}") def __del__(self): """Destructor: make sure to close resource""" if self.is_open: self.logger.debug(f"[RemoteSerial][{self.port}] Destructor: closing unreleased remote serial") self.close()