3ad29356c9
集成通用机器人数值接口、本机控制桥、LeRobot 插件和统一键盘遥操作。采用离线 CoACD 全臂碰撞配方 revision 4、局部装配区切分与结构自接触,限制直接关节位姿写入并保留安全看门狗。同步版本号、变更记录、来源许可证和兼容性验证。
216 lines
8.6 KiB
TypeScript
216 lines
8.6 KiB
TypeScript
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();
|
|
}
|
|
});
|