diff --git a/.github/scripts/wheel_smoke.py b/.github/scripts/wheel_smoke.py index d9640729614..9be465be6f0 100755 --- a/.github/scripts/wheel_smoke.py +++ b/.github/scripts/wheel_smoke.py @@ -49,7 +49,7 @@ def main() -> None: image_type = types["sensor_msgs/msg/Image"] value = image_type(height=1, width=2, step=2, encoding="mono8", data=[3, 7]) assert list(image_type.decode(value.encode()).data) == [3, 7] - assert "MSG: std_msgs/msg/Header" in image_type.schema + assert "MSG: std_msgs/Header" in image_type.schema dist = Path(locate.__file__).resolve().parent / "_relay_dist" for rel in REQUIRED: if not (dist / rel).is_file(): diff --git a/.github/workflows/message-codegen.yml b/.github/workflows/message-codegen.yml index 1655f0564ef..ef68bc1f6ed 100644 --- a/.github/workflows/message-codegen.yml +++ b/.github/workflows/message-codegen.yml @@ -8,6 +8,9 @@ on: - native/rust/dimos-lcm-transport/** - scripts/*message*codegen* - scripts/test_message_packages.sh + - scripts/test_message_mcap.sh + - dimos/protocol/cdr_mcap.py + - dimos/protocol/test_cdr_mcap.py - scripts/vendor_message_definitions.py - .github/workflows/message-codegen.yml - .github/docker/message-codegen-reference.Dockerfile @@ -25,11 +28,14 @@ jobs: - uses: actions/checkout@v7 - uses: astral-sh/setup-uv@v10.0.1 - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-node@v6 + with: + node-version: '22' - name: Set up a ROS-free Python environment run: | test ! -d /opt/ros uv venv .venv --python 3.12 - uv pip install --python .venv/bin/python pybind11==3.0.1 rosbags==0.11.0 numpy pytest pytest-asyncio pytest-env lcm-dimos-fork setuptools wheel + uv pip install --python .venv/bin/python pybind11==3.0.1 rosbags==0.11.0 numpy pytest pytest-asyncio pytest-env lcm-dimos-fork setuptools wheel mcap==1.4.0 rerun-sdk==0.32.0 pillow .venv/bin/python -c "import importlib.util; assert importlib.util.find_spec('rclpy') is None" - name: Build the pinned standalone CDR library run: bash scripts/setup_message_codegen.sh @@ -37,6 +43,16 @@ jobs: run: bash scripts/test_message_codegen.sh - name: Build and consume wheel, sdist, CMake, and Cargo packages run: bash scripts/test_message_packages.sh + - name: Record generated CDR and verify both viewer decoders + run: bash scripts/test_message_mcap.sh + - name: Retain self-contained recording and viewer evidence + uses: actions/upload-artifact@v7 + with: + name: message-mcap-evidence + path: | + build/message-codegen/viewers/demo.mcap + build/message-codegen/viewers/demo.rrd + build/message-codegen/viewers/evidence/ - name: Retain external application and packages uses: actions/upload-artifact@v7 with: diff --git a/dimos/message_codegen/definitions.py b/dimos/message_codegen/definitions.py index 7ad27e45b72..e2f8db261f4 100644 --- a/dimos/message_codegen/definitions.py +++ b/dimos/message_codegen/definitions.py @@ -196,5 +196,8 @@ def schema(self, name: str) -> str: sections = [root.text.rstrip() + "\n"] for message in sorted(closure, key=lambda item: item.name): if message.name != name: - sections.append(f"{'=' * 80}\nMSG: {message.name}\n{message.text.rstrip()}\n") + # Concatenated .msg sections use the package/resource spelling + # used by field references. The outer schema name stays pkg/msg/T. + resource_name = message.name.replace("/msg/", "/") + sections.append(f"{'=' * 80}\nMSG: {resource_name}\n{message.text.rstrip()}\n") return "".join(sections) diff --git a/dimos/protocol/cdr_mcap.py b/dimos/protocol/cdr_mcap.py new file mode 100644 index 00000000000..15ffb9fa507 --- /dev/null +++ b/dimos/protocol/cdr_mcap.py @@ -0,0 +1,97 @@ +# 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. + +"""Self-describing CDR recording, independent of transport and generated classes.""" + +from __future__ import annotations + +from pathlib import Path +from types import TracebackType + +from mcap.writer import CompressionType, Writer +from typing_extensions import Self + + +class CdrMcapWriter: + """Write raw generated CDR with embedded ROS2 definitions and chunk compression. + + Callers supply source and reception timestamps explicitly. No message package + is imported, so a recorder can accept schemas from external stream providers. + """ + + def __init__(self, path: str | Path) -> None: + self._stream = Path(path).open("wb") + try: + self._writer = Writer(self._stream, compression=CompressionType.ZSTD) + self._writer.start(profile="ros2", library="dimos") + except BaseException: + self._stream.close() + raise + self._schemas: dict[tuple[str, str], int] = {} + self._channels: dict[tuple[str, int], int] = {} + self._closed = False + + def write( + self, + topic: str, + payload: bytes, + *, + schema_name: str, + schema: str, + log_time_ns: int, + publish_time_ns: int | None = None, + sequence: int = 0, + ) -> None: + if self._closed: + raise ValueError("MCAP writer is closed") + schema_key = (schema_name, schema) + if schema_key not in self._schemas: + self._schemas[schema_key] = self._writer.register_schema( + name=schema_name, encoding="ros2msg", data=schema.encode("utf-8") + ) + schema_id = self._schemas[schema_key] + channel_key = (topic, schema_id) + if channel_key not in self._channels: + self._channels[channel_key] = self._writer.register_channel( + topic=topic, + message_encoding="cdr", + schema_id=schema_id, + metadata={"offered_qos_profiles": "[]"}, + ) + self._writer.add_message( + channel_id=self._channels[channel_key], + data=payload, + log_time=log_time_ns, + publish_time=log_time_ns if publish_time_ns is None else publish_time_ns, + sequence=sequence, + ) + + def close(self) -> None: + if not self._closed: + self._closed = True + try: + self._writer.finish() + finally: + self._stream.close() + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() diff --git a/dimos/protocol/test_cdr_mcap.py b/dimos/protocol/test_cdr_mcap.py new file mode 100644 index 00000000000..921ba329288 --- /dev/null +++ b/dimos/protocol/test_cdr_mcap.py @@ -0,0 +1,110 @@ +# 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. + +from mcap.reader import make_reader +from mcap.records import Chunk +from mcap.stream_reader import StreamReader +import pytest +from rosbags.typesys import Stores, get_types_from_msg, get_typestore + +from dimos.message_codegen.definitions import Definitions +from dimos.protocol.cdr_mcap import CdrMcapWriter + + +@pytest.fixture +def point_message(): + name = "geometry_msgs/msg/Point" + schema = Definitions([]).schema(name) + reference = get_typestore(Stores.ROS2_JAZZY) + value = reference.types[name](x=1.25, y=-2.5, z=3.0) + return name, schema, bytes(reference.serialize_cdr(value, name)) + + +def test_embeds_schemas_and_preserves_cdr_timestamps_and_sequence(tmp_path, point_message): + name, schema, payload = point_message + path = tmp_path / "capture.mcap" + with CdrMcapWriter(path) as writer: + for topic, timestamp in (("/first", 100), ("/second", 200)): + writer.write( + topic, + payload, + schema_name=name, + schema=schema, + log_time_ns=timestamp, + publish_time_ns=0, + sequence=7, + ) + with path.open("rb") as stream: + reader = make_reader(stream, validate_crcs=True) + assert reader.get_header().profile == "ros2" + summary = reader.get_summary() + assert len(summary.schemas) == 1 + assert len(summary.channels) == 2 + messages = list(reader.iter_messages()) + assert [message.log_time for _, _, message in messages] == [100, 200] + reference = get_typestore(Stores.EMPTY) + for stored_schema, channel, message in messages: + assert stored_schema.encoding == "ros2msg" + assert stored_schema.name == name + assert stored_schema.data.decode() == schema + assert channel.message_encoding == "cdr" + assert channel.metadata["offered_qos_profiles"] == "[]" + assert message.data == payload + assert message.publish_time == 0 + assert message.sequence == 7 + reference.register(get_types_from_msg(stored_schema.data.decode(), stored_schema.name)) + decoded = reference.deserialize_cdr(message.data, name) + assert (decoded.x, decoded.y, decoded.z) == (1.25, -2.5, 3.0) + + +def test_unstamped_messages_use_reception_time_and_chunks_are_compressed(tmp_path, point_message): + name, schema, payload = point_message + path = tmp_path / "capture.mcap" + with CdrMcapWriter(path) as writer: + writer.write("/point", payload, schema_name=name, schema=schema, log_time_ns=123) + with path.open("rb") as stream: + messages = list(make_reader(stream).iter_messages()) + assert messages[0][2].publish_time == 123 + with path.open("rb") as stream: + chunks = [ + record + for record in StreamReader(stream, emit_chunks=True).records + if isinstance(record, Chunk) + ] + assert chunks + assert all(chunk.compression == "zstd" for chunk in chunks) + + +def test_context_finalizes_recording_when_producer_raises(tmp_path, point_message): + name, schema, payload = point_message + path = tmp_path / "capture.mcap" + with pytest.raises(RuntimeError, match="producer"), CdrMcapWriter(path) as writer: + writer.write("/point", payload, schema_name=name, schema=schema, log_time_ns=123) + raise RuntimeError("producer failed") + with path.open("rb") as stream: + assert make_reader(stream).get_summary().statistics.message_count == 1 + with pytest.raises(ValueError, match="closed"): + writer.write("/point", payload, schema_name=name, schema=schema, log_time_ns=124) + + +def test_nested_schema_uses_resolvable_resource_names(): + schema = Definitions([]).schema("sensor_msgs/msg/Image") + + assert "MSG: std_msgs/Header\n" in schema + assert "MSG: builtin_interfaces/Time\n" in schema + assert set(get_types_from_msg(schema, "sensor_msgs/msg/Image")) == { + "sensor_msgs/msg/Image", + "std_msgs/msg/Header", + "builtin_interfaces/msg/Time", + } diff --git a/examples/message-codegen/EVIDENCE.md b/examples/message-codegen/EVIDENCE.md index 441b0eabd23..743c025b824 100644 --- a/examples/message-codegen/EVIDENCE.md +++ b/examples/message-codegen/EVIDENCE.md @@ -96,3 +96,36 @@ content check (1,928 entries; 10.7 MB). A fresh environment with both wheels discovered 141 types and accepted a built-in Point inside external Telemetry. Two separately installed CMake packages can include the same standard Point without duplicate definitions. Focused tests now pass 34 cases. + +## Stage 3 automated recording checks + +`bash scripts/test_message_mcap.sh` passed on 2026-09-21 with Rerun 0.32.0, +MCAP 1.4.0, and the locked Foxglove JavaScript packages. The artifact contains +150 messages and is 3,123,201 bytes, including all schema dependencies. + +```text +4 passed +Foxglove libraries decoded 30 /camera/image messages from embedded definitions. +Foxglove libraries decoded 30 /camera/compressed messages from embedded definitions. +Foxglove libraries decoded 30 /robot/pose messages from embedded definitions. +Foxglove libraries decoded 30 /telemetry messages from embedded definitions. +Foxglove libraries decoded 30 /tf messages from embedded definitions. +Rerun imported 30 /telemetry rows as demo_msgs.msg.Telemetry:message. +Rerun imported 30 /camera/image rows as Image:buffer. +Rerun imported 30 /camera/compressed rows as EncodedImage:blob. +Rerun imported 30 /robot/pose rows as InstancePoses3D:translations. +Rerun imported 30 /tf rows as Transform3D:translation. +Custom telemetry is a structured Arrow value with nested fields. +``` + +The stage-1 conformance/buffer/raw-transport demos and stage-2 installed-package +demo were rerun after correcting dependency section names to `package/Type`. +The Rerun browser viewer displayed both images and exposed custom Telemetry's +`sequence: 29`, `reading.temperature: 22.9`, and `hops: [1, 2, 3]` from the MCAP. +Local screenshots are in `build/message-codegen/viewers/`. + +Full viewer acceptance is still pending. Foxglove's application redirected to +sign-in in the isolated browser; no credentials were entered. Its decoding +libraries pass independently, but that does not establish application UI +acceptance. Rerun's direct-file screen capture also needs final confirmation +of pose visualization and complete semantic playback before task 3.5 is closed. diff --git a/examples/message-codegen/README.md b/examples/message-codegen/README.md index 22d307f8e24..dae3f433fc3 100644 --- a/examples/message-codegen/README.md +++ b/examples/message-codegen/README.md @@ -172,3 +172,54 @@ type discovery validates conflicting qualified definitions before loading types. Generation, installation from built artifacts, and runtime require no schema service or runtime download. Dependency setup can use the network; cached Cargo builds and local wheel installation are exercised offline by the demo. + +## Stage 3: one recording for Foxglove and Rerun + +After building the stage-1 demo, install the viewer-check dependencies and run: + +```bash +uv pip install --python .venv/bin/python mcap==1.4.0 rerun-sdk==0.32.0 pillow +bash scripts/test_message_mcap.sh +``` + +Node.js 22 and npm are required for the independent Foxglove decoder check. The +script installs its locked packages with `npm ci`. It writes +`build/message-codegen/viewers/demo.mcap`: 30 frames each of raw RGB Image, PNG +CompressedImage, PoseStamped, TFMessage, and nested custom Telemetry. Source +timestamps start at 2023-11-14 22:13:20 UTC and advance by 100 ms. Reception +timestamps are 1 ms later. Each channel contains ordinary encapsulated CDR; +the MCAP chunks use Zstandard compression. + +The checks read the embedded definitions using Foxglove's official libraries +and the native Rerun 0.32.0 importer. Rerun must produce image, encoded-image, +pose, and transform components plus a structured custom telemetry column. It +must import all 30 rows per topic. This catches unresolved schema dependencies +that can otherwise silently omit custom data. CI uploads the MCAP, converted +RRD, and decoder evidence as `message-mcap-evidence`. + +To inspect the file in Rerun directly: + +```bash +RERUN_ANALYTICS_ENABLED=false .venv/bin/rerun build/message-codegen/viewers/demo.mcap +``` + +For a browser-hosted viewer, add `--serve-web --bind 127.0.0.1 --port auto` and +open the URL printed by Rerun. No ROS or `demo_msgs` installation is needed by +the viewer. Raw and compressed images should show the same changing gradient. +Select `/robot/pose` in Streams to inspect the map-frame position, and select +`/telemetry` to inspect the nested message fields. The CLI conversion explicitly +enables `ros2msg` and `ros2_reflection`; the saved `demo.rrd` is also available +for examining these decoder results. + +In Foxglove, sign in, open the **same** local `demo.mcap`, add an Image panel for +`/camera/image`, a 3D panel for `/robot/pose` with fixed frame `map`, and a Raw +Messages panel for `/telemetry`. Scrub from first to last frame: sequence should +change from 0 to 29 and `reading.temperature` from 20.0 to 22.9. The compressed +image topic should display the same pixels. Foxglove's web app currently +requires authentication; library decoding is automated, but application UI +acceptance remains pending until an authenticated viewer is available. + +Stop the Rerun process with Ctrl-C and close its browser tab. The script starts +no persistent services. Remove `build/message-codegen/viewers/` to clean up +recordings and `examples/message-codegen/viewer-checker/node_modules/` to clean +up the Node dependencies. diff --git a/examples/message-codegen/check_rerun_mcap.py b/examples/message-codegen/check_rerun_mcap.py new file mode 100644 index 00000000000..e5c5ed4011c --- /dev/null +++ b/examples/message-codegen/check_rerun_mcap.py @@ -0,0 +1,52 @@ +# 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. + +"""Check native Rerun's semantic and reflection imports, without message packages.""" + +from collections import Counter +from pathlib import Path +import re +import sys + + +def main() -> None: + evidence = Path(sys.argv[1]) + summary = (evidence / "rerun-summary.txt").read_text() + expected = { + "/camera/image": "Image:buffer", + "/camera/compressed": "EncodedImage:blob", + "/robot/pose": "InstancePoses3D:translations", + "/tf": "Transform3D:translation", + "/telemetry": "demo_msgs.msg.Telemetry:message", + } + counts: Counter[str] = Counter() + for line in summary.splitlines(): + match = re.search(r"with (\d+) rows .* - (/[\w/]+) - data columns: (.*)", line) + if match: + rows, topic, columns = match.groups() + assert topic in expected, topic + assert expected[topic] in columns, (topic, columns) + counts[topic] += int(rows) + assert counts == dict.fromkeys(expected, 30), counts + telemetry = (evidence / "rerun-telemetry.txt").read_text() + # This must be an Arrow struct with nested fields, not an opaque byte blob. + for field in ('"temperature": Float64', '"sequence": UInt32', '"hops": List(Int32)'): + assert field in telemetry, field + for topic, count in counts.items(): + print(f"Rerun imported {count} {topic} rows as {expected[topic]}.") + print("Custom telemetry is a structured Arrow value with nested fields.") + + +if __name__ == "__main__": + main() diff --git a/examples/message-codegen/demo_mcap.py b/examples/message-codegen/demo_mcap.py new file mode 100644 index 00000000000..c0fa5e1264a --- /dev/null +++ b/examples/message-codegen/demo_mcap.py @@ -0,0 +1,111 @@ +# 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. + +"""Record synthetic images, poses, transforms, and custom fields for both viewers.""" + +import argparse +from io import BytesIO +import math +from pathlib import Path + +from dimos_generated.demo_msgs.msg import Telemetry +from dimos_generated.geometry_msgs.msg import PoseStamped, TransformStamped +from dimos_generated.sensor_msgs.msg import CompressedImage, Image +from dimos_generated.tf2_msgs.msg import TFMessage +import numpy as np +from PIL import Image as PillowImage + +from dimos.protocol.cdr_mcap import CdrMcapWriter + +START_NS = 1_700_000_000_000_000_000 + + +def write_demo(path: Path, frames: int = 30) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with CdrMcapWriter(path) as writer: + for index in range(frames): + stamp_ns = START_NS + index * 100_000_000 + rows, columns = np.indices((192, 256), dtype=np.uint16) + pixels = np.stack( + ( + (columns + index * 8) % 256, + (rows + index * 5) % 256, + np.full_like(rows, (index * 17) % 256), + ), + axis=-1, + ).astype(np.uint8) + image = Image( + height=192, width=256, step=256 * 3, encoding="rgb8", data=pixels.reshape(-1) + ) + pose = PoseStamped() + pose.pose.position.x = math.cos(index / 5) + pose.pose.position.y = math.sin(index / 5) + pose.pose.orientation.w = 1.0 + pose.header.frame_id = "map" + image.header.frame_id = "camera" + for value in (image, pose): + value.header.stamp.sec, value.header.stamp.nanosec = divmod(stamp_ns, 1_000_000_000) + compressed_bytes = BytesIO() + PillowImage.fromarray(pixels).save(compressed_bytes, format="PNG") + compressed = CompressedImage( + header=image.header, format="png", data=compressed_bytes.getvalue() + ) + telemetry = Telemetry( + header=pose.header, + sequence=index, + label="synthetic", + position=pose.pose.position, + hops=[1, 2, 3], + ) + telemetry.reading.temperature = 20.0 + index / 10 + transform = TransformStamped(header=pose.header, child_frame_id="camera") + transform.transform.translation.x = pose.pose.position.x + transform.transform.translation.y = pose.pose.position.y + transform.transform.rotation.w = 1.0 + transforms = TFMessage(transforms=[transform]) + for topic, value in ( + ("/camera/image", image), + ("/camera/compressed", compressed), + ("/robot/pose", pose), + ("/telemetry", telemetry), + ("/tf", transforms), + ): + writer.write( + topic, + value.encode(), + schema_name=value.msg_name, + schema=value.schema, + log_time_ns=stamp_ns + 1_000_000, + publish_time_ns=stamp_ns, + sequence=index, + ) + print(f"Wrote {frames * 5} messages to {path} ({path.stat().st_size:,} bytes)") + print("Five CDR channels; ROS2 schemas and all dependencies are embedded.") + print( + "Image: 256x192 RGB gradient; pose: circular path; telemetry: sequence 0..29 and temperature 20.0..22.9." + ) + print("Open this same file in Foxglove and Rerun without installing demo_msgs.") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", type=Path, default=Path("build/message-codegen/viewers/demo.mcap") + ) + args = parser.parse_args() + write_demo(args.output) + + +if __name__ == "__main__": + main() diff --git a/examples/message-codegen/viewer-checker/check.mjs b/examples/message-codegen/viewer-checker/check.mjs new file mode 100644 index 00000000000..86e8d8b9a57 --- /dev/null +++ b/examples/message-codegen/viewer-checker/check.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { McapIndexedReader } from '@mcap/core'; +import { parse, fixupTypes } from '@foxglove/rosmsg'; +import { MessageReader } from '@foxglove/rosmsg2-serialization'; +import zstd from '@foxglove/wasm-zstd'; + +await zstd.isLoaded; +const bytes = await readFile(process.argv[2]); +const reader = await McapIndexedReader.Initialize({ + readable: { + size: async () => BigInt(bytes.length), + read: async (offset, size) => bytes.subarray(Number(offset), Number(offset + size)), + }, + decompressHandlers: { zstd: (data, size) => zstd.decompress(data, Number(size)) }, +}); +assert.equal(reader.header.profile, 'ros2'); +const decoders = new Map(); +for (const schema of reader.schemasById.values()) { + assert.equal(schema.encoding, 'ros2msg'); + const definitions = parse(new TextDecoder().decode(schema.data), { ros2: true, skipTypeFixup: true }); + definitions[0].name = schema.name.replace('/msg/', '/'); + fixupTypes(definitions); + decoders.set(schema.id, new MessageReader(definitions)); +} +const counts = new Map(); +for await (const message of reader.readMessages({ validateCrcs: true })) { + const channel = reader.channelsById.get(message.channelId); + assert.equal(channel.messageEncoding, 'cdr'); + const value = decoders.get(channel.schemaId).readMessage(message.data); + const index = message.sequence; + assert.equal(message.logTime - message.publishTime, 1000000n); + if (channel.topic === '/telemetry') { + assert.equal(value.sequence, index); + assert.equal(value.reading.temperature, 20 + index / 10); + assert.equal(value.label, 'synthetic'); + assert.deepEqual(Array.from(value.hops), [1, 2, 3]); + } else if (channel.topic === '/camera/image') { + assert.equal(value.width, 256); + assert.equal(value.height, 192); + assert.equal(value.data.length, 256 * 192 * 3); + assert.equal(value.data[0], index * 8 % 256); + } else if (channel.topic === '/camera/compressed') { + assert.equal(value.format, 'png'); + assert.deepEqual(Array.from(value.data.slice(0, 8)), [137,80,78,71,13,10,26,10]); + } else if (channel.topic === '/robot/pose') { + assert.equal(value.pose.position.x, Math.cos(index / 5)); + assert.equal(value.pose.orientation.w, 1); + } else if (channel.topic === '/tf') { + assert.equal(value.transforms[0].child_frame_id, 'camera'); + } + counts.set(channel.topic, (counts.get(channel.topic) ?? 0) + 1); +} +assert.equal(counts.size, 5); +for (const [topic, count] of counts) { + assert.equal(count, 30); + console.log(`Foxglove libraries decoded ${count} ${topic} messages from embedded definitions.`); +} diff --git a/examples/message-codegen/viewer-checker/package-lock.json b/examples/message-codegen/viewer-checker/package-lock.json new file mode 100644 index 00000000000..b4ee01c90a6 --- /dev/null +++ b/examples/message-codegen/viewer-checker/package-lock.json @@ -0,0 +1,110 @@ +{ + "name": "viewer-checker", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@foxglove/rosmsg": "5.0.5", + "@foxglove/rosmsg2-serialization": "3.1.2", + "@foxglove/wasm-zstd": "1.0.1", + "@mcap/core": "2.2.2" + } + }, + "node_modules/@foxglove/cdr": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@foxglove/cdr/-/cdr-3.5.1.tgz", + "integrity": "sha512-Fubm04/xC1SkeNaviwUhbFk8nAiiNvIqlmP5MW7r6IVGFa35rsA0E3/Rg/pGdE77ZXf4XNwShZcQoEmje62ddQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@foxglove/crc": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@foxglove/crc/-/crc-0.0.3.tgz", + "integrity": "sha512-DjIZsnL3CyP/yQ/vUYA9cjrD0a/8YXejI5ZmsaOiT16cLfZcTwaCxIN01/ys4jsy+dZCQ/9DnWFn7AEFbiMDaA==", + "license": "MIT" + }, + "node_modules/@foxglove/message-definition": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@foxglove/message-definition/-/message-definition-0.5.0.tgz", + "integrity": "sha512-B/2GvWn4wRSsPDywcmy6uNXgAfZRhiOAnmy3qAaS6MHVqiHQqs/RoDsoW6Gw9sXSweJpeH5qAqAihvm1pR9Thg==", + "license": "MIT" + }, + "node_modules/@foxglove/rosmsg": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@foxglove/rosmsg/-/rosmsg-5.0.5.tgz", + "integrity": "sha512-+qY6xW+4HdbPI3ItAlFFMjXgAiplXyRsv+kqdBk8OcWGH38BmEd7s6v/VnCkbYovAoB5rfR5giuKfmPRNzwbrA==", + "license": "MIT", + "dependencies": { + "@foxglove/message-definition": "0.5.0", + "md5-typescript": "^1.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@foxglove/rosmsg2-serialization": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@foxglove/rosmsg2-serialization/-/rosmsg2-serialization-3.1.2.tgz", + "integrity": "sha512-KKtmHuuIQy6I0JLUi01LFhV7hrZb5xd+LegtuGz2+9L9pftBxtVVKhoxO6B/kQREGocIUOLP8494kxLF58LMWw==", + "license": "MIT", + "dependencies": { + "@foxglove/cdr": "^3.5.1", + "@foxglove/message-definition": "0.5.0", + "@foxglove/rostime": "^1.1.3" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@foxglove/rostime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@foxglove/rostime/-/rostime-1.1.3.tgz", + "integrity": "sha512-Gr3Xy4sNbo2NcXg0tBHb/jVUqIcDyZ1kgHj9dB7K+Y1A7AQ0oSCmSXIuf8oqeteSIs0pl8BJOWEwAtLNC99Izg==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@foxglove/wasm-zstd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@foxglove/wasm-zstd/-/wasm-zstd-1.0.1.tgz", + "integrity": "sha512-hqKTO45Sl+kSq9SDqM9xi5L+GncjFEZIJgYEkGTlpJXTfhy1Gf+a+3epWMcE0tLUKmzM77dtu8snape/J+7ueQ==", + "license": "MIT" + }, + "node_modules/@mcap/core": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@mcap/core/-/core-2.2.2.tgz", + "integrity": "sha512-W4NFiyqQlJsNjw8JxAAQ872u0xLWJy0GFC3YFm8bTLbNs+HdDWe8DwBqznr0MGYpWtw+AX8XzHWB5Y9rLEGJJg==", + "license": "MIT", + "dependencies": { + "@foxglove/crc": "^0.0.3", + "heap-js": "^2.7.1", + "tslib": "^2.8.1" + } + }, + "node_modules/heap-js": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/heap-js/-/heap-js-2.7.1.tgz", + "integrity": "sha512-EQfezRg0NCZGNlhlDR3Evrw1FVL2G3LhU7EgPoxufQKruNBSYA8MiRPHeWbU+36o+Fhel0wMwM+sLEiBAlNLJA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/md5-typescript": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/md5-typescript/-/md5-typescript-1.0.5.tgz", + "integrity": "sha512-ovAc4EtiNt2dY8JPhPr/wkC9h4U5k/nuClNVcG0Ga3V1rMlYpAY24ZaaymFXJlz+ccJ6UMPo3FSaVKe7czBsXw==", + "license": "ISC" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + } + } +} diff --git a/examples/message-codegen/viewer-checker/package.json b/examples/message-codegen/viewer-checker/package.json new file mode 100644 index 00000000000..9d02547d3bc --- /dev/null +++ b/examples/message-codegen/viewer-checker/package.json @@ -0,0 +1,10 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "@foxglove/rosmsg": "5.0.5", + "@foxglove/rosmsg2-serialization": "3.1.2", + "@mcap/core": "2.2.2", + "@foxglove/wasm-zstd": "1.0.1" + } +} diff --git a/openspec/changes/replace-lcm-message-encoding/design.md b/openspec/changes/replace-lcm-message-encoding/design.md index ff424faa897..427cf12b187 100644 --- a/openspec/changes/replace-lcm-message-encoding/design.md +++ b/openspec/changes/replace-lcm-message-encoding/design.md @@ -97,6 +97,16 @@ For Rust's raw LCM transport, first inspect the existing external implementation Write standard MCAP ROS2-profile channels with message encoding `cdr`, schema encoding `ros2msg`, qualified schema names, and the complete concatenated dependency definitions. Keep the stored payload exactly the generated CDR message. Use MCAP chunk compression rather than wrapping channel payloads in a custom compression codec. Compressed image data remains a standard `CompressedImage` message. +The top-level schema name is `package/msg/Type`; concatenated dependency section +headers use `MSG: package/Type`, as in the ROS concatenated message format. Rerun +0.32 reflection resolves nested fields against these resource names. Using +`package/msg/Type` in section headers can silently omit custom messages from +its importer even when other readers accept the file. The stage-3 regression +checks require all custom rows to appear as structured fields in native Rerun, +in addition to decoding every payload with Foxglove's libraries. ROS2-profile +channels include `offered_qos_profiles: "[]"`; this transport-neutral writer +does not invent DDS QoS settings. + Feed schema metadata to the recorder through its existing stream startup configuration. Recording an arbitrary custom type requires no recorder-specific generated decoder or rebuild. Existing recognized-type optimizations must preserve the declared schema and message semantics. Use source stamps for supported stamped messages and reception time for otherwise unstamped messages, with recording log time always recording reception. First prove viewer interoperability using a standalone producer and recording, then connect the actual recorder and replay paths. Both Foxglove and the repository's pinned Rerun version must inspect custom fields using only embedded definitions. Standard image and pose messages must have semantic visualization; arbitrary custom messages need only field inspection unless an explicit visualization adapter exists. Preserve existing live Rerun behavior; do not add a live Foxglove server in this stack. diff --git a/openspec/changes/replace-lcm-message-encoding/tasks.md b/openspec/changes/replace-lcm-message-encoding/tasks.md index b573868b2fe..6da65858296 100644 --- a/openspec/changes/replace-lcm-message-encoding/tasks.md +++ b/openspec/changes/replace-lcm-message-encoding/tasks.md @@ -25,10 +25,10 @@ ## 3. PR 3 — Inspect the same MCAP in Foxglove and Rerun -- [ ] 3.1 Generate complete concatenated `ros2msg` definitions and qualified schema metadata for every demo type, including transitive standard and custom dependencies. -- [ ] 3.2 Add reusable MCAP writing support for the ROS2 profile, `cdr` channels, embedded `ros2msg` schemas, and MCAP chunk compression without private payload wrappers. -- [ ] 3.3 Extend the example with a deterministic synthetic image, moving pose, and custom telemetry producer writing generated CDR directly to one MCAP artifact. -- [ ] 3.4 Add automated checks for schema dependency closure, channel encodings, independently decodable payloads, source/log timestamps, and standard compressed-image representation. +- [x] 3.1 Generate complete concatenated `ros2msg` definitions and qualified schema metadata for every demo type, including transitive standard and custom dependencies. +- [x] 3.2 Add reusable MCAP writing support for the ROS2 profile, `cdr` channels, embedded `ros2msg` schemas, and MCAP chunk compression without private payload wrappers. +- [x] 3.3 Extend the example with a deterministic synthetic image, moving pose, and custom telemetry producer writing generated CDR directly to one MCAP artifact. +- [x] 3.4 Add automated checks for schema dependency closure, channel encodings, independently decodable payloads, source/log timestamps, and standard compressed-image representation. - [ ] 3.5 Open the same artifact in Foxglove and pinned Rerun with the custom message package absent from the viewer environment; demonstrate image/pose visualization and custom-field inspection, recording exact UI steps and screen evidence. - [ ] 3.6 Publish the demo MCAP as a review artifact, document reproduction and cleanup, and rerun all preceding demos and automated checks. diff --git a/scripts/test_message_mcap.sh b/scripts/test_message_mcap.sh new file mode 100644 index 00000000000..8799c04bb74 --- /dev/null +++ b/scripts/test_message_mcap.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run after test_message_codegen.sh. Viewers consume only the resulting MCAP. +cd "$(dirname "$0")/.." +output="$PWD/build/message-codegen/viewers" +mkdir -p "$output/evidence" +export PYTHONPATH="$PWD:$PWD/build/message-codegen/demo/cpp/build${PYTHONPATH:+:$PYTHONPATH}" +.venv/bin/pytest dimos/protocol/test_cdr_mcap.py --noconftest -o addopts='' -q \ + | tee "$output/evidence/pytest.txt" +.venv/bin/python examples/message-codegen/demo_mcap.py --output "$output/demo.mcap" \ + | tee "$output/evidence/producer.txt" +npm ci --ignore-scripts --prefix examples/message-codegen/viewer-checker +node examples/message-codegen/viewer-checker/check.mjs "$output/demo.mcap" \ + | tee "$output/evidence/foxglove.txt" +# The native Rerun executable has no Python message-provider imports. +unset PYTHONPATH +export RERUN_ANALYTICS_ENABLED=false +.venv/bin/rerun mcap convert "$output/demo.mcap" \ + -d ros2msg -d ros2_reflection -o "$output/demo.rrd" +.venv/bin/rerun rrd print "$output/demo.rrd" -v > "$output/evidence/rerun-summary.txt" +.venv/bin/rerun rrd print "$output/demo.rrd" --entity /telemetry -vvv \ + > "$output/evidence/rerun-telemetry.txt" +.venv/bin/python examples/message-codegen/check_rerun_mcap.py "$output/evidence"