feat: update hand gestures and direct locomotion

This commit is contained in:
2026-08-11 14:35:51 +08:00
parent 89a8c3418f
commit 4cceebfa5f
11 changed files with 469 additions and 71 deletions

6
.gitignore vendored
View File

@@ -14,6 +14,12 @@ python/build/
*.egg-info/ *.egg-info/
*.so *.so
# Final datasets stay local and must never be committed. Keep only the two
# tracked instructions/ignore files inside the destination directory.
Data_Get/*
!Data_Get/.gitignore
!Data_Get/README.md
# Local deployment and editor leftovers. # Local deployment and editor leftovers.
.deploy-*/ .deploy-*/
.DS_Store .DS_Store

View File

@@ -45,10 +45,12 @@ TOML 解析检查。若还要核对外置 OmniSocketGo 版本,可设置任务
- 左 Z + 右 C 连续 3 秒:开始遥操;再次连续 3 秒:结束并限速回 Home。 - 左 Z + 右 C 连续 3 秒:开始遥操;再次连续 3 秒:结束并限速回 Home。
若 Hub 尚未运行,本次长按只检测一次并立即结束,不发送控制数据;服务器启动后须先 若 Hub 尚未运行,本次长按只检测一次并立即结束,不发送控制数据;服务器启动后须先
稳定松开,再重新长按 3 秒。pending 和活动会话都不会自动重试或自动恢复。 稳定松开,再重新长按 3 秒。pending 和活动会话都不会自动重试或自动恢复。
- 右 C + 左摇杆上下:HBWALK 前进/后退。 - 遥操开启且双摇杆先回中过一次后,左摇杆上下直接控制 HBWALK 前进/后退。
- 左 Z + 右摇杆左右:HBWALK 原地转向。 - 遥操开启且双摇杆先回中过一次后,右摇杆左右直接控制 HBWALK 原地转向。
- 右 B 连续 1 秒:右手进入厂商“单食指”姿态;松开至少 0.5 秒后再次连续 - 右 B 连续 1 秒:左右手同时进入厂商“单食指”姿态;松开至少 0.5 秒后再次连续
1 秒退出。手指动作继续受 `400 units/s` 限速。 1 秒退出。手指动作继续受 `400 units/s` 限速。
- 按住右 A:右手限速摆到目标位置 `[428,735,500,77,77,72]`;松开 A 后恢复
原有右手输入。A 在每个新遥操会话中必须先稳定松开 0.5 秒,防止跨会话误触。
- 左摇杆按下(L3)连续 1 秒:开始数采;松开至少 0.5 秒后再次连续 1 秒: - 左摇杆按下(L3)连续 1 秒:开始数采;松开至少 0.5 秒后再次连续 1 秒:
结束并保存。只有遥操已开启时才接受;Z+C 结束遥操或安全解除会自动结束数采。 结束并保存。只有遥操已开启时才接受;Z+C 结束遥操或安全解除会自动结束数采。

View File

@@ -628,9 +628,9 @@ python3 -m json.tool Data_Get/sync_status.json
2. 长按左 Z + 右 C 3 秒启动; 2. 长按左 Z + 右 C 3 秒启动;
3. 先验证双臂小幅单关节跟随; 3. 先验证双臂小幅单关节跟随;
4. 小幅验证左右扳机对应的双手; 4. 小幅验证左右扳机对应的双手;
5. 长按右 B 1 秒验证右手单食指,再松开 0.5 秒、长按 1 秒退出; 5. 长按右 B 1 秒验证左右手同时单食指,再松开 0.5 秒、长按 1 秒退出;
6. 右 C + 左摇杆小幅验证前后,松开任一输入应立即零速; 6. 确认双摇杆回中后,直接小幅推动左摇杆验证前后,回中应立即零速;
7. 左 Z + 右摇杆小幅验证原地转向; 7. 直接小幅横推右摇杆验证原地转向,回中应立即零速;
8. L3 长按 1 秒开始短数采,再次长按 1 秒结束; 8. L3 长按 1 秒开始短数采,再次长按 1 秒结束;
9. 长按 Z+C 3 秒结束遥操,确认限速回新机器人的 Home。 9. 长按 Z+C 3 秒结束遥操,确认限速回新机器人的 Home。
@@ -741,4 +741,3 @@ EAI:
数采/VERIFIED: 数采/VERIFIED:
机器人临时副本回收: 机器人临时副本回收:
``` ```

View File

@@ -839,7 +839,7 @@ mode = monitor-only
| 左右扳机 BrainCo 手控 | 5003 原始 `hand.position` 存在;当前禁止武装 | | 左右扳机 BrainCo 手控 | 5003 原始 `hand.position` 存在;当前禁止武装 |
| 右 B 长按 1 秒单食指 | 5003 原始 B 键存在;当前禁止武装 | | 右 B 长按 1 秒单食指 | 5003 原始 B 键存在;当前禁止武装 |
| Z+C 长按 3 秒启动/停止、STOP 后限速 Home | 改由机器人本地计时;断流可锁存按键,当前禁止武装 | | Z+C 长按 3 秒启动/停止、STOP 后限速 Home | 改由机器人本地计时;断流可锁存按键,当前禁止武装 |
| C + 左摇杆行走、Z + 右摇杆转向 | 代码路径存在;当前必须 `enabled=false` | | 左摇杆直接行走、右摇杆直接转向 | 代码路径存在;当前必须 `enabled=false` |
| xTELE 5001 处理后的其他六维组合手势 | 不保留;机器人只订阅 5003 | | xTELE 5001 处理后的其他六维组合手势 | 不保留;机器人只订阅 5003 |
| OmniSocket session ID、Peer 校验、300 ms 晚包过滤 | 不使用 | | OmniSocket session ID、Peer 校验、300 ms 晚包过滤 | 不使用 |
| 公网 Hub、EAI OmniSocket sender | 不需要 | | 公网 Hub、EAI OmniSocket sender | 不需要 |
@@ -855,7 +855,7 @@ Hub 模式,不是本节的“完全无 Hub”直连。
因此纯 ZMQ 模式断网后: 因此纯 ZMQ 模式断网后:
- 最后一帧双臂和手目标仍会以桥的周期重复发布; - 最后一帧双臂和手目标仍会以桥的周期重复发布;
- 如果行走仍启用且最后一帧是非零速度组合,非零 `/hric/robot/cmd_vel` 也会持续发布; - 如果行走仍启用且最后一帧摇杆速度非零,非零 `/hric/robot/cmd_vel` 也会持续发布;
- 如果最后一帧仍按住 Z+C,本地 3 秒计时会在没有新数据时继续,可能在断网后武装或停止; - 如果最后一帧仍按住 Z+C,本地 3 秒计时会在没有新数据时继续,可能在断网后武装或停止;
- 网络恢复后会直接继续当前 armed 会话,不要求重新 Z+C; - 网络恢复后会直接继续当前 armed 会话,不要求重新 Z+C;
- `connected=true` 不能证明数据仍然新鲜。 - `connected=true` 不能证明数据仍然新鲜。
@@ -914,8 +914,8 @@ systemctl --user enable --now tg3-omnisocket-sender.service
5. 松开两个扳机,长按左 Z + 右 C 3 秒启动; 5. 松开两个扳机,长按左 Z + 右 C 3 秒启动;
6. 先做小幅单关节跟随,再逐渐扩大动作; 6. 先做小幅单关节跟随,再逐渐扩大动作;
7. 分别小幅扣左右扳机,验证双手方向、范围和限速; 7. 分别小幅扣左右扳机,验证双手方向、范围和限速;
8. 保持右摇杆回中,连续长按右 B 1 秒,确认右手以 `400 units/s` 限速进入厂商 8. 保持右摇杆回中,连续长按右 B 1 秒,确认左右手都以 `400 units/s` 限速进入厂家
“单食指”姿态;松开至少 0.5 秒后再次长按 1 秒,确认退出并恢复正常右手跟随; “单食指”姿态;松开至少 0.5 秒后再次长按 1 秒,确认退出并恢复双手正常跟随;
9. 仅在 OmniSocket/5001 合并模式下:先停止并确认 `armed=false`,按飞书指南逐个选择 9. 仅在 OmniSocket/5001 合并模式下:先停止并确认 `armed=false`,按飞书指南逐个选择
其他手势组合键;检查 EAI 其他手势组合键;检查 EAI
`status.json` 的 `command_frames_accepted` 增长,并在 EAI 本机 5001 抓帧确认处理后的 `status.json` 的 `command_frames_accepted` 增长,并在 EAI 本机 5001 抓帧确认处理后的
@@ -923,11 +923,9 @@ systemctl --user enable --now tg3-omnisocket-sender.service
10. 仅在 OmniSocket/5001 合并模式下:再次武装后检查 `command_hand_merges` 增长和机器人 10. 仅在 OmniSocket/5001 合并模式下:再次武装后检查 `command_hand_merges` 增长和机器人
`iarm_hand_position` 为六维, `iarm_hand_position` 为六维,
再只做小幅动作,逐个验证所需的其他组合手势方向和限速; 再只做小幅动作,逐个验证所需的其他组合手势方向和限速;
11. 仅在 `[locomotion].enabled=true` 且相应链路断流保护已经验证后:保持右 C,把左摇杆 11. 仅在 `[locomotion].enabled=true` 且相应链路断流保护已经验证后:先确认双摇杆回中,
小幅向前推出死区,确认下一个 50 Hz 周期立即响应;松 C,确认 再把左摇杆小幅向前推出死区,确认下一个 50 Hz 周期立即响应;回中后确认立即零速停止;
立即零速停止。再次按 C/推杆也应立即响应,不再等待 3 秒; 12. 在同一行走前提下,直接把右摇杆小幅横推,确认机器人原地转向;回中应立即清零角速度;
12. 在同一行走前提下,保持左 Z,把右摇杆小幅横推,确认机器人原地转向;松开任一输入
应立即清零角速度;
13. 再次长按 Z+C 停止,观察双臂限速回到新机器人保存的 Home; 13. 再次长按 Z+C 停止,观察双臂限速回到新机器人保存的 Home;
14. 仅在 OmniSocket 模式、机器人已静止且急停可用时测试 Hub 断线:EAI sender 的 KCP 14. 仅在 OmniSocket 模式、机器人已静止且急停可用时测试 Hub 断线:EAI sender 的 KCP
反馈超时约为 500 ms,触发后会作废 session;机器人若持续约 2 秒收不到活动帧,会另行 反馈超时约为 500 ms,触发后会作废 session;机器人若持续约 2 秒收不到活动帧,会另行

View File

@@ -13,7 +13,7 @@ TS1P 同构臂
-> 天工 3.0 双臂 -> 天工 3.0 双臂
-> /left_hand/set_motor_multi + /right_hand/set_motor_multi -> /left_hand/set_motor_multi + /right_hand/set_motor_multi
-> 天工 3.0 BrainCo Revo2 双灵巧手 -> 天工 3.0 BrainCo Revo2 双灵巧手
-> /hric/robot/cmd_vel(右 C + 左摇杆前后 / 左 Z + 右摇杆转向;50 Hz TwistStamped) -> /hric/robot/cmd_vel(左摇杆前后 / 右摇杆转向;50 Hz TwistStamped)
-> 天工 3.0 HBWALK 行走 -> 天工 3.0 HBWALK 行走
``` ```
@@ -29,9 +29,9 @@ TS1P 同构臂
- 回 Home 过程中用新的 Z+C 3 秒会话可取消回位并重新启动遥操。 - 回 Home 过程中用新的 Z+C 3 秒会话可取消回位并重新启动遥操。
- 本地桥控制左右各 7 个手臂关节、BrainCo Revo2 双灵巧手和 HBWALK - 本地桥控制左右各 7 个手臂关节、BrainCo Revo2 双灵巧手和 HBWALK
前后/转向;不控制腰和头。 前后/转向;不控制腰和头。
- 行走:遥操已启动后,右 `C` + 左摇杆上下控制前后,左 `Z` + 右摇杆左右控制原地 - 行走:遥操已启动且双摇杆先回中过一次后,左摇杆上下直接控制前后,右摇杆左右
转向;两个组合都在下一个 `50 Hz` 周期立即响应,不再等待 3 秒。松开对应按键或 直接控制原地转向;无需组合键,在下一个 `50 Hz` 周期立即响应。摇杆回中立即把
摇杆回中立即把该轴清零,再按也立即恢复。 对应轴清零,再推动也立即恢复。
前后仍使用二次细控曲线;转向在死区后使用线性曲线,使右摇杆中段有足够角速度, 前后仍使用二次细控曲线;转向在死区后使用线性曲线,使右摇杆中段有足够角速度,
但最大值仍受官方 `0.8 rad/s` 上限约束。 但最大值仍受官方 `0.8 rad/s` 上限约束。
按官方半身行走 Topic 范围限幅:前进 `1.0 m/s`、后退 `0.8 m/s`、转向 按官方半身行走 Topic 范围限幅:前进 `1.0 m/s`、后退 `0.8 m/s`、转向
@@ -43,15 +43,19 @@ TS1P 同构臂
- 同构臂当前的一维手部开合量以机器人实测默认打开姿态为 0 端点,并按厂家 xTELE 的 - 同构臂当前的一维手部开合量以机器人实测默认打开姿态为 0 端点,并按厂家 xTELE 的
BrainCoRevo2 保守“常规”抓握姿态作为 1 端点映射到六电机; BrainCoRevo2 保守“常规”抓握姿态作为 1 端点映射到六电机;
若 xTELE 后续直接输出六维归一化位置,会自动使用六维位置。 若 xTELE 后续直接输出六维归一化位置,会自动使用六维位置。
- 右手指向手势:遥操已启动且灵巧手反馈健康时,连续长按右 `B` 1 秒开启;松开 - 双手指向手势:遥操已启动且灵巧手反馈健康时,连续长按右 `B` 1 秒开启;松开
至少 `0.5 s` 后,再连续长按右 `B` 1 秒关闭。首次启动和每个新遥操会话都必须先 至少 `0.5 s` 后,再连续长按右 `B` 1 秒关闭。首次启动和每个新遥操会话都必须先
稳定松开 B,保持同一次按压不会反复切换。计时未满、中途松开、输入畸形或反馈 稳定松开 B,保持同一次按压不会反复切换。计时未满、中途松开、输入畸形或反馈
中断均不触发;计时期间冻结右手最后一条命令,左手和双臂仍照常跟随。 中断均不触发;计时期间冻结双手各自最后一条命令,双臂仍照常跟随。
- 指向只覆盖右手。目标采用工控机 xTELE `GestureController` 的 BrainCoRevo2 第 2 号 - 指向同时覆盖左右手。目标采用工控机 xTELE `GestureController` 的 BrainCoRevo2 第 2 号
“单食指”手势 `state 0`:归一化目标 “单食指”手势 `state 0`:归一化目标
`[0.2, 0.688, 0.0, 0.98, 0.98, 0.98]`,对应当前 `1~1000` 位置范围约为 `[0.2, 0.688, 0.0, 0.98, 0.98, 0.98]`,对应当前 `1~1000` 位置范围约为
`[201, 688, 1, 980, 980, 980]`;顺序为大拇指弯曲、大拇指旋转、食指、中指、 `[201, 688, 1, 980, 980, 980]`;顺序为大拇指弯曲、大拇指旋转、食指、中指、
无名指、小拇指。进入和退出手势都继续使用每秒最多 400 个位置单位的现有限速。 无名指、小拇指。进入和退出手势都继续使用每秒最多 400 个位置单位的现有限速。
- 右 `A` 是右手瞬时姿态键:按住时采用六电机目标位置
`[428, 735, 500, 77, 77, 72]`,松开后恢复已有右手输入;若右 B 双手指向手势已开启,
A 按住期间只在右手优先,左手维持单食指;松开后右手恢复单食指。A 也必须在新会话中先稳定松开 `0.5 s`,
全程继续使用同一个 `400 units/s` 限速器。
## 安全门控 ## 安全门控
@@ -65,8 +69,8 @@ HBWALK 状态等运行时保护仍持续检查。厂家 `freq_change_tg3_node`
实际发出的速度由本桥按官方 Topic 范围限制为 `linear.x=[-0.8, 1.0] m/s`、 实际发出的速度由本桥按官方 Topic 范围限制为 `linear.x=[-0.8, 1.0] m/s`、
`angular.z=[-0.8, 0.8] rad/s`;本桥不发布侧移速度。 `angular.z=[-0.8, 0.8] rad/s`;本桥不发布侧移速度。
行走速度只在同一次遥操武装、`HBWALK/HBWALK/running`、网络数据新鲜且右 C + 左摇杆 行走速度只在同一次遥操武装、`HBWALK/HBWALK/running`、网络数据新鲜且左摇杆前后
前后或左 Z + 右摇杆横向即时成立时发布。解除组合后立即发布零速,并补发 10 帧零速。公网断线、退出遥操、 或右摇杆横向越过死区时发布。摇杆回中后立即发布零速,并补发 10 帧零速。公网断线、退出遥操、
回 Home 或机器人退出 HBWALK 也走同一停止逻辑。 回 Home 或机器人退出 HBWALK 也走同一停止逻辑。
灵巧手仅在同一次长按武装后发布;以机器人真实手指位置为起点,并按每秒最多 400 个 灵巧手仅在同一次长按武装后发布;以机器人真实手指位置为起点,并按每秒最多 400 个
@@ -74,7 +78,7 @@ HBWALK 状态等运行时保护仍持续检查。厂家 `freq_change_tg3_node`
和 `3`(持续力)均为厂家定义的正常运行状态;仅状态失联、未知/非法状态 和 `3`(持续力)均为厂家定义的正常运行状态;仅状态失联、未知/非法状态
或检测到另一命令源时,双臂和双手会一起解除武装并停止发布。停止遥操后灵巧手保持 或检测到另一命令源时,双臂和双手会一起解除武装并停止发布。停止遥操后灵巧手保持
最后目标,双臂按原逻辑限速回 Home。BrainCo 驱动原有的电流、堵转和碰撞保护不作修改。 最后目标,双臂按原逻辑限速回 Home。BrainCo 驱动原有的电流、堵转和碰撞保护不作修改。
匹配的 STOP、安全解除武装或服务退出都会清除右 B 指向手势的逻辑状态,但不会在 匹配的 STOP、安全解除武装或服务退出都会清除右 B 与右 A 手势的逻辑状态,但不会在
STOP 后额外发送张手/恢复命令;下一次会话从机器人实测手指位置重新限速跟随。 STOP 后额外发送张手/恢复命令;下一次会话从机器人实测手指位置重新限速跟随。
## 限速双臂回 Home ## 限速双臂回 Home

View File

@@ -172,16 +172,15 @@ topics = [
"/ob_camera_waist/depth/image_raw/compressedDepth" = 20.0 "/ob_camera_waist/depth/image_raw/compressedDepth" = 20.0
[locomotion] [locomotion]
# The two original immediate bindings are independent: right C + left-stick # Once Z+C has armed teleoperation and both sticks have been observed neutral,
# vertical controls translation; left Z + right-stick horizontal controls # left-stick vertical directly controls translation and right-stick horizontal
# in-place yaw. Both act on the next 50 Hz tick, with no second hold. # directly controls in-place yaw. No locomotion button or second hold is used.
enabled = true enabled = true
command_topic = "/hric/robot/cmd_vel" command_topic = "/hric/robot/cmd_vel"
# This bridge never publishes FSM commands. The robot must already report # This bridge never publishes FSM commands. The robot must already report
# HBWALK/running through the existing safety gate before velocity is allowed. # HBWALK/running through the existing safety gate before velocity is allowed.
# Match the TG3 secondary-development TwistStamped example. # Match the TG3 secondary-development TwistStamped example.
frame_id = "pelvis" frame_id = "pelvis"
hold_seconds = 0.0
joystick_deadzone = 0.2 joystick_deadzone = 0.2
joystick_expo = 2.0 joystick_expo = 2.0
# Turning is linear after the deadzone so medium right-stick travel is not # Turning is linear after the deadzone so medium right-stick travel is not
@@ -224,8 +223,8 @@ invert_scalar = false
open_normalized = [0.4, 0.4, 0.05, 0.05, 0.05, 0.05] open_normalized = [0.4, 0.4, 0.05, 0.05, 0.05, 0.05]
closed_normalized = [0.9, 0.5, 0.45, 0.52, 0.52, 0.45] closed_normalized = [0.9, 0.5, 0.45, 0.52, 0.52, 0.45]
# Right B owns a robot-side, release-guarded pointing gesture while a teleop # Right B owns a robot-side, release-guarded bilateral pointing gesture while
# session is armed. Both activation and deactivation require one continuous # a teleop session is armed. Both activation and deactivation require one continuous
# 1-second hold, separated by at least 0.5 seconds of stable release. A new # 1-second hold, separated by at least 0.5 seconds of stable release. A new
# session is always release-locked. The pose is xTELE GestureController's # session is always release-locked. The pose is xTELE GestureController's
# BrainCoRevo2 gesture 2 ("single index finger"), state 0, in motor order: # BrainCoRevo2 gesture 2 ("single index finger"), state 0, in motor order:
@@ -235,6 +234,15 @@ right_b_point_gesture_hold_seconds = 1.0
right_b_point_gesture_release_seconds = 0.5 right_b_point_gesture_release_seconds = 0.5
right_b_point_pose_normalized = [0.2, 0.688, 0.0, 0.98, 0.98, 0.98] right_b_point_pose_normalized = [0.2, 0.688, 0.0, 0.98, 0.98, 0.98]
# Right A is a momentary, robot-side pose: hold A to move the right hand to
# this measured BrainCo MotorStatus position and release A to resume the
# existing right-hand input (or an active right-B gesture). A new teleop
# session must first observe A released for 0.5 seconds. The normal hand slew
# limit remains in force; these are raw driver positions, not normalized data.
right_a_pose_enabled = true
right_a_pose_release_seconds = 0.5
right_a_pose_positions = [428, 735, 500, 77, 77, 72]
[home] [home]
# Deliberately slower than manual teleoperation. This Home pose was captured # Deliberately slower than manual teleoperation. This Home pose was captured
# from the robot's real arm feedback on 2026-08-06. Joint order is 11..17, # from the robot's real arm feedback on 2026-08-06. Joint order is 11..17,

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Pure state machine for the right-B pointing-hand gesture. """Pure state machines for robot-side right-hand button gestures.
This module deliberately has no ROS dependencies so the hold/release safety This module deliberately has no ROS dependencies so the hold/release safety
rules can be exercised offline before the bridge is deployed on a robot. rules can be exercised offline before the bridge is deployed on a robot.
@@ -182,6 +182,145 @@ class GestureToggle:
return "active" if self.active else "idle" return "active" if self.active else "idle"
class GuardedMomentaryGesture:
"""Hold one gesture only while its button is pressed.
A newly armed teleoperation session must first observe a stable release,
preventing a button held before START from moving the hand. Missing or
malformed input fails closed and requires another stable release.
"""
def __init__(self, release_seconds: float) -> None:
if not math.isfinite(release_seconds) or release_seconds <= 0.0:
raise ValueError("gesture release time must be positive and finite")
self.release_seconds = float(release_seconds)
self.active = False
self.release_started_at: float | None = None
self.require_release = True
self.armed = False
self.input_healthy = False
self.button_pressed: bool | None = None
self._freeze_right_hand = False
self.activation_count = 0
self.last_transition = "initialized"
def new_session(self) -> None:
self.active = False
self.release_started_at = None
self.require_release = True
self.armed = True
self.input_healthy = False
self.button_pressed = None
self._freeze_right_hand = True
self.last_transition = "new_session"
def disarm(self, reason: str = "disarmed") -> None:
self.active = False
self.release_started_at = None
self.require_release = True
self.armed = False
self.input_healthy = False
self.button_pressed = None
self._freeze_right_hand = False
self.last_transition = reason
def update(
self,
now: float,
*,
armed: bool,
input_healthy: bool,
pressed: bool | None,
) -> bool:
"""Advance the gate and report only active/inactive edge changes."""
if not math.isfinite(now):
raise ValueError("gesture clock must be finite")
was_active = self.active
if not armed:
if self.armed or self.active:
self.disarm()
return was_active != self.active
if not self.armed:
self.new_session()
self.input_healthy = bool(input_healthy)
self.button_pressed = pressed
if not input_healthy or pressed is None:
self.active = False
self.release_started_at = None
self.require_release = True
self._freeze_right_hand = True
self.last_transition = "input_unhealthy"
return was_active != self.active
if self.require_release:
self.active = False
self._freeze_right_hand = True
if pressed:
self.release_started_at = None
elif self.release_started_at is None:
self.release_started_at = now
elif now - self.release_started_at >= self.release_seconds:
self.require_release = False
self.release_started_at = None
self._freeze_right_hand = False
self.last_transition = "release_ready"
return was_active != self.active
self.release_started_at = None
self._freeze_right_hand = False
self.active = bool(pressed)
if self.active != was_active:
if self.active:
self.activation_count += 1
self.last_transition = "activated"
else:
self.last_transition = "deactivated"
return was_active != self.active
@property
def freeze_right_hand(self) -> bool:
return self.armed and not self.active and self._freeze_right_hand
def release_elapsed(self, now: float) -> float:
if self.release_started_at is None:
return 0.0
return max(0.0, now - self.release_started_at)
@property
def state(self) -> str:
if not self.armed:
return "disarmed"
if not self.input_healthy:
return "input_unhealthy"
if self.button_pressed is None:
return "invalid_button"
if self.require_release:
return "awaiting_release"
return "active" if self.active else "idle"
def right_a_pressed(data: Mapping[str, Any]) -> bool | None:
"""Return TS1P right-A state, or ``None`` for a malformed sample."""
try:
buttons = data["button"]
if not isinstance(buttons, Mapping):
return None
right = buttons["right"]
if not isinstance(right, (list, tuple)) or len(right) < 1:
return None
value = right[0]
if isinstance(value, bool):
return value
if isinstance(value, int) and value in (0, 1):
return bool(value)
return None
except (KeyError, TypeError):
return None
def right_b_pressed(data: Mapping[str, Any]) -> bool | None: def right_b_pressed(data: Mapping[str, Any]) -> bool | None:
"""Return TS1P right-B state, or ``None`` for a malformed sample.""" """Return TS1P right-B state, or ``None`` for a malformed sample."""
@@ -219,7 +358,7 @@ def normalized_pose_to_positions(
] ]
def select_right_hand_target( def select_hand_target(
source_target: Sequence[int], source_target: Sequence[int],
previous_command: Sequence[int] | None, previous_command: Sequence[int] | None,
gesture_target: Sequence[int], gesture_target: Sequence[int],
@@ -227,7 +366,7 @@ def select_right_hand_target(
active: bool, active: bool,
freeze: bool, freeze: bool,
) -> list[int]: ) -> list[int]:
"""Apply the right-only gesture override or fail-safe input freeze.""" """Apply one hand's gesture override or fail-safe input freeze."""
source = list(source_target) source = list(source_target)
gesture = list(gesture_target) gesture = list(gesture_target)
@@ -241,3 +380,22 @@ def select_right_hand_target(
raise ValueError("cannot freeze without a complete previous command") raise ValueError("cannot freeze without a complete previous command")
return previous return previous
return source return source
def select_right_hand_target(
source_target: Sequence[int],
previous_command: Sequence[int] | None,
gesture_target: Sequence[int],
*,
active: bool,
freeze: bool,
) -> list[int]:
"""Backward-compatible name for the original right-only implementation."""
return select_hand_target(
source_target,
previous_command,
gesture_target,
active=active,
freeze=freeze,
)

View File

@@ -4,9 +4,12 @@ from __future__ import annotations
import unittest import unittest
from gesture_toggle import ( from gesture_toggle import (
GuardedMomentaryGesture,
GestureToggle, GestureToggle,
normalized_pose_to_positions, normalized_pose_to_positions,
right_a_pressed,
right_b_pressed, right_b_pressed,
select_hand_target,
select_right_hand_target, select_right_hand_target,
) )
@@ -227,6 +230,13 @@ class GestureToggleTest(unittest.TestCase):
class GestureHelpersTest(unittest.TestCase): class GestureHelpersTest(unittest.TestCase):
def test_right_a_is_index_zero_and_malformed_values_fail_closed(self) -> None:
self.assertTrue(right_a_pressed({"button": {"right": [1, 0, 0]}}))
self.assertFalse(right_a_pressed({"button": {"right": [0, 1, 1]}}))
self.assertIsNone(right_a_pressed({"button": {"right": []}}))
self.assertIsNone(right_a_pressed({"button": {"right": [-1]}}))
self.assertIsNone(right_a_pressed({"button": {"right": [0.5]}}))
def test_right_b_is_index_one_and_malformed_values_fail_closed(self) -> None: def test_right_b_is_index_one_and_malformed_values_fail_closed(self) -> None:
self.assertTrue(right_b_pressed({"button": {"right": [0, 1, 0]}})) self.assertTrue(right_b_pressed({"button": {"right": [0, 1, 0]}}))
self.assertFalse(right_b_pressed({"button": {"right": [1, 0, 1]}})) self.assertFalse(right_b_pressed({"button": {"right": [1, 0, 1]}}))
@@ -263,6 +273,81 @@ class GestureHelpersTest(unittest.TestCase):
source, source,
) )
def test_same_point_target_can_be_applied_to_both_hands(self) -> None:
point = [201, 688, 1, 980, 980, 980]
sources = {
"left": [401, 401, 51, 51, 51, 51],
"right": [402, 402, 52, 52, 52, 52],
}
previous = {
"left": [410, 420, 60, 70, 80, 90],
"right": [430, 440, 100, 110, 120, 130],
}
selected = {
side: select_hand_target(
sources[side],
previous[side],
point,
active=True,
freeze=False,
)
for side in ("left", "right")
}
self.assertEqual(selected, {"left": point, "right": point})
class GuardedMomentaryGestureTest(unittest.TestCase):
def setUp(self) -> None:
self.gesture = GuardedMomentaryGesture(release_seconds=0.5)
self.gesture.new_session()
def test_held_at_session_start_cannot_activate(self) -> None:
self.assertFalse(
self.gesture.update(
0.0, armed=True, input_healthy=True, pressed=True
)
)
self.assertFalse(
self.gesture.update(
10.0, armed=True, input_healthy=True, pressed=True
)
)
self.assertFalse(self.gesture.active)
self.assertTrue(self.gesture.freeze_right_hand)
def test_press_is_immediate_after_stable_release_and_release_restores(self) -> None:
self.gesture.update(0.0, armed=True, input_healthy=True, pressed=False)
self.gesture.update(0.5, armed=True, input_healthy=True, pressed=False)
self.assertFalse(self.gesture.require_release)
self.assertTrue(
self.gesture.update(
0.51, armed=True, input_healthy=True, pressed=True
)
)
self.assertTrue(self.gesture.active)
self.assertEqual(self.gesture.activation_count, 1)
self.assertTrue(
self.gesture.update(
0.52, armed=True, input_healthy=True, pressed=False
)
)
self.assertFalse(self.gesture.active)
self.assertFalse(self.gesture.freeze_right_hand)
def test_malformed_input_deactivates_and_requires_release(self) -> None:
self.gesture.update(0.0, armed=True, input_healthy=True, pressed=False)
self.gesture.update(0.5, armed=True, input_healthy=True, pressed=False)
self.gesture.update(0.6, armed=True, input_healthy=True, pressed=True)
self.assertTrue(self.gesture.active)
self.assertTrue(
self.gesture.update(
0.7, armed=True, input_healthy=True, pressed=None
)
)
self.assertFalse(self.gesture.active)
self.assertTrue(self.gesture.require_release)
self.assertTrue(self.gesture.freeze_right_hand)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -82,6 +82,7 @@ class RobotSessionGateTest(unittest.TestCase):
bridge.cfg = {"control": {"auto_home_on_stop": True}} bridge.cfg = {"control": {"auto_home_on_stop": True}}
bridge.hands_enabled = False bridge.hands_enabled = False
bridge.right_point_gesture_enabled = False bridge.right_point_gesture_enabled = False
bridge.right_a_pose_enabled = False
bridge.robot_arm_positions = [0.0] * 14 bridge.robot_arm_positions = [0.0] * 14
bridge.last_command = None bridge.last_command = None
bridge.last_publish_at = 0.0 bridge.last_publish_at = 0.0
@@ -229,10 +230,9 @@ class RobotSessionGateTest(unittest.TestCase):
bridge._source_restart_reason(12.0, active), bridge._source_restart_reason(12.0, active),
) )
def test_locomotion_reacts_immediately_after_repress(self) -> None: def test_locomotion_reacts_directly_to_sticks_without_buttons(self) -> None:
bridge = LocalTeleopBridge.__new__(LocalTeleopBridge) bridge = LocalTeleopBridge.__new__(LocalTeleopBridge)
bridge.locomotion_cfg = { bridge.locomotion_cfg = {
"hold_seconds": 0.0,
"joystick_deadzone": 0.2, "joystick_deadzone": 0.2,
"joystick_expo": 2.0, "joystick_expo": 2.0,
"yaw_joystick_expo": 1.0, "yaw_joystick_expo": 1.0,
@@ -243,10 +243,10 @@ class RobotSessionGateTest(unittest.TestCase):
"yaw_axis_sign": -1.0, "yaw_axis_sign": -1.0,
"zero_burst_frames": 10, "zero_burst_frames": 10,
} }
bridge.walk_combo_started_at = None
bridge.walk_active = False bridge.walk_active = False
bridge.walk_command = [0.0, 0.0] bridge.walk_command = [0.0, 0.0]
bridge.walk_zero_frames_remaining = 0 bridge.walk_zero_frames_remaining = 0
bridge.walk_require_neutral = False
bridge.get_logger = MethodType(lambda _self: NullLogger(), bridge) bridge.get_logger = MethodType(lambda _self: NullLogger(), bridge)
published: list[tuple[float, float]] = [] published: list[tuple[float, float]] = []
@@ -262,7 +262,7 @@ class RobotSessionGateTest(unittest.TestCase):
{ {
"button": { "button": {
"left": [False, False, False], "left": [False, False, False],
"right": [False, False, True], "right": [False, False, False],
}, },
"joystick": { "joystick": {
"left": [1.0, 0.0], "left": [1.0, 0.0],
@@ -278,7 +278,7 @@ class RobotSessionGateTest(unittest.TestCase):
"right": [False, False, False], "right": [False, False, False],
}, },
"joystick": { "joystick": {
"left": [1.0, 0.0], "left": [0.0, 0.0],
"right": [0.0, 0.0], "right": [0.0, 0.0],
}, },
}, },
@@ -294,7 +294,7 @@ class RobotSessionGateTest(unittest.TestCase):
turning = ArmSnapshot( turning = ArmSnapshot(
{ {
"button": { "button": {
"left": [False, False, True], "left": [False, False, False],
"right": [False, False, False], "right": [False, False, False],
}, },
"joystick": { "joystick": {
@@ -323,6 +323,49 @@ class RobotSessionGateTest(unittest.TestCase):
bridge._tick_locomotion(1.4, old_wrong_binding) bridge._tick_locomotion(1.4, old_wrong_binding)
self.assertEqual(published[-1], (0.0, 0.0)) self.assertEqual(published[-1], (0.0, 0.0))
def test_locomotion_requires_one_neutral_sample_after_arming(self) -> None:
bridge = LocalTeleopBridge.__new__(LocalTeleopBridge)
bridge.locomotion_cfg = {
"joystick_deadzone": 0.2,
"joystick_expo": 2.0,
"yaw_joystick_expo": 1.0,
"max_forward_m_s": 1.0,
"max_reverse_m_s": 0.8,
"max_angular_rad_s": 0.8,
"forward_axis_sign": 1.0,
"yaw_axis_sign": -1.0,
"zero_burst_frames": 10,
}
bridge.walk_active = False
bridge.walk_command = [0.0, 0.0]
bridge.walk_zero_frames_remaining = 0
bridge.walk_require_neutral = True
bridge.get_logger = MethodType(lambda _self: NullLogger(), bridge)
published: list[tuple[float, float]] = []
def publish(
self: object, linear_x: float, angular_z: float, _now: float
) -> None:
published.append((linear_x, angular_z))
self.walk_command = [linear_x, angular_z]
bridge._publish_walk = MethodType(publish, bridge)
deflected = ArmSnapshot(
{"joystick": {"left": [1.0, 0.0], "right": [0.0, 0.0]}},
received_at=2.0,
)
neutral = ArmSnapshot(
{"joystick": {"left": [0.0, 0.0], "right": [0.0, 0.0]}},
received_at=2.1,
)
bridge._tick_locomotion(2.0, deflected)
self.assertTrue(bridge.walk_require_neutral)
self.assertEqual(bridge.walk_command, [0.0, 0.0])
bridge._tick_locomotion(2.1, neutral)
self.assertFalse(bridge.walk_require_neutral)
bridge._tick_locomotion(2.2, deflected)
self.assertEqual(published[-1], (1.0, -0.0))
def test_l3_hold_reaches_nonblocking_capture_request(self) -> None: def test_l3_hold_reaches_nonblocking_capture_request(self) -> None:
bridge = LocalTeleopBridge.__new__(LocalTeleopBridge) bridge = LocalTeleopBridge.__new__(LocalTeleopBridge)
bridge.data_collection_enabled = True bridge.data_collection_enabled = True

View File

@@ -34,10 +34,12 @@ from std_srvs.srv import Trigger
from data_collection import RecordingToggleGate, left_joystick_pressed from data_collection import RecordingToggleGate, left_joystick_pressed
from gesture_toggle import ( from gesture_toggle import (
GuardedMomentaryGesture,
GestureToggle, GestureToggle,
normalized_pose_to_positions, normalized_pose_to_positions,
right_a_pressed,
right_b_pressed, right_b_pressed,
select_right_hand_target, select_hand_target,
) )
@@ -425,6 +427,28 @@ class LocalTeleopBridge(Node):
int(self.hands_cfg.get("position_min", 1)), int(self.hands_cfg.get("position_min", 1)),
int(self.hands_cfg.get("position_max", 1000)), int(self.hands_cfg.get("position_max", 1000)),
) )
self.right_a_pose_enabled = self.hands_enabled and bool(
self.hands_cfg.get("right_a_pose_enabled", False)
)
self.right_a_pose = GuardedMomentaryGesture(
release_seconds=float(
self.hands_cfg.get("right_a_pose_release_seconds", 0.5)
)
)
right_a_target = self.hands_cfg.get(
"right_a_pose_positions", [428, 735, 500, 77, 77, 72]
)
self.right_a_pose_target = [int(value) for value in right_a_target]
hand_position_min = int(self.hands_cfg.get("position_min", 1))
hand_position_max = int(self.hands_cfg.get("position_max", 1000))
if len(self.right_a_pose_target) != 6 or not all(
hand_position_min <= value <= hand_position_max
for value in self.right_a_pose_target
):
raise ValueError(
"hands.right_a_pose_positions must contain 6 values within "
"the configured BrainCo position range"
)
self.locomotion_cfg = config.get("locomotion", {}) self.locomotion_cfg = config.get("locomotion", {})
self.locomotion_enabled = bool( self.locomotion_enabled = bool(
self.locomotion_cfg.get("enabled", False) self.locomotion_cfg.get("enabled", False)
@@ -474,11 +498,11 @@ class LocalTeleopBridge(Node):
self.hand_output_ready = False self.hand_output_ready = False
self.runtime_hand_output_reasons: list[str] = [] self.runtime_hand_output_reasons: list[str] = []
self.foreign_hand_source_seen = False self.foreign_hand_source_seen = False
self.walk_combo_started_at: float | None = None
self.walk_active = False self.walk_active = False
self.walk_command = [0.0, 0.0] self.walk_command = [0.0, 0.0]
self.walk_publish_count = 0 self.walk_publish_count = 0
self.walk_zero_frames_remaining = 0 self.walk_zero_frames_remaining = 0
self.walk_require_neutral = True
self.last_walk_publish_at = 0.0 self.last_walk_publish_at = 0.0
self.publisher = self.create_publisher(JointState, ros_cfg["command_topic"], 10) self.publisher = self.create_publisher(JointState, ros_cfg["command_topic"], 10)
@@ -994,9 +1018,27 @@ class LocalTeleopBridge(Node):
if gesture_toggled: if gesture_toggled:
state = "ACTIVE" if self.right_point_gesture.active else "INACTIVE" state = "ACTIVE" if self.right_point_gesture.active else "INACTIVE"
self.get_logger().warning( self.get_logger().warning(
f"RIGHT-HAND POINT GESTURE {state}: right B held for " f"BILATERAL POINT GESTURE {state}: right B held for "
f"{self.right_point_gesture.hold_seconds:.1f}s" f"{self.right_point_gesture.hold_seconds:.1f}s"
) )
if self.right_a_pose_enabled:
pose_changed = self.right_a_pose.update(
now,
armed=self.armed,
input_healthy=(
self.armed
and sample is not None
and not runtime_hand_reasons
),
pressed=(
None if sample is None else right_a_pressed(sample.data)
),
)
if pose_changed:
state = "ACTIVE" if self.right_a_pose.active else "INACTIVE"
self.get_logger().warning(
f"RIGHT-HAND A POSE {state}: momentary right A"
)
if self.returning_home: if self.returning_home:
self._tick_home(now) self._tick_home(now)
@@ -1028,15 +1070,34 @@ class LocalTeleopBridge(Node):
self.hand_output_ready = True self.hand_output_ready = True
desired_hands = self._hand_targets(sample.data) desired_hands = self._hand_targets(sample.data)
if self.right_point_gesture_enabled: if self.right_point_gesture_enabled:
for side in HAND_SIDES:
freeze_reference = self.last_hand_commands[side]
if (
freeze_reference is None
or len(freeze_reference) != 6
):
freeze_reference = self.robot_hand_positions[side]
desired_hands[side] = select_hand_target(
desired_hands[side],
freeze_reference,
self.right_point_gesture_target,
active=self.right_point_gesture.active,
freeze=self.right_point_gesture.freeze_right_hand,
)
if self.right_a_pose_enabled:
freeze_reference = self.last_hand_commands["right"] freeze_reference = self.last_hand_commands["right"]
if freeze_reference is None or len(freeze_reference) != 6: if freeze_reference is None or len(freeze_reference) != 6:
freeze_reference = self.robot_hand_positions["right"] freeze_reference = self.robot_hand_positions["right"]
desired_hands["right"] = select_right_hand_target( # Right A has momentary priority over the persistent B
# bilateral gesture on the right hand; releasing A
# returns through the same slew limiter to B or live
# xTELE input. The left B target remains unchanged.
desired_hands["right"] = select_hand_target(
desired_hands["right"], desired_hands["right"],
freeze_reference, freeze_reference,
self.right_point_gesture_target, self.right_a_pose_target,
active=self.right_point_gesture.active, active=self.right_a_pose.active,
freeze=self.right_point_gesture.freeze_right_hand, freeze=self.right_a_pose.freeze_right_hand,
) )
commands = { commands = {
side: self._slew_hand(side, desired_hands[side], now) side: self._slew_hand(side, desired_hands[side], now)
@@ -1225,11 +1286,14 @@ class LocalTeleopBridge(Node):
self.active_session_id = session_id self.active_session_id = session_id
if self.right_point_gesture_enabled: if self.right_point_gesture_enabled:
self.right_point_gesture.new_session() self.right_point_gesture.new_session()
if self.right_a_pose_enabled:
self.right_a_pose.new_session()
if getattr(self, "data_collection_enabled", False): if getattr(self, "data_collection_enabled", False):
self.data_capture_session_id = session_id self.data_capture_session_id = session_id
self.data_collection_gate.new_session() self.data_collection_gate.new_session()
self.data_last_transition = "new teleoperation session" self.data_last_transition = "new teleoperation session"
self.armed = True self.armed = True
self.walk_require_neutral = True
# Start both slew limiters at measured robot feedback, never at a # Start both slew limiters at measured robot feedback, never at a
# potentially distant first network target. # potentially distant first network target.
self.last_command = list(self.robot_arm_positions or []) self.last_command = list(self.robot_arm_positions or [])
@@ -1294,11 +1358,14 @@ class LocalTeleopBridge(Node):
return return
if self.right_point_gesture_enabled: if self.right_point_gesture_enabled:
self.right_point_gesture.new_session() self.right_point_gesture.new_session()
if self.right_a_pose_enabled:
self.right_a_pose.new_session()
if getattr(self, "data_collection_enabled", False): if getattr(self, "data_collection_enabled", False):
self.data_capture_session_id = "direct_" + uuid.uuid4().hex self.data_capture_session_id = "direct_" + uuid.uuid4().hex
self.data_collection_gate.new_session() self.data_collection_gate.new_session()
self.data_last_transition = "new direct-LAN teleoperation session" self.data_last_transition = "new direct-LAN teleoperation session"
self.armed = True self.armed = True
self.walk_require_neutral = True
# Start the slew limiter at measured robot feedback. Using None here # Start the slew limiter at measured robot feedback. Using None here
# would make the first armed frame jump directly to the TS1P target. # would make the first armed frame jump directly to the TS1P target.
self.last_command = list(self.robot_arm_positions or []) self.last_command = list(self.robot_arm_positions or [])
@@ -1329,6 +1396,8 @@ class LocalTeleopBridge(Node):
# Existing STOP behavior leaves the physical hand at its last # Existing STOP behavior leaves the physical hand at its last
# limited command until a later, newly armed session. # limited command until a later, newly armed session.
self.right_point_gesture.disarm(reason) self.right_point_gesture.disarm(reason)
if self.right_a_pose_enabled:
self.right_a_pose.disarm(reason)
self.armed = False self.armed = False
self.hand_output_ready = False self.hand_output_ready = False
self.runtime_hand_output_reasons = [] self.runtime_hand_output_reasons = []
@@ -1359,12 +1428,8 @@ class LocalTeleopBridge(Node):
def _tick_locomotion(self, now: float, sample: ArmSnapshot) -> None: def _tick_locomotion(self, now: float, sample: ArmSnapshot) -> None:
cfg = self.locomotion_cfg cfg = self.locomotion_cfg
try: try:
left_buttons = sample.data["button"]["left"]
right_buttons = sample.data["button"]["right"]
left_joystick = sample.data["joystick"]["left"] left_joystick = sample.data["joystick"]["left"]
right_joystick = sample.data["joystick"]["right"] right_joystick = sample.data["joystick"]["right"]
left_z = len(left_buttons) >= 3 and bool(left_buttons[2])
right_c = len(right_buttons) >= 3 and bool(right_buttons[2])
if len(left_joystick) != 2 or len(right_joystick) != 2: if len(left_joystick) != 2 or len(right_joystick) != 2:
raise ValueError("left and right joysticks must each contain two axes") raise ValueError("left and right joysticks must each contain two axes")
# xTELE 0.1.2 stores each TS1P stick as [vertical, horizontal]. # xTELE 0.1.2 stores each TS1P stick as [vertical, horizontal].
@@ -1397,19 +1462,19 @@ class LocalTeleopBridge(Node):
self._stop_locomotion(f"invalid locomotion input: {exc}") self._stop_locomotion(f"invalid locomotion input: {exc}")
return return
forward_active = right_c and shaped_forward != 0.0 forward_active = shaped_forward != 0.0
yaw_active = left_z and shaped_yaw != 0.0 yaw_active = shaped_yaw != 0.0
if not forward_active and not yaw_active: if self.walk_require_neutral:
self._stop_locomotion( if forward_active or yaw_active:
"right C + left forward and left Z + right yaw are both inactive" self._stop_locomotion(
) "waiting for both locomotion sticks to return to neutral"
self._tick_walk_zero_burst(now) )
return self._tick_walk_zero_burst(now)
return
self.walk_require_neutral = False
if self.walk_combo_started_at is None: if not forward_active and not yaw_active:
self.walk_combo_started_at = now self._stop_locomotion("both locomotion stick axes are neutral")
hold_seconds = float(cfg["hold_seconds"])
if now - self.walk_combo_started_at < hold_seconds:
self._tick_walk_zero_burst(now) self._tick_walk_zero_burst(now)
return return
@@ -1417,8 +1482,8 @@ class LocalTeleopBridge(Node):
self.walk_active = True self.walk_active = True
self.walk_zero_frames_remaining = 0 self.walk_zero_frames_remaining = 0
self.get_logger().warning( self.get_logger().warning(
"LOCAL HBWALK VELOCITY STARTED: right C + left-stick forward " "LOCAL HBWALK VELOCITY STARTED: direct left-stick forward "
"or left Z + right-stick yaw" "or right-stick yaw"
) )
linear_limit = max_forward if signed_forward >= 0.0 else max_reverse linear_limit = max_forward if signed_forward >= 0.0 else max_reverse
@@ -1436,7 +1501,6 @@ class LocalTeleopBridge(Node):
was_active = self.walk_active was_active = self.walk_active
had_nonzero_command = any(abs(value) > 1e-9 for value in self.walk_command) had_nonzero_command = any(abs(value) > 1e-9 for value in self.walk_command)
self.walk_active = False self.walk_active = False
self.walk_combo_started_at = None
if was_active or had_nonzero_command: if was_active or had_nonzero_command:
now = time.monotonic() now = time.monotonic()
self._publish_walk(0.0, 0.0, now) self._publish_walk(0.0, 0.0, now)
@@ -1942,7 +2006,7 @@ class LocalTeleopBridge(Node):
"right_point_gesture_enabled": self.right_point_gesture_enabled, "right_point_gesture_enabled": self.right_point_gesture_enabled,
"right_point_gesture_binding": ( "right_point_gesture_binding": (
"right_B hold " "right_B hold "
f"{self.right_point_gesture.hold_seconds:.1f}s toggle" f"{self.right_point_gesture.hold_seconds:.1f}s bilateral toggle"
), ),
"right_point_gesture_active": ( "right_point_gesture_active": (
self.right_point_gesture.active self.right_point_gesture.active
@@ -1990,12 +2054,43 @@ class LocalTeleopBridge(Node):
if self.right_point_gesture_enabled if self.right_point_gesture_enabled
else None else None
), ),
"right_a_pose_enabled": self.right_a_pose_enabled,
"right_a_pose_binding": "right_A hold; release restores live input",
"right_a_pose_active": (
self.right_a_pose.active if self.right_a_pose_enabled else False
),
"right_a_pose_state": (
self.right_a_pose.state if self.right_a_pose_enabled else "disabled"
),
"right_a_pose_requires_release": (
self.right_a_pose.require_release
if self.right_a_pose_enabled
else False
),
"right_a_pose_release_s": (
round(self.right_a_pose.release_elapsed(now), 2)
if self.right_a_pose_enabled
else 0.0
),
"right_a_pose_activation_count": (
self.right_a_pose.activation_count
if self.right_a_pose_enabled
else 0
),
"right_a_pose_last_transition": (
self.right_a_pose.last_transition
if self.right_a_pose_enabled
else "disabled"
),
"right_a_pose_target_positions": (
self.right_a_pose_target if self.right_a_pose_enabled else None
),
"locomotion_enabled": self.locomotion_enabled, "locomotion_enabled": self.locomotion_enabled,
"locomotion_binding": ( "locomotion_binding": (
"right_C + left_stick_vertical; " "left_stick_vertical; right_stick_horizontal, direct"
"left_Z + right_stick_horizontal, immediate"
), ),
"locomotion_active": self.walk_active, "locomotion_active": self.walk_active,
"locomotion_requires_neutral": self.walk_require_neutral,
"locomotion_hold_s": 0.0, "locomotion_hold_s": 0.0,
"locomotion_command": { "locomotion_command": {
"linear_x_m_s": self.walk_command[0], "linear_x_m_s": self.walk_command[0],

View File

@@ -29,7 +29,7 @@ xTELE 已处理的标量或六维 BrainCoRevo2 目标。5002 的界面事件和
右侧原始按键顺序是 `A/B/C`。按住右 `B` 时,sender 仍合并 5001 的左手 右侧原始按键顺序是 `A/B/C`。按住右 `B` 时,sender 仍合并 5001 的左手
目标,但保留 5003 的原始右手开合量,不用 xTELE 处理后的右手目标覆盖。 目标,但保留 5003 的原始右手开合量,不用 xTELE 处理后的右手目标覆盖。
这样右 B 的自定义长按手势还未满 1 秒时,原厂 B 处理不会让右手提前动作。 这样右 B 的自定义双手长按手势还未满 1 秒时,原厂 B 处理不会让右手提前动作。
松开 B 并连续稳定释放 `0.5 s` 后,还要等新鲜 5001 右手目标连续至少 `0.25 s` 松开 B 并连续稳定释放 `0.5 s` 后,还要等新鲜 5001 右手目标连续至少 `0.25 s`
恢复到按键前基线或当前 5003 原始开合量,才恢复双侧合并;无法确认时持续使用原始 恢复到按键前基线或当前 5003 原始开合量,才恢复双侧合并;无法确认时持续使用原始
右手值,防止释放沿后的锁存/延迟目标漏入。 右手值,防止释放沿后的锁存/延迟目标漏入。