pro3_control_panel/base/remote_serial.py
2025-12-15 09:23:52 +08:00

371 lines
16 KiB
Python

#! /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()