增加了遥控器控制功能
This commit is contained in:
parent
1ae2146ff0
commit
99e35eba95
@ -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
|
||||
|
||||
@ -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()
|
||||
traceback.print_exc()
|
||||
|
||||
266
backend/devices/remote_control_manager.py
Normal file
266
backend/devices/remote_control_manager.py
Normal 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("遥控器串口线程结束")
|
||||
@ -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)}'
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
70
document/串口遥控器遥控界面操作说明.md
Normal file
70
document/串口遥控器遥控界面操作说明.md
Normal 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 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 错误:
|
||||
- 检查物理连接和电气参数
|
||||
- 若报文格式与约定不一致,请提供示例报文以调整解析逻辑
|
||||
|
||||
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 |
BIN
document/登录页.png
BIN
document/登录页.png
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB |
Binary file not shown.
@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:renderer\" \"wait-on http://localhost:3000 && npm run dev:electron\"",
|
||||
"dev:renderer": "vite",
|
||||
"dev:electron": "set NODE_ENV=development&& \"d:\\electron-v36.4.0-win32-x64\\electron.exe\" .",
|
||||
"dev:electron": "electron .",
|
||||
"build": "npm run build:renderer && npm run build:electron",
|
||||
"build:renderer": "vite build",
|
||||
"build:electron": "set HTTP_PROXY=&& set HTTPS_PROXY=&&electron-builder --config ./build/electron-builder.install.json",
|
||||
|
||||
@ -1007,6 +1007,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()
|
||||
@ -1067,6 +1068,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) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user