diff --git a/backend/config.ini b/backend/config.ini index c8f0fc9d..bbdf0940 100644 --- a/backend/config.ini +++ b/backend/config.ini @@ -61,6 +61,13 @@ pressure_use_mock = False pressure_port = COM5 pressure_baudrate = 115200 +[REMOTE] +port = COM6 +baudrate = 115200 +timeout = 0.1 +enable = True +strict_crc = False + [SYSTEM] log_level = INFO max_cache_size = 10 diff --git a/backend/devices/device_coordinator.py b/backend/devices/device_coordinator.py index bd1d2c0a..1e6760b9 100644 --- a/backend/devices/device_coordinator.py +++ b/backend/devices/device_coordinator.py @@ -77,7 +77,7 @@ class DeviceCoordinator: } # 线程池 - self.executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="DeviceCoord") + self.executor = ThreadPoolExecutor(max_workers=8, thread_name_prefix="DeviceCoord") self.logger.info("设备协调器初始化完成") @@ -180,6 +180,11 @@ class DeviceCoordinator: future = self.executor.submit(self._init_pressure) futures.append(('pressure', future)) + # 遥控器 + if self.device_configs.get('remote', {}).get('enabled', False): + future = self.executor.submit(self._init_remote) + futures.append(('remote', future)) + # 等待所有设备初始化完成 @@ -275,6 +280,21 @@ class DeviceCoordinator: self.logger.error(f"初始化{device_name}失败: {e}") return False + def _init_remote(self) -> bool: + """ + 初始化串口遥控器 + """ + try: + from .remote_control_manager import RemoteControlManager + remote = RemoteControlManager(self.socketio, self.config_manager) + self.devices['remote'] = remote + if remote.initialize(): + return True + return False + except Exception as e: + self.logger.error(f"初始化遥控器失败: {e}") + return False + def _init_imu(self) -> bool: """ 初始化IMU传感器 @@ -1090,4 +1110,4 @@ if __name__ == "__main__": except Exception as e: print(f"\n❌ 测试启动失败: {e}") import traceback - traceback.print_exc() \ No newline at end of file + traceback.print_exc() diff --git a/backend/devices/remote_control_manager.py b/backend/devices/remote_control_manager.py new file mode 100644 index 00000000..f9c58dd2 --- /dev/null +++ b/backend/devices/remote_control_manager.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import threading +import time +from typing import Optional, Dict, Any +import logging +import re +try: + import serial # pyserial +except Exception: + serial = None + +try: + from .base_device import BaseDevice + from .utils.config_manager import ConfigManager +except ImportError: + from base_device import BaseDevice + from utils.config_manager import ConfigManager + + +def _modbus_crc16(data: bytes) -> int: + crc = 0xFFFF + for b in data: + crc ^= b + for _ in range(8): + if crc & 0x0001: + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + return crc & 0xFFFF + + +class RemoteControlManager(BaseDevice): + + def __init__(self, socketio, config_manager: Optional[ConfigManager] = None): + self.config_manager = config_manager or ConfigManager() + port = self.config_manager.get_config_value('REMOTE', 'port', fallback='COM6') + baudrate = int(self.config_manager.get_config_value('REMOTE', 'baudrate', fallback='115200')) + timeout = float(self.config_manager.get_config_value('REMOTE', 'timeout', fallback='0.1')) + + instance_config: Dict[str, Any] = { + 'enabled': True, + 'port': port, + 'baudrate': baudrate, + 'timeout': timeout, + 'strict_crc': bool(str(self.config_manager.get_config_value('REMOTE', 'strict_crc', fallback='False')).lower() == 'true'), + } + + super().__init__("remote", instance_config) + self._socketio = socketio + self.logger = logging.getLogger(self.__class__.__name__) + + self.port = port + self.baudrate = baudrate + self.timeout = timeout + self.bytesize = getattr(serial, 'EIGHTBITS', 8) + self.parity = getattr(serial, 'PARITY_NONE', 'N') + self.stopbits = getattr(serial, 'STOPBITS_ONE', 1) + self.strict_crc = instance_config['strict_crc'] + + self._ser: Optional[serial.Serial] = None + self._thread: Optional[threading.Thread] = None + self._running = False + self._buffer = bytearray() + + def initialize(self) -> bool: + try: + self.logger.info(f"初始化遥控器串口: {self.port}, {self.baudrate}bps, 8N1") + self.set_connected(True) + self._device_info['initialized_at'] = time.time() + return True + except Exception as e: + self.logger.error(f"遥控器初始化失败: {e}") + self.set_connected(False) + return False + + def start_streaming(self) -> bool: + try: + if self._running: + return True + if serial is None: + raise RuntimeError("pyserial 未安装或不可用") + self._ser = serial.Serial( + port=self.port, + baudrate=self.baudrate, + bytesize=self.bytesize, + parity=self.parity, + stopbits=self.stopbits, + timeout=self.timeout, + ) + self._running = True + self._thread = threading.Thread(target=self._worker_loop, daemon=True) + self._thread.start() + self.logger.info("遥控器串口监听已启动") + return True + except Exception as e: + self.logger.error(f"启动遥控器监听失败: {e}") + self._running = False + try: + if self._ser and self._ser.is_open: + self._ser.close() + except Exception: + pass + return False + + def stop_streaming(self) -> bool: + try: + self._running = False + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=2.0) + if self._ser and self._ser.is_open: + self._ser.close() + self.logger.info("遥控器串口监听已停止") + return True + except Exception as e: + self.logger.error(f"停止遥控器监听失败: {e}") + return False + + def disconnect(self): + try: + self.stop_streaming() + self.set_connected(False) + except Exception as e: + self.logger.error(f"断开遥控器失败: {e}") + + def reload_config(self) -> bool: + try: + self.logger.info("重新加载遥控器配置") + self.port = self.config_manager.get_config_value('REMOTE', 'port', fallback=self.port) or self.port + self.baudrate = int(self.config_manager.get_config_value('REMOTE', 'baudrate', fallback=self.baudrate)) + self.timeout = float(self.config_manager.get_config_value('REMOTE', 'timeout', fallback=self.timeout)) + self._device_info.update({ + 'port': self.port, + 'baudrate': self.baudrate, + 'timeout': self.timeout, + }) + return True + except Exception as e: + self.logger.error(f"重新加载遥控器配置失败: {e}") + return False + + def calibrate(self) -> Dict[str, Any]: + return {'status': 'success'} + + def get_status(self) -> Dict[str, Any]: + return { + 'name': self.device_name, + 'port': self.port, + 'baudrate': self.baudrate, + 'timeout': self.timeout, + 'is_connected': self.is_connected, + 'is_streaming': self._running, + 'has_serial': bool(self._ser and getattr(self._ser, 'is_open', False)), + 'last_error': self._device_info.get('last_error') + } + + def cleanup(self) -> None: + try: + self.stop_streaming() + except Exception: + pass + + def check_hardware_connection(self) -> bool: + try: + return bool(self._ser and self._ser.is_open) + except Exception: + return False + + def _emit_code(self, key_code: int): + code_hex = f"{key_code:02X}" + name_map = { + 0x11: "start", + 0x14: "stop", + 0x13: "up", + 0x15: "down", + 0x12: "center", + 0x0E: "power", + 0x0F: "screenshot", + } + name = name_map.get(key_code, 'unknown') + payload = { + 'code': code_hex, + 'name': name, + 'timestamp': time.time() + } + try: + msg = f"接收到遥控器按键: code={code_hex}, name={name}" + print(msg) + self.logger.info(msg) + except Exception: + pass + try: + if self._socketio: + self._socketio.emit('remote_control', payload, namespace='/devices') + except Exception as e: + self.logger.error(f"推送遥控器事件失败: {e}") + + def _try_parse_frames(self): + while True: + if len(self._buffer) < 7: + break + idx = self._buffer.find(b'\x01\x04\x02\x00') + if idx >= 0 and len(self._buffer) - idx >= 7: + frame = bytes(self._buffer[idx:idx + 7]) + calc_crc = _modbus_crc16(frame[:5]) + recv_crc = frame[5] | (frame[6] << 8) + if calc_crc == recv_crc: + key_code = frame[4] + self._emit_code(key_code) + del self._buffer[:idx + 7] + continue + else: + if not self.strict_crc and len(frame) >= 7: + key_code = frame[4] + self._emit_code(key_code) + del self._buffer[:idx + 7] + continue + del self._buffer[idx:idx + 1] + continue + # ASCII HEX fallback + try: + text = bytes(self._buffer).decode(errors='ignore') + except Exception: + break + m = re.search(r'01\s*04\s*02\s*00\s*([0-9A-Fa-f]{2})\s*([0-9A-Fa-f]{2})\s*([0-9A-Fa-f]{2})', text) + if m: + k = int(m.group(1), 16) + crcL = int(m.group(2), 16) + crcH = int(m.group(3), 16) + calc = _modbus_crc16(bytes.fromhex(f'01 04 02 00 {m.group(1)}')) + recv = crcL | (crcH << 8) + if calc == recv: + self._emit_code(k) + elif not self.strict_crc: + self._emit_code(k) + self._buffer.clear() + break + # no header; trim + del self._buffer[:max(0, len(self._buffer) - 3)] + break + + def _worker_loop(self): + self.logger.info("遥控器串口线程启动") + last_rx_ts = time.time() + while self._running: + try: + if not self._ser or not self._ser.is_open: + time.sleep(0.05) + continue + chunk = self._ser.read(64) + if chunk: + try: + hexstr = ' '.join(f'{b:02X}' for b in chunk) + except Exception: + pass + self._buffer.extend(chunk) + self._try_parse_frames() + last_rx_ts = time.time() + else: + time.sleep(0.01) + if time.time() - last_rx_ts > 5.0: + self.logger.debug("遥控器串口暂无数据") + except Exception as e: + self.logger.error(f"遥控器串口读取异常: {e}") + time.sleep(0.1) + self.logger.info("遥控器串口线程结束") diff --git a/backend/devices/utils/config_manager.py b/backend/devices/utils/config_manager.py index 5870e223..c5895d89 100644 --- a/backend/devices/utils/config_manager.py +++ b/backend/devices/utils/config_manager.py @@ -613,7 +613,14 @@ class ConfigManager: 'pressure': self.get_device_config('pressure'), 'camera1': self.get_device_config('camera1'), 'camera2': self.get_device_config('camera2'), - 'femtobolt': self.get_device_config('femtobolt') + 'femtobolt': self.get_device_config('femtobolt'), + 'remote': { + 'enabled': self.config.getboolean('DEVICES', 'remote_enabled', fallback=True), + 'port': self.config.get('REMOTE', 'port', fallback='COM6'), + 'baudrate': self.config.getint('REMOTE', 'baudrate', fallback=115200), + 'timeout': self.config.getfloat('REMOTE', 'timeout', fallback=0.1), + 'strict_crc': self.config.getboolean('REMOTE', 'strict_crc', fallback=False) + } } def _batch_update_device_configs(self, configs: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: @@ -847,4 +854,4 @@ class ConfigManager: return { 'success': False, 'message': f'批量设置设备配置失败: {str(e)}' - } \ No newline at end of file + } diff --git a/backend/main.py b/backend/main.py index 48a889a4..449e1d5d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -89,7 +89,8 @@ class AppServer: 'camera': None, 'femtobolt': None, 'imu': None, - 'pressure': None + 'pressure': None, + 'remote': None, } # 注册路由和事件 @@ -1834,7 +1835,7 @@ class AppServer: def handle_subscribe_device(data): """订阅特定设备数据""" device_type = data.get('device_type') - if device_type in ['camera1', 'camera2', 'femtobolt', 'imu', 'pressure']: + if device_type in ['camera1', 'camera2', 'femtobolt', 'imu', 'pressure', 'remote']: self.logger.info(f'客户端订阅{device_type}设备数据') emit('subscription_status', { 'device_type': device_type, diff --git a/document/串口遥控器遥控界面操作说明.md b/document/串口遥控器遥控界面操作说明.md new file mode 100644 index 00000000..4f721397 --- /dev/null +++ b/document/串口遥控器遥控界面操作说明.md @@ -0,0 +1,70 @@ +# 串口遥控器遥控界面操作说明 + +## 概述 +- 通过串口接收遥控器报文,解析键码并通过 WebSocket 推送到前端,实现对检测页面的远程控制。 +- 后端设备名为 `remote`,事件名为 `remote_control`,命名空间为 `/devices`。 + +## 串口配置 +- 配置文件位置:backend/config.ini +- 读取段与键: + - [REMOTE] port,缺省 COM6 + - [REMOTE] baudrate,缺省 115200 + - [REMOTE] timeout,缺省 0.1 秒 + - [DEVICES] remote_enabled(是否启用),缺省 true +- 串口参数:115200 bps,8 数据位,1 停止位,无校验(8N1)。 + +## 报文格式 +- 参照 Modbus RTU 协议中功能码 0x04(读输入寄存器)的应答帧格式: + - 帧结构:`01 04 02 00 [键码] crcL crcH` + - 固定头:`01 04 02 00` + - 第 5 字节为键码(KeyCode) + - CRC16:Modbus RTU 小端(crcL, crcH),计算范围为前 5 个字节 + - 报文由接收器主动上传,无需主机轮询 + - 除键码与 CRC 外,前 4 字节保持不变 + +## 键码约定 +- 左:`11` +- 右:`14` +- 上:`13` +- 下:`15` +- 中:`12` +- 电源:`0E` +- 抓屏:`0F` + +## 后端实现 +- 代码文件:`backend/devices/remote_control_manager.py` +- 主要逻辑: + - 打开串口并启动后台线程读取数据 + - 在缓冲区中查找帧头 `01 04 02 00`,截取 7 字节帧 + - 计算前 5 字节 Modbus CRC16(多项式 0xA001,初值 0xFFFF),校验通过后解析键码 + - 通过 Socket.IO 向 `/devices` 命名空间推送事件 `remote_control`,载荷示例: + - `{ "code": "0F", "name": "screenshot", "timestamp": 1731234567.89 }` + +## 前端对接 +- 页面:`frontend/src/renderer/src/views/Detection.vue` +- 统一设备命名空间 Socket:`devicesSocket = io(BACKEND_URL + '/devices', ...)` +- 事件监听与映射: + - 监听:`devicesSocket.on('remote_control', handler)` + - 根据编码触发页面方法: + - `11` → `startVideoClick()`(开始录像) + - `14` → `stopVideoClick()`(结束录像) + - `0F` → `saveDetectionData()`(截图) +- 页面中相关按钮: + - 截图按钮:调用 `saveDetectionData` + - 开始录像按钮:调用 `startVideoClick` + - 结束录像按钮:调用 `stopVideoClick` + +## 运行与验证 +- 打包后 Electron 主进程会在窗口创建前启动后端服务 +- 打开检测页面,确保设备命名空间连接成功 +- 使用遥控器按键,观察页面动作对应触发 + +## 常见问题 +- 无法接收到事件: + - 检查后端串口配置是否正确(端口被占用或不存在) + - 确认遥控器接收器已连接且在串口管理器线程持续读取 + - 确认前端已连接到 `/devices` 命名空间并注册了事件监听 +- CRC 错误: + - 检查物理连接和电气参数 + - 若报文格式与约定不一致,请提供示例报文以调整解析逻辑 + diff --git a/document/姿态检测页.png b/document/姿态检测页.png deleted file mode 100644 index 4a356376..00000000 Binary files a/document/姿态检测页.png and /dev/null differ diff --git a/document/开始检测中的录屏界面.png b/document/开始检测中的录屏界面.png deleted file mode 100644 index cd3a3bc0..00000000 Binary files a/document/开始检测中的录屏界面.png and /dev/null differ diff --git a/document/登录进入的起始页.png b/document/登录进入的起始页.png deleted file mode 100644 index 80f7e96f..00000000 Binary files a/document/登录进入的起始页.png and /dev/null differ diff --git a/document/登录页.png b/document/登录页.png deleted file mode 100644 index f345fc72..00000000 Binary files a/document/登录页.png and /dev/null differ diff --git a/document/系统界面原型.pdf b/document/系统界面原型.pdf deleted file mode 100644 index 45c39fce..00000000 Binary files a/document/系统界面原型.pdf and /dev/null differ diff --git a/frontend/src/renderer/src/views/Detection.vue b/frontend/src/renderer/src/views/Detection.vue index 58344cf8..2bc5380f 100644 --- a/frontend/src/renderer/src/views/Detection.vue +++ b/frontend/src/renderer/src/views/Detection.vue @@ -1019,6 +1019,7 @@ function connectWebSocket() { devicesSocket.emit('subscribe_device', { device_type: 'femtobolt' }) devicesSocket.emit('subscribe_device', { device_type: 'imu' }) devicesSocket.emit('subscribe_device', { device_type: 'pressure' }) + devicesSocket.emit('subscribe_device', { device_type: 'remote' }) // 设备连接成功后启动数据推送 startDeviceDataPush() @@ -1079,6 +1080,27 @@ function connectWebSocket() { tempInfo.value.pressure_data = data handlePressureData(data) }) + + // 监听遥控器事件:根据编码触发页面方法 + devicesSocket.on('remote_control', (data) => { + const code = String((data && data.code) || '').toUpperCase() + switch (code) { + case '11': + startVideoClick() + break + case '14': + stopVideoClick() + break + case '12': + clearAndStartTracking() + break + case '0F': + saveDetectionData() + break + default: + break + } + }) // 监听测试状态事件 devicesSocket.on('test_status', (data) => {