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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/cdr-native.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: CDR native interoperability

on:
pull_request:
workflow_dispatch:
push:
branches: [main]

permissions:
contents: read

jobs:
native:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
DIMOS_ALLOW_MISSING_COCKPIT: "1"
CARGO_TARGET_DIR: ${{ github.workspace }}/target
steps:
- uses: actions/checkout@v7
with:
lfs: false
persist-credentials: false
- uses: astral-sh/setup-uv@v10.2.0
with:
python-version: '3.12'
enable-cache: true
- uses: denoland/setup-deno@v2
with:
deno-version: v2.6.10
- name: Install build and media dependencies
run: |
sudo apt-get update
sudo apt-get install -y cmake pkg-config clang libclang-dev portaudio19-dev libturbojpeg ffmpeg
- name: Install Python project and test dependencies
run: uv sync --frozen --group tests
- name: Test SDK and build native consumers
run: |
cargo test --locked --manifest-path native/rust/dimos-module/Cargo.toml
cargo test --locked --manifest-path native/rust/dimos-module-macros/Cargo.toml
cargo test --locked --manifest-path dimos/mapping/ray_tracing/rust/Cargo.toml --lib
cargo test --locked --manifest-path dimos/navigation/global_planner/mls_planner/rust/Cargo.toml --lib
cargo test --locked --manifest-path dimos/navigation/local_planner/rust/Cargo.toml --features module,test-bodies
cargo test --locked --manifest-path dimos/navigation/trajectory_follower/fancy/rust/Cargo.toml --features module
cargo test --locked --manifest-path dimos/hardware/sensors/camera/v4l2/rust/Cargo.toml --lib
cargo check --locked --manifest-path dimos/perception/depth2depth_cloud/rust/Cargo.toml --lib
cargo check --locked --manifest-path dimos/hardware/sensors/camera/realsense/rust/Cargo.toml --all-targets --features docs-only
cargo build --locked --manifest-path dimos/mapping/ray_tracing/rust/Cargo.toml --bin voxel_ray_tracing
cargo build --locked --manifest-path dimos/navigation/global_planner/mls_planner/rust/Cargo.toml --bin mls_planner
cargo build --locked --manifest-path dimos/navigation/local_planner/rust/Cargo.toml --features module --bin local_planner
cargo build --locked --manifest-path dimos/navigation/trajectory_follower/fancy/rust/Cargo.toml --features module --bin trajectory_follower
- name: Prepare DimSim dependencies
working-directory: misc/DimSim/cli
run: deno install --node-modules-dir=auto --entrypoint bridge/cdr_test.ts
- name: Exchange generated messages with real native and TypeScript consumers
run: |
export DIMOS_NATIVE_RAY_TRACING_BINARY="$CARGO_TARGET_DIR/debug/voxel_ray_tracing"
export DIMOS_NATIVE_MLS_BINARY="$CARGO_TARGET_DIR/debug/mls_planner"
export DIMOS_NATIVE_LOCAL_PLANNER_BINARY="$CARGO_TARGET_DIR/debug/local_planner"
export DIMOS_NATIVE_FOLLOWER_BINARY="$CARGO_TARGET_DIR/debug/trajectory_follower"
export DIMOS_DIMSIM_DENO="$(command -v deno)"
uv run --no-sync pytest --noconftest --import-mode=importlib -o addopts='' -q \
dimos/mapping/ray_tracing/test_generated_native.py \
dimos/navigation/global_planner/mls_planner/test_generated_native.py \
dimos/navigation/local_planner/test_generated_native.py \
dimos/robot/unitree/test_dimsim_connection.py \
dimos/navigation/trajectory_follower/fancy/test_generated_native.py \
dimos/hardware/sensors/camera/realsense/test_camera.py \
dimos/perception/depth2depth_cloud/test_depth2depth_cloud_config.py
42 changes: 42 additions & 0 deletions bin/generate-native-tf-messages
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
# 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.

"""Regenerate the SDK's standard TF message crate with dimos-cdrgen 0.1.2."""

from importlib.metadata import version
from pathlib import Path
import shutil
from tempfile import TemporaryDirectory

from dimos_cdrgen.generate import generate

root = Path(__file__).resolve().parents[1]
crate = root / "native/rust/dimos-tf-messages"
if version("dimos-cdrgen") != "0.1.2":
raise RuntimeError("Install dimos-cdrgen==0.1.2 before regenerating TF messages")
with TemporaryDirectory() as temporary:
output = Path(temporary) / "generated"
generate(
[crate / "interfaces"],
output,
["tf2_msgs/msg/TFMessage"],
module="dimos_tf",
version="0.1.0",
languages=("rust",),
)
shutil.copytree(output / "rust", crate, dirs_exist_ok=True)
generated_lib = crate / "src/lib.rs"
license_header = (crate / "build.rs").read_text().split("use ros2msg", 1)[0]
generated_lib.write_text(license_header + generated_lib.read_text())
52 changes: 52 additions & 0 deletions native/message-source.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Copyright 2026 Dimensional Inc.
# SPDX-License-Identifier: Apache-2.0

# Generate the built-in definitions through the same configuration and core as pip.
# Nix fetches fixed-hash inputs; the generation builder itself is offline.
{ pkgs }:
let
generatorWheel = pkgs.fetchurl {
url = "https://files.pythonhosted.org/packages/29/9f/a4fe6f8ea4dcafd3001c868ebbc751bb0af83aecb1fe6523765c12cdedba/dimos_cdrgen-0.1.2-py3-none-any.whl";
sha256 = "9857c8cc40de58b03f94cb81e7d27ae3caac98101c96cdaa2965413a332e685f";
};
generator = pkgs.runCommand "dimos-cdrgen-0.1.2-source" { nativeBuildInputs = [ pkgs.unzip ]; } ''
mkdir -p $out
unzip -q ${generatorWheel} -d $out
'';
python = pkgs.python3.withPackages (p: [
p.setuptools p.wheel p.pydantic p.empy p.lark p.rosbags p.numpy p.requests
]);
product = pkgs.lib.fileset.toSource {
root = ../.;
fileset = pkgs.lib.fileset.unions [
../pyproject.toml
../dimos/message_codegen/build.py
../packages/dimos-generated/schema-sources.json
../packages/dimos-generated/custom
];
};
schemaLock = builtins.fromJSON (builtins.readFile ../packages/dimos-generated/schema-sources.json);
schemaArchives = map (source: {
name = builtins.hashString "sha256" source.url + ".tar.gz";
archive = pkgs.fetchurl { inherit (source) url sha256; };
}) schemaLock.sources;
source = pkgs.runCommand "dimos-generated-message-source" { nativeBuildInputs = [ python ]; } ''
cp -r ${product} product
chmod -R u+w product
mkdir -p cache/archives
${pkgs.lib.concatMapStringsSep "\n" (item: "cp ${item.archive} cache/archives/${item.name}") schemaArchives}
export PYTHONPATH="$PWD/product:${generator}:$PYTHONPATH"
python3 - <<'PYTHON'
from pathlib import Path
from dimos.message_codegen.build import prepare_builtin_sources
from dimos_cdrgen.source_package import prepare
prepare_builtin_sources(Path("cache"), offline=True)
prepare(Path("product"))
PYTHON
cp -r product/build/message-source $out
test -f $out/cpp/CMakeLists.txt
test -f $out/rust/Cargo.toml
'';
in {
inherit generator python source;
}
102 changes: 87 additions & 15 deletions native/rust/dimos-module-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,20 +454,33 @@ fn port_decls(classified: &[ClassifiedField], want_input: bool) -> Vec<PortDecl>
FieldKind::Tf => want_input,
_ => false,
})
.map(|f| PortDecl {
name: f.name.to_string(),
// `Tf` carries no generic payload. The port is TFMessage by
// construction.
// An explicit `msg` wins: a view type's wire payload and the dimos
// message it stands for are allowed to differ.
ty: match f.kind {
FieldKind::Tf => TF_PAYLOAD_TYPE.to_string(),
_ => f
.msg
.clone()
.or_else(|| port_payload_type(f.ty))
.unwrap_or_default(),
},
.flat_map(|f| {
let port = PortDecl {
name: f.name.to_string(),
// `Tf` carries no generic payload. The port is TFMessage by
// construction.
// An explicit `msg` wins: a view type's wire payload and the dimos
// message it stands for are allowed to differ.
ty: match f.kind {
FieldKind::Tf => TF_PAYLOAD_TYPE.to_string(),
_ => f
.msg
.clone()
.or_else(|| port_payload_type(f.ty))
.unwrap_or_default(),
},
};
if matches!(f.kind, FieldKind::Tf) {
vec![
port,
PortDecl {
name: "tf_static".into(),
ty: TF_PAYLOAD_TYPE.into(),
},
]
} else {
vec![port]
}
})
.collect()
}
Expand Down Expand Up @@ -537,7 +550,7 @@ fn check_port_table(entry: &toml::Value, kind: &str, declared: &[PortDecl]) -> R
let msg = msg
.as_str()
.ok_or_else(|| format!("`{kind}.{}` must be a string", port.name))?;
let want = msg.rsplit('.').next().unwrap_or(msg);
let want = msg.rsplit(['.', '/']).next().unwrap_or(msg);
if want != port.ty {
return Err(format!(
"port `{}` is declared `{msg}` in `{kind}` but the struct field carries `{}`",
Expand Down Expand Up @@ -649,6 +662,12 @@ fn classify_field(field: &Field, name: &Ident) -> syn::Result<FieldAttr> {
if found.is_some() {
return Err(syn::Error::new_spanned(attr, ONE_ATTR_ONLY));
}
if !matches!(&attr.meta, syn::Meta::Path(_)) {
return Err(syn::Error::new_spanned(
attr,
"#[tf] uses standard ROS TF messages and takes no codec arguments",
));
}
found = Some(FieldKind::Tf);
}
}
Expand Down Expand Up @@ -736,6 +755,36 @@ mod tests {
};
use syn::parse_quote;

#[test]
fn tf_accepts_legacy_or_explicit_codec_pair_and_keeps_registry_type() {
for field in [parse_quote!(#[tf] tf: Tf)] {
let attr = super::classify_field(&field, &parse_quote!(tf)).expect("valid TF field");
let ty: syn::Type = parse_quote!(Tf);
let name = parse_quote!(tf);
let fields = [ClassifiedField {
name: &name,
ty: &ty,
kind: attr.kind,
msg: attr.msg,
}];
let ports = super::port_decls(&fields, true);
assert_eq!(ports.len(), 2);
assert_eq!(ports[0].ty, "TFMessage");
assert_eq!(ports[1].name, "tf_static");
}
}

#[test]
fn tf_rejects_partial_or_unknown_codec_settings() {
for field in [
parse_quote!(#[tf(decode = wire::decode)] tf: Tf),
parse_quote!(#[tf(encode = wire::encode)] tf: Tf),
parse_quote!(#[tf(codec = wire::codec)] tf: Tf),
] {
assert!(super::classify_field(&field, &parse_quote!(tf)).is_err());
}
}

#[test]
fn io_ports_are_rejected_by_the_registry_check() {
let name: syn::LitStr = parse_quote!("demo");
Expand Down Expand Up @@ -790,6 +839,29 @@ mod tests {
.expect("matching ports should pass");
}

#[test]
fn accepts_canonical_cdr_names_and_still_rejects_type_mismatch() {
let manifest = MANIFEST.replace("sensor_msgs.PointCloud2", "sensor_msgs/msg/PointCloud2");
check_manifest_ports(
&manifest,
"demo",
&[port("lidar", "PointCloud2")],
&[port("global_map", "PointCloud2")],
)
.expect("canonical ROS message names match their imported Rust type");
let err = check_manifest_ports(
&manifest,
"demo",
&[port("lidar", "Image")],
&[port("global_map", "PointCloud2")],
)
.expect_err("canonical names must still enforce the payload type");
assert!(
err.contains("sensor_msgs/msg/PointCloud2") && err.contains("Image"),
"{err}"
);
}

#[test]
fn rejects_missing_metadata_section() {
let err = check_manifest_ports(MANIFEST, "other", &[], &[])
Expand Down
Loading
Loading