Files
ReinLoopTest/ReinLoop/ui/main_window.py

1113 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# main_window.py
"""主窗口:组装所有 UI 组件,连接 Core 层业务逻辑"""
import time
import os
import threading
from collections import deque
from PySide6.QtWidgets import (
QApplication,
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QStackedWidget, QLabel, QPushButton, QTabBar, QFrame
)
from PySide6.QtCore import Qt, QTimer, Signal, QObject, QSize
from PySide6.QtGui import QFont, QIcon
from PySide6.QtSvgWidgets import QSvgWidget
from ui.status_bar import StatusBar
from ui.connection_tab import ConnectionTab
from ui.control_tab import ControlTab
from ui.debug_tab import DebugTab
from ui.plot_window import PlotWindow
from core.connection_manager import ConnectionManager
from core.control_engine import ControlEngine
from core.model_manager import ModelManager
from core.data_collector import DataCollector
from core.identification import IdentificationManager
from core.identification_config import download_identification_config
from core.identification_feedback import (
acknowledge_identification_feedback,
get_identification_feedback,
register_identification_result,
)
from core.volume_config import (
acknowledge_volume_config_request,
create_volume_config_request,
poll_volume_config_request,
)
from core.device_heartbeat import heartbeat_device
from controllers import IncrementalPID
import PcControl
IDENTIFICATION_FEEDBACK_POLL_INTERVAL_MS = 2000
IDENTIFICATION_CONFIG_RETRY_INTERVAL_MS = 2000
VOLUME_REQUEST_STATUS_POLL_INTERVAL_MS = 2000
DEVICE_HEARTBEAT_INTERVAL_MS = 10_000
class _Bridge(QObject):
"""线程安全的信号桥:后台线程通过 signal.emit() → 主线程 slot 更新 UI"""
log_signal = Signal(str)
status_signal = Signal(bool, str)
display_signal = Signal(float, float, float)
pid_ui_signal = Signal(float, float, float)
models_loaded_signal = Signal(object)
model_load_complete = Signal(bool, str)
sample_signal = Signal(object, object) # (valve_cmd, pressure)
volume_result_signal = Signal(float)
identification_config_loaded = Signal(int, object, object)
identification_upload_complete = Signal(bool, object, object)
identification_feedback_loaded = Signal(int, object, object)
volume_request_created = Signal(int, object, object)
volume_config_loaded = Signal(int, object, object)
heartbeat_completed = Signal(object)
class MainWindow(QMainWindow):
"""主窗口:ReinLoop-V1.0"""
def __init__(self, colors: dict):
super().__init__()
self.colors = colors
self.setWindowTitle("ReinLoop-V1.0 - 收敛有界")
self.resize(900, 700)
# ---- 业务层初始化 ----
self.pid = IncrementalPID(kp=1.0, ki=0.4, kd=0, dt=0.1, out_min=0, out_max=100, xa_full=1062.5)
self.conn_mgr = ConnectionManager()
self.model_mgr = ModelManager()
self.data_collector = DataCollector()
self.ident_mgr = IdentificationManager()
self.engine = ControlEngine(self.pid)
# 注入依赖到控制引擎
self.engine.set_connection_manager(self.conn_mgr)
self.engine.set_model_manager(self.model_mgr)
self.engine.set_data_collector(self.data_collector)
# ---- 信号桥 (线程安全) ----
self._bridge = _Bridge()
self._ident_config_request_id = 0
self._ident_config_request_inflight = False
self._ident_workflow_active = False
self._ident_waiting_for_updated_config = False
self._ident_current_config = None
self._ident_feedback_run_id = None
self._ident_feedback_request_id = 0
self._ident_feedback_request_inflight = False
self._ident_feedback_registered = False
self._ident_feedback_last_error = None
self._volume_workflow_active = False
self._volume_measurement_running = False
self._volume_config_request_id = 0
self._volume_config_request_inflight = False
self._volume_cloud_request_id = None
self._volume_request_expires_at_ms = None
self._heartbeat_inflight = False
self._heartbeat_last_error = None
# 确认的目标压力
self.confirmed_target_pressure = 80.0
# 数据历史 (用于绘图,最多保留 10 小时 / 720000 点)
_MAX = 720_000
self.pressure_data = deque(maxlen=_MAX)
self.target_data = deque(maxlen=_MAX)
self.valve_data = deque(maxlen=_MAX)
self.time_data = deque(maxlen=_MAX)
self.start_time = None
# 绘图窗口引用
self._plot_window = None
# ---- 搭建 UI ----
self._setup_ui()
self._connect_signals()
# ---- 初始化回调 ----
self._setup_callbacks()
# 初始扫描模型列表
self.model_mgr.scan_models()
def _setup_ui(self):
"""构建主窗口布局:顶部两层结构 (Layer1: Logo标题行, Layer2: Tab栏)"""
central = QWidget()
self.setCentralWidget(central)
main_layout = QVBoxLayout(central)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
_src = os.path.join(os.path.dirname(__file__), "..", "src")
# ==========================================
# 1. 顶部区域 — Layer 1: 标题行(纯白背景)
# ==========================================
title_row = QWidget()
title_row.setProperty("cssClass", "titleRow")
title_layout = QHBoxLayout(title_row)
title_layout.setContentsMargins(24, 12, 24, 12)
title_layout.setSpacing(0)
# Logo SVG
logo_path = os.path.normpath(os.path.join(_src, "logo.svg"))
logo_widget = QSvgWidget(logo_path)
logo_widget.setFixedSize(100, 33)
title_layout.addWidget(logo_widget)
# 竖线分隔符
vdivider = QFrame()
vdivider.setFrameShape(QFrame.VLine)
vdivider.setFrameShadow(QFrame.Plain)
vdivider.setFixedWidth(1)
vdivider.setFixedHeight(28)
vdivider.setStyleSheet("background-color: #E2E8F0; border: none;")
title_layout.addSpacing(16)
title_layout.addWidget(vdivider)
title_layout.addSpacing(16)
# 标题 "ReinLoop V1.0"
title_lbl = QLabel("ReinLoop V1.0")
title_lbl.setProperty("cssClass", "navTitle")
title_layout.addWidget(title_lbl)
title_layout.addStretch()
main_layout.addWidget(title_row)
# -- 分割线 1:标题行与 Tab 行之间的灰色细线 --
div1 = QFrame()
div1.setFrameShape(QFrame.HLine)
div1.setFixedHeight(1)
div1.setStyleSheet("background-color: #E2E8F0; border: none;")
main_layout.addWidget(div1)
# ==========================================
# 1. 顶部区域 — Layer 2: Tab 栏行(浅灰背景 #F8FAFC
# ==========================================
tab_container = QWidget()
tab_container.setProperty("cssClass", "tabRow")
tab_container_layout = QHBoxLayout(tab_container)
tab_container_layout.setContentsMargins(24, 0, 24, 0)
tab_container_layout.setSpacing(0)
# 使用 QTabBar(仅导航栏)+ 独立 QStackedWidget(内容区)
self.tab_bar = QTabBar()
self.tab_bar.setProperty("cssClass", "mainTab")
self.tab_bar.setIconSize(QSize(22, 22))
self.tab_bar.setCursor(Qt.PointingHandCursor)
self.tab_bar.setExpanding(False)
self.tab_bar.setDrawBase(False)
# Tab 图标路径(灰色/蓝色两套),保存为类属性方便动态切换
self._icons_gray = [
os.path.normpath(os.path.join(_src, "link_icon_gray.svg")),
os.path.normpath(os.path.join(_src, "control_icon_gray.svg")),
os.path.normpath(os.path.join(_src, "debug_icon_gray.svg"))
]
self._icons_blue = [
os.path.normpath(os.path.join(_src, "link_icon.svg")),
os.path.normpath(os.path.join(_src, "control_icon.svg")),
os.path.normpath(os.path.join(_src, "debug_icon.svg"))
]
# 初始默认全灰图标,文字前加半角空格确保图标与文字间距
self.tab_bar.addTab(QIcon(self._icons_gray[0]), " 连接设置")
self.tab_bar.addTab(QIcon(self._icons_gray[1]), " 控制设置")
self.tab_bar.addTab(QIcon(self._icons_gray[2]), " 模型调试")
tab_container_layout.addWidget(self.tab_bar)
tab_container_layout.addStretch()
main_layout.addWidget(tab_container)
# -- 分割线 2:Tab 行与主体内容之间的灰色细线 --
div2 = QFrame()
div2.setFrameShape(QFrame.HLine)
div2.setFixedHeight(1)
div2.setStyleSheet("background-color: #E2E8F0; border: none;")
main_layout.addWidget(div2)
# ==========================================
# 2. 页面堆栈
# ==========================================
self.stack = QStackedWidget()
self.tab_connection = ConnectionTab(self.colors)
self.tab_control = ControlTab(self.colors)
self.tab_debug = DebugTab(self.colors)
self.stack.addWidget(self.tab_connection)
self.stack.addWidget(self.tab_control)
self.stack.addWidget(self.tab_debug)
main_layout.addWidget(self.stack, stretch=1)
# ==========================================
# 3. 底部状态栏
# ==========================================
self.status_bar = StatusBar(self.colors)
main_layout.addWidget(self.status_bar)
# 默认选中第一个标签
self.tab_bar.setCurrentIndex(0)
self.stack.setCurrentIndex(0)
def _switch_tab(self, index: int):
"""切换标签页,并动态刷新图标与 QSS 状态"""
self.stack.setCurrentIndex(index)
# 阻断信号,防止改变时死循环
self.tab_bar.blockSignals(True)
self.tab_bar.setCurrentIndex(index)
self.tab_bar.blockSignals(False)
# 动态轮询,更新图标:选中项换蓝色高亮图标,其余换回灰色
for i in range(self.tab_bar.count()):
if i == index:
self.tab_bar.setTabIcon(i, QIcon(self._icons_blue[i]))
else:
self.tab_bar.setTabIcon(i, QIcon(self._icons_gray[i]))
# 强制让控件重新 polish 样式,确保 QSS 中的 :selected 样式立即生效
self.tab_bar.style().unpolish(self.tab_bar)
self.tab_bar.style().polish(self.tab_bar)
# ==========================================
# 回调设置(Core → UI 通过信号桥)
# ==========================================
def _setup_callbacks(self):
"""将 Core 层的回调全部桥接到主线程信号"""
bridge = self._bridge
# 日志 → status_bar
def on_log(msg):
bridge.log_signal.emit(msg)
self.conn_mgr.set_log_callback(on_log)
self.model_mgr.set_log_callback(on_log)
self.data_collector.set_log_callback(on_log)
self.ident_mgr.set_log_callback(on_log)
self.engine.set_log_callback(on_log)
# 连接状态 → status_bar
self.conn_mgr.set_status_callback(
lambda connected, text: bridge.status_signal.emit(connected, text)
)
# 控制显示更新 → control_tab
self.engine.set_display_update_callback(
lambda p, t, v: bridge.display_signal.emit(p, t, v)
)
# PID UI 更新 → control_tab
self.engine.set_pid_ui_update_callback(
lambda kp, ki, kd: bridge.pid_ui_signal.emit(kp, ki, kd)
)
# 控制启停 → control_tab 按钮状态 + 模式锁定
self.engine.set_started_callback(
self._on_engine_started
)
self.engine.set_stopped_callback(
self._on_engine_stopped
)
# 模型列表加载完成
self.model_mgr.set_models_loaded_callback(
lambda files: bridge.models_loaded_signal.emit(files)
)
# 模型加载完成
self.model_mgr.set_load_complete_callback(
lambda success, msg: bridge.model_load_complete.emit(success, msg)
)
# 辨识/容积采样
self.ident_mgr.set_sample_callback(
lambda u, p: bridge.sample_signal.emit(u, p)
)
# 容积结果
self.ident_mgr.set_volume_result_callback(
lambda vol: bridge.volume_result_signal.emit(vol)
)
self.ident_mgr.set_identification_upload_callback(
lambda success, filename, error:
bridge.identification_upload_complete.emit(
success, filename, error
)
)
def _connect_signals(self):
"""连接信号桥到各 UI 组件槽函数"""
bridge = self._bridge
bridge.log_signal.connect(self.status_bar.set_log)
bridge.status_signal.connect(self.status_bar.set_connection_status)
bridge.display_signal.connect(self._on_display_update)
bridge.pid_ui_signal.connect(self.tab_control.update_pid_entries)
bridge.models_loaded_signal.connect(self.tab_control.update_model_list)
bridge.model_load_complete.connect(self._on_model_load_complete)
bridge.sample_signal.connect(self._on_sample_update)
bridge.volume_result_signal.connect(self.tab_debug.set_volume_text)
bridge.identification_config_loaded.connect(
self._on_identification_config_loaded
)
bridge.identification_upload_complete.connect(
self._on_identification_upload_complete
)
bridge.identification_feedback_loaded.connect(
self._on_identification_feedback_loaded
)
bridge.volume_request_created.connect(self._on_volume_request_created)
bridge.volume_config_loaded.connect(self._on_volume_config_loaded)
bridge.heartbeat_completed.connect(self._on_heartbeat_completed)
# ---- UI 按钮 → Core 方法 ----
# 连接设置页
self.tab_connection.connect_btn.clicked.connect(self._on_connect_toggle)
# 控制设置页
self.tab_control.target_set_requested.connect(self._on_set_target)
self.tab_control.mode_changed.connect(self._on_mode_changed)
self.tab_control.pid_update_requested.connect(self._on_update_pid)
self.tab_control.model_load_requested.connect(self.model_mgr.load_model)
self.tab_control.models_refresh_requested.connect(self.model_mgr.scan_models)
self.tab_control.control_toggle_requested.connect(self._on_control_toggle)
self.tab_control.plot_requested.connect(self._on_plot)
self.tab_control.manual_valve_set_requested.connect(self._on_set_manual_valve)
self.tab_control.log_message_requested.connect(self.status_bar.set_log)
# 模型调试页
self.tab_debug.identify_start_requested.connect(self._on_identify_start)
self.tab_debug.identify_stop_requested.connect(self._on_identify_stop)
self.tab_debug.volume_measure_requested.connect(self._on_volume_measure)
self.tab_debug.volume_stop_requested.connect(self._on_volume_stop)
# 轮询辨识任务完成状态(100ms),任务结束后恢复按钮
self._ident_poll_timer = QTimer(self)
self._ident_poll_timer.setInterval(100)
self._ident_poll_timer.timeout.connect(self._poll_ident_done)
self._ident_feedback_timer = QTimer(self)
self._ident_feedback_timer.setInterval(
IDENTIFICATION_FEEDBACK_POLL_INTERVAL_MS
)
self._ident_feedback_timer.timeout.connect(
self._request_identification_feedback
)
self._ident_config_retry_timer = QTimer(self)
self._ident_config_retry_timer.setInterval(
IDENTIFICATION_CONFIG_RETRY_INTERVAL_MS
)
self._ident_config_retry_timer.timeout.connect(
self._request_identification_config
)
self._volume_request_status_timer = QTimer(self)
self._volume_request_status_timer.setInterval(
VOLUME_REQUEST_STATUS_POLL_INTERVAL_MS
)
self._volume_request_status_timer.timeout.connect(
self._request_volume_config
)
self._heartbeat_timer = QTimer(self)
self._heartbeat_timer.setInterval(DEVICE_HEARTBEAT_INTERVAL_MS)
self._heartbeat_timer.timeout.connect(self._request_device_heartbeat)
self._heartbeat_timer.start()
self._request_device_heartbeat()
# ---- 导航栏 Tab 切换 → 页面切换 ----
self.tab_bar.currentChanged.connect(self._switch_tab)
def _request_device_heartbeat(self):
if self._heartbeat_inflight:
return
self._heartbeat_inflight = True
def heartbeat_thread():
try:
heartbeat_device()
except Exception as exc:
self._bridge.heartbeat_completed.emit(str(exc))
else:
self._bridge.heartbeat_completed.emit(None)
threading.Thread(
target=heartbeat_thread,
name="device-heartbeat",
daemon=True,
).start()
def _on_heartbeat_completed(self, error):
self._heartbeat_inflight = False
if error and error != self._heartbeat_last_error:
self.status_bar.set_log(f"设备在线状态未上报: {error}")
self._heartbeat_last_error = error
# ==========================================
# 槽函数
# ==========================================
def _on_connect_toggle(self):
if self.conn_mgr.is_connected():
self.status_bar.set_log("正在断开连接...")
self.conn_mgr.disconnect()
self.status_bar.set_connection_status(False, "● 未连接")
self.tab_connection._connect_text_lbl.setText(" 连接设备")
self.status_bar.set_log("已断开设备连接")
else:
self.status_bar.set_log("正在连接设备,请稍候...")
QApplication.processEvents() # 强制刷新 UI,让日志立即可见
params = self.tab_connection.get_connection_params()
success = self.conn_mgr.connect(**params)
if success:
self.status_bar.set_connection_status(True, "● 已连接")
self.tab_connection._connect_text_lbl.setText(" 断开连接")
self.status_bar.set_log("设备连接成功")
else:
self.status_bar.set_connection_status(False, "● 未连接")
self.status_bar.set_log("设备连接失败,请检查参数和硬件连接")
def _on_set_target(self, target: float):
if target < 0 or target > 3000:
self.status_bar.set_log("错误: 目标压力必须在0-3000 kPa范围内")
return
self.confirmed_target_pressure = target
self.engine.target_pressure = target # 运行中实时同步到控制引擎
# 智能自动模式(RL):每次设置目标时自动从 PLC 读取流量
if self.engine.mode == "RL":
if self.conn_mgr.is_connected():
q = self.conn_mgr.read_flow()
if q is not None:
self.engine.flow = q
self.tab_control.flow_entry.setText(f"{q:.1f}")
self.status_bar.set_log(
f"目标压力已设置为: {target} kPa | 已从模块读取流量: {q:.1f} L/min")
return
self.status_bar.set_log(f"目标压力已设置为: {target} kPa")
def _on_mode_changed(self, mode: str):
self.engine.mode = mode
def _on_update_pid(self, kp: float, ki: float, kd: float):
self.pid.kp = kp
self.pid.ki = ki
self.pid.kd = kd
self.pid._calculate_coefficients()
self.status_bar.set_log(f"PID参数已更新: Kp={kp:.3f}, Ki={ki:.3f}, Kd={kd:.3f}")
def _on_engine_started(self):
self.tab_control.set_control_running(True)
self.tab_control.set_mode_switch_enabled(False)
def _on_engine_stopped(self):
self.tab_control.set_control_running(False)
self.tab_control.set_mode_switch_enabled(True)
def _on_control_toggle(self):
if self.engine.is_running:
self.engine.stop()
self.status_bar.set_log("控制已停止")
else:
try:
self._do_start_control()
except Exception as e:
import traceback
err = traceback.format_exc()
self.status_bar.set_log(f"启动控制失败: {e}")
# 同时写入日志文件(打包后无控制台)
try:
import os, sys
from datetime import datetime
log_dir = os.path.join(os.path.dirname(sys.executable), "logs")
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, "reinloop_error.log")
with open(log_file, "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().strftime('%Y%m%d_%H%M%S')}] _on_control_toggle 异常:\n{err}\n")
except Exception:
pass
def _do_start_control(self):
"""实际启动控制的逻辑(从 _on_control_toggle 拆出,便于异常隔离)"""
# 自动从 PLC 读取流量值并填入输入框
if self.conn_mgr.is_connected():
q = self.conn_mgr.read_flow()
if q is not None:
self.tab_control.flow_entry.setText(f"{q:.1f}")
self.status_bar.set_log(f"已从模块读取流量: {q:.1f} L/min")
# 收集参数
params = self.tab_control.get_control_params()
pid_params = self.tab_control.get_pid_params()
adv = self.tab_debug.get_advanced_params()
# 同步电机限幅参数到 PcControl(总限幅→x_max, 模拟量映射→volthege_min/max
PcControl.set_motor_limits(
volthege_min_val=adv.get("volthege_min"),
volthege_max_val=adv.get("volthege_max"),
x_max_val=adv.get("xa_full")
)
self.engine.mode = self.tab_control.get_mode()
self.engine.flow = params["flow"]
self.engine.volume = params["volume"]
self.engine.target_pressure = self.confirmed_target_pressure
self.engine.dz = adv["dz"]
self.engine.motor_max = adv["motor_max"]
xa_full_val = adv.get("xa_full")
self.engine.xa_full = float(xa_full_val) if xa_full_val is not None else 1062.5
self.pid.xa_full = float(xa_full_val) if xa_full_val is not None else 1062.5
self.engine.collect_data = self.tab_control.get_collect_data()
if self.engine.mode == "MANUAL":
self.engine.manual_valve = self.tab_control.get_manual_valve()
# 重置数据
_MAX = 720_000
self.pressure_data = deque(maxlen=_MAX)
self.target_data = deque(maxlen=_MAX)
self.valve_data = deque(maxlen=_MAX)
self.time_data = deque(maxlen=_MAX)
self.start_time = time.time()
# 启动定时器(周期 = PID dt,与 control_tick 内的 sleep 配合保证精确周期)
# 先停止旧 timer(防止重复启动累积多个 timer)
if hasattr(self, '_ui_timer') and self._ui_timer is not None:
self._ui_timer.stop()
self._ui_timer = QTimer(self)
self._ui_timer.timeout.connect(self._poll_display)
timer_ms = max(1, int(self.pid.dt * 1000))
self._ui_timer.start(timer_ms)
self.engine.start()
def _poll_display(self):
"""定时轮询:在主线程执行一个控制周期(读压力→计算→写电机→更新UI)
全部在主线程运行,避免 Cython 编译后在 PyInstaller 子线程中 segfault。
"""
self.engine.control_tick()
def _on_display_update(self, pressure: float, target: float, valve: float):
"""更新 UI 显示 + 记录数据(deque 自动滚动,最多保留 10 小时)"""
self.tab_control.update_display(pressure, target, valve)
if self.start_time is not None:
elapsed = time.time() - self.start_time
self.time_data.append(elapsed)
self.pressure_data.append(pressure)
self.target_data.append(target)
self.valve_data.append(valve)
def _on_model_load_complete(self, success: bool, message: str):
self.status_bar.set_log(message)
def _on_sample_update(self, valve_cmd, pressure):
if valve_cmd is not None:
self.tab_control.valve_opening_lbl.setText(f"{valve_cmd:.1f}")
if pressure is not None:
self.tab_control.current_pressure_lbl.setText(f"{pressure:.1f}")
def _on_set_manual_valve(self, valve: float):
if valve == -1:
self.status_bar.set_log("错误: 目标阀开度超出范围")
elif valve == -2:
self.status_bar.set_log("错误: 请输入有效的数字")
else:
self.engine.manual_valve = valve
self.status_bar.set_log(f"阀门开度已设置为: {valve}%")
def _on_plot(self):
if not self.pressure_data:
self.status_bar.set_log("没有可绘制的数据")
return
# 关闭已有窗口(先断开 finished 信号,防止回调中 enable_plot_button(True)
# 与下面紧跟的 enable_plot_button(False) 产生竞争,导致按钮状态闪烁)
if self._plot_window is not None:
try:
self._plot_window.finished.disconnect(self._on_plot_closed)
except RuntimeError:
pass # 信号可能已被断开
self._plot_window.close()
self._plot_window = None
self.tab_control.enable_plot_button(False)
try:
self._plot_window = PlotWindow(
self.time_data, self.pressure_data,
self.target_data, self.valve_data,
parent=self
)
except Exception as e:
import traceback
self.status_bar.set_log(f"绘图失败: {e}")
traceback.print_exc()
self.tab_control.enable_plot_button(True)
return
self._plot_window.finished.connect(self._on_plot_closed)
self._plot_window.show()
self.status_bar.set_log("绘图窗口已打开")
def _on_plot_closed(self):
self._plot_window = None
self.tab_control.enable_plot_button(True)
def _on_identify_start(self):
if self._volume_workflow_active:
self.status_bar.set_log("请先停止容积测试")
self.tab_debug.set_identify_finished()
return
self._ident_workflow_active = True
self._ident_waiting_for_updated_config = False
self._ident_current_config = None
self._ident_config_request_id += 1
self._ident_config_request_inflight = False
self._ident_feedback_request_id += 1
self._ident_feedback_request_inflight = False
self._ident_feedback_registered = False
self._ident_feedback_run_id = None
self._ident_feedback_last_error = None
self._ident_feedback_timer.stop()
self._ident_config_retry_timer.stop()
self.tab_debug.set_identification_feedback("正在获取参数", "pending")
self.status_bar.set_log("正在从云端获取辨识参数...")
self._request_identification_config()
def _request_identification_config(self):
"""异步获取 9 参数 CSV;失败重试时禁止并发请求。"""
if (not self._ident_workflow_active
or self._ident_config_request_inflight):
return
request_id = self._ident_config_request_id
self._ident_config_request_inflight = True
def download_thread():
try:
config = download_identification_config()
except Exception as exc:
self._bridge.identification_config_loaded.emit(
request_id, None, str(exc)
)
else:
self._bridge.identification_config_loaded.emit(
request_id, config, None
)
threading.Thread(
target=download_thread,
name="identification-config-download",
daemon=True,
).start()
def _on_identification_config_loaded(self, request_id, config, error):
"""在主线程使用云端参数启动辨识,忽略已被停止的旧请求。"""
if (request_id != self._ident_config_request_id
or not self._ident_workflow_active):
return
self._ident_config_request_inflight = False
if error:
if self._ident_waiting_for_updated_config:
self.status_bar.set_log(
f"等待新辨识参数,将继续重试: {error}"
)
else:
self.status_bar.set_log(f"无法启动辨识: {error}")
self.tab_debug.set_identification_feedback(
"参数获取失败", "failed"
)
self._ident_workflow_active = False
self.tab_debug.set_identify_finished()
return
if (self._ident_waiting_for_updated_config
and config == self._ident_current_config):
self.status_bar.set_log(
"辨识未通过,正在等待云端更新 9 参数 CSV..."
)
return
self._ident_waiting_for_updated_config = False
self._ident_config_retry_timer.stop()
self._ident_current_config = dict(config)
# xa_full 同时参与 PRBS 行程换算和 PcControl 电机限幅。
PcControl.set_motor_limits(x_max_val=config["xa_full"])
self.status_bar.set_log("云端参数已加载,正在启动辨识数据采集...")
self.tab_debug.set_identification_feedback("正在辨识", "pending")
started = self.ident_mgr.start_identification(
conn_mgr=self.conn_mgr,
running_flag_check=lambda: self.engine.is_running,
**config,
)
if not started:
self._ident_workflow_active = False
self.tab_debug.set_identification_feedback("启动失败", "failed")
self.tab_debug.set_identify_finished()
return
self._ident_poll_timer.start()
def _on_identification_upload_complete(self, success, filename, error):
"""辨识 CSV 上传后登记本轮审核任务。"""
if not self._ident_workflow_active:
return
if not success or not filename:
self._ident_workflow_active = False
self.tab_debug.set_identification_feedback("上传失败", "failed")
self.status_bar.set_log(f"辨识结果上传失败: {error or '未知错误'}")
self.tab_debug.set_identify_finished()
return
self._ident_feedback_run_id = filename
self._ident_feedback_request_id += 1
self._ident_feedback_request_inflight = False
self._ident_feedback_registered = False
self._ident_feedback_last_error = None
self.tab_debug.set_identification_feedback("等待反馈", "pending")
self.status_bar.set_log("辨识 CSV 已上传,正在等待云端返回 0/1...")
self._request_identification_feedback()
self._ident_feedback_timer.start()
def _request_identification_feedback(self):
"""登记本轮 CSV,并轮询服务器返回的数字 0/1。"""
if (not self._ident_workflow_active
or not self._ident_feedback_run_id
or self._ident_feedback_request_inflight):
return
request_id = self._ident_feedback_request_id
run_id = self._ident_feedback_run_id
registered = self._ident_feedback_registered
self._ident_feedback_request_inflight = True
def feedback_thread():
try:
if not registered:
register_identification_result(run_id)
payload = {"registered": True}
else:
payload = {
"registered": True,
"feedback": get_identification_feedback(run_id),
}
except Exception as exc:
self._bridge.identification_feedback_loaded.emit(
request_id, None, str(exc)
)
else:
self._bridge.identification_feedback_loaded.emit(
request_id, payload, None
)
threading.Thread(
target=feedback_thread,
name="identification-feedback-poll",
daemon=True,
).start()
def _on_identification_feedback_loaded(self, request_id, payload, error):
if (request_id != self._ident_feedback_request_id
or not self._ident_workflow_active):
return
self._ident_feedback_request_inflight = False
if error:
if error != self._ident_feedback_last_error:
self.status_bar.set_log(
f"辨识反馈查询失败,将继续重试: {error}"
)
self._ident_feedback_last_error = error
return
self._ident_feedback_last_error = None
if payload.get("registered") and not self._ident_feedback_registered:
self._ident_feedback_registered = True
self._request_identification_feedback()
return
feedback = payload.get("feedback")
if feedback is None:
return
self._ident_feedback_timer.stop()
run_id = self._ident_feedback_run_id
self._ident_feedback_request_id += 1
self._ident_feedback_request_inflight = False
self._ident_feedback_registered = False
self._ident_feedback_run_id = None
self._ack_identification_feedback(run_id)
if feedback == 1:
self._ident_workflow_active = False
self.tab_debug.set_identification_feedback("已通过", "passed")
self.status_bar.set_log("辨识结果:已通过")
self.tab_debug.set_identify_finished()
return
self.tab_debug.set_identification_feedback("未通过", "failed")
self.status_bar.set_log(
"辨识结果:未通过,等待重新制定并上传 9 参数 CSV"
)
self._ident_waiting_for_updated_config = True
self._ident_config_request_inflight = False
self._request_identification_config()
self._ident_config_retry_timer.start()
def _ack_identification_feedback(self, run_id):
def ack_thread():
try:
acknowledge_identification_feedback(run_id)
except Exception as exc:
self._bridge.log_signal.emit(f"确认辨识反馈失败: {exc}")
threading.Thread(
target=ack_thread,
name="identification-feedback-ack",
daemon=True,
).start()
def _on_identify_stop(self):
self._ident_workflow_active = False
self._ident_waiting_for_updated_config = False
self._ident_config_request_id += 1
self._ident_config_request_inflight = False
self._ident_feedback_request_id += 1
self._ident_feedback_request_inflight = False
self._ident_feedback_run_id = None
self._ident_feedback_timer.stop()
self._ident_config_retry_timer.stop()
self.ident_mgr.stop()
self.tab_debug.set_identification_feedback("已停止", "neutral")
self.tab_debug.set_identify_finished()
self.status_bar.set_log("已停止辨识及反馈监听")
def _on_volume_measure(self):
if self._ident_workflow_active:
self.status_bar.set_log("请先结束辨识及反馈流程")
self.tab_debug.set_volume_finished()
return
if self._volume_workflow_active:
return
self._volume_workflow_active = True
self._volume_config_request_id += 1
self._volume_config_request_inflight = False
self._volume_cloud_request_id = None
self._volume_request_expires_at_ms = None
self.status_bar.set_log("正在向云端发送容积参数请求指令...")
self._create_volume_config_request()
def _create_volume_config_request(self):
"""点击测试后只创建一次请求指令。"""
generation = self._volume_config_request_id
self._volume_config_request_inflight = True
def create_thread():
try:
request_info = create_volume_config_request()
except Exception as exc:
self._bridge.volume_request_created.emit(
generation, None, str(exc)
)
else:
self._bridge.volume_request_created.emit(
generation, request_info, None
)
threading.Thread(
target=create_thread,
name="volume-request-create",
daemon=True,
).start()
def _on_volume_request_created(self, generation, request_info, error):
if (generation != self._volume_config_request_id
or not self._volume_workflow_active):
if request_info and request_info.get("request_id"):
self._ack_volume_config_request(request_info["request_id"])
return
self._volume_config_request_inflight = False
if error:
self._volume_workflow_active = False
self.status_bar.set_log(f"发送容积参数请求失败: {error}")
self.tab_debug.set_volume_finished()
return
self._volume_cloud_request_id = request_info["request_id"]
self._volume_request_expires_at_ms = request_info["expires_at_ms"]
self.status_bar.set_log(
"容积参数请求已发送,等待公司端上传本次 JSON..."
)
self._volume_request_status_timer.start()
self._request_volume_config()
def _request_volume_config(self):
"""只查询已创建 requestId 的状态,不重复创建请求指令。"""
if (not self._volume_workflow_active
or self._volume_config_request_inflight
or not self._volume_cloud_request_id):
return
generation = self._volume_config_request_id
cloud_request_id = self._volume_cloud_request_id
self._volume_config_request_inflight = True
def download_thread():
try:
result = poll_volume_config_request(cloud_request_id)
except Exception as exc:
self._bridge.volume_config_loaded.emit(
generation, None, str(exc)
)
else:
self._bridge.volume_config_loaded.emit(
generation, result, None
)
threading.Thread(
target=download_thread,
name="volume-request-status",
daemon=True,
).start()
def _on_volume_config_loaded(self, generation, result, error):
"""检测本次请求关联的几分钟内 JSON,成功后加载一次。"""
if (generation != self._volume_config_request_id
or not self._volume_workflow_active):
return
self._volume_config_request_inflight = False
if error:
if (self._volume_request_expires_at_ms is not None
and time.time() * 1000 >= self._volume_request_expires_at_ms):
self._finish_volume_request("等待公司端参数超时")
else:
self.status_bar.set_log(
f"查询本次容积参数请求失败,将继续等待: {error}"
)
return
if result.get("expired"):
self._finish_volume_request("等待公司端参数超时")
return
if not result.get("ready"):
return
self._volume_request_status_timer.stop()
cloud_request_id = self._volume_cloud_request_id
self._volume_cloud_request_id = None
self._volume_request_expires_at_ms = None
self._ack_volume_config_request(cloud_request_id)
config = result["config"]
PcControl.set_motor_limits(x_max_val=config["xa_full"])
started = self.ident_mgr.start_volume_measurement(
conn_mgr=self.conn_mgr,
running_flag_check=lambda: self.engine.is_running,
**config,
)
if not started:
self._volume_workflow_active = False
self.tab_debug.set_volume_finished()
return
self._volume_measurement_running = True
self.status_bar.set_log("已获取云端 8 参数,开始容积测量")
self._ident_poll_timer.start()
def _finish_volume_request(self, message):
cloud_request_id = self._volume_cloud_request_id
self._volume_workflow_active = False
self._volume_config_request_inflight = False
self._volume_cloud_request_id = None
self._volume_request_expires_at_ms = None
self._volume_request_status_timer.stop()
if cloud_request_id:
self._ack_volume_config_request(cloud_request_id)
self.status_bar.set_log(message)
self.tab_debug.set_volume_finished()
def _ack_volume_config_request(self, request_id):
def ack_thread():
try:
acknowledge_volume_config_request(request_id)
except Exception as exc:
self._bridge.log_signal.emit(f"清理容积参数请求失败: {exc}")
threading.Thread(
target=ack_thread,
name="volume-request-ack",
daemon=True,
).start()
def _on_volume_stop(self):
cloud_request_id = self._volume_cloud_request_id
self._volume_workflow_active = False
self._volume_config_request_id += 1
self._volume_config_request_inflight = False
self._volume_cloud_request_id = None
self._volume_request_expires_at_ms = None
self._volume_request_status_timer.stop()
if cloud_request_id:
self._ack_volume_config_request(cloud_request_id)
if self._volume_measurement_running:
self.ident_mgr.stop()
self._volume_measurement_running = False
self.tab_debug.set_volume_finished()
self.status_bar.set_log("已停止容积测试")
def _poll_ident_done(self):
"""100ms 轮询:检测辨识/测量任务是否自然结束,恢复按钮状态"""
if self.ident_mgr.is_running:
return
self._ident_poll_timer.stop()
if not self._ident_workflow_active:
self.tab_debug.set_identify_finished()
if self._volume_measurement_running:
self._volume_measurement_running = False
self._volume_workflow_active = False
self.status_bar.set_log("容积测量完成")
self.tab_debug.set_volume_finished()
elif not self._volume_workflow_active:
self.tab_debug.set_volume_finished()
def closeEvent(self, event):
"""窗口关闭时清理资源"""
self._ident_config_request_id += 1
self._ident_config_request_inflight = False
self._ident_workflow_active = False
self._ident_feedback_request_id += 1
self._ident_feedback_request_inflight = False
self._ident_feedback_timer.stop()
self._ident_config_retry_timer.stop()
self._volume_workflow_active = False
self._volume_config_request_id += 1
self._volume_request_status_timer.stop()
self._heartbeat_timer.stop()
if self._volume_cloud_request_id:
self._ack_volume_config_request(self._volume_cloud_request_id)
self._volume_cloud_request_id = None
self.ident_mgr.stop()
if self.engine.is_running:
self.engine.stop()
if self.conn_mgr.is_connected():
self.conn_mgr.disconnect()
if self._plot_window is not None:
self._plot_window.close()
event.accept()