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
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from '@playwright/test';
import { startBridge } from './fixtures/controlBridge';
import type {} from '../physics/runner';
test('通用单关节:真实 Python SDK ↔ 本机桥接 ↔ 浏览器 WASM,断开后安全保持', async ({ page }) => {
const bridge = await startBridge();
try {
await page.goto('http://127.0.0.1:4174/physics/runner.html');
await page.waitForFunction(() => Boolean(window.lekiwiPhysics));
await page.evaluate(() => window.lekiwiPhysics.bootSingle());
await page.evaluate(
({ endpoint, token }) => window.lekiwiPhysics.connectBridge(endpoint, token),
{ endpoint: bridge.endpoint, token: bridge.token },
);
const run = await bridge.python(`
import json, os, time
from mujoco_control_bridge import SimRobotClient
with SimRobotClient(os.environ['MUJOCO_CONTROL_ENDPOINT']) as robot:
start=time.monotonic()
for i in range(60):
accepted=robot.send_action({'slider.position': .5})
obs=robot.get_observation()
time.sleep(max(0,start+(i+1)/30-time.monotonic()))
print(json.dumps({'accepted':accepted['values'], 'measured':obs['values'], 'seq':obs['appliedActionSeq']}))
`);
const result = JSON.parse(run.stdout.trim()) as {
accepted: Record<string, number>;
measured: Record<string, number>;
seq: number;
};
expect(result.accepted['slider.position']).toBe(0.5);
expect(result.measured['slider.position']).toBeGreaterThan(0.48);
expect(result.measured['slider.position']).toBeLessThan(0.51);
expect(result.seq).toBeGreaterThan(55);
await expect
.poll(
async () =>
(await page.evaluate(() => window.lekiwiPhysics.externalState())).observation?.paused,
)
.toBe(true);
expect(bridge.errors()).toBe('');
} finally {
await page.evaluate(() => window.lekiwiPhysics?.dispose()).catch(() => {});
await bridge.stop();
}
});
@@ -0,0 +1,82 @@
import { spawn, execFile, type ChildProcess } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export const bridgePython =
process.env.BRIDGE_PYTHON ??
(existsSync(resolve('.venv/bin/python')) ? resolve('.venv/bin/python') : 'python3');
export async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
await new Promise<void>((resolve) => {
const timer = setTimeout(() => child.kill('SIGKILL'), 3000);
child.once('exit', () => {
clearTimeout(timer);
resolve();
});
child.kill('SIGTERM');
});
}
export async function startBridge() {
const token = randomBytes(32).toString('hex');
const child = spawn(bridgePython, ['-m', 'mujoco_control_bridge', '--port', '0'], {
env: { ...process.env, PYTHONPATH: '', PYTHONNOUSERSITE: '1', MUJOCO_CONTROL_TOKEN: token },
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '',
errors = '';
child.stderr.on('data', (chunk: Buffer) => {
errors = (errors + chunk.toString()).slice(-65536);
});
try {
const endpoint = await new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`桥接启动超时:${errors}`)), 15000);
child.once('error', (error) => {
clearTimeout(timeout);
reject(error);
});
child.once('exit', () => {
clearTimeout(timeout);
reject(new Error(`桥接启动失败:${errors}`));
});
child.stdout.on('data', (chunk: Buffer) => {
output += chunk.toString();
const ready = output.match(/CONTROL_BRIDGE_READY (\{[^\n]+\})/);
if (ready) {
clearTimeout(timeout);
resolve((JSON.parse(ready[1]) as { endpoint: string }).endpoint);
}
});
});
return {
endpoint,
token,
stop: () => stopProcess(child),
errors: () => errors,
python: async (
script: string,
python = bridgePython,
environment: Record<string, string> = {},
) => {
const { stdout, stderr } = await execFileAsync(python, ['-c', script], {
env: {
...process.env,
PYTHONPATH: '',
PYTHONNOUSERSITE: '1',
...environment,
MUJOCO_CONTROL_TOKEN: token,
MUJOCO_CONTROL_ENDPOINT: endpoint,
},
timeout: 120000,
maxBuffer: 4 * 1024 * 1024,
});
return { stdout, stderr };
},
};
} catch (error) {
await stopProcess(child);
throw error;
}
}
@@ -0,0 +1,42 @@
import { expect, test } from '@playwright/test';
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import type {} from '../physics/runner';
test('真实 WASM:上臂不能穿过自身臂座组件,接触后仍能反向离开', async ({ page }, info) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto('/physics/runner.html');
await page.waitForFunction(() => Boolean(window.lekiwiPhysics));
const initial = await page.evaluate(
(path) => window.lekiwiPhysics.boot(path),
`/@fs${resolve('build/lekiwi')}`,
);
try {
const samples = await page.evaluate(() => window.lekiwiPhysics.armSelfCollisionChecks());
console.log('arm/base contacts', JSON.stringify(samples));
const evidence = info.outputPath('arm-base-contacts.json');
await writeFile(evidence, JSON.stringify({ version: initial.version, samples }, null, 2));
await info.attach('arm-base-contacts', { path: evidence, contentType: 'application/json' });
expect(initial.version).toBe('3.11.0');
for (const sample of samples) {
expect(Math.abs(sample.neutral.actual), `neutral, pan=${sample.pan}`).toBeLessThan(0.01);
expect(sample.neutral.contacts).toEqual([]);
// Full CAD changes the first obstruction: WaveShare plate at pan=0,
// rotating shoulder clip at +/-0.8. Keep real contact/penetration/release
// assertions; original Base_08q coverage is independently probed from STL.
expect(sample.blocked.maxContacts, `blocked, pan=${sample.pan}`).toBeGreaterThan(0);
expect(sample.blocked.contacts.length).toBeGreaterThan(0);
expect(sample.blocked.actual).toBeLessThan(sample.blocked.target - 0.15);
expect(sample.blocked.maxPenetration).toBeLessThan(0.002);
for (const contact of sample.blocked.contacts)
expect(contact.distance).toBeGreaterThan(-0.0005);
expect(sample.blocked.finite).toBe(true);
expect(Math.abs(sample.released.actual + 0.3)).toBeLessThan(0.01);
expect(sample.released.contacts).toEqual([]);
}
expect(errors).toEqual([]);
} finally {
await page.evaluate(() => window.lekiwiPhysics.dispose());
}
});
+52
View File
@@ -0,0 +1,52 @@
import { expect, test } from '@playwright/test';
import { resolve } from 'node:path';
import { startBridge } from './fixtures/controlBridge';
import type {} from '../physics/runner';
test('LeKiwi:真实通用 Python SDK 双向控制,3秒30Hz,无 LeRobot 依赖', async ({ page }) => {
const bridge = await startBridge();
try {
await page.goto('/physics/runner.html');
await page.waitForFunction(() => Boolean(window.lekiwiPhysics));
await page.evaluate(
(path) => window.lekiwiPhysics.boot(path),
`/@fs${resolve(process.cwd(), 'build/lekiwi')}`,
);
await page.evaluate(
({ endpoint, token }) => window.lekiwiPhysics.connectBridge(endpoint, token),
{ endpoint: bridge.endpoint, token: bridge.token },
);
const run = await bridge.python(`
import json, os, time
from mujoco_control_bridge import SimRobotClient
with SimRobotClient(os.environ['MUJOCO_CONTROL_ENDPOINT']) as robot:
values={c['id']:0.0 for c in robot.describe()['actionChannels']}
values.update({'arm_shoulder_pan.pos':.1,'arm_gripper.pos':.5})
before=robot.get_observation()['values']
start=time.monotonic(); latency=[]
for i in range(90):
values.update({'x.vel':.08 if i<30 else 0,'y.vel':.08 if 30<=i<60 else 0,'theta.vel':.3 if i>=60 else 0})
tick=time.monotonic(); robot.send_action(values); obs=robot.get_observation()
latency.append((time.monotonic()-tick)*1000)
time.sleep(max(0,start+(i+1)/30-time.monotonic()))
print(json.dumps({'before':before,'after':obs['values'],'rttMeanMs':sum(latency)/len(latency),'rttMaxMs':max(latency),'seq':obs['appliedActionSeq']}))
`);
const result = JSON.parse(run.stdout.trim());
expect(result.after['base.x'] - result.before['base.x']).toBeGreaterThan(0.04);
expect(result.after['base.y'] - result.before['base.y']).toBeGreaterThan(0.04);
expect(result.after['base.yaw']).toBeGreaterThan(0.2);
expect(result.after['arm_gripper.pos']).toBeCloseTo(0.5, 1);
expect(result.seq).toBeGreaterThan(85);
console.log('SDK bridge timing', { meanMs: result.rttMeanMs, maxMs: result.rttMaxMs });
await expect
.poll(
async () =>
(await page.evaluate(() => window.lekiwiPhysics.externalState())).observation?.paused,
)
.toBe(true);
expect(bridge.errors()).toBe('');
} finally {
await page.evaluate(() => window.lekiwiPhysics?.dispose()).catch(() => {});
await bridge.stop();
}
});
@@ -0,0 +1,91 @@
import { expect, test } from '@playwright/test';
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import type {} from '../physics/runner';
test('真实 WASM:相邻 Mirror/Square 连杆主体阻挡、反向脱离,机器人禁止 qpos 瞬移', async ({
page,
}, info) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/physics/runner.html');
await page.waitForFunction(() => Boolean(window.lekiwiPhysics));
await page.evaluate((path) => window.lekiwiPhysics.boot(path), `/@fs${resolve('build/lekiwi')}`);
try {
const result = await page.evaluate(() => window.lekiwiPhysics.adjacentArmChecks());
console.log('adjacent arm', JSON.stringify(result));
await writeFile(info.outputPath('adjacent-arm-contacts.json'), JSON.stringify(result, null, 2));
expect(result.teleportAccepted).toBe(false);
expect(result.jointResetError).toMatch(/profile.*瞬移/);
expect(result.editable).toBe(false);
expect(result.before).toEqual(result.after);
for (const sample of result.samples) {
expect(sample.neutral.contacts).toEqual([]);
expect(Math.abs(sample.neutral.panActual - sample.pan)).toBeLessThan(0.01);
expect(Math.abs(sample.neutral.actual)).toBeLessThan(0.015);
expect(sample.blocked.maxContacts).toBeGreaterThan(0);
expect(sample.blocked.contacts.length).toBeGreaterThan(0);
expect(sample.blocked.actual).toBeGreaterThan(sample.blocked.target + 0.5);
expect(sample.blocked.minContactRadius).toBeGreaterThan(0.04); // structural contact, NOT the bearing core
expect(sample.blocked.maxPenetration).toBeLessThan(0.002);
expect(sample.blocked.finite).toBe(true);
for (const c of sample.blocked.contacts) expect(c.distance).toBeGreaterThan(-0.0005);
expect(Math.abs(sample.released.actual - 0.3)).toBeLessThan(0.015);
expect(sample.released.contacts).toEqual([]);
}
expect(errors).toEqual([]);
} finally {
await page.evaluate(() => window.lekiwiPhysics.dispose());
}
});
test('真实 WASM:六关节双向有界目标扫掠,无整体穿入或数值重置', async ({ page }, info) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/physics/runner.html');
await page.waitForFunction(() => Boolean(window.lekiwiPhysics));
await page.evaluate((path) => window.lekiwiPhysics.boot(path), `/@fs${resolve('build/lekiwi')}`);
try {
const result = await page.evaluate(() => window.lekiwiPhysics.jointSweepChecks());
await writeFile(info.outputPath('joint-sweeps.json'), JSON.stringify(result, null, 2));
console.log('joint sweeps', JSON.stringify(result));
expect(result).toHaveLength(12);
for (const sample of result) {
expect(sample.finite, sample.joint).toBe(true);
expect(sample.elapsed).toBeCloseTo(sample.expectedTime, 6);
expect(sample.maxPenetration, sample.joint).toBeLessThan(0.002);
if (sample.joint === 'arm_shoulder_pan') {
expect(Math.abs(sample.actual - sample.target)).toBeLessThan(0.01);
expect(sample.maxContacts).toBe(0); // core/structural straddlers must not lock the bearing
}
if (Math.abs(sample.actual - sample.target) > 0.05)
expect(sample.maxContacts, sample.joint).toBeGreaterThan(0);
}
expect(errors).toEqual([]);
} finally {
await page.evaluate(() => window.lekiwiPhysics.dispose());
}
});
test('真实 WASM:从原始 STL 独立取六向极值,整臂 18 个视觉网格均有外部物体接触覆盖', async ({
page,
}, info) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/physics/runner.html');
await page.waitForFunction(() => Boolean(window.lekiwiPhysics));
await page.evaluate((path) => window.lekiwiPhysics.boot(path), `/@fs${resolve('build/lekiwi')}`);
try {
const result = await page.evaluate(() => window.lekiwiPhysics.visualCollisionCoverage());
await writeFile(info.outputPath('arm-visual-coverage.json'), JSON.stringify(result, null, 2));
expect(result).toHaveLength(18);
for (const sample of result) {
expect(sample.probes).toHaveLength(6);
for (const probe of sample.probes)
expect(probe.contacts.length, `${sample.visual}: ${probe.point}`).toBeGreaterThan(0);
}
expect(errors).toEqual([]);
} finally {
await page.evaluate(() => window.lekiwiPhysics.dispose());
}
});
+55
View File
@@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import type {} from '../physics/runner';
test('真实 WASM:夹爪两侧指尖覆盖、开度方向和物体阻挡,不能只检查关节数值', async ({
page,
}, info) => {
const errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto('/physics/runner.html');
await page.waitForFunction(() => Boolean(window.lekiwiPhysics));
await page.evaluate((path) => window.lekiwiPhysics.boot(path), `/@fs${resolve('build/lekiwi')}`);
try {
const result = await page.evaluate(() => window.lekiwiPhysics.gripperCollisionChecks());
console.log('gripper contacts', JSON.stringify(result));
await writeFile(info.outputPath('gripper-contacts.json'), JSON.stringify(result, null, 2));
await writeFile(
info.outputPath('gripper-scene.xml'),
await page.evaluate(() => window.lekiwiPhysics.xml()),
);
expect(result.finite).toBe(true);
for (const tip of result.coverage) {
expect(
tip.contacts.some((c) =>
c.other.startsWith(
tip.name === 'fixed_tip'
? '__lekiwi_cad_Wrist_Roll_08c-v1__'
: '__lekiwi_cad_Moving_Jaw_08d-v1__',
),
),
tip.name,
).toBe(true);
}
for (const state of result.openings) {
expect(Math.abs(state.actual - state.target)).toBeLessThan(0.01);
expect(state.contacts).toEqual([]);
}
expect(result.openings[0].separation).toBeGreaterThan(0);
expect(result.openings[0].separation).toBeLessThan(0.002);
expect(result.openings[1].separation).toBeGreaterThan(result.openings[0].separation + 0.02);
expect(result.openings[2].separation).toBeGreaterThan(result.openings[1].separation + 0.01);
expect(result.gapContacts).toEqual([]); // a hull must not seal the open space
expect(result.obstruction.maxContacts).toBeGreaterThan(0);
expect(result.obstruction.maxPenetration).toBeLessThan(0.0015);
expect(result.obstruction.actual).toBeGreaterThan(result.obstruction.target + 0.03);
expect(result.obstruction.contacts.length).toBeGreaterThan(0);
for (const contact of result.obstruction.contacts)
expect(contact.distance).toBeGreaterThan(-0.0002);
expect(result.fingerSelfContacts).toBeGreaterThan(0);
expect(errors).toEqual([]);
} finally {
await page.evaluate(() => window.lekiwiPhysics.dispose());
}
});
+215
View File
@@ -0,0 +1,215 @@
import { expect, test } from '@playwright/test';
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { startBridge } from './fixtures/controlBridge';
import type {} from '../physics/runner';
const python = process.env.LEROBOT_PYTHON ?? resolve('build/venvs/lerobot/bin/python');
test('真实终端输入:底盘与六路臂/夹爪点动、反向、保持和退出撤权', async ({ page }, info) => {
expect(existsSync(python), '需要真实的独立 LeRobot 环境').toBe(true);
const bridge = await startBridge();
try {
await page.goto('/physics/runner.html');
await page.waitForFunction(() => Boolean(window.lekiwiPhysics));
await page.evaluate(
(path) => window.lekiwiPhysics.boot(path),
`/@fs${resolve('build/lekiwi')}`,
);
await page.evaluate(
({ endpoint, token }) => window.lekiwiPhysics.connectBridge(endpoint, token),
{ endpoint: bridge.endpoint, token: bridge.token },
);
const result = await bridge.python(
`
import json, os, pty, select, subprocess, sys, termios, time
from urllib.request import Request, urlopen
def observe():
request=Request(os.environ['MUJOCO_CONTROL_ENDPOINT']+'/api/control/v1/observation',
headers={'Authorization':'Bearer '+os.environ['MUJOCO_CONTROL_TOKEN']})
with urlopen(request,timeout=2) as response:
return json.load(response)
master,slave=pty.openpty()
original_terminal=termios.tcgetattr(slave)
# This checks free-space hold, not servo compliance under self contact. The
# complete CAD assembly now blocks shoulder_lift near .12rad at this posture.
# Use 5 deg/s for this free-space test; keep hold/reversal thresholds unchanged.
# armCollision/fullCollision.spec separately exercise physical obstruction.
process=subprocess.Popen([sys.executable,'-u',os.environ['KEYBOARD_DEMO'],'--arm-speed','5'],stdin=slave,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
try:
assert select.select([process.stdout],[],[],10)[0], 'terminal demo did not start'
assert 'w/s' in process.stdout.readline().decode()
initial=observe()
for _ in range(10):
os.write(master,b'wuiotyv');time.sleep(.1)
# No input for longer than the watchdog: base stops, arm holds, actions continue.
time.sleep(.35)
held=observe()
time.sleep(.4)
held_again=observe()
for _ in range(6):
os.write(master,b'jklghb');time.sleep(.1)
os.write(master,b' ')
time.sleep(.35)
reversed_pose=observe()
os.write(master,b'q')
_,err=process.communicate(timeout=5)
assert process.returncode==0,err.decode()
assert termios.tcgetattr(slave)==original_terminal, 'terminal settings were not restored'
print(json.dumps({'exit':process.returncode,'initial':initial,'held':held,
'heldAgain':held_again,'reversed':reversed_pose}))
finally:
if process.poll() is None:
process.kill();process.wait()
os.close(master);os.close(slave)
`,
python,
{ KEYBOARD_DEMO: resolve('examples/lekiwi/teleoperate_sim.py') },
);
const report = JSON.parse(result.stdout.trim());
const reportPath = info.outputPath('keyboard-teleop.json');
await writeFile(reportPath, JSON.stringify(report, null, 2));
await info.attach('keyboard-teleop.json', {
path: reportPath,
contentType: 'application/json',
});
expect(report.exit).toBe(0);
expect(report.held.values['base.x'] - report.initial.values['base.x']).toBeGreaterThan(0.01);
expect(Math.abs(report.heldAgain.values['x.vel'])).toBeLessThan(0.02);
expect(report.heldAgain.appliedActionSeq).toBeGreaterThan(report.held.appliedActionSeq);
for (const joint of [
'arm_shoulder_pan',
'arm_shoulder_lift',
'arm_elbow_flex',
'arm_wrist_flex',
'arm_wrist_roll',
'arm_gripper',
]) {
const key = `${joint}.pos`;
expect(report.held.values[key] - report.initial.values[key], `${joint} 正向`).toBeGreaterThan(
0.03,
);
expect(
Math.abs(report.heldAgain.values[key] - report.held.values[key]),
`${joint} 保持`,
).toBeLessThan(0.03);
expect(
report.heldAgain.values[key] - report.reversed.values[key],
`${joint} 反向`,
).toBeGreaterThan(0.03);
}
await expect
.poll(
async () =>
(await page.evaluate(() => window.lekiwiPhysics.externalState())).observation?.paused,
)
.toBe(true);
const final = await page.evaluate(() => window.lekiwiPhysics.externalState());
expect(final.control).toMatchObject({ enabled: false, connected: false });
expect(bridge.errors()).toBe('');
} finally {
await page.evaluate(() => window.lekiwiPhysics?.dispose()).catch(() => {});
await bridge.stop();
}
});
test('真实 LeRobot 0.6.1 工厂/插件:无硬件 demo 与实际部分动作/限幅回传', async ({ page }) => {
expect(
existsSync(python),
'请先创建隔离 LeRobot 环境或设置 LEROBOT_PYTHON;兼容门槛不能 skipped',
).toBe(true);
const bridge = await startBridge();
try {
await page.goto('/physics/runner.html');
await page.waitForFunction(() => Boolean(window.lekiwiPhysics));
await page.evaluate(
(path) => window.lekiwiPhysics.boot(path),
`/@fs${resolve('build/lekiwi')}`,
);
await page.evaluate(
({ endpoint, token }) => window.lekiwiPhysics.connectBridge(endpoint, token),
{ endpoint: bridge.endpoint, token: bridge.token },
);
const run = await bridge.python(
`
import os, runpy, sys
sys.argv=['demo_control.py','--duration','3']
runpy.run_path(os.environ['LEKIWI_DEMO'],run_name='__main__')
`,
python,
{ LEKIWI_DEMO: resolve('examples/lekiwi/demo_control.py') },
);
const result = JSON.parse(run.stdout.trim());
expect(result.lerobot).toBe('0.6.1');
expect(result.steps).toBe(90);
expect(result.maxTranslationM).toBeGreaterThan(0.05);
expect(result.timeouts).toBe(0);
expect(Object.keys(result.observation)).toHaveLength(9);
await expect
.poll(
async () =>
(await page.evaluate(() => window.lekiwiPhysics.externalState())).observation?.paused,
)
.toBe(true);
// The previous demo paused the simulation. An old paused sample must explain
// the missing play step, not misreport it as a running simulation freeze.
const paused = await bridge.python(
`
import json, os, time
from mujoco_control_bridge import RobotError
from lerobot_robot_mujoco import LeKiwiSim, LeKiwiSimConfig
time.sleep(.6)
robot=LeKiwiSim(LeKiwiSimConfig(endpoint=os.environ['MUJOCO_CONTROL_ENDPOINT']))
try:
robot.connect()
except RobotError as error:
assert not robot.is_connected
print(json.dumps({'code':error.code,'message':str(error)}))
else:
robot.disconnect()
raise AssertionError('paused simulation must refuse a new controller')
`,
python,
);
expect(JSON.parse(paused.stdout.trim())).toMatchObject({
code: 'PAUSED',
message: expect.stringContaining('播放'),
});
await page.evaluate(() => window.lekiwiPhysics.authorize());
const partial = await bridge.python(
`
import json, os, torch, sys
from lerobot.utils.import_utils import register_third_party_plugins
from lerobot.robots.config import RobotConfig
from lerobot.robots.utils import make_robot_from_config
register_third_party_plugins()
robot=make_robot_from_config(RobotConfig.get_choice_class('lekiwi_sim')(endpoint=os.environ['MUJOCO_CONTROL_ENDPOINT']))
robot.connect()
try:
first=robot.send_action({'arm_shoulder_pan.pos':180,'arm_gripper.pos':150,'x.vel':.1})
second=robot.send_action({'arm_shoulder_lift.pos':5})
observed=robot.get_observation()
assert not torch.cuda.is_initialized()
assert not any(m in sys.modules for m in ('serial','zmq','scservo_sdk','pyrealsense2'))
print(json.dumps({'first':first,'second':second,'observed':observed}))
finally:
robot.disconnect()
`,
python,
);
const values = JSON.parse(partial.stdout.trim());
expect(values.first['arm_gripper.pos']).toBe(100);
expect(values.first['arm_shoulder_pan.pos']).toBeCloseTo((1.57 * 180) / Math.PI);
expect(values.second['arm_shoulder_pan.pos']).toBe(values.first['arm_shoulder_pan.pos']);
expect(values.second['x.vel']).toBe(0);
expect(values.second['arm_gripper.pos']).toBe(100);
expect(
Math.abs(values.observed['arm_shoulder_pan.pos'] - values.first['arm_shoulder_pan.pos']),
).toBeGreaterThan(1);
expect(bridge.errors()).toBe('');
} finally {
await page.evaluate(() => window.lekiwiPhysics?.dispose()).catch(() => {});
await bridge.stop();
}
});
+97
View File
@@ -0,0 +1,97 @@
import { expect, test } from '@playwright/test';
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import type {} from '../physics/runner';
test('真实 WASM 3.11:LeKiwi 站稳、全向轮、臂夹爪与障碍阻挡', async ({ page }, info) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('console', (msg) => {
if (msg.type() === 'error') console.log(msg.text());
});
await page.goto('/physics/runner.html');
await page.waitForFunction(() => Boolean(window.lekiwiPhysics));
const assetBase = `/@fs${resolve(process.cwd(), 'build/lekiwi')}`;
const initial = await page.evaluate((path) => window.lekiwiPhysics.boot(path), assetBase);
console.log('initial', initial);
expect(initial.version).toBe('3.11.0');
expect(initial.nu).toBe(9);
expect(initial.njnt).toBe(46);
await writeFile(
info.outputPath('lekiwi-profile.xml'),
await page.evaluate(() => window.lekiwiPhysics.xml()),
);
const stable = await page.evaluate(() => window.lekiwiPhysics.step(30));
console.log('stable', stable);
expect(stable.finite).toBe(true);
expect(Math.abs(stable.roll)).toBeLessThan(0.15);
expect(Math.abs(stable.pitch)).toBeLessThan(0.15);
const traces: unknown[] = [{ initial, stable }];
for (const [x, y, w] of [
[0.1, 0, 0],
[-0.1, 0, 0],
[0, 0.1, 0],
[0, -0.1, 0],
[0, 0, Math.PI / 6],
[0, 0, -Math.PI / 6],
]) {
const start = await page.evaluate(() => window.lekiwiPhysics.reset());
const end = await page.evaluate(
([x, y, w]) => window.lekiwiPhysics.drive(x, y, w, 2),
[x, y, w],
);
console.log('drive', x, y, w, 'start', start.position, start.yaw, 'end', end.position, end.yaw);
traces.push({ command: [x, y, w], start, end });
expect(end.finite).toBe(true);
if (x || y) {
expect(Math.abs(end.position[0] - start.position[0] - x * 2)).toBeLessThan(0.05);
expect(Math.abs(end.position[1] - start.position[1] - y * 2)).toBeLessThan(0.05);
} else expect(Math.abs(end.yaw - start.yaw - w * 2)).toBeLessThan((10 * Math.PI) / 180);
}
await page.evaluate(() => window.lekiwiPhysics.reset());
// Free-space tracking; negative elbow folding now has its own obstruction test.
const targets = [0.1, 0.1, 0.1, 0.1, 0.1, 0.3];
const arm = await page.evaluate((values) => window.lekiwiPhysics.arm(values, 2), targets);
console.log('arm', arm.arm);
traces.push({ targets, arm });
arm.arm.forEach((value, i) => expect(Math.abs(value - targets[i])).toBeLessThan(0.015));
await page.evaluate(() => window.lekiwiPhysics.wall());
const blocked = await page.evaluate(() => window.lekiwiPhysics.drive(0.1, 0, 0, 5));
console.log('blocked', blocked);
const contacts = await page.evaluate(() => window.lekiwiPhysics.contactPairs());
traces.push({ blocked, contacts });
expect(contacts.some((c) => c.geom1 === 'test_wall' || c.geom2 === 'test_wall')).toBe(true);
expect(blocked.maxContacts).toBeGreaterThan(0);
expect(blocked.position[0]).toBeLessThan(0.22);
// The complete arm/accessory outline reaches the wall before the old chassis proxy.
expect(blocked.position[0]).toBeGreaterThan(0.02);
expect(blocked.finite).toBe(true);
await writeFile(info.outputPath('physics-traces.json'), JSON.stringify(traces, null, 2));
expect(errors).toEqual([]);
await page.evaluate(() => window.lekiwiPhysics.dispose());
});
test('真实 Python/WASM:控制权、旧回调隔离、安全保持、reset 与导出重载', async ({ page }) => {
await page.goto('/physics/runner.html');
await page.waitForFunction(() => Boolean(window.lekiwiPhysics));
await page.evaluate(
(path) => window.lekiwiPhysics.boot(path),
`/@fs${resolve(process.cwd(), 'build/lekiwi')}`,
);
const result = await page.evaluate(() => window.lekiwiPhysics.runtimeChecks());
expect(result.pythonTarget).toBe(0.1);
expect(result.accepted.values['arm_shoulder_pan.pos']).toBe(0.3);
expect(result.afterOldDispose).toBe(0.3);
expect(result.manualBlocked).toBe(true);
expect(result.held).toBeCloseTo(result.actual, 10);
expect(result.paused?.enabled).toBe(false);
expect(result.pausedOwner).toBe('manual');
expect(result.cancel).toBe('DISCONNECTED');
expect(result.reset?.modelEpoch).toBe(1);
expect(result.reset?.paused).toBe(true);
const reloaded = await page.evaluate(() => window.lekiwiPhysics.reimport());
expect(reloaded.nu).toBe(9);
expect(reloaded.njnt).toBe(46);
expect(reloaded.robot?.profileId).toBe('lekiwi-v1');
await page.evaluate(() => window.lekiwiPhysics.dispose());
});
+317
View File
@@ -0,0 +1,317 @@
import { expect, test, type Page } from '@playwright/test';
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { startBridge } from './fixtures/controlBridge';
async function loadWorkspace(page: Page) {
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles(resolve('build/lekiwi/lekiwi-v1.zip'));
const dialog = page.getByRole('dialog', { name: '配置 URDF 仿真组件' });
await expect(dialog).toBeVisible({ timeout: 30000 });
await expect(dialog.getByLabel('URDF 机器人 profile')).toHaveValue('');
await dialog.getByLabel('URDF 机器人 profile').selectOption('lekiwi-v1');
await expect(dialog.getByRole('checkbox', { name: /添加传感器/ })).toBeDisabled();
await dialog.getByRole('button', { name: '转换并加载' }).click();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 60000 });
await page.getByRole('tab', { name: '控制台', exact: true }).click();
await expect(page.getByRole('region', { name: '开源机器人控制' })).toContainText('lekiwi-v1');
}
async function connect(page: Page, bridge: Awaited<ReturnType<typeof startBridge>>) {
await page.getByLabel('本机桥接地址', { exact: true }).fill(bridge.endpoint);
await page.getByLabel('控制 token', { exact: true }).fill(bridge.token);
await page.getByRole('button', { name: '连接桥接', exact: true }).click();
await expect(page.getByLabel('桥接连接状态', { exact: true })).toHaveText('已连接');
}
async function authorize(page: Page) {
await page.getByRole('button', { name: '▶ 播放' }).click();
await page.getByRole('button', { name: '允许外部控制', exact: true }).click();
await expect(page.getByLabel('当前控制所有者', { exact: true })).toHaveText('外部');
}
async function state(page: Page) {
return page.evaluate(async () => {
const path = '/src/stores/useAppStore.ts';
const { useAppStore } = (await import(
/* @vite-ignore */ path
)) as typeof import('../src/stores/useAppStore');
const s = useAppStore.getState();
return {
paused: s.paused,
owner: s.snapshot?.controlOwner,
time: s.snapshot?.time ?? 0,
epoch: s.snapshot?.externalControl?.modelEpoch,
sessionId: s.snapshot?.externalControl?.sessionId,
wasmHeapBytes: s.snapshot?.wasmHeapBytes,
fps: s.fps,
stepMs: s.stepMs,
memoryMb: s.memoryMb,
overBudget: s.overBudget,
actuators: s.snapshot?.actuators.map((a) => ({ name: a.jointName, target: a.value })),
joints: s.snapshot?.joints.map((j) => ({ name: j.name, value: j.value })),
diagnostic: s.diagnostic?.summary,
};
});
}
test('完整工作台 + 真实 LeRobot:文件导入、profile、授权和60秒30Hz控制', async ({ page }, info) => {
const bridge = await startBridge(),
errors: string[] = [];
page.on('pageerror', (error) => errors.push(error.message));
try {
await loadWorkspace(page);
// Profile pose editing is disabled even before an external controller owns it.
await expect(page.getByRole('button', { name: '关节拖动', exact: true })).toBeDisabled();
await connect(page, bridge);
await authorize(page);
await expect(page.getByLabel('仿真速度', { exact: true })).toBeDisabled();
await expect(page.getByLabel('仿真速度', { exact: true })).toHaveValue('1');
await expect(page.getByRole('button', { name: '关节拖动', exact: true })).toBeDisabled();
const consolePanel = page.getByRole('tabpanel', { name: '控制台' });
await consolePanel.getByRole('button', { name: /执行器实时控制/ }).click();
await expect(consolePanel.getByRole('slider').first()).toBeDisabled();
await consolePanel.getByRole('button', { name: /执行器实时控制/ }).click();
const metrics: Awaited<ReturnType<typeof state>>[] = [];
let done = false;
const run = bridge
.python(
`
import os, runpy, sys
sys.argv=['demo_control.py','--duration','60']
runpy.run_path(os.environ['LEKIWI_DEMO'],run_name='__main__')
`,
process.env.LEROBOT_PYTHON ?? resolve('build/venvs/lerobot/bin/python'),
{ LEKIWI_DEMO: resolve('examples/lekiwi/demo_control.py') },
)
.finally(() => {
done = true;
});
const monitor = (async () => {
while (!done) {
await page.waitForTimeout(1000);
metrics.push(await state(page));
}
})();
const [result] = await Promise.all([run, monitor]).catch(async (error: unknown) => {
const path = info.outputPath('partial-metrics.json');
await writeFile(path, JSON.stringify(metrics));
await info.attach('partial-metrics.json', { path, contentType: 'application/json' });
throw error;
});
const report = JSON.parse(result.stdout.trim());
const path = info.outputPath('lekiwi-60s.json');
const graphics = await page.evaluate(() => {
const canvas = document.querySelector('main canvas') as HTMLCanvasElement;
const gl = canvas.getContext('webgl2')!,
ext = gl.getExtension('WEBGL_debug_renderer_info');
return {
size: [canvas.width, canvas.height],
renderer: ext ? String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)) : 'unknown',
};
});
await writeFile(path, JSON.stringify({ report, metrics, graphics }, null, 2));
await info.attach('lekiwi-60s.json', { path, contentType: 'application/json' });
console.log('LeKiwi 60s evidence', report);
expect(report.steps).toBe(1800);
expect(report.elapsedSeconds).toBeGreaterThanOrEqual(60);
expect(report.elapsedSeconds).toBeLessThan(63);
expect(report.simSeconds).toBeGreaterThan(55);
expect(report.maxTranslationM).toBeGreaterThan(0.05);
expect(report.rttMaxMs).toBeLessThan(500);
expect(report.maxObservationSimGap).toBeLessThan(0.5);
expect(report.timeouts).toBe(0);
expect(report.droppedRequests).toBe(0);
expect(report.appliedActionSeq).toBeGreaterThan(1790);
expect(metrics.every((m) => Number.isFinite(m.time) && !m.diagnostic)).toBe(true);
const heaps = metrics.slice(10).flatMap((m) => (m.memoryMb === undefined ? [] : [m.memoryMb]));
if (heaps.length > 10) expect(heaps.at(-1)! - heaps[0]).toBeLessThan(64);
const wasm = metrics.slice(10).map((m) => m.wasmHeapBytes!);
expect(wasm.every((v) => Number.isFinite(v) && v > 0)).toBe(true);
expect(wasm.at(-1)! - wasm[0]).toBeLessThan(16 * 1024 * 1024);
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
await expect(page.getByLabel('当前控制所有者', { exact: true })).toHaveText('手动');
await page.screenshot({ path: info.outputPath('lekiwi-workspace.png') });
expect(bridge.errors()).toBe('');
expect(errors).toEqual([]);
} finally {
await page.close();
await bridge.stop();
}
});
test('工作台重编译与缓存编辑失败回滚不会恢复外部授权', async ({ page }) => {
page.setDefaultTimeout(15000);
const bridge = await startBridge();
try {
await loadWorkspace(page);
await connect(page, bridge);
await authorize(page);
const old = await state(page);
const driving = bridge.python(
genericDrive +
`
try:
for i in range(900):
robot.send_action(values);time.sleep(1/30)
raise AssertionError('reload must disconnect the controller')
except RobotError as error:
print(json.dumps({'stopped':error.code}))
finally:
robot.disconnect()
`,
);
await expect(page.getByLabel('外部控制状态', { exact: true })).toHaveText('外部控制者已连接');
await page.getByRole('button', { name: '校验并重新编译 profile' }).click();
await expect.poll(async () => (await state(page)).sessionId).not.toBe(old.sessionId);
expect(JSON.parse((await driving).stdout.trim()).stopped).toBeTruthy();
await expect(page.getByLabel('桥接连接状态', { exact: true })).toHaveText('未连接');
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
await connect(page, bridge);
await authorize(page);
const before = await state(page);
await page.getByRole('button', { name: '源代码', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '转换后的 MJCF 编辑器' });
const editor = dialog.locator('.monaco-editor');
await editor.click({ position: { x: 240, y: 120 } });
await page.keyboard.press('Control+a');
await page.keyboard.press('Backspace');
await page.keyboard.insertText('<mujoco>\n <worldbody/>\n</mujoco>\n');
await dialog.getByRole('button', { name: '保存并重新载入', exact: true }).click();
await expect.poll(async () => (await state(page)).diagnostic).toBeTruthy();
expect(await state(page)).toMatchObject({
sessionId: before.sessionId,
paused: true,
owner: 'manual',
});
await dialog.getByRole('button', { name: '关闭源代码编辑器' }).click();
await expect(page.getByLabel('桥接连接状态', { exact: true })).toHaveText('未连接');
await expect(page.getByLabel('外部控制状态', { exact: true })).toHaveText('未授权');
} finally {
await bridge.stop();
}
});
test('普通模型不自动套用 profile,错误 profile 保留原场景', async ({ page }) => {
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'generic.xml',
mimeType: 'text/xml',
buffer: Buffer.from(
'<mujoco><worldbody><body><joint name="j" type="slide" axis="1 0 0"/><geom type="sphere" size=".1"/></body></worldbody><actuator><motor joint="j"/></actuator></mujoco>',
),
});
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
await page.getByRole('tab', { name: '控制台', exact: true }).click();
await page.getByRole('button', { name: '开源项目 / 外部控制' }).click();
await expect(page.getByLabel('控制接口 profile', { exact: true })).toHaveValue('');
await expect(page.getByRole('button', { name: '连接桥接', exact: true })).toBeDisabled();
const before = await state(page);
await page.getByLabel('控制接口 profile', { exact: true }).selectOption('lekiwi-v1');
await page.getByRole('button', { name: '校验并重新编译 profile' }).click();
await expect.poll(async () => (await state(page)).diagnostic).toBeTruthy();
expect((await state(page)).actuators).toEqual(before.actuators);
await expect(page.getByRole('button', { name: '连接桥接', exact: true })).toBeDisabled();
});
const genericDrive = `
import json, os, time
from mujoco_control_bridge import SimRobotClient, RobotError
robot=SimRobotClient(os.environ['MUJOCO_CONTROL_ENDPOINT']);robot.connect()
values={c['id']:0 for c in robot.describe()['actionChannels']}
values.update({'arm_shoulder_pan.pos':.2,'arm_gripper.pos':.7,'x.vel':.1})
`;
test('工作台安全生命周期:真实 reset、新 epoch、控制者崩溃、页面隐藏与冻结恢复', async ({
page,
}) => {
const bridge = await startBridge();
try {
await loadWorkspace(page);
await connect(page, bridge);
await authorize(page);
const reset = await bridge.python(
genericDrive +
`
robot.send_action(values)
result=robot.reset()
print(json.dumps({'epoch':result['modelEpoch'],'paused':result['paused']}))
`,
);
expect(JSON.parse(reset.stdout.trim())).toEqual({ epoch: 1, paused: true });
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
await expect(page.getByLabel('桥接连接状态', { exact: true })).toHaveText('已连接');
expect((await state(page)).epoch).toBe(1);
await authorize(page);
await bridge.python(
genericDrive +
`
robot.send_action(values)
os._exit(0) # Deliberately crash without a finalizer; watchdog must stop it.
`,
);
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled({ timeout: 3000 });
const stopped = await state(page);
expect(stopped.owner).toBe('manual');
expect(
stopped.actuators?.filter((a) => a.name?.startsWith('base_')).map((a) => a.target),
).toEqual([0, 0, 0]);
const held = stopped.actuators?.find((a) => a.name === 'arm_shoulder_pan')?.target;
expect(held).toBeGreaterThan(0.05);
expect(held).toBeCloseTo(stopped.joints!.find((j) => j.name === 'arm_shoulder_pan')!.value, 8);
await authorize(page);
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
document.dispatchEvent(new Event('visibilitychange'));
});
await expect(page.getByLabel('桥接连接状态', { exact: true })).toHaveText('未连接');
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
await page.evaluate(() => {
Reflect.deleteProperty(document, 'visibilityState');
document.dispatchEvent(new Event('visibilitychange'));
});
await expect(page.getByLabel('外部控制状态', { exact: true })).toHaveText('未授权');
await connect(page, bridge);
await authorize(page);
const driving = bridge.python(
genericDrive +
`
try:
for i in range(900):
robot.send_action(values);time.sleep(1/30)
raise AssertionError('Frozen browser must revoke the lease')
except RobotError as error:
print(json.dumps({'stopped':error.code}))
finally:
robot.disconnect()
`,
);
await expect(page.getByLabel('外部控制状态', { exact: true })).toHaveText('外部控制者已连接');
const before = await state(page),
cdp = await page.context().newCDPSession(page);
await cdp.send('Debugger.enable');
const paused = new Promise<void>((resolve) => cdp.once('Debugger.paused', () => resolve()));
await cdp.send('Debugger.pause');
await paused;
try {
await new Promise((resolve) => setTimeout(resolve, 800));
} finally {
await cdp.send('Debugger.resume');
await cdp.detach();
}
const frozen = await driving;
expect(JSON.parse(frozen.stdout.trim()).stopped).toBeTruthy();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeEnabled();
const after = await state(page);
expect(after.time - before.time).toBeLessThan(0.3);
expect(
after.actuators?.filter((a) => a.name?.startsWith('base_')).map((a) => a.target),
).toEqual([0, 0, 0]);
} finally {
await page.close();
await bridge.stop();
}
});