Files
Mujoco_WASM/control_bridge/src/mujoco_control_bridge/client.py
T
chenlin 3ad29356c9
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
feat(lekiwi): release V0.10.1 初步集成 LeKiwi,优化碰撞模型
集成通用机器人数值接口、本机控制桥、LeRobot 插件和统一键盘遥操作。采用离线 CoACD 全臂碰撞配方 revision 4、局部装配区切分与结构自接触,限制直接关节位姿写入并保留安全看门狗。同步版本号、变更记录、来源许可证和兼容性验证。
2026-09-20 14:42:30 +08:00

161 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Synchronous, generic robot SDK; stdlib only on the client path."""
import contextlib
import json
import os
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
from . import protocol as p
from .protocol import RobotError
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None # Never forward the bearer token to a redirected endpoint.
class SimRobotClient:
def __init__(self, endpoint="http://127.0.0.1:8766", token=None, timeout=2.0):
parsed = urlparse(endpoint)
if (
parsed.scheme != "http"
or parsed.hostname not in {"127.0.0.1", "localhost"}
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
or parsed.path not in {"", "/"}
):
raise ValueError("V1 仅支持本机 http://127.0.0.1:port 或 localhost")
self.endpoint = endpoint.rstrip("/")
self.token = token or os.environ.get("MUJOCO_CONTROL_TOKEN", "")
if not self.token:
raise ValueError("需要 token 或 MUJOCO_CONTROL_TOKEN")
if (
not isinstance(self.token, str)
or not 16 <= len(self.token) <= 4096
or not all(33 <= ord(c) <= 126 for c in self.token)
):
raise ValueError("控制 token 必须为16–4096字符的可打印 ASCII,且不含空白")
self.timeout = timeout
self._opener = build_opener(ProxyHandler({}), NoRedirect())
self._descriptor = None
self._identity = None
self._action_seq = 0
self._last_action_at = 0.0
def _request(self, method, path, payload=None, lease=None):
headers = {"Authorization": f"Bearer {self.token}"}
if lease:
headers["X-Control-Lease"] = lease["leaseId"]
raw = None
if payload is not None:
raw = json.dumps(payload, allow_nan=False).encode()
if len(raw) > 65536:
raise RobotError("INVALID_MESSAGE", "消息超过64KiB")
headers["Content-Type"] = "application/json"
request = Request(
f"{self.endpoint}/api/control/v1{path}", data=raw, headers=headers, method=method
)
try:
with self._opener.open(request, timeout=self.timeout) as response:
data = response.read(65537)
if len(data) > 65536:
raise RobotError("INVALID_MESSAGE", "响应超过64KiB")
return p.loads(data)
except HTTPError as exc:
try:
error = p.loads(exc.read(65537))["error"]
code, message = error["code"], error["message"]
except (RobotError, KeyError, TypeError):
code, message = "DISCONNECTED", f"桥接 HTTP {exc.code}"
raise RobotError(code, message) from None
except (URLError, TimeoutError, ConnectionError, OSError) as exc:
raise RobotError("DISCONNECTED", "无法访问本机控制桥接") from exc
@property
def is_connected(self):
return self._identity is not None and time.monotonic() - self._last_action_at <= 0.5
def describe(self):
self._descriptor = p.descriptor(self._request("GET", "/robot"))
return self._descriptor
def connect(self):
if self._identity:
raise RobotError("CONFLICT", "客户端已连接;请先 disconnect")
desc = self.describe()
obs = p.observation(self._request("GET", "/observation"), desc)
claim = {
"sessionId": obs["sessionId"],
"modelEpoch": obs["modelEpoch"],
"modelFingerprint": desc["modelFingerprint"],
}
lease = p.record(self._request("POST", "/lease", claim), p.IDENTITY)
p.identity(lease)
p.same_identity(lease, obs, ("sessionId", "modelEpoch"))
self._identity = lease
self._action_seq = 0
self._last_action_at = time.monotonic()
return desc
def _connected(self):
if not self.is_connected:
raise RobotError("DISCONNECTED", "没有有效租约;请重新在浏览器授权并连接")
return dict(self._identity)
def get_observation(self):
identity = self._connected()
obs = p.observation(self._request("GET", "/observation"), self._descriptor)
p.same_identity(obs, identity, ("sessionId", "modelEpoch"))
return obs
def send_action(self, values):
identity = self._connected()
values = p.values(values, self._descriptor["actionChannels"])
self._action_seq += 1
packet = {"protocolVersion": 1, **identity, "actionSeq": self._action_seq, "values": values}
try:
result = p.action_result(
self._request("POST", "/action", packet, lease=identity), self._descriptor
)
p.same_identity(result, packet, (*p.IDENTITY, "actionSeq"))
self._last_action_at = time.monotonic()
return result
except RobotError:
self.disconnect()
raise
def reset(self):
identity = self._connected()
try:
observed = p.observation(
self._request("POST", "/reset", {}, lease=identity), self._descriptor
)
if (
observed["sessionId"] != identity["sessionId"]
or observed["modelEpoch"] <= identity["modelEpoch"]
or not observed["paused"]
):
raise RobotError("STALE", "reset 没有返回新代次")
return observed
finally:
self.disconnect()
def disconnect(self):
identity, self._identity = self._identity, None
if identity:
# The server/browser watchdog remains the final safety barrier.
with contextlib.suppress(RobotError):
self._request("DELETE", "/lease", lease=identity)
def __enter__(self):
self.connect()
return self
def __exit__(self, *_):
self.disconnect()