From 22f8b1269040ac710931c389ad681229ddba2848 Mon Sep 17 00:00:00 2001 From: "xinzhu.yin" Date: Mon, 22 Jun 2026 10:31:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9UCD=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E3=80=81=E6=B7=BB=E5=8A=A0=E5=85=B3=E9=97=AD=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E7=9A=84x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/device/connection.py | 88 +++++++++++++---- app/ucd/device.py | 52 +++++++--- app/views/panel_manager.py | 169 ++++++++++++++++++++++++++++++-- app/views/panels/main_layout.py | 33 ++++--- settings/pq_config.json | 4 + 5 files changed, 294 insertions(+), 52 deletions(-) diff --git a/app/device/connection.py b/app/device/connection.py index 7dba2ed..87ffb19 100644 --- a/app/device/connection.py +++ b/app/device/connection.py @@ -78,12 +78,21 @@ class ConnectionController: # -- UCD 连接 ------------------------------------------------ + def _pick_ucd_device(self, ucd_list: list[str], display: str) -> str | None: + """Prefer devices not marked in-use; fall back to full list.""" + usable = [d for d in ucd_list if "(in use)" not in d.lower()] + if display in usable: + return display + if usable: + return usable[0] + if display in ucd_list: + return display + return ucd_list[0] if ucd_list else None + def _refresh_ucd_selection(self, display: str) -> tuple[list[str], str | None]: """Rescan devices and return (device list, valid selection or None).""" ucd_list = self.list_ucd_devices() - if display in ucd_list: - return ucd_list, display - return ucd_list, (ucd_list[0] if ucd_list else None) + return ucd_list, self._pick_ucd_device(ucd_list, display) def _sync_ucd_combo(self, ucd_list: list[str], selected: str) -> None: app = self._app @@ -91,6 +100,19 @@ class ConnectionController: app.ucd_list_var.set(selected) app.update_config() + def _prepare_ucd_reconnect(self) -> None: + """Release stale SDK/native handles before a new open attempt.""" + if self._device.state.name != "CLOSED": + try: + self._device.close() + return + except Exception as exc: # noqa: BLE001 + self._log(f"关闭旧 UCD 会话失败,将强制重置: {exc}", level="info") + try: + self._device.force_reset() + except Exception as exc: # noqa: BLE001 + self._log(f"强制重置 UCD SDK 失败: {exc}", level="error") + def connect_ucd(self, display: str, *, rescan: bool = True) -> bool: """打开指定 UCD 设备。成功返回 True。""" if rescan: @@ -99,26 +121,48 @@ class ConnectionController: self._log("未检测到 UCD 设备,请确认电源已打开后重试", level="error") return False - if self._device.state.name != "CLOSED": + self._prepare_ucd_reconnect() + + last_error: Exception | None = None + for attempt in range(2): try: - self._device.close() - except Exception: # noqa: BLE001 - pass - try: - self._device.open(DeviceInfo.parse(display)) - return True - except UcdError as exc: - self._log(f"设备 {display} 异常,UCD323 连接失败: {exc}", level="error") - return False - except Exception as exc: # noqa: BLE001 - self._log(f"UCD323 连接异常: {exc}", level="error") - return False + self._device.open(DeviceInfo.parse(display)) + return True + except UcdError as exc: + last_error = exc + self._log( + f"设备 {display} 连接失败 ({attempt + 1}/2): {exc}", + level="error", + ) + except Exception as exc: # noqa: BLE001 + last_error = exc + self._log( + f"UCD323 连接异常 ({attempt + 1}/2): {exc}", + level="error", + ) + + if attempt == 0: + try: + self._device.force_reset() + except Exception as exc: # noqa: BLE001 + self._log(f"重连前强制重置 UCD 失败: {exc}", level="error") + if rescan: + _, display = self._refresh_ucd_selection(display) + if not display: + return False + + if last_error is not None: + self._log(f"UCD323 重连失败: {last_error}", level="error") + return False def disconnect_ucd(self) -> None: try: self._device.close() except Exception: # noqa: BLE001 - pass + try: + self._device.force_reset() + except Exception: # noqa: BLE001 + pass self._log("UCD连接已断开", level="info") # -- CA 连接 ------------------------------------------------- @@ -297,9 +341,15 @@ class ConnectionController: app = self._app com_ports = self.list_com_ports() ucd_list = self.list_ucd_devices() + if not ucd_list or all("(in use)" in d.lower() for d in ucd_list): + try: + self._device.force_reset() + except Exception as exc: # noqa: BLE001 + self._log(f"刷新时重置 UCD SDK: {exc}", level="info") + ucd_list = self.list_ucd_devices() - if app.ucd_list_var.get() not in ucd_list: - app.ucd_list_var.set(ucd_list[0] if ucd_list else "") + selected = self._pick_ucd_device(ucd_list, app.ucd_list_var.get()) + app.ucd_list_var.set(selected or "") app.ucd_list_combo.config(values=ucd_list) if app.ca_com_var.get() not in com_ports: diff --git a/app/ucd/device.py b/app/ucd/device.py index 39b3738..04dfa1c 100644 --- a/app/ucd/device.py +++ b/app/ucd/device.py @@ -156,8 +156,7 @@ class _UcdSdkBackend: return True except Exception as e: - self._force_cleanup() - self._reinit_tsi_lib() + self.hard_reset() return False def close(self): @@ -168,19 +167,24 @@ class _UcdSdkBackend: self._stop_audio_output() except Exception: pass - self._close_device_object(self.dev) - - self._reset_state() - self._reinit_tsi_lib() - return True - - except Exception as e: + try: + self._close_device_object(self.dev) + except Exception: + log.exception("UcdSdk.close device object failed") + finally: self._reset_state() try: self._reinit_tsi_lib() except Exception: - pass - return False + log.exception("UcdSdk.close reinit failed") + return True + + def hard_reset(self) -> None: + """丢弃失效句柄并重建 TSI(意外断连后重连前调用)。""" + self.dev = None + self.role = None + self._reset_state() + self._reinit_tsi_lib() def _ensure_tsi_lib(self) -> None: if self.lUniTAP is None: @@ -188,7 +192,13 @@ class _UcdSdkBackend: def _reinit_tsi_lib(self) -> None: """Destroy and recreate TsiLib so the native driver rescans USB devices.""" + old = self.lUniTAP self.lUniTAP = None + if old is not None: + try: + old.cleanup() + except Exception: + log.exception("UcdSdk._reinit_tsi_lib cleanup failed") for _ in range(3): gc.collect() time.sleep(2.0) @@ -1068,6 +1078,26 @@ class UCD323Device(IUcdDevice): self._info = None self._bus.publish(ConnectionChanged(DeviceKind.UCD, False, prev_serial)) + def force_reset(self) -> None: + """释放失效的 SDK 句柄,软件状态复位为 CLOSED(意外断连后重连前调用)。""" + with self._acquire_device_lock("force_reset"): + was_open = self._state != UcdState.CLOSED + prev_serial = self._info.serial if self._info else None + try: + self._sdk.hard_reset() + except Exception: # noqa: BLE001 + log.exception("UCD force_reset SDK failed") + self._state = UcdState.CLOSED + self._curr_signal = None + self._curr_timing = None + self._curr_pattern = None + self._last_applied = None + self._info = None + if was_open: + self._bus.publish( + ConnectionChanged(DeviceKind.UCD, False, prev_serial) + ) + # --- 信号 / 图案配置 --- def configure(self, signal: SignalFormat, timing: TimingSpec) -> bool: diff --git a/app/views/panel_manager.py b/app/views/panel_manager.py index 36b35d8..e424459 100644 --- a/app/views/panel_manager.py +++ b/app/views/panel_manager.py @@ -3,19 +3,174 @@ register_panel / show_panel / hide_all_panels —— 在右侧栏不同浮动面板间切换。 """ -import tkinter as tk +from __future__ import annotations -from typing import TYPE_CHECKING +import tkinter as tk +import tkinter.font as tkfont + +from typing import TYPE_CHECKING, Any, Callable if TYPE_CHECKING: from pqAutomationApp import PQAutomationApp +def create_tool_panel_sidebar_item( + self: "PQAutomationApp", parent: tk.Misc, label: str, command: Callable[[], None] +) -> dict[str, Any]: + """创建工具面板侧栏项:名称左对齐,选中时 × 贴右端,整体可点击。""" + from app.views.modern_styles import _contrast_text, _is_dark, get_theme_palette + + row = tk.Frame(parent, bd=0, highlightthickness=0, cursor="hand2") + name_lbl = tk.Label(row, text=label, anchor=tk.W, bd=0, padx=18, pady=9) + close_lbl = tk.Label( + row, + text="×", + anchor=tk.CENTER, + bd=0, + padx=0, + pady=9, + width=2, + font=("Segoe UI", 9), + ) + name_lbl.pack(side=tk.LEFT, fill=tk.X, expand=True) + + item: dict[str, Any] = { + "row": row, + "name_label": name_lbl, + "close_label": close_lbl, + "label": label, + "selected": False, + "hover": False, + } + + def _apply_style() -> None: + palette = get_theme_palette() + sidebar_bg = palette["sidebar_bg"] + sidebar_hover = palette["sidebar_hover"] + sidebar_selected = palette["sidebar_selected"] + sidebar_fg = palette["sidebar_fg"] + selected_fg = _contrast_text( + sidebar_selected, + dark_text=palette["badge_fg"], + light_text=sidebar_fg, + ) + + if item["selected"]: + bg = sidebar_selected + fg = selected_fg + name_font = ("Segoe UI Semibold", 10) + elif item["hover"]: + bg = sidebar_hover + fg = "#ffffff" if _is_dark(sidebar_hover) else sidebar_fg + name_font = ("Segoe UI", 10) + else: + bg = sidebar_bg + fg = sidebar_fg + name_font = ("Segoe UI", 10) + + row.configure(bg=bg) + for widget in (name_lbl, close_lbl): + widget.configure( + bg=bg, + fg=fg, + activebackground=bg, + activeforeground=fg, + ) + name_lbl.configure(font=name_font) + + def _truncate_name() -> None: + full = item["label"] + if not item["selected"]: + name_lbl.configure(text=full) + return + + row.update_idletasks() + close_w = close_lbl.winfo_width() if close_lbl.winfo_ismapped() else 20 + avail = max(24, row.winfo_width() - close_w - 18 - 14) + font = tkfont.Font(font=name_lbl.cget("font")) + if font.measure(full) <= avail: + name_lbl.configure(text=full) + return + for i in range(len(full), 0, -1): + candidate = full[:i].rstrip() + "…" + if font.measure(candidate) <= avail: + name_lbl.configure(text=candidate) + return + name_lbl.configure(text="…") + + def set_selected(selected: bool) -> None: + item["selected"] = selected + if selected: + close_lbl.pack(side=tk.RIGHT, padx=(0, 14)) + else: + close_lbl.pack_forget() + _apply_style() + row.after_idle(_truncate_name) + + def _on_click(_event=None) -> None: + command() + + def _on_enter(_event=None) -> None: + if not item["selected"]: + item["hover"] = True + _apply_style() + + def _on_leave(_event=None) -> None: + px, py = row.winfo_pointerxy() + rx, ry = row.winfo_rootx(), row.winfo_rooty() + rw, rh = row.winfo_width(), row.winfo_height() + if rx <= px < rx + rw and ry <= py < ry + rh: + return + item["hover"] = False + _apply_style() + + def _on_configure(_event=None) -> None: + if item["selected"]: + _truncate_name() + + for widget in (row, name_lbl, close_lbl): + widget.bind("", _on_click) + widget.bind("", _on_enter) + widget.bind("", _on_leave) + row.bind("", _on_configure) + + item["set_selected"] = set_selected + item["apply_theme"] = _apply_style + item["pack"] = lambda **kw: row.pack(fill=tk.X, padx=0, pady=1, **kw) + + _apply_style() + return item + + +def _set_tool_panel_sidebar_selected( + panel_info: dict[str, Any], selected: bool +) -> None: + sidebar_item = panel_info.get("sidebar_item") + if sidebar_item is not None: + sidebar_item["set_selected"](selected) + return + button = panel_info.get("button") + if button is not None: + button.configure( + style="SidebarSelected.TButton" if selected else "Sidebar.TButton" + ) + + +def refresh_tool_panel_sidebar_theme(self: "PQAutomationApp") -> None: + """主题切换后刷新工具面板侧栏项配色。""" + for panel_info in self.panels.values(): + sidebar_item = panel_info.get("sidebar_item") + if sidebar_item is not None: + sidebar_item["apply_theme"]() + + def register_panel(self: "PQAutomationApp", panel_name, frame, button, visible_attr): """注册一个面板到管理系统""" self.panels[panel_name] = { "frame": frame, "button": button, + "button_label": None, + "sidebar_item": None, "visible_attr": visible_attr, } @@ -66,9 +221,8 @@ def show_panel(self: "PQAutomationApp", panel_name): # 显示目标面板 panel_info["frame"].pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=5, pady=5) - # 更新按钮样式 - if panel_info["button"]: - panel_info["button"].configure(style="SidebarSelected.TButton") + # 更新侧栏选中样式 + _set_tool_panel_sidebar_selected(panel_info, True) # 更新状态 setattr(self, panel_info["visible_attr"], True) @@ -80,8 +234,7 @@ def hide_all_panels(self: "PQAutomationApp"): # 隐藏所有注册的面板 for panel_name, panel_info in self.panels.items(): panel_info["frame"].pack_forget() - if panel_info["button"]: - panel_info["button"].configure(style="Sidebar.TButton") + _set_tool_panel_sidebar_selected(panel_info, False) setattr(self, panel_info["visible_attr"], False) # 显示主内容区域 @@ -110,6 +263,8 @@ class PanelManagerMixin: """由 tools/refactor_to_mixins.py 自动生成。 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 """ + create_tool_panel_sidebar_item = create_tool_panel_sidebar_item register_panel = register_panel show_panel = show_panel hide_all_panels = hide_all_panels + refresh_tool_panel_sidebar_theme = refresh_tool_panel_sidebar_theme diff --git a/app/views/panels/main_layout.py b/app/views/panels/main_layout.py index 4de66fb..b9148a0 100644 --- a/app/views/panels/main_layout.py +++ b/app/views/panels/main_layout.py @@ -774,21 +774,19 @@ def create_test_type_frame(self: "PQAutomationApp"): ).pack(fill=tk.X, padx=16, pady=(16, 6), anchor="w") panel_buttons = [ - ("ai_image_btn", "AI 图片", self.toggle_ai_image_panel), - ("pantone_baseline_btn", "Pantone 摸底", self.toggle_pantone_baseline_panel), - ("gamma_pattern_btn", "Gamma Pattern编辑", self.toggle_gamma_pattern_panel), - ("calman_btn", "CALMAN 灰阶", self.toggle_calman_panel), + ("ai_image_btn", "ai_image", "AI 图片", self.toggle_ai_image_panel), + ("pantone_baseline_btn", "pantone_baseline", "Pantone 摸底", self.toggle_pantone_baseline_panel), + ("gamma_pattern_btn", "gamma_pattern", "Gamma Pattern编辑", self.toggle_gamma_pattern_panel), + ("calman_btn", "calman", "CALMAN 灰阶", self.toggle_calman_panel), ] - for attr, text, cmd in panel_buttons: - btn = ttk.Button( - self.sidebar_frame, - text=text, - style="Sidebar.TButton", - command=cmd, - takefocus=False, - ) - btn.pack(fill=tk.X, padx=0, pady=1) - setattr(self, attr, btn) + for attr, panel_name, text, cmd in panel_buttons: + item = self.create_tool_panel_sidebar_item(self.sidebar_frame, text, cmd) + item["pack"]() + setattr(self, attr, item["row"]) + if hasattr(self, "panels") and panel_name in self.panels: + self.panels[panel_name]["button_label"] = text + self.panels[panel_name]["sidebar_item"] = item + self.panels[panel_name]["button"] = item["row"] # 测试版水印标签(版本 x.x.0.0 时显示) from app_version import is_beta_version, APP_VERSION @@ -822,7 +820,7 @@ def create_test_type_frame(self: "PQAutomationApp"): self.theme_toggle_btn.pack(fill=tk.X, padx=0, pady=(0, 2), side=tk.BOTTOM) _refresh_theme_toggle_label(self) - # 注册面板按钮 + # 注册面板侧栏按钮 if hasattr(self, "panels"): if "ai_image" in self.panels: self.panels["ai_image"]["button"] = self.ai_image_btn @@ -862,6 +860,11 @@ def _on_toggle_theme(self: "PQAutomationApp") -> None: toggle_theme() # apply_modern_styles() _refresh_theme_toggle_label(self) + if hasattr(self, "refresh_tool_panel_sidebar_theme"): + try: + self.refresh_tool_panel_sidebar_theme() + except Exception: + pass if hasattr(self, "refresh_connection_indicators"): try: self.refresh_connection_indicators() diff --git a/settings/pq_config.json b/settings/pq_config.json index e6750ae..626ade2 100644 --- a/settings/pq_config.json +++ b/settings/pq_config.json @@ -105,5 +105,9 @@ "measurement_bit_depth": 8, "measurement_max_value": 0, "pattern_params": [] + }, + "acquisition_timing": { + "pattern_settle_time": 0.4, + "pattern_progress_log_step": 5 } } \ No newline at end of file