Skip to content

Commit e4dd044

Browse files
AIFlowMLclaude
andcommitted
feat: 8 new thorctl commands, live ROS2 testing, agent ROS2 env fix
thorctl new commands (8 added, total 25): power [port] — power mode, clocks, fan speed sysinfo [port] — model, hostname, OS, kernel, JetPack, uptime disks [port] — filesystem usage table cameras [port] — camera list with CSI/USB/ZED types gpu [port] — GPU info, CUDA, TensorRT, models usb [port] — USB device list network [port] — interface table with IP/MAC/state ros2-echo [port] <topic> — echo a ROS2 topic message Agent ROS2 fixes: - All ros2 commands now use _ros2_cmd() helper that sources /opt/ros/humble/setup.bash and sets ROS_LOG_DIR - Fixed topic echo, lifecycle, pub commands that were using raw subprocess.run without ROS2 environment Docker sim: - Added ros-humble-demo-nodes-py to Dockerfile - Entrypoint starts ros2 talker in background automatically - Creates /tmp/ros_logs for ROS2 log directory Tests updated: - ROS2 nodes test: now expects live /talker node (was: empty/error) - ROS2 topics test: now expects live /chatter topic (was: empty/error) - All 71 tests hitting REAL Docker sim data — zero mocks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 71f2e50 commit e4dd044

5 files changed

Lines changed: 172 additions & 28 deletions

File tree

Agent/routers/ros2.py

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,26 @@
88

99
router = APIRouter(prefix="/v1/ros2", tags=["ros2"])
1010

11+
# ROS2 environment setup
12+
ROS2_SETUP = "/opt/ros/humble/setup.bash"
13+
ROS2_ENV = {**os.environ, "ROS_LOG_DIR": "/tmp/ros_logs", "HOME": os.environ.get("HOME", "/home/jetson")}
14+
15+
16+
def _ros2_cmd(args: list[str], timeout: int = 10) -> subprocess.CompletedProcess:
17+
"""Run a ROS2 command with proper environment sourcing."""
18+
cmd = f"source {ROS2_SETUP} 2>/dev/null && {' '.join(args)}"
19+
return subprocess.run(
20+
["bash", "-c", cmd],
21+
capture_output=True, text=True, timeout=timeout,
22+
env=ROS2_ENV,
23+
)
24+
1125

1226
@router.get("/nodes")
1327
async def ros2_nodes():
1428
"""List ROS2 nodes."""
1529
try:
16-
result = subprocess.run(["ros2", "node", "list"], capture_output=True, text=True, timeout=10)
30+
result = _ros2_cmd(["ros2", "node", "list"])
1731
nodes = [n.strip() for n in result.stdout.strip().split("\n") if n.strip()]
1832
return {"nodes": nodes, "count": len(nodes)}
1933
except FileNotFoundError:
@@ -24,7 +38,7 @@ async def ros2_nodes():
2438
async def ros2_topics():
2539
"""List ROS2 topics with types."""
2640
try:
27-
result = subprocess.run(["ros2", "topic", "list", "-t"], capture_output=True, text=True, timeout=10)
41+
result = _ros2_cmd(["ros2", "topic", "list", "-t"])
2842
topics = []
2943
for line in result.stdout.strip().split("\n"):
3044
if line.strip():
@@ -41,7 +55,7 @@ async def ros2_topics():
4155
async def ros2_services():
4256
"""List ROS2 services with types."""
4357
try:
44-
result = subprocess.run(["ros2", "service", "list", "-t"], capture_output=True, text=True, timeout=10)
58+
result = _ros2_cmd(["ros2", "service", "list", "-t"])
4559
services = []
4660
for line in result.stdout.strip().split("\n"):
4761
if line.strip():
@@ -92,15 +106,12 @@ async def ros2_launches():
92106
async def ros2_lifecycle():
93107
"""List lifecycle-managed nodes."""
94108
try:
95-
result = subprocess.run(["ros2", "lifecycle", "nodes"], capture_output=True, text=True, timeout=10)
109+
result = _ros2_cmd(["ros2", "lifecycle", "nodes"])
96110
nodes = []
97111
for name in result.stdout.strip().split("\n"):
98112
if name.strip():
99113
# Get state for each
100-
state_result = subprocess.run(
101-
["ros2", "lifecycle", "get", name.strip()],
102-
capture_output=True, text=True, timeout=5
103-
)
114+
state_result = _ros2_cmd(["ros2", "lifecycle", "get", name.strip()])
104115
state = state_result.stdout.strip() if state_result.returncode == 0 else "unknown"
105116
nodes.append({"name": name.strip(), "state": state})
106117
return {"nodes": nodes}
@@ -122,10 +133,7 @@ async def ros2_lifecycle_transition(payload: dict):
122133
return JSONResponse(status_code=400, content={"error": f"Invalid transition. Use: {valid_transitions}"})
123134

124135
try:
125-
result = subprocess.run(
126-
["ros2", "lifecycle", "set", node, transition],
127-
capture_output=True, text=True, timeout=10
128-
)
136+
result = _ros2_cmd(["ros2", "lifecycle", "set", node, transition])
129137
return {"node": node, "transition": transition, "success": result.returncode == 0, "output": result.stdout}
130138
except FileNotFoundError:
131139
return JSONResponse(status_code=500, content={"error": "ROS2 not installed"})
@@ -138,10 +146,7 @@ async def ros2_topic_echo(payload: dict):
138146
if not topic:
139147
return JSONResponse(status_code=400, content={"error": "topic required"})
140148
try:
141-
result = subprocess.run(
142-
["ros2", "topic", "echo", "--once", topic],
143-
capture_output=True, text=True, timeout=10
144-
)
149+
result = _ros2_cmd(["ros2", "topic", "echo", "--once", topic])
145150
return {"topic": topic, "message": result.stdout, "error": result.stderr if result.returncode != 0 else None}
146151
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
147152
return {"topic": topic, "message": None, "error": str(e)}
@@ -157,10 +162,7 @@ async def ros2_topic_pub(payload: dict):
157162
if not topic or not msg_type:
158163
return JSONResponse(status_code=400, content={"error": "topic and type required"})
159164
try:
160-
result = subprocess.run(
161-
["ros2", "topic", "pub", "--once", topic, msg_type, data],
162-
capture_output=True, text=True, timeout=10
163-
)
165+
result = _ros2_cmd(["ros2", "topic", "pub", "--once", topic, msg_type, data])
164166
return {"topic": topic, "type": msg_type, "success": result.returncode == 0}
165167
except FileNotFoundError:
166168
return {"success": False, "error": "ROS2 not installed"}

Docker/Dockerfile.jetson-sim

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ RUN curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key | g
3030
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(lsb_release -cs) main" \
3131
> /etc/apt/sources.list.d/ros2.list \
3232
&& apt-get update \
33-
&& apt-get install -y --no-install-recommends ros-humble-ros-base python3-colcon-common-extensions \
33+
&& apt-get install -y --no-install-recommends ros-humble-ros-base ros-humble-demo-nodes-py python3-colcon-common-extensions \
3434
&& rm -rf /var/lib/apt/lists/*
3535

3636
# ROS2 environment setup

Docker/entrypoint.sh

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,20 @@ fi
1212
# Source ROS2
1313
source /opt/ros/humble/setup.bash 2>/dev/null || true
1414

15+
# Create ROS2 log directory for jetson user
16+
mkdir -p /tmp/ros_logs
17+
chown jetson:jetson /tmp/ros_logs
18+
19+
# Start ROS2 demo talker in background (so there are live topics for testing)
20+
su -c "source /opt/ros/humble/setup.bash && ROS_LOG_DIR=/tmp/ros_logs ros2 run demo_nodes_py talker &" jetson 2>/dev/null &
21+
1522
# Export env vars for the agent process
1623
export THOR_AGENT_HOST=0.0.0.0
1724

1825
# Start THOR agent as jetson user, preserving environment
1926
echo "[entrypoint] Starting THOR Agent on port 8470..."
2027
echo "[entrypoint] Model: ${THOR_SIM_MODEL:-unknown}, Serial: ${THOR_SIM_SERIAL:-unknown}"
21-
echo "[entrypoint] ROS2: $(ros2 --version 2>/dev/null || echo 'not available')"
28+
echo "[entrypoint] ROS2 talker started on /chatter topic"
2229
echo "[entrypoint] Docker: $(docker --version 2>/dev/null || echo 'not available')"
2330

24-
exec su -m jetson -c "source /opt/ros/humble/setup.bash 2>/dev/null; THOR_AGENT_HOST=${THOR_AGENT_HOST} THOR_SIM_MODEL='${THOR_SIM_MODEL}' THOR_SIM_SERIAL='${THOR_SIM_SERIAL}' THOR_SIM_JETPACK='${THOR_SIM_JETPACK}' python3 /opt/thor-agent/main.py"
31+
exec su -m jetson -c "source /opt/ros/humble/setup.bash 2>/dev/null; ROS_LOG_DIR=/tmp/ros_logs THOR_AGENT_HOST=${THOR_AGENT_HOST} THOR_SIM_MODEL='${THOR_SIM_MODEL}' THOR_SIM_SERIAL='${THOR_SIM_SERIAL}' THOR_SIM_JETPACK='${THOR_SIM_JETPACK}' python3 /opt/thor-agent/main.py"

Sources/THORctl/main.swift

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,31 @@ func run() async {
5252
case "ros2-topics":
5353
let port = args.count > 2 ? Int(args[2]) ?? 8470 : 8470
5454
await ros2Topics(port: port)
55+
case "power", "power-mode":
56+
let port = args.count > 2 ? Int(args[2]) ?? 8470 : 8470
57+
await powerInfo(port: port)
58+
case "sysinfo":
59+
let port = args.count > 2 ? Int(args[2]) ?? 8470 : 8470
60+
await systemInfoCmd(port: port)
61+
case "disks":
62+
let port = args.count > 2 ? Int(args[2]) ?? 8470 : 8470
63+
await disksCmd(port: port)
64+
case "cameras":
65+
let port = args.count > 2 ? Int(args[2]) ?? 8470 : 8470
66+
await camerasCmd(port: port)
67+
case "gpu":
68+
let port = args.count > 2 ? Int(args[2]) ?? 8470 : 8470
69+
await gpuCmd(port: port)
70+
case "usb":
71+
let port = args.count > 2 ? Int(args[2]) ?? 8470 : 8470
72+
await usbCmd(port: port)
73+
case "network":
74+
let port = args.count > 2 ? Int(args[2]) ?? 8470 : 8470
75+
await networkCmd(port: port)
76+
case "ros2-echo":
77+
let port = args.count > 2 ? Int(args[2]) ?? 8470 : 8470
78+
let topic = args.count > 3 ? args[3] : "/chatter"
79+
await ros2EchoCmd(port: port, topic: topic)
5580
case "screenshot":
5681
let output = args.count > 2 ? args[2] : "thor-screenshot.png"
5782
await takeScreenshot(outputPath: output)
@@ -311,6 +336,113 @@ func ros2Topics(port: Int) async {
311336
}
312337
}
313338

339+
// MARK: - New Jetson Control Commands
340+
341+
func powerInfo(port: Int) async {
342+
let client = AgentClient(port: port)
343+
do {
344+
let pm = try await client.powerMode()
345+
let fan = try await client.fanStatus()
346+
let clocks = try await client.powerClocks()
347+
print("Power Mode: \(pm.currentMode)")
348+
if let modes = pm.modes {
349+
for mode in modes {
350+
let marker = mode.modeId == pm.currentMode ? ">>>" : " "
351+
print(" \(marker) [\(mode.modeId)] \(mode.name)")
352+
}
353+
}
354+
print("Clocks: \(clocks.enabled ? "LOCKED (max performance)" : "Dynamic")")
355+
print("Fan: \(Int(fan.speedPercent))% (PWM: \(fan.currentPwm)/255)")
356+
} catch { print("Error: \(error.localizedDescription)") }
357+
}
358+
359+
func systemInfoCmd(port: Int) async {
360+
let client = AgentClient(port: port)
361+
do {
362+
let s = try await client.systemInfo()
363+
print("Model: \(s.model)")
364+
print("Hostname: \(s.hostname)")
365+
print("OS: \(s.osRelease)")
366+
print("Kernel: \(s.kernel)")
367+
print("Arch: \(s.architecture)")
368+
print("JetPack: \(s.l4tVersion ?? "N/A")")
369+
print("Uptime: \(s.uptime)")
370+
} catch { print("Error: \(error.localizedDescription)") }
371+
}
372+
373+
func disksCmd(port: Int) async {
374+
let client = AgentClient(port: port)
375+
do {
376+
let d = try await client.disks()
377+
print(String(format: "%-20s %-8s %-8s %-8s %s", "MOUNT", "SIZE", "USED", "AVAIL", "USE%"))
378+
for fs in d.filesystems {
379+
print(String(format: "%-20s %-8s %-8s %-8s %s", fs.mount, fs.size, fs.used, fs.available, fs.percent))
380+
}
381+
} catch { print("Error: \(error.localizedDescription)") }
382+
}
383+
384+
func camerasCmd(port: Int) async {
385+
let client = AgentClient(port: port)
386+
do {
387+
let c = try await client.cameras()
388+
print("Cameras (\(c.count)):")
389+
for cam in c.cameras {
390+
print(" [\(cam.type)] \(cam.name)\(cam.device)")
391+
}
392+
} catch { print("Error: \(error.localizedDescription)") }
393+
}
394+
395+
func gpuCmd(port: Int) async {
396+
let client = AgentClient(port: port)
397+
do {
398+
let g = try await client.gpuDetail()
399+
print("GPU: \(g.gpuName)")
400+
print("CUDA: \(g.cudaVersion ?? "N/A")")
401+
print("TensorRT: \(g.tensorrtVersion ?? "N/A")")
402+
print("Memory: \(g.memoryUsedMb)/\(g.memoryTotalMb) MB")
403+
print("Temp: \(Int(g.temperatureC))°C")
404+
print("Power: \(String(format: "%.1f", g.powerDrawW)) W")
405+
let m = try await client.modelList()
406+
if m.count > 0 {
407+
print("Models (\(m.count)):")
408+
for model in m.models { print(" [\(model.format)] \(model.name)") }
409+
}
410+
} catch { print("Error: \(error.localizedDescription)") }
411+
}
412+
413+
func usbCmd(port: Int) async {
414+
let client = AgentClient(port: port)
415+
do {
416+
let u = try await client.usbDevices()
417+
print("USB Devices (\(u.count)):")
418+
for dev in u.devices { print(" \(dev.vendorProduct) \(dev.description)") }
419+
} catch { print("Error: \(error.localizedDescription)") }
420+
}
421+
422+
func networkCmd(port: Int) async {
423+
let client = AgentClient(port: port)
424+
do {
425+
let n = try await client.networkInterfaces()
426+
print(String(format: "%-12s %-6s %-18s %s", "IFACE", "STATE", "IP", "MAC"))
427+
for iface in n.interfaces {
428+
let ip = iface.addresses?.first { $0.family == "inet" }?.address ?? ""
429+
print(String(format: "%-12s %-6s %-18s %s", iface.name, iface.state ?? "?", ip, iface.mac ?? ""))
430+
}
431+
} catch { print("Error: \(error.localizedDescription)") }
432+
}
433+
434+
func ros2EchoCmd(port: Int, topic: String) async {
435+
let client = AgentClient(port: port)
436+
do {
437+
let r = try await client.ros2TopicEcho(topic: topic)
438+
if let msg = r.message, !msg.isEmpty {
439+
print(msg)
440+
} else {
441+
print(r.error ?? "No message received")
442+
}
443+
} catch { print("Error: \(error.localizedDescription)") }
444+
}
445+
314446
// MARK: - Screenshot
315447

316448
func takeScreenshot(outputPath: String) async {

Tests/THORTests/ANIMATests.swift

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,18 +135,21 @@ struct ANIMATests {
135135

136136
// MARK: - ROS2 Introspection
137137

138-
@Test("Fetch ROS2 nodes from agent")
138+
@Test("Fetch ROS2 nodes from agent (live talker running)")
139139
func ros2Nodes() async throws {
140140
let client = AgentClient(port: 8470)
141141
let response = try await client.ros2Nodes()
142-
// ROS2 not installed in Docker sim, should get error
143-
#expect(response.error != nil || response.nodes.isEmpty)
142+
// With demo talker running, we should find /talker node
143+
#expect(response.count >= 1)
144+
#expect(response.nodes.contains("/talker"))
144145
}
145146

146-
@Test("Fetch ROS2 topics from agent")
147+
@Test("Fetch ROS2 topics from agent (live /chatter topic)")
147148
func ros2Topics() async throws {
148149
let client = AgentClient(port: 8470)
149150
let response = try await client.ros2Topics()
150-
#expect(response.error != nil || response.topics.isEmpty)
151+
// With demo talker running, /chatter should exist
152+
#expect(response.count >= 1)
153+
#expect(response.topics.contains { $0.name == "/chatter" })
151154
}
152155
}

0 commit comments

Comments
 (0)