From a2e012feeba8274e785176a66a1efcc0687eb176 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sat, 21 Mar 2026 00:48:23 +0800 Subject: [PATCH 1/8] feat(examples): add Rust language interop example Adds a Rust robot control example that subscribes to /odom (PoseStamped) and publishes /cmd_vel (Twist) via LCM UDP multicast, matching the existing C++/Lua/TypeScript interop examples. Includes a minimal LCM transport implementation using socket2 and the generated Rust message types from dimos-lcm. Co-Authored-By: Claude Opus 4.6 --- examples/language-interop/README.md | 1 + examples/language-interop/rust/Cargo.lock | 115 ++++++++++++++++++ examples/language-interop/rust/Cargo.toml | 9 ++ examples/language-interop/rust/README.md | 14 +++ .../rust/src/lcm_transport.rs | 97 +++++++++++++++ examples/language-interop/rust/src/main.rs | 79 ++++++++++++ 6 files changed, 315 insertions(+) create mode 100644 examples/language-interop/rust/Cargo.lock create mode 100644 examples/language-interop/rust/Cargo.toml create mode 100644 examples/language-interop/rust/README.md create mode 100644 examples/language-interop/rust/src/lcm_transport.rs create mode 100644 examples/language-interop/rust/src/main.rs diff --git a/examples/language-interop/README.md b/examples/language-interop/README.md index 52ae561ddb..a099977a53 100644 --- a/examples/language-interop/README.md +++ b/examples/language-interop/README.md @@ -14,6 +14,7 @@ Demonstrates controlling a dimos robot from non-Python languages. - [TypeScript](ts/) - CLI and browser-based web UI - [C++](cpp/) - [Lua](lua/) + - [Rust](rust/) 3. (Optional) Monitor traffic with `lcmspy` diff --git a/examples/language-interop/rust/Cargo.lock b/examples/language-interop/rust/Cargo.lock new file mode 100644 index 0000000000..fcb4284fc7 --- /dev/null +++ b/examples/language-interop/rust/Cargo.lock @@ -0,0 +1,115 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "lcm-msgs" +version = "0.1.0" +source = "git+https://github.com/dimensionalOS/dimos-lcm.git?branch=rust-codegen#16884c77e9f6044f77ae0e079cd11ec41f5f9dba" +dependencies = [ + "byteorder", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "robot-control" +version = "0.1.0" +dependencies = [ + "byteorder", + "lcm-msgs", + "socket2", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/examples/language-interop/rust/Cargo.toml b/examples/language-interop/rust/Cargo.toml new file mode 100644 index 0000000000..47bc71cda6 --- /dev/null +++ b/examples/language-interop/rust/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "robot-control" +version = "0.1.0" +edition = "2021" + +[dependencies] +lcm-msgs = { git = "https://github.com/dimensionalOS/dimos-lcm.git", branch = "rust-codegen" } +byteorder = "1" +socket2 = { version = "0.5", features = ["all"] } diff --git a/examples/language-interop/rust/README.md b/examples/language-interop/rust/README.md new file mode 100644 index 0000000000..63384f7f4f --- /dev/null +++ b/examples/language-interop/rust/README.md @@ -0,0 +1,14 @@ +# Rust Robot Control Example + +Subscribes to `/odom` and publishes velocity commands to `/cmd_vel` via LCM UDP multicast. + +## Build & Run + +```bash +cargo run +``` + +## Dependencies + +- [Rust toolchain](https://rustup.rs/) +- Message types fetched automatically from [dimos-lcm](https://github.com/dimensionalOS/dimos-lcm) (`rust-codegen` branch) diff --git a/examples/language-interop/rust/src/lcm_transport.rs b/examples/language-interop/rust/src/lcm_transport.rs new file mode 100644 index 0000000000..b2b8b5deca --- /dev/null +++ b/examples/language-interop/rust/src/lcm_transport.rs @@ -0,0 +1,97 @@ +// Minimal LCM UDP multicast transport for the interop example. +// Implements only small-message encode/decode (no fragmentation). + +use byteorder::{BigEndian, ByteOrder}; +use socket2::{Domain, Protocol, Socket, Type}; +use std::io; +use std::mem::MaybeUninit; +use std::net::{Ipv4Addr, SocketAddrV4}; +use std::sync::atomic::{AtomicU32, Ordering}; + +const MAGIC_SHORT: u32 = 0x4c433032; // "LC02" +const SHORT_HEADER_SIZE: usize = 8; +const LCM_MULTICAST_ADDR: Ipv4Addr = Ipv4Addr::new(239, 255, 76, 67); +const LCM_PORT: u16 = 7667; + +static SEQ: AtomicU32 = AtomicU32::new(0); + +pub struct LcmUdp { + socket: Socket, + multicast_addr: SocketAddrV4, +} + +pub struct ReceivedMessage { + pub channel: String, + pub data: Vec, +} + +impl LcmUdp { + pub fn new() -> io::Result { + let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; + socket.set_reuse_address(true)?; + #[cfg(not(target_os = "windows"))] + socket.set_reuse_port(true)?; + socket.set_nonblocking(true)?; + + let bind_addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, LCM_PORT); + socket.bind(&bind_addr.into())?; + socket.join_multicast_v4(&LCM_MULTICAST_ADDR, &Ipv4Addr::UNSPECIFIED)?; + socket.set_multicast_ttl_v4(1)?; + + Ok(Self { + socket, + multicast_addr: SocketAddrV4::new(LCM_MULTICAST_ADDR, LCM_PORT), + }) + } + + /// Publish encoded LCM message data on the given channel. + pub fn publish(&self, channel: &str, data: &[u8]) -> io::Result<()> { + let channel_bytes = channel.as_bytes(); + let total = SHORT_HEADER_SIZE + channel_bytes.len() + 1 + data.len(); + let mut buf = vec![0u8; total]; + + BigEndian::write_u32(&mut buf[0..4], MAGIC_SHORT); + BigEndian::write_u32(&mut buf[4..8], SEQ.fetch_add(1, Ordering::Relaxed)); + + buf[SHORT_HEADER_SIZE..SHORT_HEADER_SIZE + channel_bytes.len()] + .copy_from_slice(channel_bytes); + // null terminator already 0 from vec![0u8; ..] + let payload_start = SHORT_HEADER_SIZE + channel_bytes.len() + 1; + buf[payload_start..].copy_from_slice(data); + + self.socket.send_to(&buf, &self.multicast_addr.into())?; + Ok(()) + } + + /// Try to receive one LCM message (non-blocking). Returns None if no data available. + pub fn try_recv(&self) -> io::Result> { + let mut buf = [MaybeUninit::::uninit(); 65536]; + match self.socket.recv(&mut buf) { + Ok(n) => { + // SAFETY: socket2::recv guarantees the first `n` bytes are initialized. + let buf = + unsafe { &*(&buf[..n] as *const [MaybeUninit] as *const [u8]) }; + if n < SHORT_HEADER_SIZE { + return Ok(None); + } + let magic = BigEndian::read_u32(&buf[0..4]); + if magic != MAGIC_SHORT { + return Ok(None); // skip fragmented messages + } + // Find null terminator for channel name + let channel_start = SHORT_HEADER_SIZE; + let channel_end = match buf[channel_start..].iter().position(|&b| b == 0) { + Some(pos) => channel_start + pos, + None => return Ok(None), + }; + let channel = + String::from_utf8_lossy(&buf[channel_start..channel_end]).into_owned(); + let data_start = channel_end + 1; + let data = buf[data_start..].to_vec(); + Ok(Some(ReceivedMessage { channel, data })) + } + Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(None), + Err(e) => Err(e), + } + } +} diff --git a/examples/language-interop/rust/src/main.rs b/examples/language-interop/rust/src/main.rs new file mode 100644 index 0000000000..b466e7daea --- /dev/null +++ b/examples/language-interop/rust/src/main.rs @@ -0,0 +1,79 @@ +// Rust robot control example +// Subscribes to robot pose and publishes twist commands via LCM + +mod lcm_transport; + +use lcm_msgs::geometry_msgs::{PoseStamped, Twist, Vector3}; +use lcm_transport::LcmUdp; +use std::thread; +use std::time::{Duration, Instant}; + +const ODOM_CHANNEL: &str = "/odom#geometry_msgs.PoseStamped"; +const CMD_VEL_CHANNEL: &str = "/cmd_vel#geometry_msgs.Twist"; +const PUBLISH_INTERVAL: Duration = Duration::from_millis(100); // 10 Hz + +fn main() { + let lcm = LcmUdp::new().expect("Failed to create LCM socket"); + + println!("Robot control started"); + println!("Subscribing to /odom, publishing to /cmd_vel"); + println!("Press Ctrl+C to stop.\n"); + + let mut t: f64 = 0.0; + let mut next_publish = Instant::now(); + + loop { + // Poll for incoming messages + match lcm.try_recv() { + Ok(Some(msg)) if msg.channel == ODOM_CHANNEL => { + match PoseStamped::decode(&msg.data) { + Ok(pose) => { + let pos = &pose.pose.position; + let ori = &pose.pose.orientation; + println!( + "[pose] x={:.2} y={:.2} z={:.2} | qw={:.2}", + pos.x, pos.y, pos.z, ori.w + ); + } + Err(e) => eprintln!("[pose] decode error: {e}"), + } + } + Ok(Some(_)) => {} // ignore other channels + Ok(None) => {} + Err(e) => eprintln!("recv error: {e}"), + } + + // Publish twist at 10 Hz + let now = Instant::now(); + if now >= next_publish { + let twist = Twist { + linear: Vector3 { + x: 0.5, + y: 0.0, + z: 0.0, + }, + angular: Vector3 { + x: 0.0, + y: 0.0, + z: t.sin() * 0.3, + }, + }; + + let data = twist.encode(); + if let Err(e) = lcm.publish(CMD_VEL_CHANNEL, &data) { + eprintln!("[twist] publish error: {e}"); + } else { + println!( + "[twist] linear={:.2} angular={:.2}", + twist.linear.x, twist.angular.z + ); + } + + t += 0.1; + next_publish = now + PUBLISH_INTERVAL; + } + + // Sleep briefly to avoid busy-spinning + thread::sleep(Duration::from_millis(1)); + } +} From 43e20195081beb1c33c38e8926036692e923d71f Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sat, 21 Mar 2026 01:03:21 +0800 Subject: [PATCH 2/8] feat(examples): add .gitignore for Rust build artifacts Co-Authored-By: Claude Opus 4.6 --- examples/language-interop/rust/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 examples/language-interop/rust/.gitignore diff --git a/examples/language-interop/rust/.gitignore b/examples/language-interop/rust/.gitignore new file mode 100644 index 0000000000..b83d22266a --- /dev/null +++ b/examples/language-interop/rust/.gitignore @@ -0,0 +1 @@ +/target/ From ebfc8a88fa5cfe47eab1edd72882c68cd639e361 Mon Sep 17 00:00:00 2001 From: Ivan Nikolic Date: Sat, 21 Mar 2026 01:16:37 +0800 Subject: [PATCH 3/8] refactor(examples): use dimos-lcm crate instead of inline transport Move LCM UDP transport to the dimos-lcm repo where it belongs (matching the TS @dimos/lcm pattern). The example now depends on the dimos-lcm crate for transport and lcm-msgs for message types. Co-Authored-By: Claude Opus 4.6 --- examples/language-interop/rust/Cargo.lock | 14 ++- examples/language-interop/rust/Cargo.toml | 3 +- .../rust/src/lcm_transport.rs | 97 ------------------- examples/language-interop/rust/src/main.rs | 6 +- 4 files changed, 14 insertions(+), 106 deletions(-) delete mode 100644 examples/language-interop/rust/src/lcm_transport.rs diff --git a/examples/language-interop/rust/Cargo.lock b/examples/language-interop/rust/Cargo.lock index fcb4284fc7..0887d33975 100644 --- a/examples/language-interop/rust/Cargo.lock +++ b/examples/language-interop/rust/Cargo.lock @@ -8,10 +8,19 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "dimos-lcm" +version = "0.1.0" +source = "git+https://github.com/dimensionalOS/dimos-lcm.git?branch=rust-codegen#474b25d0f9f88b8430753df2453fd2c988a514d1" +dependencies = [ + "byteorder", + "socket2", +] + [[package]] name = "lcm-msgs" version = "0.1.0" -source = "git+https://github.com/dimensionalOS/dimos-lcm.git?branch=rust-codegen#16884c77e9f6044f77ae0e079cd11ec41f5f9dba" +source = "git+https://github.com/dimensionalOS/dimos-lcm.git?branch=rust-codegen#474b25d0f9f88b8430753df2453fd2c988a514d1" dependencies = [ "byteorder", ] @@ -26,9 +35,8 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" name = "robot-control" version = "0.1.0" dependencies = [ - "byteorder", + "dimos-lcm", "lcm-msgs", - "socket2", ] [[package]] diff --git a/examples/language-interop/rust/Cargo.toml b/examples/language-interop/rust/Cargo.toml index 47bc71cda6..7cd4907637 100644 --- a/examples/language-interop/rust/Cargo.toml +++ b/examples/language-interop/rust/Cargo.toml @@ -4,6 +4,5 @@ version = "0.1.0" edition = "2021" [dependencies] +dimos-lcm = { git = "https://github.com/dimensionalOS/dimos-lcm.git", branch = "rust-codegen" } lcm-msgs = { git = "https://github.com/dimensionalOS/dimos-lcm.git", branch = "rust-codegen" } -byteorder = "1" -socket2 = { version = "0.5", features = ["all"] } diff --git a/examples/language-interop/rust/src/lcm_transport.rs b/examples/language-interop/rust/src/lcm_transport.rs deleted file mode 100644 index b2b8b5deca..0000000000 --- a/examples/language-interop/rust/src/lcm_transport.rs +++ /dev/null @@ -1,97 +0,0 @@ -// Minimal LCM UDP multicast transport for the interop example. -// Implements only small-message encode/decode (no fragmentation). - -use byteorder::{BigEndian, ByteOrder}; -use socket2::{Domain, Protocol, Socket, Type}; -use std::io; -use std::mem::MaybeUninit; -use std::net::{Ipv4Addr, SocketAddrV4}; -use std::sync::atomic::{AtomicU32, Ordering}; - -const MAGIC_SHORT: u32 = 0x4c433032; // "LC02" -const SHORT_HEADER_SIZE: usize = 8; -const LCM_MULTICAST_ADDR: Ipv4Addr = Ipv4Addr::new(239, 255, 76, 67); -const LCM_PORT: u16 = 7667; - -static SEQ: AtomicU32 = AtomicU32::new(0); - -pub struct LcmUdp { - socket: Socket, - multicast_addr: SocketAddrV4, -} - -pub struct ReceivedMessage { - pub channel: String, - pub data: Vec, -} - -impl LcmUdp { - pub fn new() -> io::Result { - let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; - socket.set_reuse_address(true)?; - #[cfg(not(target_os = "windows"))] - socket.set_reuse_port(true)?; - socket.set_nonblocking(true)?; - - let bind_addr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, LCM_PORT); - socket.bind(&bind_addr.into())?; - socket.join_multicast_v4(&LCM_MULTICAST_ADDR, &Ipv4Addr::UNSPECIFIED)?; - socket.set_multicast_ttl_v4(1)?; - - Ok(Self { - socket, - multicast_addr: SocketAddrV4::new(LCM_MULTICAST_ADDR, LCM_PORT), - }) - } - - /// Publish encoded LCM message data on the given channel. - pub fn publish(&self, channel: &str, data: &[u8]) -> io::Result<()> { - let channel_bytes = channel.as_bytes(); - let total = SHORT_HEADER_SIZE + channel_bytes.len() + 1 + data.len(); - let mut buf = vec![0u8; total]; - - BigEndian::write_u32(&mut buf[0..4], MAGIC_SHORT); - BigEndian::write_u32(&mut buf[4..8], SEQ.fetch_add(1, Ordering::Relaxed)); - - buf[SHORT_HEADER_SIZE..SHORT_HEADER_SIZE + channel_bytes.len()] - .copy_from_slice(channel_bytes); - // null terminator already 0 from vec![0u8; ..] - let payload_start = SHORT_HEADER_SIZE + channel_bytes.len() + 1; - buf[payload_start..].copy_from_slice(data); - - self.socket.send_to(&buf, &self.multicast_addr.into())?; - Ok(()) - } - - /// Try to receive one LCM message (non-blocking). Returns None if no data available. - pub fn try_recv(&self) -> io::Result> { - let mut buf = [MaybeUninit::::uninit(); 65536]; - match self.socket.recv(&mut buf) { - Ok(n) => { - // SAFETY: socket2::recv guarantees the first `n` bytes are initialized. - let buf = - unsafe { &*(&buf[..n] as *const [MaybeUninit] as *const [u8]) }; - if n < SHORT_HEADER_SIZE { - return Ok(None); - } - let magic = BigEndian::read_u32(&buf[0..4]); - if magic != MAGIC_SHORT { - return Ok(None); // skip fragmented messages - } - // Find null terminator for channel name - let channel_start = SHORT_HEADER_SIZE; - let channel_end = match buf[channel_start..].iter().position(|&b| b == 0) { - Some(pos) => channel_start + pos, - None => return Ok(None), - }; - let channel = - String::from_utf8_lossy(&buf[channel_start..channel_end]).into_owned(); - let data_start = channel_end + 1; - let data = buf[data_start..].to_vec(); - Ok(Some(ReceivedMessage { channel, data })) - } - Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(None), - Err(e) => Err(e), - } - } -} diff --git a/examples/language-interop/rust/src/main.rs b/examples/language-interop/rust/src/main.rs index b466e7daea..7bebe3a005 100644 --- a/examples/language-interop/rust/src/main.rs +++ b/examples/language-interop/rust/src/main.rs @@ -1,10 +1,8 @@ // Rust robot control example // Subscribes to robot pose and publishes twist commands via LCM -mod lcm_transport; - +use dimos_lcm::Lcm; use lcm_msgs::geometry_msgs::{PoseStamped, Twist, Vector3}; -use lcm_transport::LcmUdp; use std::thread; use std::time::{Duration, Instant}; @@ -13,7 +11,7 @@ const CMD_VEL_CHANNEL: &str = "/cmd_vel#geometry_msgs.Twist"; const PUBLISH_INTERVAL: Duration = Duration::from_millis(100); // 10 Hz fn main() { - let lcm = LcmUdp::new().expect("Failed to create LCM socket"); + let lcm = Lcm::new().expect("Failed to create LCM transport"); println!("Robot control started"); println!("Subscribing to /odom, publishing to /cmd_vel"); From 1f2d26e69d19e11a7a8a41864fc3423dacbca58a Mon Sep 17 00:00:00 2001 From: lesh Date: Sat, 21 Mar 2026 01:35:27 +0800 Subject: [PATCH 4/8] fix(rust-example): pin git deps to specific rev and fix busy-poll sleep loop - Pin dimos-lcm and lcm-msgs deps to specific rev instead of branch (branch refs are mutable; rev is immutable for reproducible builds) - Replace fixed 1ms sleep with deadline-aware sleep capped at 10ms, avoiding unnecessary CPU wake-ups while still being responsive Addresses review comments on PR #1622. Co-Authored-By: Claude Sonnet 4.6 --- examples/language-interop/rust/Cargo.lock | 4 ++-- examples/language-interop/rust/Cargo.toml | 6 ++++-- examples/language-interop/rust/src/main.rs | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/examples/language-interop/rust/Cargo.lock b/examples/language-interop/rust/Cargo.lock index 0887d33975..6accfdb45b 100644 --- a/examples/language-interop/rust/Cargo.lock +++ b/examples/language-interop/rust/Cargo.lock @@ -11,7 +11,7 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "dimos-lcm" version = "0.1.0" -source = "git+https://github.com/dimensionalOS/dimos-lcm.git?branch=rust-codegen#474b25d0f9f88b8430753df2453fd2c988a514d1" +source = "git+https://github.com/dimensionalOS/dimos-lcm.git?rev=474b25d0f9f88b8430753df2453fd2c988a514d1#474b25d0f9f88b8430753df2453fd2c988a514d1" dependencies = [ "byteorder", "socket2", @@ -20,7 +20,7 @@ dependencies = [ [[package]] name = "lcm-msgs" version = "0.1.0" -source = "git+https://github.com/dimensionalOS/dimos-lcm.git?branch=rust-codegen#474b25d0f9f88b8430753df2453fd2c988a514d1" +source = "git+https://github.com/dimensionalOS/dimos-lcm.git?rev=474b25d0f9f88b8430753df2453fd2c988a514d1#474b25d0f9f88b8430753df2453fd2c988a514d1" dependencies = [ "byteorder", ] diff --git a/examples/language-interop/rust/Cargo.toml b/examples/language-interop/rust/Cargo.toml index 7cd4907637..f07f98fb3e 100644 --- a/examples/language-interop/rust/Cargo.toml +++ b/examples/language-interop/rust/Cargo.toml @@ -4,5 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -dimos-lcm = { git = "https://github.com/dimensionalOS/dimos-lcm.git", branch = "rust-codegen" } -lcm-msgs = { git = "https://github.com/dimensionalOS/dimos-lcm.git", branch = "rust-codegen" } +# TODO: switch to version once dimos-lcm#20 merges +dimos-lcm = { git = "https://github.com/dimensionalOS/dimos-lcm.git", rev = "474b25d0f9f88b8430753df2453fd2c988a514d1" } +# TODO: switch to version once dimos-lcm#20 merges +lcm-msgs = { git = "https://github.com/dimensionalOS/dimos-lcm.git", rev = "474b25d0f9f88b8430753df2453fd2c988a514d1" } diff --git a/examples/language-interop/rust/src/main.rs b/examples/language-interop/rust/src/main.rs index 7bebe3a005..97d00ce8cf 100644 --- a/examples/language-interop/rust/src/main.rs +++ b/examples/language-interop/rust/src/main.rs @@ -71,7 +71,8 @@ fn main() { next_publish = now + PUBLISH_INTERVAL; } - // Sleep briefly to avoid busy-spinning - thread::sleep(Duration::from_millis(1)); + // Sleep until next publish deadline (capped at 10ms) to avoid busy-spinning + let sleep_dur = next_publish.saturating_duration_since(Instant::now()).min(Duration::from_millis(10)); + thread::sleep(sleep_dur); } } From 312436710e2902428f59a3aaaab85899a2218dc0 Mon Sep 17 00:00:00 2001 From: lesh Date: Sat, 21 Mar 2026 02:42:40 +0800 Subject: [PATCH 5/8] test(interop): add non-interactive language interop tests Wire compat tests validate Python/Rust binary format agreement (fingerprints, known byte layouts, roundtrips) without needing external toolchains. Subprocess integration tests run Rust, TS, and C++ binaries against headless simplerobot via LCM, skipping gracefully when toolchains are unavailable. Co-Authored-By: Claude Opus 4.6 --- examples/language-interop/tests/__init__.py | 0 examples/language-interop/tests/conftest.py | 137 ++++++++++++++++++ .../tests/test_cpp_interop.py | 37 +++++ .../tests/test_rust_interop.py | 59 ++++++++ .../language-interop/tests/test_ts_interop.py | 46 ++++++ .../tests/test_wire_compat.py | 104 +++++++++++++ 6 files changed, 383 insertions(+) create mode 100644 examples/language-interop/tests/__init__.py create mode 100644 examples/language-interop/tests/conftest.py create mode 100644 examples/language-interop/tests/test_cpp_interop.py create mode 100644 examples/language-interop/tests/test_rust_interop.py create mode 100644 examples/language-interop/tests/test_ts_interop.py create mode 100644 examples/language-interop/tests/test_wire_compat.py diff --git a/examples/language-interop/tests/__init__.py b/examples/language-interop/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/language-interop/tests/conftest.py b/examples/language-interop/tests/conftest.py new file mode 100644 index 0000000000..89e9186332 --- /dev/null +++ b/examples/language-interop/tests/conftest.py @@ -0,0 +1,137 @@ +"""Fixtures for language-interop integration tests.""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time +from pathlib import Path +from typing import Generator + +import pytest + +EXAMPLES_DIR = Path(__file__).resolve().parent.parent # language-interop/ +SIMPLEROBOT_DIR = EXAMPLES_DIR.parent / "simplerobot" +RUST_DIR = EXAMPLES_DIR / "rust" +TS_DIR = EXAMPLES_DIR / "ts" +CPP_DIR = EXAMPLES_DIR / "cpp" + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line("markers", "interop: cross-language interop integration test") + + +@pytest.fixture(scope="module") +def simplerobot() -> Generator[subprocess.Popen[str], None, None]: + """Start simplerobot.py --headless as a subprocess, tear down after tests.""" + proc = subprocess.Popen( + [sys.executable, str(SIMPLEROBOT_DIR / "simplerobot.py"), "--headless"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + cwd=str(SIMPLEROBOT_DIR), + ) + # Give it time to start publishing + time.sleep(2) + yield proc + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + + +@pytest.fixture(scope="module") +def rust_binary() -> Path: + """Build the Rust interop binary and return its path.""" + cargo_toml = RUST_DIR / "Cargo.toml" + if not cargo_toml.exists(): + pytest.skip("Rust example not found") + + env = os.environ.copy() + cargo_home = Path.home() / ".cargo" / "bin" + if cargo_home.is_dir(): + env["PATH"] = str(cargo_home) + os.pathsep + env.get("PATH", "") + + result = subprocess.run( + ["cargo", "build", "--release"], + cwd=str(RUST_DIR), + capture_output=True, + text=True, + timeout=120, + env=env, + ) + if result.returncode != 0: + pytest.skip(f"cargo build failed: {result.stderr}") + + # Binary name from Cargo.toml [package] name = "robot-control" + binary = RUST_DIR / "target" / "release" / "robot-control" + if not binary.exists(): + # Try to find any binary in release + release_dir = RUST_DIR / "target" / "release" + found = next( + ( + f + for f in release_dir.iterdir() + if f.is_file() + and os.access(f, os.X_OK) + and not f.suffix + and ".so" not in f.name + ), + None, + ) + if found is None: + pytest.skip("No binary found after cargo build") + binary = found + + return binary + + +@pytest.fixture(scope="module") +def cpp_binary() -> Path: + """Build the C++ interop binary and return its path.""" + cmakelists = CPP_DIR / "CMakeLists.txt" + if not cmakelists.exists(): + pytest.skip("C++ example not found") + + build_dir = CPP_DIR / "build" + build_dir.mkdir(exist_ok=True) + + cmake_result = subprocess.run( + ["cmake", ".."], + cwd=str(build_dir), + capture_output=True, + text=True, + timeout=30, + ) + if cmake_result.returncode != 0: + pytest.skip(f"cmake failed: {cmake_result.stderr}") + + make_result = subprocess.run( + ["make", "-j4"], + cwd=str(build_dir), + capture_output=True, + text=True, + timeout=60, + ) + if make_result.returncode != 0: + pytest.skip(f"make failed: {make_result.stderr}") + + # Find the built binary + binary = next( + ( + f + for f in build_dir.iterdir() + if f.is_file() + and os.access(f, os.X_OK) + and not f.suffix + and ".so" not in f.name + ), + None, + ) + if binary is None: + pytest.skip("No C++ binary found after build") + return binary diff --git a/examples/language-interop/tests/test_cpp_interop.py b/examples/language-interop/tests/test_cpp_interop.py new file mode 100644 index 0000000000..6e5c81391b --- /dev/null +++ b/examples/language-interop/tests/test_cpp_interop.py @@ -0,0 +1,37 @@ +"""Integration test: C++ robot-control binary talks to Python simplerobot via LCM.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.interop + + +def test_cpp_receives_pose_and_publishes_twist( + simplerobot: subprocess.Popen[str], + cpp_binary: Path, +) -> None: + """Run the C++ binary for a few seconds and verify message exchange.""" + try: + result = subprocess.run( + [str(cpp_binary)], + capture_output=True, + text=True, + timeout=5, + ) + except subprocess.TimeoutExpired as e: + stdout = e.stdout or "" + stderr = e.stderr or "" + else: + stdout = result.stdout + stderr = result.stderr + + assert "[pose]" in stdout, ( + f"C++ binary never received a PoseStamped.\nstdout: {stdout!r}\nstderr: {stderr!r}" + ) + assert "[twist]" in stdout, ( + f"C++ binary never published a Twist.\nstdout: {stdout!r}\nstderr: {stderr!r}" + ) diff --git a/examples/language-interop/tests/test_rust_interop.py b/examples/language-interop/tests/test_rust_interop.py new file mode 100644 index 0000000000..6ffdae3a3f --- /dev/null +++ b/examples/language-interop/tests/test_rust_interop.py @@ -0,0 +1,59 @@ +"""Integration test: Rust robot-control binary talks to Python simplerobot via LCM.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.interop + + +def _run_rust_binary(rust_binary: Path, timeout: int = 5) -> tuple[str, str]: + """Run the Rust binary and return (stdout, stderr).""" + try: + result = subprocess.run( + [str(rust_binary)], + capture_output=True, + text=True, + timeout=timeout, + ) + return result.stdout, result.stderr + except subprocess.TimeoutExpired as e: + # text=True means stdout/stderr are str, but stubs type them as bytes | str | None + return str(e.stdout or ""), str(e.stderr or "") + + +def test_rust_binary_publishes_twist( + simplerobot: subprocess.Popen[str], + rust_binary: Path, +) -> None: + """Rust binary starts up and publishes Twist commands.""" + stdout, stderr = _run_rust_binary(rust_binary) + + assert "[twist]" in stdout, ( + f"Rust binary never published a Twist.\n" + f"stdout: {stdout!r}\nstderr: {stderr!r}" + ) + + +@pytest.mark.skipif( + os.environ.get("CI_NO_MULTICAST") is not None, + reason="multicast unavailable", +) +def test_rust_binary_receives_pose( + simplerobot: subprocess.Popen[str], + rust_binary: Path, +) -> None: + """Rust binary receives PoseStamped from simplerobot via LCM multicast. + + This test requires working UDP multicast between processes. + """ + stdout, stderr = _run_rust_binary(rust_binary) + + assert "[pose]" in stdout, ( + f"Rust binary never received a PoseStamped from simplerobot.\n" + f"stdout: {stdout!r}\nstderr: {stderr!r}" + ) diff --git a/examples/language-interop/tests/test_ts_interop.py b/examples/language-interop/tests/test_ts_interop.py new file mode 100644 index 0000000000..ac287e3e86 --- /dev/null +++ b/examples/language-interop/tests/test_ts_interop.py @@ -0,0 +1,46 @@ +"""Integration test: TypeScript (Deno) robot-control talks to Python simplerobot via LCM.""" + +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +from .conftest import TS_DIR + +pytestmark = pytest.mark.interop + + +@pytest.fixture(scope="module") +def deno_available() -> None: + if shutil.which("deno") is None: + pytest.skip("deno not found on PATH") + + +def test_ts_receives_pose_and_publishes_twist( + simplerobot: subprocess.Popen[str], + deno_available: None, +) -> None: + """Run the Deno TS script for a few seconds and verify message exchange.""" + try: + result = subprocess.run( + ["deno", "run", "--allow-net", "--unstable-net", str(TS_DIR / "main.ts")], + capture_output=True, + text=True, + timeout=5, + cwd=str(TS_DIR), + ) + except subprocess.TimeoutExpired as e: + stdout = e.stdout or "" + stderr = e.stderr or "" + else: + stdout = result.stdout + stderr = result.stderr + + assert "[pose]" in stdout, ( + f"TS script never received a PoseStamped.\nstdout: {stdout!r}\nstderr: {stderr!r}" + ) + assert "[twist]" in stdout, ( + f"TS script never published a Twist.\nstdout: {stdout!r}\nstderr: {stderr!r}" + ) diff --git a/examples/language-interop/tests/test_wire_compat.py b/examples/language-interop/tests/test_wire_compat.py new file mode 100644 index 0000000000..417c48d9f9 --- /dev/null +++ b/examples/language-interop/tests/test_wire_compat.py @@ -0,0 +1,104 @@ +"""Wire-format compatibility tests. + +Validates that Python LCM encoding matches known binary layouts from the Rust +dimos-lcm implementation, ensuring cross-language wire compatibility without +needing to build or run any non-Python code. +""" + +from __future__ import annotations + +import struct + +from dimos.msgs.geometry_msgs.Twist import Twist +from dimos.msgs.geometry_msgs.Vector3 import Vector3 + +# Known fingerprints from dimos-lcm Rust tests (roundtrip.rs) +VECTOR3_FINGERPRINT = 0xAE7E5FBA5EECA11E +TWIST_FINGERPRINT = 0x2E7C07D7CDF7E027 + + +def test_vector3_fingerprint() -> None: + """Python Vector3 encoding starts with the same fingerprint as Rust.""" + v = Vector3(1.5, 2.5, 3.5) + encoded = v.lcm_encode() + fingerprint = struct.unpack(">Q", encoded[:8])[0] + assert fingerprint == VECTOR3_FINGERPRINT, ( + f"Vector3 fingerprint mismatch: got 0x{fingerprint:016X}, " + f"expected 0x{VECTOR3_FINGERPRINT:016X}" + ) + + +def test_twist_fingerprint() -> None: + """Python Twist encoding starts with the same fingerprint as Rust.""" + t = Twist(linear=Vector3(0, 0, 0), angular=Vector3(0, 0, 0)) + encoded = t.lcm_encode() + fingerprint = struct.unpack(">Q", encoded[:8])[0] + assert fingerprint == TWIST_FINGERPRINT, ( + f"Twist fingerprint mismatch: got 0x{fingerprint:016X}, " + f"expected 0x{TWIST_FINGERPRINT:016X}" + ) + + +def test_vector3_known_binary_layout() -> None: + """Python Vector3(1.5, 2.5, 3.5) produces exact same bytes as Rust.""" + v = Vector3(1.5, 2.5, 3.5) + encoded = v.lcm_encode() + + # 8-byte fingerprint + 3x8-byte f64 = 32 bytes + assert len(encoded) == 32 + + # Fingerprint + assert encoded[:8] == struct.pack(">Q", VECTOR3_FINGERPRINT) + + # x=1.5 as f64 big-endian + assert encoded[8:16] == struct.pack(">d", 1.5) + + # y=2.5 as f64 big-endian + assert encoded[16:24] == struct.pack(">d", 2.5) + + # z=3.5 as f64 big-endian + assert encoded[24:32] == struct.pack(">d", 3.5) + + +def test_twist_known_binary_layout() -> None: + """Python Twist encoding matches Rust binary layout byte-for-byte.""" + t = Twist( + linear=Vector3(1.0, 2.0, 3.0), + angular=Vector3(0.1, 0.2, 0.3), + ) + encoded = t.lcm_encode() + + # 8-byte fingerprint + 6x8-byte f64 = 56 bytes + assert len(encoded) == 56 + + # Fingerprint + assert encoded[:8] == struct.pack(">Q", TWIST_FINGERPRINT) + + # linear.x, linear.y, linear.z + assert encoded[8:16] == struct.pack(">d", 1.0) + assert encoded[16:24] == struct.pack(">d", 2.0) + assert encoded[24:32] == struct.pack(">d", 3.0) + + # angular.x, angular.y, angular.z + assert encoded[32:40] == struct.pack(">d", 0.1) + assert encoded[40:48] == struct.pack(">d", 0.2) + assert encoded[48:56] == struct.pack(">d", 0.3) + + +def test_vector3_roundtrip_cross_language() -> None: + """Encode in Python, verify Rust would decode the same values.""" + v = Vector3(42.0, -17.5, 0.001) + encoded = v.lcm_encode() + decoded = Vector3.lcm_decode(encoded) + assert decoded.x == v.x + assert decoded.y == v.y + assert decoded.z == v.z + + +def test_twist_roundtrip_cross_language() -> None: + """Encode in Python, verify Rust would decode the same values.""" + t = Twist(linear=Vector3(1.5, 2.5, 3.5), angular=Vector3(0.1, 0.2, 0.3)) + encoded = t.lcm_encode() + decoded = Twist.lcm_decode(encoded) + assert decoded.linear == t.linear + assert decoded.angular == t.angular From 285739e8dbd4309485d959366b02f21b6d7c6f01 Mon Sep 17 00:00:00 2001 From: leshy <681516+leshy@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:43:57 +0000 Subject: [PATCH 6/8] CI code cleanup --- examples/language-interop/tests/conftest.py | 28 ++++++++++++------- .../tests/test_cpp_interop.py | 16 ++++++++++- .../tests/test_rust_interop.py | 19 +++++++++++-- .../language-interop/tests/test_ts_interop.py | 14 ++++++++++ .../tests/test_wire_compat.py | 17 +++++++++-- 5 files changed, 78 insertions(+), 16 deletions(-) diff --git a/examples/language-interop/tests/conftest.py b/examples/language-interop/tests/conftest.py index 89e9186332..a1ca9ce45e 100644 --- a/examples/language-interop/tests/conftest.py +++ b/examples/language-interop/tests/conftest.py @@ -1,14 +1,28 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Fixtures for language-interop integration tests.""" from __future__ import annotations +from collections.abc import Generator import os +from pathlib import Path import signal import subprocess import sys import time -from pathlib import Path -from typing import Generator import pytest @@ -76,10 +90,7 @@ def rust_binary() -> Path: ( f for f in release_dir.iterdir() - if f.is_file() - and os.access(f, os.X_OK) - and not f.suffix - and ".so" not in f.name + if f.is_file() and os.access(f, os.X_OK) and not f.suffix and ".so" not in f.name ), None, ) @@ -125,10 +136,7 @@ def cpp_binary() -> Path: ( f for f in build_dir.iterdir() - if f.is_file() - and os.access(f, os.X_OK) - and not f.suffix - and ".so" not in f.name + if f.is_file() and os.access(f, os.X_OK) and not f.suffix and ".so" not in f.name ), None, ) diff --git a/examples/language-interop/tests/test_cpp_interop.py b/examples/language-interop/tests/test_cpp_interop.py index 6e5c81391b..356dc7446f 100644 --- a/examples/language-interop/tests/test_cpp_interop.py +++ b/examples/language-interop/tests/test_cpp_interop.py @@ -1,9 +1,23 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Integration test: C++ robot-control binary talks to Python simplerobot via LCM.""" from __future__ import annotations -import subprocess from pathlib import Path +import subprocess import pytest diff --git a/examples/language-interop/tests/test_rust_interop.py b/examples/language-interop/tests/test_rust_interop.py index 6ffdae3a3f..e563cbd56d 100644 --- a/examples/language-interop/tests/test_rust_interop.py +++ b/examples/language-interop/tests/test_rust_interop.py @@ -1,10 +1,24 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Integration test: Rust robot-control binary talks to Python simplerobot via LCM.""" from __future__ import annotations import os -import subprocess from pathlib import Path +import subprocess import pytest @@ -34,8 +48,7 @@ def test_rust_binary_publishes_twist( stdout, stderr = _run_rust_binary(rust_binary) assert "[twist]" in stdout, ( - f"Rust binary never published a Twist.\n" - f"stdout: {stdout!r}\nstderr: {stderr!r}" + f"Rust binary never published a Twist.\nstdout: {stdout!r}\nstderr: {stderr!r}" ) diff --git a/examples/language-interop/tests/test_ts_interop.py b/examples/language-interop/tests/test_ts_interop.py index ac287e3e86..80adec20ba 100644 --- a/examples/language-interop/tests/test_ts_interop.py +++ b/examples/language-interop/tests/test_ts_interop.py @@ -1,3 +1,17 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Integration test: TypeScript (Deno) robot-control talks to Python simplerobot via LCM.""" from __future__ import annotations diff --git a/examples/language-interop/tests/test_wire_compat.py b/examples/language-interop/tests/test_wire_compat.py index 417c48d9f9..4f463febf8 100644 --- a/examples/language-interop/tests/test_wire_compat.py +++ b/examples/language-interop/tests/test_wire_compat.py @@ -1,3 +1,17 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Wire-format compatibility tests. Validates that Python LCM encoding matches known binary layouts from the Rust @@ -34,8 +48,7 @@ def test_twist_fingerprint() -> None: encoded = t.lcm_encode() fingerprint = struct.unpack(">Q", encoded[:8])[0] assert fingerprint == TWIST_FINGERPRINT, ( - f"Twist fingerprint mismatch: got 0x{fingerprint:016X}, " - f"expected 0x{TWIST_FINGERPRINT:016X}" + f"Twist fingerprint mismatch: got 0x{fingerprint:016X}, expected 0x{TWIST_FINGERPRINT:016X}" ) From 5ccfb500a110d8addd63d0c15e016c289f99f180 Mon Sep 17 00:00:00 2001 From: lesh Date: Sat, 21 Mar 2026 02:44:46 +0800 Subject: [PATCH 7/8] test(interop): add Lua language interop test Adds test_lua_interop.py following the same subprocess/graceful-skip pattern as Rust/TS/C++ tests. Also adds LUA_DIR constant to conftest.py. Co-Authored-By: Claude Sonnet 4.6 --- examples/language-interop/tests/conftest.py | 1 + .../tests/test_lua_interop.py | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 examples/language-interop/tests/test_lua_interop.py diff --git a/examples/language-interop/tests/conftest.py b/examples/language-interop/tests/conftest.py index a1ca9ce45e..d206828fc5 100644 --- a/examples/language-interop/tests/conftest.py +++ b/examples/language-interop/tests/conftest.py @@ -31,6 +31,7 @@ RUST_DIR = EXAMPLES_DIR / "rust" TS_DIR = EXAMPLES_DIR / "ts" CPP_DIR = EXAMPLES_DIR / "cpp" +LUA_DIR = EXAMPLES_DIR / "lua" def pytest_configure(config: pytest.Config) -> None: diff --git a/examples/language-interop/tests/test_lua_interop.py b/examples/language-interop/tests/test_lua_interop.py new file mode 100644 index 0000000000..1e20d5fc73 --- /dev/null +++ b/examples/language-interop/tests/test_lua_interop.py @@ -0,0 +1,47 @@ +"""Integration test: Lua robot-control script talks to Python simplerobot via LCM.""" + +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +from .conftest import LUA_DIR + +pytestmark = pytest.mark.interop + + +@pytest.fixture(scope="module") +def lua_available() -> None: + if shutil.which("lua") is None and shutil.which("lua5.4") is None: + pytest.skip("lua not found on PATH") + + +def test_lua_receives_pose_and_publishes_twist( + simplerobot: subprocess.Popen[str], + lua_available: None, +) -> None: + """Run the Lua script for a few seconds and verify message exchange.""" + lua_bin = shutil.which("lua") or shutil.which("lua5.4") or "lua" + try: + result = subprocess.run( + [lua_bin, str(LUA_DIR / "main.lua")], + capture_output=True, + text=True, + timeout=5, + cwd=str(LUA_DIR), + ) + except subprocess.TimeoutExpired as e: + stdout = e.stdout or "" + stderr = e.stderr or "" + else: + stdout = result.stdout + stderr = result.stderr + + assert "[pose]" in stdout, ( + f"Lua script never received a PoseStamped.\nstdout: {stdout!r}\nstderr: {stderr!r}" + ) + assert "[twist]" in stdout, ( + f"Lua script never published a Twist.\nstdout: {stdout!r}\nstderr: {stderr!r}" + ) From 20071cbedd961e8a7e8e356030dc016d638b65b1 Mon Sep 17 00:00:00 2001 From: leshy <681516+leshy@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:45:24 +0000 Subject: [PATCH 8/8] CI code cleanup --- .../language-interop/tests/test_lua_interop.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/examples/language-interop/tests/test_lua_interop.py b/examples/language-interop/tests/test_lua_interop.py index 1e20d5fc73..6ea740fdba 100644 --- a/examples/language-interop/tests/test_lua_interop.py +++ b/examples/language-interop/tests/test_lua_interop.py @@ -1,3 +1,17 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Integration test: Lua robot-control script talks to Python simplerobot via LCM.""" from __future__ import annotations