稳定度配置条与自定义挡位、独立速度调节;状态文字中文简写与平滑;灵敏挡切向振荡判据收紧;新增重置底图按钮。

- 设置框新增 0~100 稳定度配置条(灵敏 0 / 较稳定 50 / 十分稳定 100 为内置挡位,中间按比例插值),支持滚轮/左键拖动,可另存/删除自定义挡位;速度改为独立滑块。
- 状态栏改为中文简写(悬停显示原文);夹持保护期文字按挡位区分;合并同义子状态并加最短停留,避免逐帧跳动。
- 灵敏挡切向振荡:0.5 s 内换向 4 次,并须持续至少 0.15 s 才张开(新参数 release_shear_oscillation_min_seconds)。
- 传感器控制区新增「重置底图」:左右传感器一起用当前画面更换光流参考底图(orisys Sensor.reset),不断开相机。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cheng
2026-10-08 13:34:30 +08:00
parent 3507a2d313
commit 33578fa83e
7 changed files with 842 additions and 70 deletions
+10
View File
@@ -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 追加写入
+102 -2
View File
@@ -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)
+4
View File
@@ -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")
+9
View File
@@ -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)
+6 -1
View File
@@ -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():
+53
View File
@@ -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
+658 -67
View File
@@ -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 "保持"