Skip to content
Closed
49 changes: 47 additions & 2 deletions frontend/src/pages/Recording.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ interface BackendStatus {
exit_early: boolean;
rerecord_episode: boolean;
};
error?: string | null;
hint?: string | null;
}

const Recording = () => {
Expand All @@ -88,6 +90,7 @@ const Recording = () => {
null
);
const [recordingSessionStarted, setRecordingSessionStarted] = useState(false);
const [sessionError, setSessionError] = useState<{ error: string; hint: string | null } | null>(null);

const [optimisticPhase, setOptimisticPhase] = useState<Phase | null>(null);
const [showStopConfirm, setShowStopConfirm] = useState(false);
Expand Down Expand Up @@ -141,7 +144,7 @@ const Recording = () => {

// Poll backend status continuously to stay in sync
useEffect(() => {
if (!recordingSessionStarted) return;
if (!recordingSessionStarted || sessionError !== null) return;

const pollStatus = async () => {
try {
Expand All @@ -150,6 +153,15 @@ const Recording = () => {
);
if (!response.ok) return;
const status = await response.json();

if (status.current_phase === "error" && status.session_ended) {
setSessionError({
error: status.error || "Recording session failed.",
hint: status.hint || null,
});
return;
}

setBackendStatus(status);

const currentOptimistic = optimisticPhaseRef.current;
Expand Down Expand Up @@ -205,7 +217,7 @@ const Recording = () => {
pollStatus();
const statusInterval = setInterval(pollStatus, 1000);
return () => clearInterval(statusInterval);
}, [recordingSessionStarted, recordingConfig, navigate, baseUrl, fetchWithHeaders]);
}, [recordingSessionStarted, recordingConfig, navigate, baseUrl, fetchWithHeaders, sessionError]);

const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
Expand Down Expand Up @@ -597,6 +609,39 @@ const Recording = () => {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

<AlertDialog open={sessionError !== null}>
<AlertDialogContent className="bg-gray-900 border-gray-700 text-white max-w-lg">
<AlertDialogHeader>
<AlertDialogTitle className="text-red-500 flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-red-500 animate-ping" />
Recording Session Failed
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-300 space-y-4 mt-2">
<p>The recording session encountered an error and had to stop.</p>

<div className="bg-black/50 p-4 rounded border border-red-900/50 font-mono text-xs text-red-400 max-h-40 overflow-y-auto break-all whitespace-pre-wrap">
{sessionError?.error}
</div>

{sessionError?.hint && (
<div className="bg-blue-950/40 p-4 rounded border border-blue-800/40 text-sm text-slate-200">
<strong className="text-blue-400 block mb-1">Hardware Hint:</strong>
{sessionError.hint}
</div>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction
onClick={() => navigate("/")}
className="bg-red-600 hover:bg-red-700 text-white"
>
Return to Home
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
Expand Down
11 changes: 10 additions & 1 deletion lelab/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from lerobot.teleoperators.so_leader import SO101LeaderConfig

from .utils.config import setup_calibration_files, with_lelab_tag
from .utils.devices import safe_disconnect_device
from .utils.devices import friendly_hint, safe_disconnect_device

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -409,10 +409,19 @@ def handle_recording_status() -> dict[str, Any]:
logger.info("📡 RECORDING STATUS REQUEST: Session has ended - frontend should stop polling")
print("📡 STATUS CHANGE: Frontend is still polling after session end - should stop now")

error = None
hint = None
if current_phase == "error" and last_recording_info is not None:
error = last_recording_info.get("error")
if error:
hint = friendly_hint(error)

status = {
"recording_active": recording_active,
"current_phase": current_phase, # "preparing", "recording", "resetting", "completed"
"session_ended": session_ended, # New field to indicate session completion
"error": error,
"hint": hint,
"available_controls": {
"stop_recording": recording_active, # ESC key replacement
"exit_early": recording_active, # Right arrow key replacement
Expand Down
31 changes: 2 additions & 29 deletions lelab/rollout.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from pydantic import BaseModel

from .utils.config import setup_follower_calibration_file
from .utils.devices import friendly_hint

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -185,34 +186,6 @@ def _extract_error_from_log(log_path: str | None) -> str | None:
return snippet or None


def _friendly_hint(error_text: str | None) -> str | None:
"""A plain-language, actionable headline for the common SO-101 failures."""
if not error_text:
return None
low = error_text.lower()
if "overload" in low or "torque_enable" in low:
return (
"A motor overloaded — usually the gripper holding an object too hard. Release the object / "
"open the gripper and power-cycle the arm before trying again."
)
if "missing motor ids" in low or "motor check failed" in low:
return (
"A follower motor isn't responding (often the gripper, id 6). If a skill was holding an object "
"it likely overloaded — remove it, power-cycle the arm, then try teleoperation first."
)
if "could not connect" in low or "failed to connect" in low or "not connected" in low:
return "Couldn't connect to the arm — make sure it's plugged in, powered on, and on the right port."
if "frame is too old" in low or "no frame" in low or "frame timeout" in low:
return (
"A camera can't keep up — frames are arriving too slowly. Lower its resolution/FPS, "
"set FOURCC=MJPG, and close other heavy apps, then try again."
)
if "failed to set capture_" in low or "actual_width" in low or "actual_height" in low:
return "A camera doesn't support the configured resolution — open camera settings and click Auto."
if "permission" in low and ("port" in low or "com" in low):
return "Couldn't open the serial port — close anything else using it, or run `lelab --stop`."
return None


# Errors that mean the policy actually ran and only shutdown/cleanup tripped —
# e.g. disabling torque on a gripper still holding an object. Connection-loss
Expand Down Expand Up @@ -400,7 +373,7 @@ def handle_inference_status() -> dict[str, Any]:
"exit_code": rc,
"outcome": outcome,
"error": error,
"hint": _friendly_hint(error),
"hint": friendly_hint(error),
"policy_ref": finished_meta.get("policy_ref"),
"duration_s": finished_meta.get("duration_s"),
"log_path": finished_meta.get("log_path"),
Expand Down
62 changes: 58 additions & 4 deletions lelab/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1019,9 +1019,55 @@ def _v4l2_camera_name(index: int) -> str | None:
return None


def _linux_cameras() -> list[dict[str, Any]]:
def _chrome_camera_name(index: int) -> str | None:
import os
import re

# 기본 v4l2_name 불러오기
v4l2_name = _v4l2_camera_name(index)

Comment thread
robertchoi marked this conversation as resolved.
try:
sys_path = f"/sys/class/video4linux/video{index}"
current_dir = os.path.join(sys_path, "device")

for _ in range(5):
vid_path = os.path.join(current_dir, "idVendor")
pid_path = os.path.join(current_dir, "idProduct")
product_path = os.path.join(current_dir, "product")

# vender id 와 product id가 있다면
if os.path.exists(vid_path) and os.path.exists(pid_path):
try:
with open(vid_path, "r", encoding="utf-8") as f:
vid = f.read().strip()
with open(pid_path, "r", encoding="utf-8") as f:
pid = f.read().strip()

if os.path.exists(product_path):
with open(product_path, "r", encoding="utf-8") as f:
base_name = f.read().strip()
else:
base_name = v4l2_name.split(":")[0].strip()

# 크롬 브라우저 포맷으로 이름 조합
base_name = base_name.replace("_", " ")
base_name = re.sub(r'\s+HD$', '', base_name).strip()
return f"{base_name} ({vid}:{pid})"
except Exception:
return v4l2_name
# 찾는게 없으면 상위 폴더로 이동
current_dir = os.path.join(current_dir, "..")

# 5단계를 거슬러 올라가도 못 찾으면 원래 이름 반환
return v4l2_name
except OSError:
return None

def _linux_cameras(is_chrome: bool = False) -> list[dict[str, Any]]:
"""Enumerate Linux cameras, naming each from sysfs (no extra deps)."""
import cv2
import os
import re

cameras: list[dict[str, Any]] = []
for i in range(10):
Expand All @@ -1030,12 +1076,16 @@ def _linux_cameras() -> list[dict[str, Any]]:
cap.release()
if not opened:
continue
cameras.append({"index": i, "name": _v4l2_camera_name(i) or f"Camera {i}", "available": True})

if is_chrome:
cameras.append({"index": i, "name": _chrome_camera_name(i) or f"Camera {i}", "available": True})
else:
cameras.append({"index": i, "name": _v4l2_camera_name(i) or f"Camera {i}", "available": True})
return cameras


@app.get("/available-cameras")
def get_available_cameras():
def get_available_cameras(request: Request):
"""List cameras with the same index ordering cv2 will use to record.

Each platform enumerates in the order its cv2 backend indexes devices, and
Expand All @@ -1051,6 +1101,10 @@ def get_available_cameras():
import platform

system = platform.system()

# User-Agent를 확인하여 크롬(크로미움 기반) 브라우저인지 판별
user_agent = request.headers.get("user-agent", "").lower()
is_chrome = "chrome" in user_agent

if system == "Darwin":
cameras = _avfoundation_cameras_in_cv2_order()
Expand All @@ -1060,7 +1114,7 @@ def get_available_cameras():
if system == "Windows":
return {"status": "success", "cameras": _windows_cameras()}
if system == "Linux":
return {"status": "success", "cameras": _linux_cameras()}
return {"status": "success", "cameras": _linux_cameras(is_chrome=is_chrome)}

import cv2

Expand Down
14 changes: 7 additions & 7 deletions lelab/teleoperate.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,13 @@ def get_joint_positions_from_robot(robot) -> dict[str, float]:

raw_deg = observation[motor_key]
angle_degrees = raw_deg
correction = _SO101_URDF_CORRECTIONS.get(motor_name)
if correction is not None and motor_name in calibration:
sign, urdf_zero_ticks = correction
cal = calibration[motor_name]
mid = (cal.range_min + cal.range_max) / 2
motor_at_urdf_zero = (urdf_zero_ticks - mid) * 360 / _STS3215_MAX_RES
angle_degrees = sign * (raw_deg - motor_at_urdf_zero)
#correction = _SO101_URDF_CORRECTIONS.get(motor_name)
#if correction is not None and motor_name in calibration:
# sign, urdf_zero_ticks = correction
# cal = calibration[motor_name]
# mid = (cal.range_min + cal.range_max) / 2
# motor_at_urdf_zero = (urdf_zero_ticks - mid) * 360 / _STS3215_MAX_RES
# angle_degrees = sign * (raw_deg - motor_at_urdf_zero)

joint_positions[urdf_joint_name] = angle_degrees * math.pi / 180.0
debug_rows.append(
Expand Down
30 changes: 30 additions & 0 deletions lelab/utils/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,33 @@ def _force_close_device_resources(device: Any, logger: logging.Logger) -> None:
cam.disconnect()
except Exception as exc:
logger.warning("Failed to disconnect camera after device cleanup failure: %s", exc)


def friendly_hint(error_text: str | None) -> str | None:
"""A plain-language, actionable headline for the common SO-101 failures."""
if not error_text:
return None
low = error_text.lower()
if "overload" in low or "torque_enable" in low:
return (
"A motor overloaded — usually the gripper holding an object too hard. Release the object / "
"open the gripper and power-cycle the arm before trying again."
)
if "missing motor ids" in low or "motor check failed" in low:
return (
"A follower motor isn't responding (often the gripper, id 6). If a skill was holding an object "
"it likely overloaded — remove it, power-cycle the arm, then try teleoperation first."
)
if "could not connect" in low or "failed to connect" in low or "not connected" in low:
return "Couldn't connect to the arm — make sure it's plugged in, powered on, and on the right port."
if "frame is too old" in low or "no frame" in low or "frame timeout" in low:
return (
"A camera can't keep up — frames are arriving too slowly. Lower its resolution/FPS, "
"set FOURCC=MJPG, and close other heavy apps, then try again."
)
if "failed to set capture_" in low or "actual_width" in low or "actual_height" in low:
return "A camera doesn't support the configured resolution — open camera settings and click Auto."
if "permission" in low and ("port" in low or "com" in low):
return "Couldn't open the serial port — close anything else using it, or run `lelab --stop`."
return None

Binary file added study/assets/lelab_workflow.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading