This commit is contained in:
limengnan 2026-01-09 09:44:37 +08:00
commit 394e79d770
12 changed files with 399 additions and 6 deletions

View File

@ -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

View File

@ -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传感器

View File

@ -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("遥控器串口线程结束")

View File

@ -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]:

View File

@ -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,

View File

@ -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 bps8 数据位1 停止位无校验8N1
## 报文格式
- 参照 Modbus RTU 协议中功能码 0x04读输入寄存器的应答帧格式
- 帧结构:`01 04 02 00 [键码] crcL crcH`
- 固定头:`01 04 02 00`
- 第 5 字节为键码KeyCode
- CRC16Modbus 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 错误:
- 检查物理连接和电气参数
- 若报文格式与约定不一致,请提供示例报文以调整解析逻辑

Binary file not shown.

Before

Width:  |  Height:  |  Size: 623 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

View File

@ -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()
@ -1080,6 +1081,27 @@ function connectWebSocket() {
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) => {
console.log('📊 测试状态:', data)