feat(lekiwi): release V0.10.1 初步集成 LeKiwi,优化碰撞模型
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

集成通用机器人数值接口、本机控制桥、LeRobot 插件和统一键盘遥操作。采用离线 CoACD 全臂碰撞配方 revision 4、局部装配区切分与结构自接触,限制直接关节位姿写入并保留安全看门狗。同步版本号、变更记录、来源许可证和兼容性验证。
This commit is contained in:
2026-09-20 14:42:30 +08:00
parent da4d59c31a
commit 3ad29356c9
103 changed files with 23055 additions and 280 deletions
@@ -0,0 +1,4 @@
from .client import SimRobotClient
from .protocol import RobotError
__all__ = ["RobotError", "SimRobotClient"]
@@ -0,0 +1,48 @@
"""Run an independent bridge on loopback only; token never goes in URLs."""
import argparse
import asyncio
import json
import os
import secrets
import signal
from aiohttp import web
from .server import create_app
async def serve(port, token):
runner = web.AppRunner(create_app(token), access_log=None, shutdown_timeout=2)
await runner.setup()
try:
await web.TCPSite(runner, "127.0.0.1", port).start()
address = runner.addresses[0]
print(
"CONTROL_BRIDGE_READY " + json.dumps({"endpoint": f"http://127.0.0.1:{address[1]}"}),
flush=True,
)
stopped = asyncio.Event()
loop = asyncio.get_running_loop()
for signum in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(signum, stopped.set)
await stopped.wait()
finally:
await runner.cleanup()
def main():
parser = argparse.ArgumentParser(description="MuJoCo 本机数值控制桥接(不是训练服务)")
parser.add_argument("--port", type=int, default=8766)
args = parser.parse_args()
if not 0 <= args.port <= 65535:
parser.error("端口必须为 0–65535")
token = os.environ.get("MUJOCO_CONTROL_TOKEN")
if not token:
token = secrets.token_urlsafe(32)
print(f"本次控制 token(仅显示一次;复制到浏览器/Python):{token}", flush=True)
asyncio.run(serve(args.port, token))
if __name__ == "__main__":
main()
@@ -0,0 +1,160 @@
"""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()
@@ -0,0 +1,196 @@
"""Strict, framework-independent v1 wire validation (mirrors TS validation.ts)."""
import json
import math
import re
UNITS = {"rad", "rad/s", "m", "m/s", "ratio", "N", "N.m"}
MODES = {"position", "velocity", "effort", "opening"}
IDENTITY = {"sessionId", "modelEpoch", "leaseId"}
class RobotError(RuntimeError):
def __init__(self, code, message):
super().__init__(message)
self.code = code
def as_dict(self):
return {"code": self.code, "message": str(self)}
def invalid(message):
raise RobotError("INVALID_MESSAGE", message)
def record(value, keys, label="message"):
if not isinstance(value, dict) or set(value) != set(keys):
invalid(f"{label} 字段不完整或包含未知字段")
return value
def text(value):
if not isinstance(value, str) or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}", value):
invalid("标识符无效")
return value
def finite(value):
try:
good = type(value) in (int, float) and math.isfinite(value)
except OverflowError:
good = False
if not good:
invalid("数值必须有限且不能为 bool")
return value
def integer(value, minimum=0):
finite(value)
if int(value) != value or not minimum <= value <= 2**53 - 1:
invalid("整数超出安全范围")
return value
def version(value):
if type(value) not in (int, float) or value != 1:
raise RobotError("UNSUPPORTED", "只支持协议 v1")
def identity(value):
text(value["sessionId"])
integer(value["modelEpoch"])
text(value["leaseId"])
def channels(value):
if not isinstance(value, list) or not 0 < len(value) <= 256:
invalid("通道列表无效")
seen = set()
for channel in value:
record(channel, {"id", "unit", "mode", "min", "max"}, "channel")
name = text(channel["id"])
if name in seen:
invalid("通道名称无效或重复")
seen.add(name)
if str(channel["unit"]) not in UNITS or str(channel["mode"]) not in MODES:
invalid("未知单位或通道模式")
for key in ("min", "max"):
finite(channel[key])
if channel["min"] >= channel["max"]:
invalid("通道范围无效")
return value
def descriptor(value):
record(
value,
{
"protocolVersion",
"profileId",
"profileVersion",
"modelFingerprint",
"frame",
"actionChannels",
"observationChannels",
"capabilities",
},
"descriptor",
)
version(value["protocolVersion"])
if value["frame"] != "x-forward-y-left-z-up":
raise RobotError("UNSUPPORTED", "坐标系不支持")
text(value["profileId"])
integer(value["profileVersion"], 1)
if not isinstance(value["modelFingerprint"], str) or not re.fullmatch(
r"[a-f0-9]{64}", value["modelFingerprint"]
):
invalid("需要 SHA-256 模型指纹")
channels(value["actionChannels"])
channels(value["observationChannels"])
caps = record(
value["capabilities"], {"reset", "lockstep", "cameras", "training"}, "capabilities"
)
if any(type(v) is not bool for v in caps.values()) or any(
caps[k] is not False for k in ("lockstep", "cameras", "training")
):
raise RobotError("UNSUPPORTED", "V1 不支持相机、锁步或训练")
return value
def values(value, specs, clamp=False):
record(value, {c["id"] for c in specs}, "values")
result = {}
for channel in specs:
number = finite(value[channel["id"]])
result[channel["id"]] = (
max(channel["min"], min(channel["max"], number)) if clamp else number
)
return result
def action(value, desc):
record(value, {"protocolVersion", *IDENTITY, "actionSeq", "values"}, "action")
version(value["protocolVersion"])
identity(value)
integer(value["actionSeq"], 1)
return {**value, "values": values(value["values"], desc["actionChannels"], True)}
def action_result(value, desc):
record(value, {*IDENTITY, "actionSeq", "values", "simTime"}, "action result")
identity(value)
integer(value["actionSeq"], 1)
finite(value["simTime"])
if value["simTime"] < 0:
invalid("仿真时间必须非负")
values(value["values"], desc["actionChannels"])
return value
def observation(value, desc):
record(
value,
{
"protocolVersion",
"sessionId",
"modelEpoch",
"sequence",
"simTime",
"appliedActionSeq",
"paused",
"values",
},
"observation",
)
version(value["protocolVersion"])
text(value["sessionId"])
integer(value["modelEpoch"])
integer(value["sequence"])
integer(value["appliedActionSeq"])
finite(value["simTime"])
if value["simTime"] < 0 or type(value["paused"]) is not bool:
invalid("仿真时钟/暂停标记无效")
values(value["values"], desc["observationChannels"])
return value
def same_identity(actual, expected, keys=IDENTITY):
if any(actual.get(key) != expected.get(key) for key in keys):
raise RobotError("STALE", "会话、模型代次或控制租约已失效")
def loads(raw):
def pairs(items):
result = {}
for key, value in items:
if key in result:
invalid("重复 JSON 字段")
result[key] = value
return result
try:
return json.loads(
raw, parse_constant=lambda _: invalid("禁止 NaN/Infinity"), object_pairs_hook=pairs
)
except (ValueError, TypeError, RecursionError) as exc:
raise RobotError("INVALID_MESSAGE", "无效 JSON") from exc
@@ -0,0 +1,463 @@
"""Independent loopback broker. No simulation, training or LeRobot imports."""
import asyncio
import contextlib
import hmac
import re
import secrets
import time
from collections import deque
from aiohttp import WSMsgType, web
from . import protocol as p
from .protocol import RobotError
MAX_BYTES = 65536
PREFIX = "/api/control/v1"
ORIGIN = re.compile(r"http://(?:127\.0\.0\.1|localhost)(?::[0-9]{1,5})?\Z")
HOST = re.compile(r"(?:127\.0\.0\.1|localhost)(?::[0-9]{1,5})?\Z")
BROKER = web.AppKey("broker", object)
class Broker:
def __init__(self, token):
if (
not isinstance(token, str)
or not 16 <= len(token) <= 4096
or not all(33 <= ord(c) <= 126 for c in token)
):
raise ValueError("控制 token 必须为16–4096字符的可打印 ASCII,且不含空白")
self.token = token
self.ws = None
self.descriptor = None
self.observation = None
self.observed_at = 0.0
self.authorization_generation = 0
self.blocked_generation = -1
self.authorized = False
self.lease = None
self.last_action_at = 0.0
self.last_action_seq = 0
self.rate = deque()
self.pending = {}
def robot(self):
if self.ws is None or self.ws.closed or self.descriptor is None:
raise RobotError("DISCONNECTED", "浏览器机器人未连接")
return self.descriptor
def fresh_observation(self):
self.robot()
# Paused samples stop advancing by design; do not hide the required play
# step behind a freshness error merely because the user waited to connect.
if self.observation is not None and self.observation["paused"]:
raise RobotError("PAUSED", "仿真已暂停;请先在浏览器点击“播放”,再点击“允许外部控制”")
if self.observation is None or time.monotonic() - self.observed_at > 0.5:
raise RobotError(
"STALE",
"机器人观测超过 500ms 未更新;请保持仿真页面可见,"
"检查仿真是否卡顿,再播放并重新允许外部控制",
)
return self.observation
async def state(self, packet):
p.record(packet, {"type", "observation", "enabled", "authorizationGeneration"})
if type(packet["enabled"]) is not bool:
p.invalid("授权标记必须为 bool")
generation = p.integer(packet["authorizationGeneration"])
if generation < self.authorization_generation:
raise RobotError("STALE", "授权代次倒退")
observed = p.observation(packet["observation"], self.robot())
old = self.observation
if old:
p.same_identity(observed, old, ("sessionId",))
if observed["modelEpoch"] < old["modelEpoch"] or (
observed["modelEpoch"] == old["modelEpoch"]
and observed["sequence"] < old["sequence"]
):
raise RobotError("STALE", "观测代次/序号倒退")
# Sequence may restart only in a new epoch. Same-sample heartbeats aren't fresh.
if (
old is None
or observed["modelEpoch"] > old["modelEpoch"]
or observed["sequence"] > old["sequence"]
):
self.observation = observed
self.observed_at = time.monotonic()
if self.lease and generation != self.authorization_generation:
# Cancel the previous owner without consuming the new explicit grant.
await self.revoke("浏览器已重新授权", notify=False)
self.authorization_generation = generation
self.authorized = packet["enabled"] and generation > self.blocked_generation
if self.lease and (
not self.authorized
or observed["paused"]
or any(observed[k] != self.lease[k] for k in ("sessionId", "modelEpoch"))
):
await self.revoke("浏览器已撤销控制/重置模型", notify=False)
async def rpc(self, operation, payload):
self.robot()
if operation == "action":
for key, (future, op) in list(self.pending.items()):
if op == "action":
if not future.done():
future.set_exception(RobotError("SUPERSEDED", "已由更新的目标替代"))
self.pending.pop(key, None)
if len(self.pending) >= 8:
raise RobotError("CONFLICT", "待确认请求已满")
request_id = secrets.token_hex(16)
future = asyncio.get_running_loop().create_future()
ws, lease, generation = self.ws, self.lease, self.authorization_generation
self.pending[request_id] = (future, operation)
try:
await asyncio.wait_for(
self.ws.send_json(
{"type": "request", "id": request_id, "op": operation, "payload": payload}
),
1,
)
return await asyncio.wait_for(future, 1)
except TimeoutError as exc:
if (
self.ws is ws
and self.lease == lease
and self.authorization_generation == generation
):
await self.revoke("动作应用确认超时")
raise RobotError("TIMEOUT", "浏览器未在一秒内确认应用") from exc
finally:
self.pending.pop(request_id, None)
if not future.done():
future.cancel()
elif not future.cancelled():
future.exception() # Also consume failures delivered during a blocked send.
async def revoke(self, reason, notify=True):
self.lease = None
self.authorized = False
self.blocked_generation = self.authorization_generation
for future, _ in self.pending.values():
if not future.done():
future.set_exception(RobotError("DISCONNECTED", reason))
self.pending.clear()
if notify and self.ws is not None and not self.ws.closed and self.observation:
with contextlib.suppress(ConnectionError, TimeoutError):
await asyncio.wait_for(
self.ws.send_json(
{
"type": "stop",
"reason": reason,
"sessionId": self.observation["sessionId"],
"authorizationGeneration": self.authorization_generation,
}
),
0.1,
)
def require_lease(self, request):
provided = request.headers.get("X-Control-Lease", "")
if (
not self.lease
or not provided.isascii()
or not hmac.compare_digest(provided.encode(), self.lease["leaseId"].encode())
):
raise RobotError("UNAUTHORIZED", "控制租约无效")
return dict(self.lease)
async def monitor(self):
while True:
await asyncio.sleep(0.05)
if self.lease and (
time.monotonic() - self.last_action_at > 0.5
or time.monotonic() - self.observed_at > 0.5
):
await self.revoke("动作或观测看门狗超时 (500ms)")
@web.middleware
async def security(request, handler):
broker = request.app[BROKER]
try:
if not HOST.fullmatch(request.headers.get("Host", "")):
raise RobotError("UNAUTHORIZED", "Host 不被允许")
origin = request.headers.get("Origin")
if origin is not None and not ORIGIN.fullmatch(origin):
raise RobotError("UNAUTHORIZED", "Origin 不被允许")
if request.query:
raise RobotError("INVALID_MESSAGE", "禁止 URL 查询参数及 URL 中的 token")
if request.path != "/ws/control/v1":
expected = f"Bearer {broker.token}".encode()
provided = request.headers.get("Authorization", "")
if not provided.isascii() or not hmac.compare_digest(provided.encode(), expected):
raise RobotError("UNAUTHORIZED", "需要本机控制 Bearer token")
elif origin is None:
raise RobotError("UNAUTHORIZED", "浏览器 WebSocket 必须提供 Origin")
return await handler(request)
except RobotError as exc:
status = {
"UNAUTHORIZED": 401,
"DISCONNECTED": 503,
"TIMEOUT": 504,
"INVALID_MESSAGE": 400,
"UNSUPPORTED": 400,
}.get(exc.code, 409)
return web.json_response({"error": exc.as_dict()}, status=status)
except web.HTTPRequestEntityTooLarge:
return web.json_response(
{"error": {"code": "INVALID_MESSAGE", "message": "消息不能超过64KiB"}}, status=413
)
async def body(request):
if request.content_type != "application/json":
p.invalid("需要 application/json")
return p.loads(await request.read())
async def health(request):
b = request.app[BROKER]
return web.json_response(
{
"protocolVersion": 1,
"backendConnected": b.descriptor is not None,
"authorized": b.authorized,
"hasLease": b.lease is not None,
}
)
async def robot(request):
return web.json_response(request.app[BROKER].robot())
async def observation(request):
return web.json_response(request.app[BROKER].fresh_observation())
async def claim(request):
b = request.app[BROKER]
data = p.record(await body(request), {"sessionId", "modelEpoch", "modelFingerprint"})
p.text(data["sessionId"])
p.integer(data["modelEpoch"])
b.robot()
observed = b.fresh_observation()
p.same_identity(data, observed, ("sessionId", "modelEpoch"))
if data["modelFingerprint"] != b.descriptor["modelFingerprint"]:
raise RobotError("INCOMPATIBLE_MODEL", "模型指纹已变化")
if b.lease:
raise RobotError("CONFLICT", "已有一个 Python 控制者")
if not b.authorized:
raise RobotError("UNAUTHORIZED", "请先在浏览器显式允许外部控制")
lease = {
"sessionId": data["sessionId"],
"modelEpoch": data["modelEpoch"],
"leaseId": secrets.token_hex(24),
}
b.lease = lease
b.last_action_at = time.monotonic()
b.last_action_seq = 0
b.rate.clear()
try:
result = p.record(
await b.rpc(
"claim",
{
**lease,
"modelFingerprint": data["modelFingerprint"],
"authorizationGeneration": b.authorization_generation,
},
),
p.IDENTITY,
)
p.same_identity(result, lease)
if b.lease != lease:
raise RobotError("STALE", "租约在申请期间失效")
return web.json_response(result)
except BaseException:
if b.lease == lease:
await b.revoke("租约申请失败")
raise
async def action(request):
b = request.app[BROKER]
lease = b.require_lease(request)
b.fresh_observation()
data = p.action(await body(request), b.robot())
p.same_identity(data, lease)
if data["actionSeq"] <= b.last_action_seq:
raise RobotError("STALE", "拒绝重复/乱序动作")
now = time.monotonic()
while b.rate and now - b.rate[0] >= 1:
b.rate.popleft()
if len(b.rate) >= 100:
raise RobotError("CONFLICT", "动作频率不能超过100Hz")
b.rate.append(now)
b.last_action_seq = data["actionSeq"]
b.last_action_at = now
try:
result = p.action_result(await b.rpc("action", data), b.robot())
p.same_identity(result, data, (*p.IDENTITY, "actionSeq"))
except RobotError as exc:
if exc.code != "SUPERSEDED" and b.lease == lease:
await b.revoke("动作确认失败")
raise
if b.lease != lease:
raise RobotError("STALE", "动作确认来自失效租约")
return web.json_response(result)
async def release(request):
b = request.app[BROKER]
lease = b.require_lease(request)
try:
await b.rpc("release", lease)
finally:
if b.lease == lease:
await b.revoke("控制者断开连接")
return web.json_response({"released": True})
async def reset(request):
b = request.app[BROKER]
lease = b.require_lease(request)
p.record(await body(request), set())
if not b.robot()["capabilities"]["reset"]:
raise RobotError("UNSUPPORTED", "机器人不支持 reset")
try:
result = p.observation(await b.rpc("reset", lease), b.robot())
if (
result["sessionId"] != lease["sessionId"]
or result["modelEpoch"] <= lease["modelEpoch"]
or not result["paused"]
):
p.invalid("reset 未返回新代次的暂停观测")
finally:
if b.lease == lease:
await b.revoke("reset 后需要重新授权")
return web.json_response(result)
async def websocket(request):
b = request.app[BROKER]
ws = web.WebSocketResponse(
max_msg_size=MAX_BYTES, heartbeat=2, receive_timeout=5, compress=False
)
await ws.prepare(request)
registered = False
times = deque()
try:
# Outer deadline: WS ping/pong must NOT restart the authentication clock.
first = await asyncio.wait_for(ws.receive(), 5)
if first.type != WSMsgType.TEXT:
raise RobotError("UNAUTHORIZED", "必须在5秒内通过认证")
auth = p.record(p.loads(first.data), {"type", "token", "protocolVersion"})
p.version(auth["protocolVersion"])
if (
auth["type"] != "auth"
or not isinstance(auth["token"], str)
or not auth["token"].isascii()
or not hmac.compare_digest(auth["token"].encode(), b.token.encode())
):
raise RobotError("UNAUTHORIZED", "WebSocket token 无效")
if b.ws is not None:
raise RobotError("CONFLICT", "已有浏览器后端连接")
b.ws = ws # Reserve the single backend before any further await.
await ws.send_json({"type": "authenticated"})
while True:
message = (
await asyncio.wait_for(ws.receive(), 5) if not registered else await ws.receive()
)
if message.type != WSMsgType.TEXT:
break
now = time.monotonic()
while times and now - times[0] > 1:
times.popleft()
if len(times) >= 256:
raise RobotError("CONFLICT", "浏览器消息过于频繁")
times.append(now)
packet = p.loads(message.data)
if not isinstance(packet, dict):
p.invalid("消息必须是对象")
kind = packet.get("type")
if kind == "register" and not registered:
p.record(
packet,
{"type", "descriptor", "observation", "enabled", "authorizationGeneration"},
)
b.descriptor = p.descriptor(packet["descriptor"])
await b.state({k: v for k, v in packet.items() if k != "descriptor"})
registered = True
await ws.send_json({"type": "ready"})
elif kind == "state" and registered:
await b.state(packet)
elif kind == "result" and registered:
p.record(packet, {"type", "id", "ok", "value"})
p.text(packet["id"])
if type(packet["ok"]) is not bool:
p.invalid("ok 必须为 bool")
pending = b.pending.get(packet["id"])
if pending and not pending[0].done():
if packet["ok"]:
pending[0].set_result(packet["value"])
else:
error = p.record(packet["value"], {"code", "message"})
if (
not isinstance(error["code"], str)
or not isinstance(error["message"], str)
or len(error["message"]) > 1024
):
p.invalid("错误格式无效")
pending[0].set_exception(RobotError(error["code"], error["message"]))
else:
p.invalid("未注册后端或未知消息类型")
except (RobotError, TimeoutError, ConnectionError) as exc:
error = (
exc
if isinstance(exc, RobotError)
else RobotError("DISCONNECTED", "浏览器连接中断/超时")
)
if not ws.closed:
with contextlib.suppress(ConnectionError):
await ws.send_json({"type": "error", "error": error.as_dict()})
finally:
if b.ws is ws:
await b.revoke("浏览器已断开", notify=False)
b.ws = b.descriptor = b.observation = None
b.authorization_generation = 0
b.blocked_generation = -1
await ws.close()
return ws
def create_app(token):
app = web.Application(middlewares=[security], client_max_size=MAX_BYTES)
app[BROKER] = Broker(token)
app.add_routes(
[
web.get(f"{PREFIX}/health", health),
web.get(f"{PREFIX}/robot", robot),
web.get(f"{PREFIX}/observation", observation),
web.post(f"{PREFIX}/lease", claim),
web.delete(f"{PREFIX}/lease", release),
web.post(f"{PREFIX}/action", action),
web.post(f"{PREFIX}/reset", reset),
web.get("/ws/control/v1", websocket),
]
)
async def lifetime(application):
b = application[BROKER]
task = asyncio.create_task(b.monitor())
yield
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await b.revoke("桥接服务关闭")
if b.ws is not None:
await b.ws.close()
app.cleanup_ctx.append(lifetime)
return app