Initial commit: S10 remote control module, demos, and docs
- s10_robot.py: protocol framing, mTLS client, heartbeat, status and control APIs - demo_stand_rotate_lie.py: stand, rotate in place, lie down with safe recovery - status_probe.py: read-only status subscription - tests: protocol unit tests and local fake robot - docs: remote control command reference
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""演示:起立 → 原地旋转一段时间 → 停止 → 趴下恢复。
|
||||
|
||||
用法::
|
||||
|
||||
uv run python demo_stand_rotate_lie.py --seconds 5 --yaw 0.3
|
||||
|
||||
# 指定证书目录和机器人地址
|
||||
uv run python demo_stand_rotate_lie.py --certs /path/to/certs --host 10.21.33.103
|
||||
|
||||
流程与《山猫S10 软件开发指南》的遥控时序一致:
|
||||
|
||||
1. mTLS 连接 30003/TCP,开启 2Hz 心跳(机器人随后开始主动上报状态)
|
||||
2. 切到常规模式
|
||||
3. 下发站立,等待进入 RL 控制状态(ASDU 站立完成后会自动进入)
|
||||
4. 切到基础步态(必须在完全停止时切换)
|
||||
5. 以 10Hz 持续下发 Yaw 轴指令,原地旋转指定秒数
|
||||
6. 下发全 0 停止,再下发趴下,等待回到趴卧/待机状态
|
||||
|
||||
任何异常、Ctrl-C 或超时都会走安全收尾:先发全 0 停止,若仍处于站立/RL
|
||||
状态则下发趴下,最后关闭连接。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from s10_robot import (
|
||||
Gait,
|
||||
Mode,
|
||||
MotionState,
|
||||
S10Client,
|
||||
S10CommandError,
|
||||
S10Error,
|
||||
S10Timeout,
|
||||
)
|
||||
|
||||
log = logging.getLogger("demo")
|
||||
|
||||
ABORT = threading.Event()
|
||||
|
||||
|
||||
def _handle_signal(signum, _frame):
|
||||
log.warning("收到信号 %s,准备安全收尾", signal.Signals(signum).name)
|
||||
ABORT.set()
|
||||
|
||||
|
||||
def resolve_certs(explicit: str | None) -> Path:
|
||||
candidates = []
|
||||
if explicit:
|
||||
candidates.append(Path(explicit))
|
||||
if os.environ.get("S10_CERTS"):
|
||||
candidates.append(Path(os.environ["S10_CERTS"]))
|
||||
candidates.append(Path("certs"))
|
||||
candidates.append(
|
||||
Path.home()
|
||||
/ ".zcode/workspace/draft-183385f3-4f14-43ad-ab22-e5deace0e05c/backup/certs"
|
||||
)
|
||||
for path in candidates:
|
||||
if (path / "client_chain.crt").is_file() and (path / "client.key").is_file():
|
||||
return path
|
||||
raise SystemExit(
|
||||
"找不到证书目录,请用 --certs 指定包含 client_chain.crt / client.key / root_ca.crt 的目录"
|
||||
)
|
||||
|
||||
|
||||
def describe(robot: S10Client) -> str:
|
||||
basic = robot.basic_status
|
||||
return (
|
||||
f"MotionState={basic.get('MotionState')} Gait={basic.get('Gait')} "
|
||||
f"Mode={basic.get('ControlUsageMode')} HES={basic.get('HES')}"
|
||||
)
|
||||
|
||||
|
||||
def check_alive(robot: S10Client) -> None:
|
||||
basic = robot.basic_status
|
||||
if basic.get("HES") == 1:
|
||||
raise S10Error("硬急停已触发,终止运动")
|
||||
errors = robot.active_errors
|
||||
for err in errors:
|
||||
if max(err.get("Severities") or [0]) >= 5:
|
||||
raise S10Error(f"出现 FATAL 故障: {err.get('Name')} ({err.get('Code')})")
|
||||
|
||||
|
||||
def rotate_in_place(robot: S10Client, yaw: float, seconds: float, rate_hz: float) -> None:
|
||||
"""以固定频率持续下发 Yaw 轴指令,旋转指定时间。"""
|
||||
period = 1.0 / rate_hz
|
||||
deadline = time.monotonic() + seconds
|
||||
next_tick = time.monotonic()
|
||||
sent = 0
|
||||
log.info("开始原地旋转: yaw=%+.2f, %g 秒, %g Hz", yaw, seconds, rate_hz)
|
||||
while time.monotonic() < deadline:
|
||||
if ABORT.is_set():
|
||||
log.warning("旋转被中断")
|
||||
break
|
||||
check_alive(robot)
|
||||
state = robot.basic_status.get("MotionState")
|
||||
if state != MotionState.RL:
|
||||
raise S10Error(f"旋转过程中运动状态异常: MotionState={state}(预期 {MotionState.RL})")
|
||||
robot.move(yaw=yaw)
|
||||
sent += 1
|
||||
next_tick += period
|
||||
time.sleep(max(0.0, next_tick - time.monotonic()))
|
||||
log.info("旋转结束,共下发 %d 帧轴指令", sent)
|
||||
|
||||
|
||||
def safe_recover(robot: S10Client) -> None:
|
||||
"""异常/中断后的安全收尾:停止移动,必要时趴下。"""
|
||||
if not robot.connected:
|
||||
return
|
||||
try:
|
||||
robot.stop()
|
||||
except S10Error as exc:
|
||||
log.warning("发送停止指令失败: %s", exc)
|
||||
state = robot.basic_status.get("MotionState")
|
||||
if state in (MotionState.STANDING, MotionState.RL, MotionState.JOINT_DAMP):
|
||||
try:
|
||||
log.info("安全收尾:下发趴下")
|
||||
robot.lie_down()
|
||||
robot.wait_motion_state({MotionState.LYING, MotionState.DEFAULT}, timeout=15)
|
||||
log.info("已趴下: %s", describe(robot))
|
||||
except (S10Error, S10Timeout) as exc:
|
||||
log.error("趴下失败,请手动接管: %s", exc)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> int:
|
||||
certs = resolve_certs(args.certs)
|
||||
log.info("证书目录: %s", certs)
|
||||
|
||||
robot = S10Client(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
cert=str(certs / "client_chain.crt"),
|
||||
key=str(certs / "client.key"),
|
||||
ca=str(certs / "root_ca.crt"),
|
||||
verify=args.verify,
|
||||
heartbeat_hz=args.heartbeat_hz,
|
||||
request_timeout=args.timeout,
|
||||
)
|
||||
|
||||
try:
|
||||
robot.connect()
|
||||
log.info("等待首次状态上报…")
|
||||
try:
|
||||
robot.wait_basic("MotionState", range(-2, 0x1002), timeout=5.0)
|
||||
except S10Timeout:
|
||||
log.warning("5 秒内未收到状态上报,继续尝试下发指令")
|
||||
log.info("初始状态: %s", describe(robot))
|
||||
|
||||
if ABORT.is_set():
|
||||
return 130
|
||||
|
||||
log.info("① 切换到常规模式")
|
||||
robot.set_mode(Mode.NORMAL)
|
||||
try:
|
||||
robot.wait_mode({Mode.NORMAL}, timeout=3)
|
||||
except S10Timeout:
|
||||
log.warning("模式回读超时,继续")
|
||||
|
||||
log.info("② 下发站立")
|
||||
robot.stand()
|
||||
log.info(" 等待进入 RL 控制状态(最多 25 秒)…")
|
||||
robot.wait_motion_state({MotionState.RL}, timeout=25)
|
||||
log.info(" 已站立并进入 RL 控制: %s", describe(robot))
|
||||
|
||||
log.info("③ 切换到基础步态 0x%04X", Gait.BASIC)
|
||||
robot.set_gait(Gait.BASIC)
|
||||
time.sleep(0.3)
|
||||
|
||||
log.info("④ 原地旋转")
|
||||
rotate_in_place(robot, args.yaw, args.seconds, args.rate)
|
||||
|
||||
log.info("⑤ 停止移动,等待完全静止")
|
||||
robot.stop()
|
||||
robot.stop()
|
||||
time.sleep(0.5)
|
||||
|
||||
log.info("⑥ 下发趴下")
|
||||
robot.lie_down()
|
||||
state = robot.wait_motion_state({MotionState.LYING, MotionState.DEFAULT}, timeout=20)
|
||||
log.info(" 已恢复: MotionState=%s(%s)", state, describe(robot))
|
||||
return 0
|
||||
|
||||
except S10CommandError as exc:
|
||||
log.error("机器人拒绝指令: %s", exc)
|
||||
return 2
|
||||
except (S10Error, S10Timeout) as exc:
|
||||
log.error("执行失败: %s", exc)
|
||||
return 1
|
||||
finally:
|
||||
safe_recover(robot)
|
||||
robot.close()
|
||||
log.info("连接已关闭")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="山猫 S10:起立 → 原地旋转 → 趴下 恢复演示",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--host", default="10.21.33.103", help="机器人地址")
|
||||
parser.add_argument("--port", type=int, default=30003, help="TLS 端口")
|
||||
parser.add_argument("--certs", default=None, help="证书目录(含 client_chain.crt/client.key/root_ca.crt)")
|
||||
parser.add_argument("--yaw", type=float, default=0.3,
|
||||
help="偏航比例 [-1,1],正值为逆时针")
|
||||
parser.add_argument("--seconds", type=float, default=5.0, help="旋转时长(秒)")
|
||||
parser.add_argument("--rate", type=float, default=10.0, help="轴指令下发频率(Hz)")
|
||||
parser.add_argument("--heartbeat-hz", type=float, default=2.0, help="心跳频率(Hz)")
|
||||
parser.add_argument("--timeout", type=float, default=5.0, help="单条指令响应超时(秒)")
|
||||
parser.add_argument("--verify", action="store_true", help="严格校验服务端证书链")
|
||||
parser.add_argument("-v", "--verbose", action="store_true", help="输出调试日志")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not -1.0 <= args.yaw <= 1.0:
|
||||
parser.error("--yaw 必须在 [-1, 1] 之间")
|
||||
if args.seconds <= 0:
|
||||
parser.error("--seconds 必须大于 0")
|
||||
if args.rate <= 0:
|
||||
parser.error("--rate 必须大于 0")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s.%(msecs)03d %(levelname)-7s %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
signal.signal(signal.SIGINT, _handle_signal)
|
||||
signal.signal(signal.SIGTERM, _handle_signal)
|
||||
|
||||
return run(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user