lang2sim 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lang2sim/__init__.py +47 -0
- lang2sim/agent/__init__.py +5 -0
- lang2sim/agent/nemotron_client.py +109 -0
- lang2sim/agent/react_loop.py +848 -0
- lang2sim/agent/tools.py +998 -0
- lang2sim/cli/app.py +183 -0
- lang2sim/cli/screens/__init__.py +8 -0
- lang2sim/cli/screens/command_palette_modal.py +146 -0
- lang2sim/cli/screens/landing_screen.py +96 -0
- lang2sim/cli/screens/sessions_modal.py +236 -0
- lang2sim/cli/screens/workspace_screen.py +709 -0
- lang2sim/cli/widgets/__init__.py +16 -0
- lang2sim/cli/widgets/action_pill.py +67 -0
- lang2sim/cli/widgets/command_card.py +265 -0
- lang2sim/cli/widgets/plan_card.py +106 -0
- lang2sim/cli/widgets/prompt_card.py +96 -0
- lang2sim/cli/widgets/telemetry_sidebar.py +117 -0
- lang2sim/cli/widgets/thought_box.py +64 -0
- lang2sim/compiler/__init__.py +15 -0
- lang2sim/compiler/sdf_compiler.py +65 -0
- lang2sim/compiler/sir_generator.py +46 -0
- lang2sim/compiler/sir_validator.py +87 -0
- lang2sim/core/__init__.py +50 -0
- lang2sim/core/config.py +116 -0
- lang2sim/core/models.py +279 -0
- lang2sim/core/session_manager.py +189 -0
- lang2sim/perturbation/__init__.py +50 -0
- lang2sim/perturbation/_compat.py +19 -0
- lang2sim/perturbation/gazebo_bridge.py +520 -0
- lang2sim/perturbation/mcp_server.py +129 -0
- lang2sim/perturbation/routers/__init__.py +19 -0
- lang2sim/perturbation/routers/world.py +110 -0
- lang2sim/perturbation/sdf_snippets.py +112 -0
- lang2sim/runtime/__init__.py +7 -0
- lang2sim/runtime/control_api/__init__.py +7 -0
- lang2sim/runtime/control_api/client.py +187 -0
- lang2sim/runtime/control_api/server.py +504 -0
- lang2sim/runtime/display.py +141 -0
- lang2sim/runtime/docker_runner.py +286 -0
- lang2sim/runtime/doctor.py +220 -0
- lang2sim/runtime/hardware_probe.py +144 -0
- lang2sim/runtime/health_monitor.py +66 -0
- lang2sim/runtime/launch_builder.py +137 -0
- lang2sim/runtime/nav2_dispatcher.py +215 -0
- lang2sim/runtime/platform.py +137 -0
- lang2sim/runtime/ros_bridge.py +183 -0
- lang2sim/runtime/run_folder.py +86 -0
- lang2sim/runtime/self_healing.py +387 -0
- lang2sim/runtime/session.py +136 -0
- lang2sim/runtime/world_check.py +197 -0
- lang2sim/telemetry/__init__.py +85 -0
- lang2sim/telemetry/nqi_scorer.py +645 -0
- lang2sim/telemetry/ros_subscribers.py +489 -0
- lang2sim/telemetry/trajectory.py +258 -0
- lang2sim/telemetry/vision.py +477 -0
- lang2sim/world_gen/__init__.py +16 -0
- lang2sim/world_gen/catalog_loader.py +83 -0
- lang2sim/world_gen/map_slicer.py +131 -0
- lang2sim/world_gen/shapely_solver.py +256 -0
- lang2sim-2.0.0.dist-info/METADATA +53 -0
- lang2sim-2.0.0.dist-info/RECORD +65 -0
- lang2sim-2.0.0.dist-info/WHEEL +5 -0
- lang2sim-2.0.0.dist-info/entry_points.txt +2 -0
- lang2sim-2.0.0.dist-info/licenses/LICENSE +21 -0
- lang2sim-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Starts/stops the sim container. The user never runs Docker themselves.
|
|
3
|
+
File: lang2sim/runtime/docker_runner.py
|
|
4
|
+
|
|
5
|
+
`build_run_args()` is pure (RunOptions -> argv) so every display/GPU/mount
|
|
6
|
+
combination is unit-tested without Docker. `DockerRunner` shells out to the
|
|
7
|
+
docker CLI (no SDK dependency) and takes an injectable `run` for tests.
|
|
8
|
+
|
|
9
|
+
Ports are published on 127.0.0.1 only (noVNC has no password):
|
|
10
|
+
9090 control API, 8765 foxglove_bridge, 6080 noVNC.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path, PurePosixPath
|
|
21
|
+
from typing import Callable, Dict, List, Optional, Sequence
|
|
22
|
+
|
|
23
|
+
from lang2sim.runtime.display import DisplayPlan
|
|
24
|
+
|
|
25
|
+
# NB: `from lang2sim.core import config` yields the Config *instance* (core/__init__
|
|
26
|
+
# re-exports it under the module's name), so load the module explicitly.
|
|
27
|
+
cfg = importlib.import_module("lang2sim.core.config")
|
|
28
|
+
|
|
29
|
+
CONTAINER_NAME = "lang2sim-sim"
|
|
30
|
+
LABEL = "dev.lang2sim.role=sim"
|
|
31
|
+
SIM_USER = "lang2sim" # runtime user in docker/sim/Dockerfile
|
|
32
|
+
|
|
33
|
+
# In-container ports are fixed by the image; host ports are configurable.
|
|
34
|
+
CONTAINER_API_PORT = 9090
|
|
35
|
+
CONTAINER_FOXGLOVE_PORT = 8765
|
|
36
|
+
CONTAINER_NOVNC_PORT = 6080
|
|
37
|
+
|
|
38
|
+
RunFn = Callable[..., "subprocess.CompletedProcess[str]"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DockerError(RuntimeError):
|
|
42
|
+
def __init__(self, message: str, stderr: str = ""):
|
|
43
|
+
super().__init__(message if not stderr else f"{message}: {stderr.strip()}")
|
|
44
|
+
self.stderr = stderr
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class RunOptions:
|
|
49
|
+
image: str
|
|
50
|
+
plan: DisplayPlan
|
|
51
|
+
projects_dir: Path
|
|
52
|
+
catalog_dir: Optional[Path] = None
|
|
53
|
+
src_dir: Optional[Path] = None # dev: bind-mount the repo over the baked-in package
|
|
54
|
+
host_uid: Optional[int] = None
|
|
55
|
+
host_gid: Optional[int] = None
|
|
56
|
+
api_port: int = CONTAINER_API_PORT
|
|
57
|
+
foxglove_port: int = CONTAINER_FOXGLOVE_PORT
|
|
58
|
+
novnc_port: int = CONTAINER_NOVNC_PORT
|
|
59
|
+
host_env: Dict[str, str] = field(default_factory=dict) # DISPLAY, WAYLAND_DISPLAY, XAUTHORITY
|
|
60
|
+
has_dxg: bool = False
|
|
61
|
+
extra_env: Dict[str, str] = field(default_factory=dict)
|
|
62
|
+
name: str = CONTAINER_NAME
|
|
63
|
+
shm_size: str = "2g"
|
|
64
|
+
command: Sequence[str] = ("serve",)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Pure helpers
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def image_for(base_image: str, plan: DisplayPlan) -> str:
|
|
72
|
+
"""Pick the `-vnc` tag variant when the plan needs it.
|
|
73
|
+
|
|
74
|
+
ghcr.io/x/lang2sim-sim:staging + vnc -> ghcr.io/x/lang2sim-sim:staging-vnc
|
|
75
|
+
"""
|
|
76
|
+
if plan.image_variant != "sim-vnc":
|
|
77
|
+
return base_image
|
|
78
|
+
name, sep, tag = base_image.rpartition(":")
|
|
79
|
+
if not sep or "/" in tag: # no tag (the ':' was a registry port)
|
|
80
|
+
return f"{base_image}:latest-vnc"
|
|
81
|
+
return base_image if tag.endswith("-vnc") else f"{name}:{tag}-vnc"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def host_to_container_path(host_path: Path, projects_dir: Path) -> str:
|
|
85
|
+
"""Map a host path under projects_dir to its path inside the container."""
|
|
86
|
+
rel = Path(host_path).resolve().relative_to(Path(projects_dir).resolve())
|
|
87
|
+
return str(PurePosixPath(cfg.CONTAINER_PROJECTS_DIR, *rel.parts))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_run_args(opts: RunOptions) -> List[str]:
|
|
91
|
+
"""argv after `docker` for starting the sim container (detached)."""
|
|
92
|
+
plan = opts.plan
|
|
93
|
+
a: List[str] = ["run", "-d", "--name", opts.name, "--label", LABEL,
|
|
94
|
+
"--shm-size", opts.shm_size]
|
|
95
|
+
|
|
96
|
+
def env(k: str, v: object) -> None:
|
|
97
|
+
a.extend(["-e", f"{k}={v}"])
|
|
98
|
+
|
|
99
|
+
def mount(src: object, dst: str, ro: bool = False) -> None:
|
|
100
|
+
a.extend(["-v", f"{src}:{dst}{':ro' if ro else ''}"])
|
|
101
|
+
|
|
102
|
+
# Ports (localhost only)
|
|
103
|
+
a.extend(["-p", f"127.0.0.1:{opts.api_port}:{CONTAINER_API_PORT}"])
|
|
104
|
+
if plan.foxglove:
|
|
105
|
+
a.extend(["-p", f"127.0.0.1:{opts.foxglove_port}:{CONTAINER_FOXGLOVE_PORT}"])
|
|
106
|
+
if plan.mode == "vnc":
|
|
107
|
+
a.extend(["-p", f"127.0.0.1:{opts.novnc_port}:{CONTAINER_NOVNC_PORT}"])
|
|
108
|
+
|
|
109
|
+
# Identity + plan (read by docker/sim/entrypoint.sh)
|
|
110
|
+
if opts.host_uid is not None:
|
|
111
|
+
env("L2S_HOST_UID", opts.host_uid)
|
|
112
|
+
if opts.host_gid is not None:
|
|
113
|
+
env("L2S_HOST_GID", opts.host_gid)
|
|
114
|
+
env("L2S_DISPLAY_MODE", plan.mode)
|
|
115
|
+
env("L2S_GPU", plan.gpu)
|
|
116
|
+
env("L2S_FOXGLOVE", 1 if plan.foxglove else 0)
|
|
117
|
+
|
|
118
|
+
# Data
|
|
119
|
+
mount(opts.projects_dir, cfg.CONTAINER_PROJECTS_DIR)
|
|
120
|
+
if opts.catalog_dir is not None:
|
|
121
|
+
mount(opts.catalog_dir, cfg.CONTAINER_CATALOG_DIR, ro=True)
|
|
122
|
+
if opts.src_dir is not None:
|
|
123
|
+
mount(opts.src_dir, cfg.CONTAINER_SRC_DIR, ro=True)
|
|
124
|
+
|
|
125
|
+
# Display
|
|
126
|
+
if plan.mode == "x11":
|
|
127
|
+
mount("/tmp/.X11-unix", "/tmp/.X11-unix")
|
|
128
|
+
env("DISPLAY", opts.host_env.get("DISPLAY", ":0"))
|
|
129
|
+
xauth = opts.host_env.get("XAUTHORITY")
|
|
130
|
+
if xauth:
|
|
131
|
+
mount(xauth, "/tmp/.Xauthority", ro=True)
|
|
132
|
+
env("XAUTHORITY", "/tmp/.Xauthority")
|
|
133
|
+
elif plan.mode == "wslg":
|
|
134
|
+
mount("/tmp/.X11-unix", "/tmp/.X11-unix")
|
|
135
|
+
mount("/mnt/wslg", "/mnt/wslg")
|
|
136
|
+
env("DISPLAY", opts.host_env.get("DISPLAY", ":0"))
|
|
137
|
+
env("WAYLAND_DISPLAY", opts.host_env.get("WAYLAND_DISPLAY", "wayland-0"))
|
|
138
|
+
env("XDG_RUNTIME_DIR", "/mnt/wslg/runtime-dir")
|
|
139
|
+
env("PULSE_SERVER", "/mnt/wslg/PulseServer")
|
|
140
|
+
|
|
141
|
+
# GPU
|
|
142
|
+
if plan.gpu == "nvidia":
|
|
143
|
+
a.extend(["--gpus", "all"])
|
|
144
|
+
elif plan.gpu == "dri":
|
|
145
|
+
if plan.mode == "wslg" and opts.has_dxg:
|
|
146
|
+
a.extend(["--device", "/dev/dxg"])
|
|
147
|
+
mount("/usr/lib/wsl", "/usr/lib/wsl", ro=True)
|
|
148
|
+
else:
|
|
149
|
+
a.extend(["--device", "/dev/dri"])
|
|
150
|
+
|
|
151
|
+
for k, v in sorted(opts.extra_env.items()):
|
|
152
|
+
env(k, v)
|
|
153
|
+
|
|
154
|
+
a.append(opts.image)
|
|
155
|
+
a.extend(opts.command)
|
|
156
|
+
return a
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def default_run_options(plan: DisplayPlan, *, image: Optional[str] = None,
|
|
160
|
+
project_src: Optional[Path] = None,
|
|
161
|
+
host_env: Optional[Dict[str, str]] = None,
|
|
162
|
+
has_dxg: bool = False) -> RunOptions:
|
|
163
|
+
"""RunOptions from core.config for this user (paths, ports, uid)."""
|
|
164
|
+
projects = cfg.get_projects_dir()
|
|
165
|
+
catalog = cfg.get_catalog_dir()
|
|
166
|
+
uid = os.getuid() if hasattr(os, "getuid") else None
|
|
167
|
+
gid = os.getgid() if hasattr(os, "getgid") else None
|
|
168
|
+
return RunOptions(
|
|
169
|
+
image=image_for(image or cfg.DEFAULT_SIM_IMAGE, plan),
|
|
170
|
+
plan=plan,
|
|
171
|
+
projects_dir=projects,
|
|
172
|
+
catalog_dir=catalog if catalog.is_dir() else None,
|
|
173
|
+
src_dir=project_src,
|
|
174
|
+
host_uid=uid,
|
|
175
|
+
host_gid=gid,
|
|
176
|
+
api_port=cfg.DEFAULT_CONTROL_PORT,
|
|
177
|
+
foxglove_port=cfg.DEFAULT_FOXGLOVE_PORT,
|
|
178
|
+
novnc_port=cfg.DEFAULT_NOVNC_PORT,
|
|
179
|
+
host_env=dict(host_env or {}),
|
|
180
|
+
has_dxg=has_dxg,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def allow_local_x11_clients() -> bool:
|
|
185
|
+
"""`xhost +SI:localuser:<me>`: let containers running as our UID draw on X.
|
|
186
|
+
|
|
187
|
+
Needed on Ubuntu desktops (X/XWayland reject unknown clients by default).
|
|
188
|
+
No-op where xhost or DISPLAY is missing. Returns True if it ran.
|
|
189
|
+
"""
|
|
190
|
+
import getpass
|
|
191
|
+
import shutil
|
|
192
|
+
|
|
193
|
+
if not os.environ.get("DISPLAY") or not shutil.which("xhost"):
|
|
194
|
+
return False
|
|
195
|
+
try:
|
|
196
|
+
subprocess.run(["xhost", f"+SI:localuser:{getpass.getuser()}"],
|
|
197
|
+
capture_output=True, timeout=5, check=False)
|
|
198
|
+
except (OSError, subprocess.SubprocessError):
|
|
199
|
+
return False
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# Docker CLI wrapper
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
class DockerRunner:
|
|
208
|
+
def __init__(self, docker: str = "docker", run: RunFn = subprocess.run):
|
|
209
|
+
self.docker = docker
|
|
210
|
+
self._run = run
|
|
211
|
+
|
|
212
|
+
def _call(self, args: Sequence[str], check: bool = True, timeout: Optional[float] = 60,
|
|
213
|
+
) -> "subprocess.CompletedProcess[str]":
|
|
214
|
+
try:
|
|
215
|
+
cp = self._run([self.docker, *args], capture_output=True, text=True,
|
|
216
|
+
timeout=timeout, check=False)
|
|
217
|
+
except FileNotFoundError as e:
|
|
218
|
+
raise DockerError("docker CLI not found") from e
|
|
219
|
+
except subprocess.TimeoutExpired as e:
|
|
220
|
+
raise DockerError(f"`docker {args[0]}` timed out") from e
|
|
221
|
+
if check and cp.returncode != 0:
|
|
222
|
+
raise DockerError(f"`docker {' '.join(args[:2])}` failed", cp.stderr)
|
|
223
|
+
return cp
|
|
224
|
+
|
|
225
|
+
# images
|
|
226
|
+
def image_present(self, image: str) -> bool:
|
|
227
|
+
return self._call(["image", "inspect", image], check=False).returncode == 0
|
|
228
|
+
|
|
229
|
+
def pull(self, image: str) -> None:
|
|
230
|
+
self._call(["pull", image], timeout=None)
|
|
231
|
+
|
|
232
|
+
def ensure_image(self, image: str) -> bool:
|
|
233
|
+
"""Pull if missing. Returns True if a pull happened."""
|
|
234
|
+
if self.image_present(image):
|
|
235
|
+
return False
|
|
236
|
+
self.pull(image)
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
# containers
|
|
240
|
+
def inspect(self, name: str = CONTAINER_NAME) -> Optional[dict]:
|
|
241
|
+
cp = self._call(["container", "inspect", name], check=False)
|
|
242
|
+
if cp.returncode != 0:
|
|
243
|
+
return None
|
|
244
|
+
data = json.loads(cp.stdout or "[]")
|
|
245
|
+
return data[0] if data else None
|
|
246
|
+
|
|
247
|
+
def state(self, name: str = CONTAINER_NAME) -> Optional[dict]:
|
|
248
|
+
"""Docker's .State (Status, Running, ExitCode, Health, ...) or None if absent."""
|
|
249
|
+
info = self.inspect(name)
|
|
250
|
+
return info.get("State") if info else None
|
|
251
|
+
|
|
252
|
+
def is_running(self, name: str = CONTAINER_NAME) -> bool:
|
|
253
|
+
st = self.state(name)
|
|
254
|
+
return bool(st and st.get("Running"))
|
|
255
|
+
|
|
256
|
+
def start(self, opts: RunOptions, replace: bool = True) -> str:
|
|
257
|
+
"""Start the container; returns its id. Replaces a stale one by default."""
|
|
258
|
+
if self.inspect(opts.name) is not None:
|
|
259
|
+
if not replace:
|
|
260
|
+
raise DockerError(f"container {opts.name} already exists")
|
|
261
|
+
self.remove(opts.name)
|
|
262
|
+
if opts.plan.mode == "x11":
|
|
263
|
+
allow_local_x11_clients()
|
|
264
|
+
cp = self._call(build_run_args(opts), timeout=120)
|
|
265
|
+
return cp.stdout.strip()
|
|
266
|
+
|
|
267
|
+
def stop(self, name: str = CONTAINER_NAME, timeout: int = 10) -> None:
|
|
268
|
+
self._call(["stop", "-t", str(timeout), name], check=False, timeout=timeout + 30)
|
|
269
|
+
|
|
270
|
+
def remove(self, name: str = CONTAINER_NAME) -> None:
|
|
271
|
+
self._call(["rm", "-f", name], check=False)
|
|
272
|
+
|
|
273
|
+
def logs(self, name: str = CONTAINER_NAME, tail: int = 200) -> str:
|
|
274
|
+
cp = self._call(["logs", "--tail", str(tail), name], check=False)
|
|
275
|
+
return (cp.stdout or "") + (cp.stderr or "")
|
|
276
|
+
|
|
277
|
+
def exec(self, cmd: Sequence[str], name: str = CONTAINER_NAME,
|
|
278
|
+
timeout: Optional[float] = 60, user: str = SIM_USER,
|
|
279
|
+
) -> "subprocess.CompletedProcess[str]":
|
|
280
|
+
"""Run a command in the container as the sim user.
|
|
281
|
+
|
|
282
|
+
Must match the user the sim runs as: ROS 2's default (Fast DDS) shared
|
|
283
|
+
memory transport is per-user, so a root `ros2 topic echo` discovers
|
|
284
|
+
topics but never receives data.
|
|
285
|
+
"""
|
|
286
|
+
return self._call(["exec", "-u", user, name, *cmd], check=False, timeout=timeout)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`lang2sim doctor`: can this machine run the simulation, and how will it look?
|
|
3
|
+
File: lang2sim/runtime/doctor.py
|
|
4
|
+
|
|
5
|
+
Part 2 wires the CLI subcommand to `run_checks()` / `format_report()`.
|
|
6
|
+
Standalone: python -m lang2sim.runtime.doctor [--json] [--display vnc] [--api]
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import importlib
|
|
14
|
+
import json
|
|
15
|
+
import shutil
|
|
16
|
+
import socket
|
|
17
|
+
import sys
|
|
18
|
+
from dataclasses import asdict, dataclass
|
|
19
|
+
from typing import List, Literal, Optional
|
|
20
|
+
|
|
21
|
+
from lang2sim.runtime import hardware_probe
|
|
22
|
+
from lang2sim.runtime import platform as host_platform
|
|
23
|
+
from lang2sim.runtime.display import DISPLAY_MODES, DisplayPlan, choose_display
|
|
24
|
+
from lang2sim.runtime.docker_runner import CONTAINER_NAME, DockerRunner, image_for
|
|
25
|
+
|
|
26
|
+
# NB: `from lang2sim.core import config` yields the Config *instance* (core/__init__
|
|
27
|
+
# re-exports it under the module's name), so load the module explicitly.
|
|
28
|
+
cfg = importlib.import_module("lang2sim.core.config")
|
|
29
|
+
|
|
30
|
+
Status = Literal["ok", "warn", "fail"]
|
|
31
|
+
|
|
32
|
+
MIN_DISK_GB = 10
|
|
33
|
+
MIN_RAM_MB = 8 * 1024
|
|
34
|
+
MIN_DOCKER_MEM_MB = 6 * 1024
|
|
35
|
+
MIN_DOCKER_CPUS = 4
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class CheckResult:
|
|
40
|
+
name: str
|
|
41
|
+
status: Status
|
|
42
|
+
detail: str
|
|
43
|
+
fix: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _port_free(port: int) -> bool:
|
|
47
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
48
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
49
|
+
try:
|
|
50
|
+
s.bind(("127.0.0.1", port))
|
|
51
|
+
except OSError:
|
|
52
|
+
return False
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_checks(plat: Optional[host_platform.HostPlatform] = None,
|
|
57
|
+
hw: Optional[hardware_probe.HardwareInfo] = None,
|
|
58
|
+
prefer_display: Optional[str] = None,
|
|
59
|
+
image: Optional[str] = None,
|
|
60
|
+
runner: Optional[DockerRunner] = None,
|
|
61
|
+
check_api: bool = False) -> List[CheckResult]:
|
|
62
|
+
plat = plat or host_platform.detect()
|
|
63
|
+
hw = hw or hardware_probe.probe()
|
|
64
|
+
runner = runner or DockerRunner()
|
|
65
|
+
r: List[CheckResult] = []
|
|
66
|
+
d = plat.docker
|
|
67
|
+
|
|
68
|
+
# Docker
|
|
69
|
+
if not d.installed:
|
|
70
|
+
fix = {"macos": "Install Docker Desktop: https://docs.docker.com/desktop/setup/install/mac-install/",
|
|
71
|
+
"windows": "Install Docker Desktop with the WSL 2 backend",
|
|
72
|
+
"wsl": "Install Docker Desktop on Windows and enable WSL integration for this distro",
|
|
73
|
+
}.get(plat.os, "Install Docker Engine: https://docs.docker.com/engine/install/ubuntu/")
|
|
74
|
+
r.append(CheckResult("docker", "fail", "docker CLI not found", fix))
|
|
75
|
+
elif not d.server_reachable:
|
|
76
|
+
r.append(CheckResult("docker", "fail", d.error or "daemon not reachable",
|
|
77
|
+
"Start Docker Desktop" if plat.os in ("macos", "windows", "wsl")
|
|
78
|
+
else "sudo systemctl start docker && sudo usermod -aG docker $USER"))
|
|
79
|
+
else:
|
|
80
|
+
r.append(CheckResult("docker", "ok", f"{d.operating_system or 'docker'} {d.server_version} ({d.arch})"))
|
|
81
|
+
if plat.arch != "unknown" and d.arch != "unknown" and plat.arch != d.arch:
|
|
82
|
+
r.append(CheckResult("docker arch", "warn",
|
|
83
|
+
f"host is {plat.arch} but daemon is {d.arch}; image runs emulated (slow)"))
|
|
84
|
+
|
|
85
|
+
# Docker Desktop's VM often gets less than the machine has; Gazebo + Nav2 need room.
|
|
86
|
+
if d.mem_total_mb and d.mem_total_mb < MIN_DOCKER_MEM_MB:
|
|
87
|
+
r.append(CheckResult("docker memory", "warn",
|
|
88
|
+
f"Docker can use {d.mem_total_mb / 1024:.1f} GB; Gazebo + Nav2 want 6 GB+",
|
|
89
|
+
"Docker Desktop > Settings > Resources > Memory: 8 GB"
|
|
90
|
+
if d.is_desktop else "free memory or add swap"))
|
|
91
|
+
if d.cpus and d.cpus < MIN_DOCKER_CPUS:
|
|
92
|
+
r.append(CheckResult("docker cpus", "warn",
|
|
93
|
+
f"Docker can use {d.cpus} CPUs; the sim will run below real time",
|
|
94
|
+
"Docker Desktop > Settings > Resources > CPUs: 4+" if d.is_desktop else None))
|
|
95
|
+
if plat.os == "windows":
|
|
96
|
+
r.append(CheckResult("windows", "warn",
|
|
97
|
+
"running outside WSL2: no native windows, the sim opens in a browser (noVNC)",
|
|
98
|
+
"for Gazebo/RViz windows, run lang2sim from an Ubuntu terminal inside WSL2"))
|
|
99
|
+
|
|
100
|
+
# Display plan
|
|
101
|
+
plan: Optional[DisplayPlan] = None
|
|
102
|
+
try:
|
|
103
|
+
plan = choose_display(plat, hw, prefer=prefer_display) # type: ignore[arg-type]
|
|
104
|
+
r.append(CheckResult("display", "ok", plan.describe() + " — " + "; ".join(plan.reasons)))
|
|
105
|
+
except ValueError as e:
|
|
106
|
+
r.append(CheckResult("display", "fail", str(e), f"use one of {', '.join(DISPLAY_MODES)}"))
|
|
107
|
+
|
|
108
|
+
if plan and plan.mode == "x11" and plat.display:
|
|
109
|
+
r.append(CheckResult("x11", "ok", f"DISPLAY={plat.display}",
|
|
110
|
+
None))
|
|
111
|
+
if not shutil.which("xhost"):
|
|
112
|
+
r.append(CheckResult("xhost", "warn", "xhost not found; container may be refused by X",
|
|
113
|
+
"sudo apt install x11-xserver-utils"))
|
|
114
|
+
if plan and plan.mode == "wslg" and not hw.has_dxg:
|
|
115
|
+
r.append(CheckResult("wsl gpu", "warn", "/dev/dxg missing: software rendering",
|
|
116
|
+
"Update the Windows GPU driver and `wsl --update`"))
|
|
117
|
+
nv = hw.nvidia
|
|
118
|
+
if nv and d.server_reachable and not d.has_nvidia_runtime:
|
|
119
|
+
r.append(CheckResult("nvidia toolkit", "warn", "NVIDIA GPU but Docker has no nvidia runtime",
|
|
120
|
+
"Install nvidia-container-toolkit, then `sudo nvidia-ctk runtime configure "
|
|
121
|
+
"--runtime=docker && sudo systemctl restart docker`"))
|
|
122
|
+
|
|
123
|
+
# Image
|
|
124
|
+
if plan and d.server_reachable:
|
|
125
|
+
img = image_for(image or cfg.DEFAULT_SIM_IMAGE, plan)
|
|
126
|
+
try:
|
|
127
|
+
present = runner.image_present(img)
|
|
128
|
+
except Exception as e: # noqa: BLE001 - doctor must not crash
|
|
129
|
+
present, err = False, str(e)
|
|
130
|
+
else:
|
|
131
|
+
err = ""
|
|
132
|
+
if present:
|
|
133
|
+
r.append(CheckResult("image", "ok", img))
|
|
134
|
+
else:
|
|
135
|
+
r.append(CheckResult("image", "warn", f"{img} not pulled yet {err}".strip(),
|
|
136
|
+
f"docker pull {img} (~1.5 GB; first `lang2sim` run does this)"))
|
|
137
|
+
|
|
138
|
+
# Ports
|
|
139
|
+
ours = False
|
|
140
|
+
try:
|
|
141
|
+
ours = d.server_reachable and runner.is_running(CONTAINER_NAME)
|
|
142
|
+
except Exception: # noqa: BLE001
|
|
143
|
+
pass
|
|
144
|
+
ports = {"control API": cfg.DEFAULT_CONTROL_PORT}
|
|
145
|
+
if plan and plan.foxglove:
|
|
146
|
+
ports["foxglove"] = cfg.DEFAULT_FOXGLOVE_PORT
|
|
147
|
+
if plan and plan.mode == "vnc":
|
|
148
|
+
ports["noVNC"] = cfg.DEFAULT_NOVNC_PORT
|
|
149
|
+
for label, port in ports.items():
|
|
150
|
+
if _port_free(port):
|
|
151
|
+
r.append(CheckResult(f"port {port}", "ok", f"{label} port free"))
|
|
152
|
+
elif ours:
|
|
153
|
+
r.append(CheckResult(f"port {port}", "ok", f"{label} served by running {CONTAINER_NAME}"))
|
|
154
|
+
else:
|
|
155
|
+
r.append(CheckResult(f"port {port}", "fail", f"{label} port {port} in use",
|
|
156
|
+
f"free it or set L2S_{'API' if label == 'control API' else label.upper()}_PORT"))
|
|
157
|
+
|
|
158
|
+
# Resources
|
|
159
|
+
try:
|
|
160
|
+
free_gb = shutil.disk_usage(cfg.get_projects_dir()).free / 1e9
|
|
161
|
+
r.append(CheckResult("disk", "ok" if free_gb >= MIN_DISK_GB else "warn",
|
|
162
|
+
f"{free_gb:.0f} GB free in {cfg.get_projects_dir()}"))
|
|
163
|
+
except OSError as e:
|
|
164
|
+
r.append(CheckResult("disk", "warn", str(e)))
|
|
165
|
+
if hw.mem_total_mb:
|
|
166
|
+
r.append(CheckResult("memory", "ok" if hw.mem_total_mb >= MIN_RAM_MB else "warn",
|
|
167
|
+
f"{hw.mem_total_mb // 1024} GB RAM"
|
|
168
|
+
+ ("" if hw.mem_total_mb >= MIN_RAM_MB else " (Gazebo + Nav2 want 8 GB+)")))
|
|
169
|
+
|
|
170
|
+
# Live API (optional)
|
|
171
|
+
if check_api:
|
|
172
|
+
r.append(asyncio.run(_check_api()))
|
|
173
|
+
return r
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def _check_api() -> CheckResult:
|
|
177
|
+
from lang2sim.runtime.control_api.client import ControlAPIError, ControlClient
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
async with ControlClient(timeout=3) as api:
|
|
181
|
+
h = await api.health()
|
|
182
|
+
i = await api.info()
|
|
183
|
+
return CheckResult("control API", "ok",
|
|
184
|
+
f"v{h.get('version')} display={i.get('display_mode')} gpu={i.get('gpu')}")
|
|
185
|
+
except ControlAPIError as e:
|
|
186
|
+
return CheckResult("control API", "warn", str(e), "Start the sim (it boots the container)")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def format_report(results: List[CheckResult]) -> str:
|
|
190
|
+
icon = {"ok": "✓", "warn": "!", "fail": "✗"}
|
|
191
|
+
lines = []
|
|
192
|
+
for c in results:
|
|
193
|
+
lines.append(f" {icon[c.status]} {c.name:<16} {c.detail}")
|
|
194
|
+
if c.fix and c.status != "ok":
|
|
195
|
+
lines.append(f" {'':<16} fix: {c.fix}")
|
|
196
|
+
worst = "fail" if any(c.status == "fail" for c in results) else \
|
|
197
|
+
"warn" if any(c.status == "warn" for c in results) else "ok"
|
|
198
|
+
lines.append({"ok": "\nReady to simulate.", "warn": "\nUsable, with warnings.",
|
|
199
|
+
"fail": "\nNot ready: fix the ✗ items above."}[worst])
|
|
200
|
+
return "\n".join(lines)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def exit_code(results: List[CheckResult]) -> int:
|
|
204
|
+
return 1 if any(c.status == "fail" for c in results) else 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
208
|
+
ap = argparse.ArgumentParser(prog="lang2sim doctor")
|
|
209
|
+
ap.add_argument("--json", action="store_true")
|
|
210
|
+
ap.add_argument("--display", choices=DISPLAY_MODES)
|
|
211
|
+
ap.add_argument("--image")
|
|
212
|
+
ap.add_argument("--api", action="store_true", help="also query a running control API")
|
|
213
|
+
a = ap.parse_args(argv)
|
|
214
|
+
res = run_checks(prefer_display=a.display, image=a.image, check_api=a.api)
|
|
215
|
+
print(json.dumps([asdict(c) for c in res], indent=2) if a.json else format_report(res))
|
|
216
|
+
return exit_code(res)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__":
|
|
220
|
+
sys.exit(main())
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Host hardware introspection: GPUs, VRAM, RAM, render nodes.
|
|
3
|
+
File: lang2sim/runtime/hardware_probe.py
|
|
4
|
+
|
|
5
|
+
Parsing is split from probing so the parsers are unit-testable without the
|
|
6
|
+
hardware. `probe()` never raises; missing tools just mean less information.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import glob
|
|
12
|
+
import os
|
|
13
|
+
import platform as _platform
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import List, Literal, Optional
|
|
19
|
+
|
|
20
|
+
GpuVendor = Literal["nvidia", "intel", "amd", "apple", "unknown"]
|
|
21
|
+
|
|
22
|
+
# Decision 4: below this much VRAM we render in software (GUI stays on).
|
|
23
|
+
LOW_VRAM_MB = 2048
|
|
24
|
+
|
|
25
|
+
_PCI_VENDORS = {"0x10de": "nvidia", "0x8086": "intel", "0x1002": "amd"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class GpuInfo:
|
|
30
|
+
vendor: GpuVendor
|
|
31
|
+
name: str = ""
|
|
32
|
+
vram_total_mb: Optional[int] = None
|
|
33
|
+
vram_free_mb: Optional[int] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class HardwareInfo:
|
|
38
|
+
cpu_count: int = 1
|
|
39
|
+
mem_total_mb: Optional[int] = None
|
|
40
|
+
gpus: List[GpuInfo] = field(default_factory=list)
|
|
41
|
+
dri_render_nodes: List[str] = field(default_factory=list)
|
|
42
|
+
has_dxg: bool = False # WSL2 GPU paravirtualisation (/dev/dxg)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def nvidia(self) -> Optional[GpuInfo]:
|
|
46
|
+
return next((g for g in self.gpus if g.vendor == "nvidia"), None)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def max_vram_mb(self) -> Optional[int]:
|
|
50
|
+
vals = [g.vram_total_mb for g in self.gpus if g.vram_total_mb is not None]
|
|
51
|
+
return max(vals) if vals else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Parsers (pure)
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def parse_nvidia_smi(csv_text: str) -> List[GpuInfo]:
|
|
59
|
+
"""Parse `nvidia-smi --query-gpu=name,memory.total,memory.free --format=csv,noheader,nounits`."""
|
|
60
|
+
gpus: List[GpuInfo] = []
|
|
61
|
+
for line in csv_text.strip().splitlines():
|
|
62
|
+
parts = [p.strip() for p in line.split(",")]
|
|
63
|
+
if len(parts) < 3:
|
|
64
|
+
continue
|
|
65
|
+
name, total, free = parts[0], parts[1], parts[2]
|
|
66
|
+
gpus.append(GpuInfo("nvidia", name, _to_int(total), _to_int(free)))
|
|
67
|
+
return gpus
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_meminfo(text: str) -> Optional[int]:
|
|
71
|
+
"""MemTotal from /proc/meminfo, in MB."""
|
|
72
|
+
for line in text.splitlines():
|
|
73
|
+
if line.startswith("MemTotal:"):
|
|
74
|
+
kb = _to_int(line.split()[1])
|
|
75
|
+
return kb // 1024 if kb is not None else None
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def vendor_from_pci_id(vendor_id: str) -> GpuVendor:
|
|
80
|
+
return _PCI_VENDORS.get(vendor_id.strip().lower(), "unknown") # type: ignore[return-value]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _to_int(s: str) -> Optional[int]:
|
|
84
|
+
try:
|
|
85
|
+
return int(float(s))
|
|
86
|
+
except (TypeError, ValueError):
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Probes
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def _run(cmd: List[str], timeout: float = 5.0) -> Optional[str]:
|
|
95
|
+
if not shutil.which(cmd[0]):
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
|
|
99
|
+
except (OSError, subprocess.SubprocessError):
|
|
100
|
+
return None
|
|
101
|
+
return out.stdout if out.returncode == 0 else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _probe_nvidia() -> List[GpuInfo]:
|
|
105
|
+
out = _run(["nvidia-smi", "--query-gpu=name,memory.total,memory.free",
|
|
106
|
+
"--format=csv,noheader,nounits"])
|
|
107
|
+
return parse_nvidia_smi(out) if out else []
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _probe_dri() -> List[GpuInfo]:
|
|
111
|
+
gpus: List[GpuInfo] = []
|
|
112
|
+
for node in sorted(glob.glob("/sys/class/drm/renderD*/device/vendor")):
|
|
113
|
+
try:
|
|
114
|
+
vendor = vendor_from_pci_id(Path(node).read_text())
|
|
115
|
+
except OSError:
|
|
116
|
+
continue
|
|
117
|
+
if vendor != "nvidia": # NVIDIA is covered (with VRAM) by nvidia-smi
|
|
118
|
+
gpus.append(GpuInfo(vendor))
|
|
119
|
+
return gpus
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _probe_mem_mb() -> Optional[int]:
|
|
123
|
+
try:
|
|
124
|
+
return parse_meminfo(Path("/proc/meminfo").read_text())
|
|
125
|
+
except OSError:
|
|
126
|
+
pass
|
|
127
|
+
out = _run(["sysctl", "-n", "hw.memsize"]) # macOS
|
|
128
|
+
if out and out.strip().isdigit():
|
|
129
|
+
return int(out.strip()) // (1024 * 1024)
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def probe() -> HardwareInfo:
|
|
134
|
+
"""Best-effort snapshot of this host. Never raises."""
|
|
135
|
+
gpus = _probe_nvidia() + _probe_dri()
|
|
136
|
+
if _platform.system() == "Darwin" and _platform.machine() == "arm64":
|
|
137
|
+
gpus.append(GpuInfo("apple", "Apple Silicon (not passed through to Docker)"))
|
|
138
|
+
return HardwareInfo(
|
|
139
|
+
cpu_count=os.cpu_count() or 1,
|
|
140
|
+
mem_total_mb=_probe_mem_mb(),
|
|
141
|
+
gpus=gpus,
|
|
142
|
+
dri_render_nodes=sorted(glob.glob("/dev/dri/renderD*")),
|
|
143
|
+
has_dxg=os.path.exists("/dev/dxg"),
|
|
144
|
+
)
|