Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ Grab the one-liner from the [LeLab Space](https://huggingface.co/spaces/lerobot/

A page will automatically open in your browser and you are ready to go.

### Custom host and port

By default LeLab serves the UI and API on `127.0.0.1:8000`, reachable only from the machine it runs on. Two flags change that:

```bash
lelab --host 0.0.0.0 # also listen on the network, e.g. for a laptop or tablet next to the arm
lelab --port 9000 # serve on a different port
lelab --host 0.0.0.0 --port 9000 # both at once
```

Then open `http://<server-ip>:<port>` from any device on the same network. `lelab --stop` frees the ports again; pass `--port` too if you started on a custom port. In `--dev` mode, `--host` applies to the Vite dev server (`:8080`) as well, and `--port` selects the backend port that the dev frontend talks to.

## What you can do

<div align="center">
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/components/SingleTabGuard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ const CHANNEL = "lelab-tabs-v1";
const HEARTBEAT_MS = 1000;
const PEER_TIMEOUT_MS = 3000;

// `crypto.randomUUID` is only exposed in secure contexts (HTTPS or localhost),
// so plain-HTTP LAN access (e.g. http://<host-ip>:8000) needs a v4 fallback.
const generateTabId = (): string => {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // UUID version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // UUID variant 10xx
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
};

const SingleTabGuard = ({ children }: { children: ReactNode }) => {
const [isPrimary, setIsPrimary] = useState(true);
const peersRef = useRef<Map<string, Peer>>(new Map());
Expand Down Expand Up @@ -39,7 +52,7 @@ const SingleTabGuard = ({ children }: { children: ReactNode }) => {
return;
}

myIdRef.current = crypto.randomUUID();
myIdRef.current = generateTabId();
myOpenedAtRef.current = Date.now();

const channel = new BroadcastChannel(CHANNEL);
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/contexts/ApiContext.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { createContext, useContext, ReactNode, useState, useCallback, useMemo } from "react";
import { isHostedSpace } from "@/lib/isHostedSpace";

interface ApiContextType {
baseUrl: string;
Expand Down Expand Up @@ -31,7 +32,13 @@ const resolveInitialBaseUrl = (): string => {
}
}

return DEFAULT_LOCALHOST;
// Production `lelab` serves the UI and the API on the same origin, so the
// page origin is the right default — a hardcoded localhost default breaks
// plain-HTTP LAN access (http://<host-ip>:8000). Dev keeps the API on :8000
// behind Vite's :8080, and the HF Space serves only the UI and relies on
// ?api=, so both keep the localhost default.
if (import.meta.env.DEV || isHostedSpace()) return DEFAULT_LOCALHOST;
return window.location.origin;
};

export const ApiProvider: React.FC<{ children: ReactNode }> = ({
Expand Down
80 changes: 52 additions & 28 deletions lelab/scripts/lelab.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def _ensure_port_available(name: str, port: int, host: str = HOST) -> None:
)


def _find_lelab_pids() -> dict[int, str]:
def _find_lelab_pids(ports: set[int] | None = None) -> dict[int, str]:
"""PIDs that look like a running LeLab dev backend/frontend, with a reason.

On Windows the uvicorn --reload worker inherits the listening socket from
Expand All @@ -93,11 +93,12 @@ def _find_lelab_pids() -> dict[int, str]:
1. cmdline runs `uvicorn ... lelab.server` (the reload supervisor / prod)
2. an orphaned reload worker (`multiprocessing.spawn`) whose cwd is this
project
3. anything actually LISTENING on :8000 / :8080 (per-process scan, which
is more reliable than the global table on Windows)
3. anything actually LISTENING on the LeLab ports (per-process scan,
which is more reliable than the global table on Windows)
"""
me = os.getpid()
ports = {BACKEND_PORT, FRONTEND_DEV_PORT}
if ports is None:
ports = {BACKEND_PORT, FRONTEND_DEV_PORT}
targets: dict[int, str] = {}
for proc in psutil.process_iter(["pid", "cmdline"]):
pid = proc.info["pid"]
Expand Down Expand Up @@ -125,15 +126,15 @@ def _find_lelab_pids() -> dict[int, str]:
return targets


def _run_stop() -> None:
"""Stop a running LeLab and free :8000 / :8080.
def _run_stop(port: int = BACKEND_PORT) -> None:
"""Stop a running LeLab and free the backend port and :8080.

The escape hatch for when a previous run left an orphaned Vite or uvicorn
process holding the ports.
"""
targets = _find_lelab_pids()
targets = _find_lelab_pids({port, FRONTEND_DEV_PORT})
if not targets:
logger.info("Nothing to stop: no LeLab process found on :%d / :%d.", BACKEND_PORT, FRONTEND_DEV_PORT)
logger.info("Nothing to stop: no LeLab process found on :%d / :%d.", port, FRONTEND_DEV_PORT)
return
for pid, reason in targets.items():
logger.info("Stopping pid %d (%s)...", pid, reason)
Expand Down Expand Up @@ -330,20 +331,31 @@ def _monitor_processes(processes: Sequence[tuple[str, subprocess.Popen]]) -> Non
raise SystemExit(returncode)


def _run_prod(*, no_open: bool = False, rebuild: bool = False) -> None:
def _run_prod(
*,
no_open: bool = False,
rebuild: bool = False,
host: str = HOST,
port: int = BACKEND_PORT,
) -> None:
"""Serve built frontend from backend on a single port."""
_ensure_port_available("Backend", BACKEND_PORT)
_ensure_port_available("Backend", port, host=host)
if rebuild:
_run_frontend_build()
_ensure_frontend_dist()

logger.info("Starting LeLab on http://localhost:%d ...", BACKEND_PORT)
threading.Thread(target=_open_browser_when_ready, args=(BACKEND_PORT, no_open), daemon=True).start()
logger.info("Starting LeLab on http://localhost:%d ...", port)
if host not in ("127.0.0.1", "localhost"):
logger.info(
"Bound to %s: the UI and API accept connections from other machines on the network.",
host,
)
threading.Thread(target=_open_browser_when_ready, args=(port, no_open), daemon=True).start()

config = uvicorn.Config(
"lelab.server:app",
host=HOST,
port=BACKEND_PORT,
host=host,
port=port,
log_level="info",
reload=False,
timeout_graceful_shutdown=2,
Expand Down Expand Up @@ -387,22 +399,22 @@ def _shutdown(_signum, _frame) -> None:
server.run()


def _run_dev(*, no_open: bool = False) -> None:
def _run_dev(*, no_open: bool = False, host: str = HOST, port: int = BACKEND_PORT) -> None:
"""Start Vite HMR plus uvicorn reload."""
_ensure_frontend_path()
_require_command("node")
_require_command("npm")
_ensure_port_available("Backend", BACKEND_PORT)
_ensure_port_available("Frontend", FRONTEND_DEV_PORT)
_ensure_port_available("Backend", port, host=host)
_ensure_port_available("Frontend", FRONTEND_DEV_PORT, host=host)
_ensure_frontend_deps()

processes: list[tuple[str, subprocess.Popen]] = []
frontend_url = f"http://localhost:{FRONTEND_DEV_PORT}/?api=http://localhost:{BACKEND_PORT}"
frontend_url = f"http://localhost:{FRONTEND_DEV_PORT}/?api=http://localhost:{port}"

try:
frontend_process = _start_process(
"frontend",
["npm", "run", "dev", "--", "--host", HOST, "--port", str(FRONTEND_DEV_PORT)],
["npm", "run", "dev", "--", "--host", host, "--port", str(FRONTEND_DEV_PORT)],
FRONTEND_PATH,
)
processes.append(("frontend", frontend_process))
Expand All @@ -421,9 +433,9 @@ def _run_dev(*, no_open: bool = False) -> None:
"uvicorn",
"lelab.server:app",
"--host",
HOST,
host,
"--port",
str(BACKEND_PORT),
str(port),
"--reload",
# Same as prod: an open /camera-feed stream would otherwise
# hold off the shutdown hook that saves an active recording.
Expand All @@ -434,17 +446,17 @@ def _run_dev(*, no_open: bool = False) -> None:
env=os.environ.copy(),
)
processes.append(("backend", backend_process))
if not _wait_for_port(BACKEND_PORT, timeout=15):
if not _wait_for_port(port, timeout=15):
if backend_process.poll() is not None:
_fail(
f"Backend exited early with code {backend_process.returncode}. Check the uvicorn output above."
)
_fail(f"Backend never became ready on http://localhost:{BACKEND_PORT}.")
_fail(f"Backend never became ready on http://localhost:{port}.")

_open_browser_url(frontend_url, no_open=no_open)
logger.info("Dev mode running. Press Ctrl+C to stop.")
logger.info("Frontend: http://localhost:%d", FRONTEND_DEV_PORT)
logger.info("Backend: http://localhost:%d", BACKEND_PORT)
logger.info("Backend: http://localhost:%d", port)
_install_signal_handlers()
_monitor_processes(processes)
except BaseException:
Expand All @@ -470,10 +482,22 @@ def _build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Do not open a browser automatically.",
)
parser.add_argument(
"--host",
default=HOST,
help="Address to bind the backend (and, in --dev, the Vite server) to. "
"Use 0.0.0.0 to make LeLab reachable from other machines on the network.",
)
parser.add_argument(
"--port",
type=int,
default=BACKEND_PORT,
help="Port to serve the backend (UI and API) on.",
)
parser.add_argument(
"--stop",
action="store_true",
help="Stop a running LeLab (free ports 8000/8080) and exit.",
help="Stop a running LeLab and free its ports. Pass --port to match a custom backend port.",
)
return parser

Expand All @@ -483,16 +507,16 @@ def main(argv: Sequence[str] | None = None) -> None:
args = parser.parse_args(argv)

if args.stop:
_run_stop()
_run_stop(port=args.port)
return

if args.dev and args.rebuild:
parser.error("--rebuild is for production mode; dev mode serves from Vite.")

if args.dev:
_run_dev(no_open=args.no_open)
_run_dev(no_open=args.no_open, host=args.host, port=args.port)
else:
_run_prod(no_open=args.no_open, rebuild=args.rebuild)
_run_prod(no_open=args.no_open, rebuild=args.rebuild, host=args.host, port=args.port)


if __name__ == "__main__":
Expand Down
Loading