diff --git a/docs/RELEASE_CONTROL.zh-CN.md b/docs/RELEASE_CONTROL.zh-CN.md index 1442f36..3ee5d02 100644 --- a/docs/RELEASE_CONTROL.zh-CN.md +++ b/docs/RELEASE_CONTROL.zh-CN.md @@ -15,6 +15,16 @@ 预设见 `gripper/config/behavior_profiles.py`。旧配置里的 `rapid` /「迅速开合」映射为 `sensitive`。 +### 稳定度配置条与自定义挡位 + +设置 →「行为模式」页的稳定度条(`grip_behavior_level`,0~100)可整体拖动调整,三挡是固定刻度点:灵敏 = 0,较稳定 = 50,十分稳定 = 100,刻度点上的参数与上表完全一致。 + +- 0~49:灵敏算法(`grip_behavior_mode=sensitive`)。数值参数(速度、自适应夹持力、保护期、确认帧数、门槛等)按比例从灵敏值过渡到较稳定值;开关类参数和 Y 偏移张开阈值保持灵敏档设置。 +- 50~100:稳定算法(50~99 为 `stable`,100 为 `firm`)。恒力、保护期、Y 拟合偏移阈值按比例从较稳定过渡到十分稳定(例如 75 = 85% / 2.25 s / 0.7 N)。 +- 闭合触发参数在任何位置都固定为灵敏档。 + +拖到合适位置后点「保存为新挡位」并命名,挡位立即写入 `gripper.json` 的 `grip_behavior_presets`(`[{name, level}]`),出现在预设下拉框和刻度条上;选中自定义挡位后可删除,默认三挡不能删除。拖动或选择挡位后需点「保存到 gripper.json」才会应用到运行中的控制。 + ## 会话录制(优化张开用) 主界面「录制数据」可开关。开启后每个控制 tick 追加写入 diff --git a/gripper/config/behavior_profiles.py b/gripper/config/behavior_profiles.py index b228b49..75feb26 100644 --- a/gripper/config/behavior_profiles.py +++ b/gripper/config/behavior_profiles.py @@ -8,6 +8,7 @@ import copy # sensitive = 灵敏:跟手释放,自适应夹持力 # stable = 较稳定:接触即 70% 恒力夹持,2 s 保护期,Y 拟合偏移 0.6 N 张开 # firm = 十分稳定:接触即 100% 恒力夹持,2.5 s 保护期,Y 拟合偏移 0.8 N 张开 +# 稳定度 0..100:三挡分别位于 0 / 50 / 100,中间值按比例插值(见 grip_behavior_level_profile)。 GRIP_BEHAVIOR_MODES = ("sensitive", "stable", "firm") GRIP_BEHAVIOR_LABELS = { @@ -74,6 +75,7 @@ GRIP_BEHAVIOR_KEYS = ( "release_shear_oscillation_min_flips", "release_shear_oscillation_amplitude", "release_shear_oscillation_confirm_samples", + "release_shear_oscillation_min_seconds", "release_side_shear_enabled", "release_side_normal_enabled", "release_shear_onset_enabled", @@ -157,10 +159,11 @@ _SENSITIVE_PROFILE = { "release_settle_normal_only": True, # Intentional rub/twist: oscillating shear opens even before plateau latch. "release_shear_oscillation_open": True, - "release_shear_oscillation_window_seconds": 0.4, - "release_shear_oscillation_min_flips": 3, + "release_shear_oscillation_window_seconds": 0.5, + "release_shear_oscillation_min_flips": 4, "release_shear_oscillation_amplitude": 0.15, "release_shear_oscillation_confirm_samples": 2, + "release_shear_oscillation_min_seconds": 0.15, "release_side_shear_enabled": True, "release_side_normal_enabled": True, "release_shear_onset_enabled": True, @@ -288,6 +291,103 @@ def grip_behavior_profile(mode: str = "sensitive") -> dict: return _merged_profile(mode) +# Stability level 0..100: the three built-in modes sit at fixed anchors. +# Below the stable anchor the sensitive algorithm runs with its numeric params +# scaled toward the stable values; from the stable anchor on, the held +# algorithm runs, scaled from stable to firm. +GRIP_BEHAVIOR_LEVEL_MIN = 0 +GRIP_BEHAVIOR_LEVEL_MAX = 100 +GRIP_BEHAVIOR_LEVELS = {"sensitive": 0, "stable": 50, "firm": 100} +# Only read by the held (stable/firm) release; stays off on the sensitive side. +_HELD_ONLY_KEYS = ("release_shear_displacement",) + + +def clamp_grip_behavior_level(level) -> int: + try: + value = int(round(float(level))) + except (TypeError, ValueError): + value = GRIP_BEHAVIOR_LEVEL_MIN + return max(GRIP_BEHAVIOR_LEVEL_MIN, min(GRIP_BEHAVIOR_LEVEL_MAX, value)) + + +def grip_behavior_mode_for_level(level) -> str: + level = clamp_grip_behavior_level(level) + if level >= GRIP_BEHAVIOR_LEVELS["firm"]: + return "firm" + if level >= GRIP_BEHAVIOR_LEVELS["stable"]: + return "stable" + return "sensitive" + + +def default_grip_behavior_level(mode) -> int: + return GRIP_BEHAVIOR_LEVELS[normalize_grip_behavior_mode(mode)] + + +def builtin_grip_behavior_presets() -> list: + return [ + { + "name": GRIP_BEHAVIOR_LABELS[mode], + "level": GRIP_BEHAVIOR_LEVELS[mode], + "speed": GRIP_BEHAVIOR_PROFILES[mode]["speed"], + "builtin": True, + } + for mode in GRIP_BEHAVIOR_MODES + ] + + +def normalize_grip_behavior_presets(presets) -> list: + """User presets as [{name, level[, speed]}], without duplicates of built-in names.""" + reserved = {GRIP_BEHAVIOR_LABELS[mode] for mode in GRIP_BEHAVIOR_MODES} + result = [] + seen = set() + for item in presets if isinstance(presets, list) else []: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name or name in reserved or name in seen: + continue + seen.add(name) + preset = {"name": name, "level": clamp_grip_behavior_level(item.get("level"))} + if item.get("speed") is not None: + preset["speed"] = clamp_grip_behavior_level(item.get("speed")) + result.append(preset) + return result + + +def _lerp_value(a, b, t): + if isinstance(a, bool) or isinstance(b, bool): + return copy.deepcopy(a) + if not isinstance(a, (int, float)) or not isinstance(b, (int, float)): + return copy.deepcopy(a) + value = a + (b - a) * t + if isinstance(a, int) and isinstance(b, int): + return int(round(value)) + return round(float(value), 4) + + +def grip_behavior_level_profile(level) -> dict: + """Merged params for stability *level*; anchors equal the built-in modes.""" + level = clamp_grip_behavior_level(level) + for mode, anchor in GRIP_BEHAVIOR_LEVELS.items(): + if level == anchor: + return _merged_profile(mode) + stable_at = GRIP_BEHAVIOR_LEVELS["stable"] + if level < stable_at: + low, high = _merged_profile("sensitive"), _merged_profile("stable") + t = (level - GRIP_BEHAVIOR_LEVELS["sensitive"]) / float(stable_at - GRIP_BEHAVIOR_LEVELS["sensitive"]) + else: + low, high = _merged_profile("stable"), _merged_profile("firm") + t = (level - stable_at) / float(GRIP_BEHAVIOR_LEVELS["firm"] - stable_at) + fixed = GRIP_TRIGGER_KEYS + (_HELD_ONLY_KEYS if level < stable_at else ()) + profile = {} + for key, value in low.items(): + if key in fixed: + profile[key] = copy.deepcopy(value) + else: + profile[key] = _lerp_value(value, high.get(key, value), t) + return profile + + def apply_grip_behavior_profile(target, mode: str = "sensitive") -> str: mode = normalize_grip_behavior_mode(mode) profile = _merged_profile(mode) diff --git a/gripper/config/settings.py b/gripper/config/settings.py index af41887..14cae31 100644 --- a/gripper/config/settings.py +++ b/gripper/config/settings.py @@ -122,6 +122,8 @@ class Demo02Config: release_shear_oscillation_min_flips: int = 3 release_shear_oscillation_amplitude: float = 0.15 release_shear_oscillation_confirm_samples: int = 2 + # Oscillation must persist this long (in addition to confirm samples) before opening. + release_shear_oscillation_min_seconds: float = 0.0 # Block oscillation/side-touch release during contact hold and servo motion. release_guard_fast_paths: bool = False # Shear axes for release only: "" follows shear_control_axes; "y" | "x" | "xy". @@ -281,6 +283,8 @@ class Demo02Config: default=get("release_shear_oscillation_amplitude", cls.release_shear_oscillation_amplitude)) add("--release-shear-oscillation-confirm-samples", type=int, default=get("release_shear_oscillation_confirm_samples", cls.release_shear_oscillation_confirm_samples)) + add("--release-shear-oscillation-min-seconds", type=float, + default=get("release_shear_oscillation_min_seconds", cls.release_shear_oscillation_min_seconds)) add("--release-guard-fast-paths", dest="release_guard_fast_paths", action="store_true", default=get("release_guard_fast_paths", cls.release_guard_fast_paths)) add("--no-release-guard-fast-paths", dest="release_guard_fast_paths", action="store_false") diff --git a/gripper/control/controller.py b/gripper/control/controller.py index bc3c6a1..b45bba8 100644 --- a/gripper/control/controller.py +++ b/gripper/control/controller.py @@ -55,6 +55,7 @@ class SideTriggerGripController: "move_position", "restart_left_sensor", "restart_right_sensor", + "reset_sensor_reference", }: raise ValueError(f"unsupported manual command: {command}") payload = { @@ -470,6 +471,14 @@ class SideTriggerGripController: manual_action = "right-sensor-restart" else: manual_action = "right-sensor-restart-failed" + elif manual_command == "reset_sensor_reference": + if self.reader.reset_reference_maps(): + release_watch.suspend() + reset_open_wait_stability() + trigger_armed = False + manual_action = "sensor-reference-reset" + else: + manual_action = "sensor-reference-reset-failed" # Non-blocking sample read: never stall the FSM on sensor wait. feedback = self.feedback_processor.read(self.reader, timeout=0.0) diff --git a/gripper/control/release_watch_sensitive.py b/gripper/control/release_watch_sensitive.py index 4ecae7b..1465b6a 100644 --- a/gripper/control/release_watch_sensitive.py +++ b/gripper/control/release_watch_sensitive.py @@ -243,6 +243,7 @@ class ReleaseWatch: self._candidate = None self._osc_count = 0 self._osc_channel = "" + self._osc_since = None self._last_fresh = {} self._rates = {} self._curves = {key: ForceCurve(self.config) for key in CHANNELS} @@ -377,14 +378,17 @@ class ReleaseWatch: if not channel: self._osc_count = 0 self._osc_channel = "" + self._osc_since = None return None needed = max(2, int(getattr(self.config, "release_shear_oscillation_confirm_samples", 2) or 2)) + min_seconds = max(0.0, float(getattr(self.config, "release_shear_oscillation_min_seconds", 0.0) or 0.0)) if self._osc_channel != channel: self._osc_channel = channel self._osc_count = 1 + self._osc_since = now else: self._osc_count += 1 - released = self._osc_count >= needed + released = self._osc_count >= needed and now - self._osc_since >= min_seconds if released: self.phase = ReleasePhase.RELEASED else: @@ -409,6 +413,7 @@ class ReleaseWatch: self._candidate = None self._osc_count = 0 self._osc_channel = "" + self._osc_since = None self._watch_since = None self._settle_seen.clear() for curve in self._curves.values(): diff --git a/gripper/sensors/force_reader.py b/gripper/sensors/force_reader.py index fc39037..513e533 100644 --- a/gripper/sensors/force_reader.py +++ b/gripper/sensors/force_reader.py @@ -155,6 +155,29 @@ def _make_visuals(orisys_module, img, flow, visual_size): return arrows, magnitude, float(flow_mag.mean()), float(flow_mag.max()) +def _drain_commands(command_queue, sensor): + """Apply pending worker commands. Returns True if a reference reset ran.""" + if command_queue is None: + return False + reset = False + while True: + try: + command = command_queue.get_nowait() + except queue.Empty: + break + except Exception: + break + if command == "reset": + try: + if sensor.get_img() is not None: + sensor.reset() + reset = True + except Exception as exc: + print(f"sensor reference reset failed: {exc}") + traceback.print_exc() + return reset + + def _sensor_force_worker( sensor_id, vid_src, @@ -169,6 +192,7 @@ def _sensor_force_worker( motion_threshold, include_visuals, visual_size, + command_queue=None, ): ensure_project_paths() import orisys @@ -190,6 +214,7 @@ def _sensor_force_worker( while not stop_event.is_set(): t_start = time.perf_counter() + _drain_commands(command_queue, sensor) img = sensor.get_img() if img is None: time.sleep(0.01) @@ -309,6 +334,7 @@ class TwoSensorForceReader: self.visual_size = visual_size self.result_queues = [] + self.command_queues = [] self.stop_events = [] self.processes = [] self.latest = [None, None] @@ -318,6 +344,7 @@ class TwoSensorForceReader: return self.result_queues = [Queue(maxsize=self.queue_size) for _ in self.sensor_configs] + self.command_queues = [Queue(maxsize=8) for _ in self.sensor_configs] self.stop_events = [mp.Event() for _ in self.sensor_configs] self.processes = [None] * len(self.sensor_configs) self.latest = [None] * len(self.sensor_configs) @@ -343,6 +370,7 @@ class TwoSensorForceReader: self.motion_threshold, self.include_visuals, self.visual_size, + self.command_queues[idx], ), ) process.start() @@ -364,11 +392,36 @@ class TwoSensorForceReader: old_process.join(timeout=1.0) self.result_queues[idx] = Queue(maxsize=self.queue_size) + self.command_queues[idx] = Queue(maxsize=8) self.stop_events[idx] = mp.Event() self.latest[idx] = None self._start_sensor_process(idx) return True + def reset_reference_maps(self): + """Ask both sensor workers to replace the optical-flow reference frame.""" + if not self.processes or not self.command_queues: + return False + sent = 0 + for idx, command_queue in enumerate(self.command_queues): + process = self.processes[idx] if idx < len(self.processes) else None + if process is None or not process.is_alive(): + continue + try: + command_queue.put_nowait("reset") + sent += 1 + except queue.Full: + try: + command_queue.get_nowait() + except Exception: + pass + try: + command_queue.put_nowait("reset") + sent += 1 + except queue.Full: + pass + return sent > 0 + def update(self): for idx, result_queue in enumerate(self.result_queues): data = None diff --git a/gripper/ui/visualizer.py b/gripper/ui/visualizer.py index 0740b33..b75e1ec 100644 --- a/gripper/ui/visualizer.py +++ b/gripper/ui/visualizer.py @@ -4,6 +4,7 @@ import math import multiprocessing as mp import os from pathlib import Path +import re import struct import sys import threading @@ -41,7 +42,7 @@ _fix_qt_plugin_path() _QT_IMPORT_ERROR = None try: - from PyQt5.QtCore import QEasingCurve, QEvent, QPoint, QPointF, QRect, QRectF, QSize, QPropertyAnimation, Qt, QTimer + from PyQt5.QtCore import QEasingCurve, QEvent, QPoint, QPointF, QRect, QRectF, QSize, QPropertyAnimation, Qt, QTimer, pyqtSignal from PyQt5.QtGui import QColor, QFont, QIcon, QImage, QLinearGradient, QPainter, QPen, QPixmap, QPolygonF, QRadialGradient, QVector3D from PyQt5.QtWidgets import ( QApplication, @@ -53,6 +54,7 @@ try: QGraphicsOpacityEffect, QGridLayout, QHBoxLayout, + QInputDialog, QLabel, QLineEdit, QListWidget, @@ -78,7 +80,10 @@ except Exception as exc: QGridLayout = QHBoxLayout = QLabel = QPushButton = QSizeGrip = QSizePolicy = QSplitter = None QVBoxLayout = QWidget = None QButtonGroup = QCheckBox = QComboBox = QDialog = QFrame = QGraphicsOpacityEffect = QLineEdit = QListWidget = QMessageBox = QMainWindow = object - QProgressBar = QScrollArea = QSlider = QSpinBox = QStackedWidget = QTextEdit = None + QProgressBar = QScrollArea = QSlider = QSpinBox = QStackedWidget = QTextEdit = QInputDialog = None + + def pyqtSignal(*_args, **_kwargs): + return None try: import pyqtgraph as pg @@ -98,7 +103,18 @@ else: from ..app import build_controller from ..common.paths import PACKAGE_DIR, REPO_ROOT -from ..config.behavior_profiles import grip_behavior_profile, normalize_grip_behavior_mode +from ..config.behavior_profiles import ( + GRIP_BEHAVIOR_LEVEL_MAX, + GRIP_BEHAVIOR_LEVEL_MIN, + builtin_grip_behavior_presets, + clamp_grip_behavior_level, + default_grip_behavior_level, + grip_behavior_level_profile, + grip_behavior_mode_for_level, + grip_behavior_profile, + normalize_grip_behavior_mode, + normalize_grip_behavior_presets, +) from ..sensors.camera_discovery import ( default_sensor_camera_sources, list_camera_ids, @@ -218,7 +234,7 @@ ACTION_TEXT = { "open-wait-stabilizing": "等待稳定基准", "open-wait-armed": "已就绪", "side-trigger-close": "侧边触发闭合", - "closing-continue": "继续闭合", + "closing-continue": "闭合寻找物体", "closing-to-grip": "闭合寻找物体", "object-detected-low-force": "检测到物体", "close-timeout-open": "闭合超时张开", @@ -227,21 +243,25 @@ ACTION_TEXT = { "hold-reference-armed": "记录夹持基准", "adaptive-grip-ramp": "自适应加力", "adaptive-grip-wait": "等待自适应加力", - "release-grip-settle": "法向夹稳保护,侧触可释放", - "release-self-settle": "自运动保护,侧触可释放", - "release-ready": "释放检测就绪", + "release-grip-settle": "夹持保护期 · 侧触可张开", + "release-self-settle": "自运动保护 · 侧触可张开", + "release-ready": "监视外力变化", "release-sudden": "突变外力确认", "release-touch": "轻触变化确认", "release-side_touch": "即时侧触确认", "release-slow_external": "缓推外力确认", - "release-slow_internal": "慢变跟随", + "release-slow_internal": "监视外力变化", "release-watch": "监视外力变化", "release-waiting-sample": "等待新传感器帧", "release-sample-gap": "采样断流保护", "release-invalid-sample": "传感器数据无效", "release-transient-rejected": "短脉冲已拒绝", - "release-settling-turn": "平稳拐点,继续夹持", + "release-settling-turn": "监视外力变化", "release-confirm-expired": "外力确认已过期", + "release-shear_displacement": "Y 偏移确认", + "release-shear_oscillation": "切向振荡确认", + "release-object-lost": "物体脱出", + "release-force-settling": "等待夹持力稳定", "hold-check": "保持检测", "force-change-release-open": "力变化张开", "open-recover-position": "张开位置恢复", @@ -262,29 +282,93 @@ ACTION_TEXT = { "right-sensor-restart": "右传感器重启", "left-sensor-restart-failed": "左传感器重启失败", "right-sensor-restart-failed": "右传感器重启失败", + "sensor-reference-reset": "参考底图已重置", + "sensor-reference-reset-failed": "参考底图重置失败", } -def label_with_cn(value, mapping): - value = str(value or "--") - cn = mapping.get(value) - if cn is None: - for prefix, text in mapping.items(): - if value.startswith((f"{prefix} ", f"{prefix}(")): - cn = text - break - if cn is None: - return value - return f"{value}({cn})" +# Stable / firm release: every open path is off during the contact guard +# (only object-lost still opens), so side touch cannot open there. +HELD_ACTION_TEXT = { + **ACTION_TEXT, + "release-grip-settle": "夹持保护期 · 暂不张开", + "release-self-settle": "自运动保护 · 暂不张开", +} -def shorten_status_text(text, max_len=22): - text = str(text or "--").strip() +def _elide_middle(text, max_len): if len(text) <= max_len: return text - if max_len <= 1: - return text[:max_len] - return text[: max_len - 1] + "…" + head = (max_len - 1) // 2 + return f"{text[:head]}…{text[len(text) - (max_len - 1 - head):]}" + + +def _status_part_text(part, mapping): + code, _, detail = part.strip().partition(" ") + inner = "" + if code.endswith(")") and "(" in code: + code, _, inner = code[:-1].partition("(") + text = mapping.get(code) + if text is None: + return _elide_middle(code, 20) + if inner: + text += f"({mapping.get(f'release-{inner}') or mapping.get(inner) or inner})" + confirm = re.search(r"confirm=(\d+)/(\d+)", detail) + if confirm and int(confirm.group(1)) > 0: + needed = int(confirm.group(2)) + text += f" {min(int(confirm.group(1)), needed) if needed else confirm.group(1)}/{confirm.group(2)}" + pos = re.search(r"pos=(-?\d+)", detail) + if pos: + text += f" @{pos.group(1)}" + return text + + +def status_short_text(value, mapping): + """Chinese short form of a controller state/action; raw text goes in the tooltip.""" + value = str(value or "").strip() + if not value or value == "--": + return "--" + return " · ".join(_status_part_text(part, mapping) for part in value.split(";") if part.strip()) + + +class ActionTextSmoother: + """Keeps the action label readable: the controller flips sub-labels every frame.""" + + SECONDARY_CODES = ("grip-ramp-up", "adaptive-grip-ramp", "adaptive-grip-wait") + URGENT_RE = re.compile(r"confirm=[1-9]|^force-change|^manual-") + + def __init__(self, min_hold=0.6, secondary_hold=1.2): + self.min_hold = min_hold + self.secondary_hold = secondary_hold + self._secondary = {} + self._state = None + self._shown = "--" + self._shown_at = 0.0 + + def update(self, state, action, mapping, now=None): + now = time.monotonic() if now is None else now + primary = [] + urgent = state != self._state + if urgent: + self._secondary.clear() + for part in str(action or "").split(";"): + part = part.strip() + if not part or part == "--": + continue + code = part.partition(" ")[0] + if code in self.SECONDARY_CODES: + self._secondary[code] = (now + self.secondary_hold, _status_part_text(part, mapping)) + continue + urgent = urgent or bool(self.URGENT_RE.search(part)) + primary.append(_status_part_text(part, mapping)) + self._secondary = {k: v for k, v in self._secondary.items() if v[0] > now} + texts = primary + [text for _, text in self._secondary.values() if text not in primary] + text = " · ".join(texts) or "--" + self._state = state + if text != self._shown and (urgent or now - self._shown_at >= self.min_hold): + self._shown = text + self._shown_at = now + return self._shown STYLE = """ @@ -2300,6 +2384,291 @@ class SliderControl(QFrame): return int(self.slider.value()) +class GripLevelSlider(QSlider or object): + """Press anywhere to jump, hold to drag, wheel steps by one.""" + + HANDLE = 18 + STYLE = """ +QSlider::groove:horizontal { + height: 6px; + background: #e7e1e2; + border: 0; + border-radius: 3px; +} +QSlider::sub-page:horizontal { + background: #c83b43; + border: 0; + border-radius: 3px; +} +QSlider::add-page:horizontal { + background: #e7e1e2; + border: 0; + border-radius: 3px; +} +QSlider::handle:horizontal { + background: #ffffff; + border: 2px solid #c83b43; + width: 14px; + height: 14px; + margin: -6px 0; + border-radius: 9px; +} +QSlider::handle:horizontal:hover, QSlider::handle:horizontal:pressed { + background: #fbe9ea; +} +""" + + def __init__(self): + super().__init__(Qt.Horizontal) + self.setObjectName("GripLevelSlider") + self.setStyleSheet(self.STYLE) + self.setMinimumHeight(24) + self.setCursor(Qt.PointingHandCursor) + self.setFocusPolicy(Qt.StrongFocus) + self._dragging = False + self._wheel_accum = 0 + + def x_for_value(self, value, width=None): + width = self.width() if width is None else width + span = max(1, width - self.HANDLE) + t = (value - self.minimum()) / float(max(1, self.maximum() - self.minimum())) + return self.HANDLE / 2.0 + span * t + + def value_at(self, x): + span = max(1, self.width() - self.HANDLE) + t = (x - self.HANDLE / 2.0) / span + value = self.minimum() + t * (self.maximum() - self.minimum()) + return int(round(max(self.minimum(), min(self.maximum(), value)))) + + def begin_drag(self, x): + self._dragging = True + self.setSliderDown(True) + self.setValue(self.value_at(x)) + + def drag_to(self, x): + if self._dragging: + self.setValue(self.value_at(x)) + + def end_drag(self): + if self._dragging: + self._dragging = False + self.setSliderDown(False) + + def mousePressEvent(self, event): + if event.button() != Qt.LeftButton: + return super().mousePressEvent(event) + self.setFocus(Qt.MouseFocusReason) + self.begin_drag(event.x()) + event.accept() + + def mouseMoveEvent(self, event): + if not self._dragging: + return super().mouseMoveEvent(event) + self.drag_to(event.x()) + event.accept() + + def mouseReleaseEvent(self, event): + if not self._dragging: + return super().mouseReleaseEvent(event) + self.end_drag() + event.accept() + + def wheelEvent(self, event): + delta = event.angleDelta().y() or event.angleDelta().x() + self._wheel_accum += delta + steps = int(self._wheel_accum / 120) + if steps: + self._wheel_accum -= steps * 120 + self.setValue(self.value() + steps * self.singleStep()) + event.accept() + + +class GripLevelScale(QFrame): + """Preset ticks drawn under the stability slider; click near a tick to jump to it.""" + + presetClicked = pyqtSignal(int) + + def __init__(self, slider): + super().__init__() + self.slider = slider + self.presets = [] + self.setMinimumHeight(40) + self.setCursor(Qt.PointingHandCursor) + + def set_presets(self, presets): + self.presets = list(presets) + self.update() + + def _x_for(self, level): + return self.slider.x_for_value(level, self.width()) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing, True) + font = QFont(self.font()) + font.setPointSizeF(max(7.0, font.pointSizeF() - 1)) + painter.setFont(font) + metrics = painter.fontMetrics() + width = self.width() + taken = {True: [], False: []} + for preset in self.presets: + builtin = bool(preset.get("builtin")) + x = self._x_for(preset["level"]) + color = QColor("#b52e38") if builtin else QColor("#667085") + painter.setPen(QPen(color, 2 if builtin else 1)) + painter.drawLine(QPointF(x, 0), QPointF(x, 6 if builtin else 5)) + text = str(preset["name"]) + text_width = metrics.horizontalAdvance(text) + left = min(max(0.0, x - text_width / 2.0), max(0.0, width - text_width)) + right = left + text_width + if any(left < end + 4 and right + 4 > start for start, end in taken[builtin]): + continue + taken[builtin].append((left, right)) + top = 8 if builtin else 8 + metrics.height() + painter.setPen(color) + painter.drawText(QRectF(left, top, text_width + 1, metrics.height()), Qt.AlignLeft, text) + painter.end() + + def mousePressEvent(self, event): + if event.button() != Qt.LeftButton: + return super().mousePressEvent(event) + if self.presets: + nearest = min(self.presets, key=lambda preset: abs(self._x_for(preset["level"]) - event.x())) + if abs(self._x_for(nearest["level"]) - event.x()) <= 12: + self.presetClicked.emit(int(nearest["level"])) + event.accept() + return + self.slider.begin_drag(event.x()) + event.accept() + + def mouseMoveEvent(self, event): + self.slider.drag_to(event.x()) + event.accept() + + def mouseReleaseEvent(self, event): + self.slider.end_drag() + event.accept() + + def wheelEvent(self, event): + self.slider.wheelEvent(event) + + +class GripLevelControl(QFrame): + """Stability slider 0..100 with preset ticks, parameter summary and preset buttons.""" + + levelChanged = pyqtSignal(int) + + def __init__(self, level): + super().__init__() + self.setObjectName("ControlRow") + layout = QGridLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + layout.setHorizontalSpacing(8) + layout.setVerticalSpacing(4) + low_label = QLabel("灵敏") + low_label.setObjectName("Subtle") + high_label = QLabel("稳定") + high_label.setObjectName("Subtle") + self.value_label = QLabel("--") + self.value_label.setObjectName("ControlValue") + self.slider = GripLevelSlider() + self.slider.setRange(GRIP_BEHAVIOR_LEVEL_MIN, GRIP_BEHAVIOR_LEVEL_MAX) + self.slider.setSingleStep(1) + self.slider.setPageStep(5) + self.slider.setValue(clamp_grip_behavior_level(level)) + self.scale = GripLevelScale(self.slider) + self.scale.presetClicked.connect(self.slider.setValue) + self.summary_label = QLabel() + self.summary_label.setObjectName("Subtle") + self.summary_label.setWordWrap(True) + self.save_button = AnimatedButton("保存为新挡位") + self.save_button.setObjectName("ManualButton") + self.save_button.setToolTip("把当前拖动位置保存为自定义挡位,立即写入 gripper.json") + self.delete_button = AnimatedButton("删除挡位") + self.delete_button.setObjectName("ManualButton") + self.delete_button.setToolTip("删除当前选中的自定义挡位(默认三挡不能删除)") + buttons = QHBoxLayout() + buttons.setSpacing(6) + buttons.addWidget(self.save_button) + buttons.addWidget(self.delete_button) + buttons.addStretch(1) + layout.addWidget(low_label, 0, 0) + layout.addWidget(self.slider, 0, 1) + layout.addWidget(high_label, 0, 2) + layout.addWidget(self.value_label, 0, 3) + layout.addWidget(self.scale, 1, 1) + layout.addWidget(self.summary_label, 2, 0, 1, 4) + layout.addLayout(buttons, 3, 0, 1, 4) + layout.setColumnStretch(1, 1) + self.slider.valueChanged.connect(self._on_value_changed) + self._update_value_label(self.slider.value()) + + def _update_value_label(self, value): + self.value_label.setText(f"{int(value)}") + + def _on_value_changed(self, value): + self._update_value_label(value) + self.levelChanged.emit(int(value)) + + def level(self): + return int(self.slider.value()) + + def set_level(self, level): + level = clamp_grip_behavior_level(level) + was_blocked = self.slider.blockSignals(True) + self.slider.setValue(level) + self.slider.blockSignals(was_blocked) + self._update_value_label(level) + + def set_presets(self, presets): + self.scale.set_presets(presets) + + def set_summary(self, text): + self.summary_label.setText(text) + + +class PercentSliderControl(QFrame): + """0..100% slider with press-to-jump, drag and wheel, for settings pages.""" + + valueChanged = pyqtSignal(int) + + def __init__(self, value, minimum=0, maximum=100): + super().__init__() + self.setObjectName("ControlRow") + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 4, 8, 4) + layout.setSpacing(8) + self.slider = GripLevelSlider() + self.slider.setRange(minimum, maximum) + self.slider.setSingleStep(1) + self.slider.setPageStep(5) + self.value_label = QLabel("--") + self.value_label.setObjectName("ControlValue") + self.value_label.setMinimumWidth(40) + layout.addWidget(self.slider, 1) + layout.addWidget(self.value_label) + self.set_value(value) + self.slider.valueChanged.connect(self._on_value_changed) + + def _on_value_changed(self, value): + self.value_label.setText(f"{int(value)}%") + self.valueChanged.emit(int(value)) + + def value(self): + return int(self.slider.value()) + + def set_value(self, value): + try: + value = int(round(float(value))) + except (TypeError, ValueError): + return + value = max(self.slider.minimum(), min(self.slider.maximum(), value)) + was_blocked = self.slider.blockSignals(True) + self.slider.setValue(value) + self.slider.blockSignals(was_blocked) + self.value_label.setText(f"{value}%") + + class CameraDiscoveryDialog(QDialog): def __init__(self, viewer): super().__init__(viewer) @@ -2441,11 +2810,15 @@ class CameraDiscoveryDialog(QDialog): class VisualSettingsDialog(QDialog): + # Managed by the stability bar, not shown as a raw field. + HIDDEN_CONFIG_KEYS = {"grip_behavior_presets"} CONFIG_GROUPS = [ ( "行为模式", [ "grip_behavior_mode", + "grip_behavior_level", + "speed", ], ), ( @@ -2469,7 +2842,6 @@ class VisualSettingsDialog(QDialog): [ "open_pos", "close_pos", - "speed", "accel", "decel", "release_open_speed", @@ -2558,6 +2930,7 @@ class VisualSettingsDialog(QDialog): "release_shear_oscillation_min_flips", "release_shear_oscillation_amplitude", "release_shear_oscillation_confirm_samples", + "release_shear_oscillation_min_seconds", "release_guard_fast_paths", "release_shear_axes", "release_shear_displacement", @@ -2600,7 +2973,7 @@ class VisualSettingsDialog(QDialog): "serial_timeout": "串口超时", "open_pos": "张开位置", "close_pos": "闭合位置", - "speed": "默认速度", + "speed": "速度", "accel": "加速度", "decel": "减速度", "release_open_speed": "释放张开速度", @@ -2636,6 +3009,7 @@ class VisualSettingsDialog(QDialog): "force_ramp_interval": "加力间隔", "grip_settle_seconds": "夹持稳定时间", "grip_behavior_mode": "开合行为预设", + "grip_behavior_level": "稳定度", "adaptive_grip_enabled": "启用自适应夹持", "adaptive_shear_start_force": "自适应起始切向力", "adaptive_shear_full_force": "自适应满量程切向力", @@ -2670,6 +3044,7 @@ class VisualSettingsDialog(QDialog): "release_shear_oscillation_min_flips": "切向振荡最少换向次数", "release_shear_oscillation_amplitude": "切向振荡峰峰值门槛 (N)", "release_shear_oscillation_confirm_samples": "切向振荡确认帧数", + "release_shear_oscillation_min_seconds": "切向振荡至少持续 (s)", "release_guard_fast_paths": "夹紧保护期内屏蔽振荡/侧触张开", "release_shear_axes": "张开判断切向轴 (空=跟随控制轴/y/x/xy)", "release_shear_displacement": "Y 拟合偏移张开阈值 (N, 0=关闭)", @@ -2734,6 +3109,14 @@ class VisualSettingsDialog(QDialog): self.config_data = {} if "grip_behavior_mode" not in self.config_data: self.config_data["grip_behavior_mode"] = "sensitive" + mode = normalize_grip_behavior_mode(self.config_data.get("grip_behavior_mode")) + level = self.config_data.get("grip_behavior_level") + if level is None or grip_behavior_mode_for_level(level) != mode: + level = default_grip_behavior_level(mode) + self.config_data["grip_behavior_level"] = clamp_grip_behavior_level(level) + self._custom_presets = normalize_grip_behavior_presets( + self.config_data.get("grip_behavior_presets") + ) def _build_ui(self): outer = QVBoxLayout(self) @@ -2779,7 +3162,7 @@ class VisualSettingsDialog(QDialog): other_keys = [ key for key in self.config_data - if key not in used_keys + if key not in used_keys and key not in self.HIDDEN_CONFIG_KEYS ] if other_keys: self._add_page("其他", self._build_config_page("其他", other_keys)) @@ -2802,6 +3185,7 @@ class VisualSettingsDialog(QDialog): self.nav.currentRowChanged.connect(self.stack.setCurrentIndex) self.nav.setCurrentRow(0) + self._init_level_widgets() def _add_page(self, title, widget): self.nav.addItem(title) @@ -2912,28 +3296,25 @@ class VisualSettingsDialog(QDialog): def _make_control(self, key, value): if key == "grip_behavior_mode": - from gripper.config.behavior_profiles import ( - GRIP_BEHAVIOR_LABELS, - GRIP_BEHAVIOR_MODES, - normalize_grip_behavior_mode, - ) - control = QComboBox() - try: - current = normalize_grip_behavior_mode(value) - except Exception: - current = "sensitive" - for mode in GRIP_BEHAVIOR_MODES: - control.addItem(f"{GRIP_BEHAVIOR_LABELS[mode]} ({mode})", mode) - index = control.findData(current) - control.setCurrentIndex(max(0, index)) control.setToolTip( - "灵敏:当前跟手调参,更容易开合;" - "较稳定:明显提高门槛与确认;" - "十分稳定:约高一个数量级,故意大力开合才响应。" - "切换后会立刻填入对应预设参数,保存后生效。" + "默认三挡:灵敏 / 较稳定 / 十分稳定,另可选自定义挡位。" + "选择后稳定度条跳到对应位置并填入参数,保存后生效。" ) - control.currentIndexChanged.connect(self._on_behavior_mode_changed) + self.controls[key] = control + return control + if key == "grip_behavior_level": + control = GripLevelControl(value) + control.setToolTip( + "拖动整体调整开合行为:0 灵敏,50 较稳定,100 十分稳定。" + "50 以下使用灵敏算法并按比例加大力度与门槛;50 及以上使用稳定算法。" + "速度单独在下方调节,不随稳定度变化。" + ) + self.controls[key] = control + return control + if key == "speed": + control = PercentSliderControl(value) + control.setToolTip("夹爪闭合 / 默认运动速度,独立于稳定度;选择挡位时套用该挡位保存的速度。") self.controls[key] = control return control if key == "port": @@ -2972,6 +3353,12 @@ class VisualSettingsDialog(QDialog): def _control_value(self, key, original): control = self.controls[key] + if key == "grip_behavior_mode": + return grip_behavior_mode_for_level(self._current_level()) + if isinstance(control, GripLevelControl): + return control.level() + if isinstance(control, PercentSliderControl): + return control.value() if isinstance(control, QComboBox): selected = control.currentData() if control.currentIndex() >= 0 and selected is not None: @@ -2999,7 +3386,15 @@ class VisualSettingsDialog(QDialog): def _set_control_value(self, key, value): control = self.controls.get(key) - if control is None: + if control is None or key == "grip_behavior_mode": + return + if isinstance(control, GripLevelControl): + control.set_level(value) + self._refresh_level_widgets() + return + if isinstance(control, PercentSliderControl): + control.set_value(value) + self._refresh_level_widgets() return if isinstance(control, QCheckBox): control.setChecked(bool(value)) @@ -3017,25 +3412,207 @@ class VisualSettingsDialog(QDialog): elif isinstance(control, QLineEdit): control.setText("null" if value is None else str(value)) - def _on_behavior_mode_changed(self, _index=None): - control = self.controls.get("grip_behavior_mode") - if not isinstance(control, QComboBox): - return - mode = control.currentData() - if not mode: - return - from gripper.config.behavior_profiles import grip_behavior_profile + def _level_control(self): + control = self.controls.get("grip_behavior_level") + return control if isinstance(control, GripLevelControl) else None + def _preset_combo(self): + control = self.controls.get("grip_behavior_mode") + return control if isinstance(control, QComboBox) else None + + def _current_level(self): + control = self._level_control() + if control is not None: + return control.level() + return clamp_grip_behavior_level(self.config_data.get("grip_behavior_level")) + + def _speed_control(self): + control = self.controls.get("speed") + return control if isinstance(control, PercentSliderControl) else None + + def _current_speed(self): + control = self._speed_control() + if control is not None: + return control.value() try: - profile = grip_behavior_profile(mode) - except Exception as exc: - QMessageBox.warning(self, "模式无效", str(exc)) + return int(self.config_data.get("speed")) + except (TypeError, ValueError): + return None + + def _all_presets(self): + return builtin_grip_behavior_presets() + [ + {**item, "builtin": False} for item in self._custom_presets + ] + + @staticmethod + def _preset_matches(preset, level, speed): + return preset["level"] == level and preset.get("speed") in (None, speed) + + def _init_level_widgets(self): + level_control = self._level_control() + combo = self._preset_combo() + if level_control is None or combo is None: return + level_control.levelChanged.connect(self._on_level_changed) + level_control.save_button.clicked.connect(self._save_level_preset) + level_control.delete_button.clicked.connect(self._delete_level_preset) + combo.currentIndexChanged.connect(self._on_preset_selected) + speed_control = self._speed_control() + if speed_control is not None: + speed_control.valueChanged.connect(self._on_speed_changed) + self._refresh_level_widgets() + + def _on_speed_changed(self, value): + self.config_data["speed"] = int(value) + self._refresh_level_widgets() + + def _refresh_level_widgets(self, selected_name=None): + """Rebuild the preset list and select the preset matching the bar.""" + level_control = self._level_control() + combo = self._preset_combo() + if level_control is None or combo is None: + return + presets = self._all_presets() + level = level_control.level() + speed = self._current_speed() + if selected_name is None and combo.currentIndex() >= 0: + selected_name = combo.currentData() + names = [preset["name"] for preset in presets if self._preset_matches(preset, level, speed)] + if selected_name not in names: + selected_name = names[0] if names else None + was_blocked = combo.blockSignals(True) + combo.clear() + for preset in presets: + suffix = "" if preset["builtin"] else " · 自定义" + speed_text = "" if preset.get("speed") is None else f" · 速度 {preset['speed']}%" + combo.addItem(f"{preset['name']}({preset['level']}{speed_text}){suffix}", preset["name"]) + if selected_name is None: + # Qt 5.15 does not paint placeholder text on non-editable combos. + speed_text = "" if speed is None else f" · 速度 {speed}%" + combo.addItem(f"当前位置({level}{speed_text})· 未保存", None) + combo.setCurrentIndex(combo.count() - 1) + else: + combo.setCurrentIndex(combo.findData(selected_name)) + combo.blockSignals(was_blocked) + level_control.set_presets(presets) + level_control.delete_button.setEnabled( + any(item["name"] == selected_name for item in self._custom_presets) + ) + level_control.set_summary(self._level_summary(level)) + + def _level_summary(self, level): + profile = grip_behavior_level_profile(level) + mode = grip_behavior_mode_for_level(level) + speed = self._current_speed() + parts = [ + "灵敏算法" if mode == "sensitive" else "稳定算法(恒力夹持)", + f"速度 {profile['speed'] if speed is None else speed}%", + ] + if mode == "sensitive": + parts.append(f"夹持力 {profile['adaptive_force_min']}–{profile['adaptive_force_max']}% 自适应") + else: + parts.append(f"夹持力 {profile['close_force']}% 恒力") + parts.append(f"保护期 {profile['release_min_hold_seconds']:.2f} s") + if mode == "sensitive": + parts.append(f"确认 {profile['release_confirm_samples']} 帧") + else: + parts.append(f"张开阈值 Y 偏移 {profile['release_shear_displacement']:.2f} N") + return " · ".join(parts) + + def _on_level_changed(self, level): + profile = grip_behavior_level_profile(level) for key, value in profile.items(): + if key == "speed": + continue if key in self.controls: self._set_control_value(key, value) self.config_data[key] = value - self.config_data["grip_behavior_mode"] = mode + self.config_data["grip_behavior_mode"] = grip_behavior_mode_for_level(level) + self.config_data["grip_behavior_level"] = clamp_grip_behavior_level(level) + self._refresh_level_widgets() + + def _on_preset_selected(self, _index=None): + combo = self._preset_combo() + level_control = self._level_control() + if combo is None or level_control is None: + return + name = combo.currentData() + if name is None: + return + preset = next((item for item in self._all_presets() if item["name"] == name), None) + if preset is None: + return + level_control.set_level(preset["level"]) + if preset.get("speed") is not None: + self._set_control_value("speed", preset["speed"]) + self.config_data["speed"] = preset["speed"] + self._on_level_changed(preset["level"]) + self._refresh_level_widgets(selected_name=name) + + def _persist_presets(self): + """Write only grip_behavior_presets, keeping other unsaved edits pending.""" + try: + with open(self.config_path, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + data = {} + except Exception: + data = {} + data["grip_behavior_presets"] = list(self._custom_presets) + self.config_path.parent.mkdir(parents=True, exist_ok=True) + with open(self.config_path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + f.write("\n") + self.config_data["grip_behavior_presets"] = list(self._custom_presets) + + def _save_level_preset(self): + level = self._current_level() + speed = self._current_speed() + desc = f"稳定度 {level}" + ("" if speed is None else f"、速度 {speed}%") + name, ok = QInputDialog.getText(self, "保存为新挡位", f"{desc} 的挡位名称:") + name = str(name or "").strip() + if not ok or not name: + return + if any(item["name"] == name for item in builtin_grip_behavior_presets()): + QMessageBox.warning(self, "名称冲突", f"“{name}”是默认挡位名称,请换一个。") + return + preset = {"name": name, "level": level} + if speed is not None: + preset["speed"] = speed + existing = next((item for item in self._custom_presets if item["name"] == name), None) + if existing is not None: + answer = QMessageBox.question( + self, "覆盖挡位", f"已存在自定义挡位“{name}”({existing['level']}),覆盖为{desc}?" + ) + if answer != QMessageBox.Yes: + return + existing.clear() + existing.update(preset) + else: + self._custom_presets.append(preset) + self._custom_presets.sort(key=lambda item: (item["level"], item["name"])) + try: + self._persist_presets() + except Exception as exc: + QMessageBox.warning(self, "保存失败", f"无法写入自定义挡位:\n{exc}") + return + self._refresh_level_widgets(selected_name=name) + + def _delete_level_preset(self): + combo = self._preset_combo() + name = combo.currentData() if combo is not None else None + if not any(item["name"] == name for item in self._custom_presets): + return + answer = QMessageBox.question(self, "删除挡位", f"删除自定义挡位“{name}”?") + if answer != QMessageBox.Yes: + return + self._custom_presets = [item for item in self._custom_presets if item["name"] != name] + try: + self._persist_presets() + except Exception as exc: + QMessageBox.warning(self, "删除失败", f"无法写入自定义挡位:\n{exc}") + return + self._refresh_level_widgets() def _search_serial_ports(self): control = self.controls.get("port") @@ -3241,6 +3818,7 @@ class GripperDemo02Viewer(QMainWindow): QTimer.singleShot(0, lambda: self.search_main_serial_ports(silent=True)) QTimer.singleShot(50, lambda: self.auto_assign_sensor_cameras(silent=True)) + self._action_smoother = ActionTextSmoother() self.timer = QTimer(self) self.timer.timeout.connect(self._refresh) self.timer.start(33) @@ -3797,9 +4375,9 @@ class GripperDemo02Viewer(QMainWindow): status_layout = QVBoxLayout(self.status_panel) status_layout.setContentsMargins(10, 6, 10, 6) status_layout.setSpacing(1) - self.status_chip = QLabel("state=--") + self.status_chip = QLabel("--") self.status_chip.setObjectName("StatusMain") - self.action_label = QLabel("action=--") + self.action_label = QLabel("--") self.action_label.setObjectName("StatusSub") status_layout.addWidget(self.status_chip) status_layout.addWidget(self.action_label) @@ -3997,7 +4575,17 @@ class GripperDemo02Viewer(QMainWindow): self.right_sensor_reset_button.clicked.connect( lambda: self.send_manual_command("restart_right_sensor") ) - sensor_layout.addWidget(sensor_title, 0, 0, 1, 8, Qt.AlignLeft | Qt.AlignTop) + self.sensor_reference_reset_button = AnimatedButton("重置底图") + self.sensor_reference_reset_button.setObjectName("ManualButton") + self.sensor_reference_reset_button.setToolTip( + "用当前画面更换左右传感器的光流参考底图(不断开相机)" + ) + self.sensor_reference_reset_button.setEnabled(False) + self.sensor_reference_reset_button.clicked.connect( + lambda: self.send_manual_command("reset_sensor_reference") + ) + sensor_layout.addWidget(sensor_title, 0, 0, 1, 6, Qt.AlignLeft | Qt.AlignVCenter) + sensor_layout.addWidget(self.sensor_reference_reset_button, 0, 6, 1, 2) sensor_layout.addWidget(port_label, 1, 0) sensor_layout.addWidget(self.main_port_combo, 1, 1, 1, 2) sensor_layout.addWidget(self.main_port_search_button, 1, 3) @@ -4182,6 +4770,8 @@ class GripperDemo02Viewer(QMainWindow): self.left_sensor_reset_button.setEnabled(sensor_restart_enabled) if hasattr(self, "right_sensor_reset_button"): self.right_sensor_reset_button.setEnabled(sensor_restart_enabled) + if hasattr(self, "sensor_reference_reset_button"): + self.sensor_reference_reset_button.setEnabled(sensor_restart_enabled) def set_control_mode(self, mode): if mode not in ("smart", "manual"): @@ -4492,12 +5082,13 @@ class GripperDemo02Viewer(QMainWindow): action = status.get("action", "--") unit = status.get("normal_unit", "N") shear_unit = status.get("shear_unit", "N") - self.status_chip.setText( - shorten_status_text(f"state={label_with_cn(state, STATE_TEXT)}", 28) - ) + self.status_chip.setText(status_short_text(state, STATE_TEXT)) + self.status_chip.setToolTip(f"state={state}") + held = normalize_grip_behavior_mode(status.get("grip_behavior_mode")) != "sensitive" self.action_label.setText( - shorten_status_text(f"action={label_with_cn(action, ACTION_TEXT)}", 28) + self._action_smoother.update(state, action, HELD_ACTION_TEXT if held else ACTION_TEXT) ) + self.action_label.setToolTip(f"action={action}") self._sync_mode_controls(status.get("grip_behavior_mode")) self.manual_hold_button.setText( "释放" if bool(status.get("manual_hold_active")) else "保持"