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,262 @@
import type { PhysicsAdapter } from '../simulation/PhysicsAdapter';
import { RobotError, type RobotIdentity } from './types';
import { record, exactKeys, integer, identifier } from './validation';
export interface BridgeStatus {
phase: 'disconnected' | 'connecting' | 'ready';
error?: string;
}
type Backend = Pick<
PhysicsAdapter,
| 'describeRobot'
| 'robotObservation'
| 'externalControlStatus'
| 'claimExternalControlLease'
| 'sendRobotAction'
| 'stopExternalControl'
| 'reset'
>;
/** Bounded numeric transport only. Never owns physics or a render/UI snapshot loop. */
export class ExternalControlClient {
private socket?: WebSocket;
private timer?: ReturnType<typeof setInterval>;
private handshakeTimer?: ReturnType<typeof setTimeout>;
private sessionId?: string;
private lease?: RobotIdentity;
private leaseGeneration?: number;
private rejectConnect?: (error: Error) => void;
private statusValue: BridgeStatus = { phase: 'disconnected' };
constructor(
private readonly backend: () => Backend | undefined,
private readonly onStatus: (status: BridgeStatus) => void = () => {},
) {}
status(): BridgeStatus {
return { ...this.statusValue };
}
private statusChanged(value: BridgeStatus): void {
this.statusValue = value;
this.onStatus(value);
}
private state() {
const backend = this.backend(),
observation = backend?.robotObservation(),
control = backend?.externalControlStatus();
if (
!backend ||
!observation ||
!control ||
(this.sessionId && observation.sessionId !== this.sessionId)
)
throw new RobotError('DISCONNECTED', '模型会话已变化,请重新连接');
return {
type: 'state',
observation,
enabled: control.enabled,
authorizationGeneration: control.authorizationGeneration,
};
}
async connect(endpoint: string, token: string): Promise<void> {
this.disconnect('重新连接');
let url: URL;
try {
url = new URL(endpoint);
} catch {
return Promise.reject(new RobotError('INVALID_MESSAGE', '桥接地址无效'));
}
if (
url.protocol !== 'http:' ||
!['127.0.0.1', 'localhost'].includes(url.hostname) ||
url.username ||
url.password ||
url.search ||
url.hash ||
url.pathname !== '/'
)
return Promise.reject(
new RobotError('UNAUTHORIZED', 'V1 仅允许本机 http://127.0.0.1:port 或 localhost'),
);
if (!/^[\x21-\x7e]{16,4096}$/.test(token.trim()))
return Promise.reject(
new RobotError('UNAUTHORIZED', '控制 token 需16–4096位可打印 ASCII,不能含空白'),
);
const descriptor = this.backend()?.describeRobot(),
initial = this.state();
if (!descriptor) return Promise.reject(new RobotError('UNSUPPORTED', '请先应用机器人 profile'));
this.sessionId = initial.observation.sessionId;
url.protocol = 'ws:';
url.pathname = '/ws/control/v1';
const socket = new WebSocket(url);
this.socket = socket;
this.statusChanged({ phase: 'connecting' });
return new Promise<void>((resolve, reject) => {
this.rejectConnect = reject;
this.handshakeTimer = setTimeout(() => this.disconnect('桥接认证超时'), 5000);
socket.onopen = () => {
if (this.socket === socket)
this.send({ type: 'auth', protocolVersion: 1, token: token.trim() });
};
socket.onerror = () => {
if (this.socket === socket) this.disconnect('无法连接本机控制桥接');
};
socket.onclose = () => {
if (this.socket === socket) this.disconnect('桥接已断开,请重新授权');
};
socket.onmessage = (event) => {
if (this.socket !== socket) return;
void this.message(event.data, socket, descriptor, resolve).catch((error: unknown) => {
if (this.socket === socket)
this.disconnect(error instanceof Error ? error.message : '桥接协议错误');
});
};
});
}
private send(message: unknown): void {
const json = JSON.stringify(message),
socket = this.socket;
if (
!socket ||
socket.readyState !== WebSocket.OPEN ||
socket.bufferedAmount + new TextEncoder().encode(json).length > 65536
)
throw new RobotError('DISCONNECTED', '桥接发送缓冲区已满或已关闭');
socket.send(json);
}
private async message(
raw: unknown,
socket: WebSocket,
descriptor: unknown,
ready: () => void,
): Promise<void> {
if (typeof raw !== 'string' || new TextEncoder().encode(raw).length > 65536)
throw new RobotError('INVALID_MESSAGE', '无效桥接帧');
const message = record(JSON.parse(raw));
if (message.type === 'authenticated') {
exactKeys(message, ['type']);
this.send({ ...this.state(), type: 'register', descriptor });
} else if (message.type === 'ready') {
exactKeys(message, ['type']);
clearTimeout(this.handshakeTimer);
this.rejectConnect = undefined;
this.statusChanged({ phase: 'ready' });
if (this.timer) clearInterval(this.timer);
this.timer = setInterval(() => this.sync(), 1000 / 30);
ready();
} else if (message.type === 'error') {
const error = record(message.error);
throw new RobotError(
'DISCONNECTED',
typeof error.message === 'string' ? error.message : '桥接错误',
);
} else if (message.type === 'stop') {
exactKeys(message, ['type', 'reason', 'sessionId', 'authorizationGeneration']);
const current = this.state();
if (
message.sessionId === current.observation.sessionId &&
message.authorizationGeneration === current.authorizationGeneration
) {
this.backend()?.stopExternalControl(
typeof message.reason === 'string' ? message.reason : '服务端撤销控制',
);
this.lease = undefined;
this.sync();
}
} else if (message.type === 'request') {
exactKeys(message, ['type', 'id', 'op', 'payload']);
const id = identifier(message.id);
let value: unknown,
ok = true;
try {
value = await this.request(message.op, message.payload);
} catch (error: unknown) {
ok = false;
value = {
code: error instanceof RobotError ? error.code : 'DISCONNECTED',
message: error instanceof Error ? error.message.slice(0, 1024) : '控制请求失败',
};
}
// A delayed physics acknowledgement must never land on a replacement socket.
if (this.socket === socket) this.send({ type: 'result', id, ok, value });
} else throw new RobotError('INVALID_MESSAGE', '未知桥接消息类型');
}
private async request(op: unknown, payload: unknown): Promise<unknown> {
if (this.statusValue.phase !== 'ready') throw new RobotError('UNAUTHORIZED', '桥接握手未完成');
const current = this.state(),
backend = this.backend()!;
const data = record(payload);
if (op === 'claim') {
exactKeys(data, [
'sessionId',
'modelEpoch',
'leaseId',
'modelFingerprint',
'authorizationGeneration',
]);
identifier(data.sessionId);
identifier(data.leaseId);
integer(data.modelEpoch, 'modelEpoch');
if (
data.sessionId !== current.observation.sessionId ||
data.modelEpoch !== current.observation.modelEpoch ||
data.authorizationGeneration !== current.authorizationGeneration ||
data.modelFingerprint !== backend.describeRobot()?.modelFingerprint
)
throw new RobotError('STALE', '模型或授权代次已变化');
this.lease = backend.claimExternalControlLease(data.leaseId as string);
this.leaseGeneration = current.authorizationGeneration;
return this.lease;
}
if (
!this.lease ||
!current.enabled ||
!backend.externalControlStatus()?.connected ||
current.authorizationGeneration !== this.leaseGeneration ||
current.observation.modelEpoch !== this.lease.modelEpoch ||
data.leaseId !== this.lease.leaseId ||
data.sessionId !== this.lease.sessionId ||
data.modelEpoch !== this.lease.modelEpoch
)
throw new RobotError('STALE', '控制租约失效');
if (op === 'action') return backend.sendRobotAction(data);
exactKeys(data, ['sessionId', 'modelEpoch', 'leaseId']);
if (op === 'release') {
backend.stopExternalControl('Python 控制者已断开');
this.lease = undefined;
return { released: true };
}
if (op === 'reset') {
backend.reset();
this.lease = undefined;
return backend.robotObservation();
}
throw new RobotError('UNSUPPORTED', '不支持的机器人操作');
}
/** Also called after explicit UI authorization; never resends a stale observation as fresh. */
sync(): void {
if (this.statusValue.phase !== 'ready') return;
try {
this.send(this.state());
} catch (error: unknown) {
this.disconnect(error instanceof Error ? error.message : '桥接状态异常');
}
}
disconnect(reason = '桥接连接已关闭'): void {
clearInterval(this.timer);
clearTimeout(this.handshakeTimer);
this.timer = undefined;
const socket = this.socket;
this.socket = undefined;
this.rejectConnect?.(new RobotError('DISCONNECTED', reason));
this.rejectConnect = undefined;
socket?.close();
try {
if (this.sessionId && this.backend()?.robotObservation()?.sessionId === this.sessionId)
this.backend()?.stopExternalControl(reason);
} catch {
/* Session teardown already revoked controls before deleting WASM handles. */
}
this.sessionId = undefined;
this.lease = undefined;
this.statusChanged({ phase: 'disconnected', error: reason });
}
}