Files
Mujoco_WASM/web_platform/src/robot/ExternalControlPanel.tsx
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

172 lines
5.8 KiB
TypeScript
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.
import { useState } from 'react';
import { Button, Select } from '../components/ui';
import type { SimulationSnapshot } from '../simulation/SimulationSession';
import type { BridgeStatus } from './ExternalControlClient';
import { ROBOT_PROFILES } from './registry';
export interface ExternalControlUI {
status: BridgeStatus;
endpoint: string;
setEndpoint: (value: string) => void;
token: string;
setToken: (value: string) => void;
observationAgeMs?: number;
connect: () => Promise<void>;
disconnect: (reason?: string) => void;
authorize: () => void;
stop: () => void;
}
export function ExternalControlPanel({
snapshot,
loading,
bridge,
onApplyProfile,
}: {
snapshot: SimulationSnapshot;
loading: boolean;
bridge: ExternalControlUI;
onApplyProfile: (id?: string) => void;
}) {
const [profileId, setProfileId] = useState(snapshot.robot?.profileId ?? '');
const external = snapshot.externalControl,
ready = bridge.status.phase === 'ready';
const owner = { manual: '手动', python: 'Python', policy: 'ONNX', external: '外部' }[
snapshot.controlOwner ?? 'manual'
];
return (
<section aria-label="开源机器人控制" className="space-y-3 text-xs">
<label className="block">
机器人 profile
<Select
aria-label="控制接口 profile"
className="mt-1 w-full"
value={profileId}
disabled={loading}
onChange={(e) => setProfileId(e.target.value)}
>
<option value="">不启用(普通模型)</option>
{ROBOT_PROFILES.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</Select>
</label>
<Button disabled={loading} onClick={() => onApplyProfile(profileId || undefined)}>
校验并重新编译 profile
</Button>
<p className="text-text-secondary">
仅显式选择后生效;未知模型/版本拒绝接管。LeKiwi 使用简化动力学,不是实机标定。
</p>
<p>
当前 profile:<strong>{snapshot.robot?.profileId ?? '未启用'}</strong>
</p>
{snapshot.robot && (
<p>
模型指纹:
<code title={snapshot.robot.modelFingerprint}>
{snapshot.robot.modelFingerprint.slice(0, 16)}
</code>
</p>
)}
<label className="block">
本机桥接地址
<input
aria-label="本机桥接地址"
className="field mt-1 w-full px-2 py-1"
value={bridge.endpoint}
disabled={bridge.status.phase !== 'disconnected'}
onChange={(e) => bridge.setEndpoint(e.target.value)}
/>
</label>
<label className="block">
控制 token(仅当前页面内存)
<input
aria-label="控制 token"
type="password"
autoComplete="off"
className="field mt-1 w-full px-2 py-1"
value={bridge.token}
disabled={bridge.status.phase !== 'disconnected'}
onChange={(e) => bridge.setToken(e.target.value)}
/>
</label>
<div className="flex flex-wrap gap-2">
<Button
disabled={
loading ||
!snapshot.robot ||
bridge.status.phase !== 'disconnected' ||
!bridge.token.trim()
}
onClick={() => void bridge.connect()}
>
{bridge.status.phase === 'connecting' ? '连接中…' : '连接桥接'}
</Button>
<Button
disabled={bridge.status.phase === 'disconnected'}
onClick={() => bridge.disconnect()}
>
断开桥接
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
disabled={loading || !ready || snapshot.paused || Boolean(external?.enabled)}
onClick={bridge.authorize}
>
允许外部控制
</Button>
<Button disabled={!external?.enabled} onClick={bridge.stop}>
停止外部控制
</Button>
</div>
<p>
连接:
<span aria-label="桥接连接状态">
{ready ? '已连接' : bridge.status.phase === 'connecting' ? '连接中' : '未连接'}
</span>
</p>
<p>
当前控制所有者:<strong aria-label="当前控制所有者">{owner}</strong>
</p>
<p aria-label="外部控制状态">
{external?.connected
? '外部控制者已连接'
: external?.enabled
? '等待 Python 控制者'
: '未授权'}
</p>
<p aria-label="机器人观测状态">
{snapshot.paused
? '已暂停(非实时观测)'
: bridge.observationAgeMs === undefined
? '尚无观测'
: `观测年龄 ${bridge.observationAgeMs.toFixed(0)} ms${bridge.observationAgeMs > 500 ? ' · 已过期' : ''}`}
</p>
{external && (
<p className="technical-value">
epoch {external.modelEpoch} · applied seq {external.lastActionSeq}
</p>
)}
{(bridge.status.error || (external?.error && !external.enabled)) && (
<p role="status" className="text-warning">
{bridge.status.error || external?.error}
</p>
)}
<p className="text-text-tertiary">
先播放再授权。接管会停止 Python/ONNX,并锁定
1×。暂停、重载、页面隐藏和500ms超时均撤销授权;不会自动恢复旧命令。
</p>
<p className="text-text-tertiary">
物理/观测不依赖渲染帧率。外控视图上限30FPS;检测到软件渲染时关闭阴影并降至5FPS,为控制保留时间。
</p>
<p className="text-text-tertiary">
V1:底盘/臂/夹爪和状态回读;不支持相机、锁步、LeRobot 数据采集或训练。
</p>
</section>
);
}