topicforge 0.6.0__tar.gz → 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. {topicforge-0.6.0 → topicforge-0.6.1}/CHANGELOG.md +33 -1
  2. {topicforge-0.6.0 → topicforge-0.6.1}/PKG-INFO +5 -3
  3. {topicforge-0.6.0 → topicforge-0.6.1}/README.md +4 -2
  4. {topicforge-0.6.0 → topicforge-0.6.1}/docs/TESTING.md +2 -2
  5. topicforge-0.6.1/docs/VALIDATION.md +15 -0
  6. topicforge-0.6.1/plugin/LICENSE +21 -0
  7. topicforge-0.6.1/plugin/README.md +60 -0
  8. {topicforge-0.6.0 → topicforge-0.6.1}/pyproject.toml +1 -1
  9. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/__init__.py +1 -1
  10. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/common/cdr_decoder.py +11 -1
  11. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/composite.py +5 -0
  12. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/ros2_live/adapter.py +10 -0
  13. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/ros2_mock/fixtures.py +12 -5
  14. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/models/schemas.py +30 -5
  15. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/services/bag_service.py +17 -5
  16. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/services/health.py +16 -0
  17. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/tools/handlers.py +6 -1
  18. {topicforge-0.6.0 → topicforge-0.6.1}/tests/integration/ros2/test_live_adapter.py +24 -0
  19. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_bag_omnisim_humble.py +16 -0
  20. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_cdr_decoder.py +7 -0
  21. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_health.py +56 -0
  22. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_live_sample_notes.py +3 -1
  23. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_mock_adapter.py +10 -0
  24. {topicforge-0.6.0 → topicforge-0.6.1}/.gitignore +0 -0
  25. {topicforge-0.6.0 → topicforge-0.6.1}/LICENSE +0 -0
  26. {topicforge-0.6.0 → topicforge-0.6.1}/docs/DDS_QUICKSTART.md +0 -0
  27. {topicforge-0.6.0 → topicforge-0.6.1}/docs/TROUBLESHOOTING.md +0 -0
  28. {topicforge-0.6.0 → topicforge-0.6.1}/docs/TUTORIEL.md +0 -0
  29. {topicforge-0.6.0 → topicforge-0.6.1}/docs/dds-interop-matrix.md +0 -0
  30. {topicforge-0.6.0 → topicforge-0.6.1}/examples/README.md +0 -0
  31. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/00_hello_pub_sub/README.md +0 -0
  32. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/01_who_is_on_the_bus/README.md +0 -0
  33. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/02_why_cant_they_talk/README.md +0 -0
  34. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/03_a_node_crashed/README.md +0 -0
  35. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/04_late_joiner_misses_data/README.md +0 -0
  36. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/05_reliability_in_code/README.md +0 -0
  37. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/06_durability_late_joiner_in_code/README.md +0 -0
  38. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/07_deadline_in_code/README.md +0 -0
  39. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/08_crash_seen_from_inside/README.md +0 -0
  40. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/10_lidar_silent_after_driver_swap/README.md +0 -0
  41. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/11_who_talks_to_whom/README.md +0 -0
  42. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/12_safety_monitor_dropout/README.md +0 -0
  43. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/13_deadline_not_offered/README.md +0 -0
  44. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/14_restart_loop/README.md +0 -0
  45. {topicforge-0.6.0 → topicforge-0.6.1}/examples/dds/README.md +0 -0
  46. {topicforge-0.6.0 → topicforge-0.6.1}/scripts/agent_eval/README.md +0 -0
  47. {topicforge-0.6.0 → topicforge-0.6.1}/scripts/integration/README.md +0 -0
  48. {topicforge-0.6.0 → topicforge-0.6.1}/scripts/integration/publishers/cyclone_c/README.md +0 -0
  49. {topicforge-0.6.0 → topicforge-0.6.1}/scripts/integration/publishers/cyclone_cpp/README.md +0 -0
  50. {topicforge-0.6.0 → topicforge-0.6.1}/scripts/integration/publishers/cyclone_rust/README.md +0 -0
  51. {topicforge-0.6.0 → topicforge-0.6.1}/scripts/integration/publishers/dust_py/README.md +0 -0
  52. {topicforge-0.6.0 → topicforge-0.6.1}/scripts/integration/publishers/fast_publisher_cpp/README.md +0 -0
  53. {topicforge-0.6.0 → topicforge-0.6.1}/scripts/integration/publishers/fast_py/README.md +0 -0
  54. {topicforge-0.6.0 → topicforge-0.6.1}/scripts/integration/publishers/opensplice_publisher/README.md +0 -0
  55. {topicforge-0.6.0 → topicforge-0.6.1}/scripts/integration/publishers/rti_c/README.md +0 -0
  56. {topicforge-0.6.0 → topicforge-0.6.1}/scripts/integration/publishers/rti_cpp/README.md +0 -0
  57. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/__main__.py +0 -0
  58. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/__init__.py +0 -0
  59. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/base.py +0 -0
  60. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/common/__init__.py +0 -0
  61. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/common/dds_helpers.py +0 -0
  62. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/common/dds_introspection.py +0 -0
  63. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/common/discovery_tracker.py +0 -0
  64. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/common/endpoints.py +0 -0
  65. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/common/lifecycle.py +0 -0
  66. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/common/metrics_buffer.py +0 -0
  67. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/common/qos_analyzer.py +0 -0
  68. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/common/qos_endpoints.py +0 -0
  69. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/common/qos_normalize.py +0 -0
  70. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/common/qos_scan.py +0 -0
  71. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/common/topic_filter.py +0 -0
  72. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/common/xtypes.py +0 -0
  73. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/dds_cyclone/__init__.py +0 -0
  74. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/dds_cyclone/adapter.py +0 -0
  75. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/dds_dust/__init__.py +0 -0
  76. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/dds_dust/adapter.py +0 -0
  77. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/dds_fast/__init__.py +0 -0
  78. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/dds_fast/adapter.py +0 -0
  79. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/dds_opendds/__init__.py +0 -0
  80. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/dds_opendds/adapter.py +0 -0
  81. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/ros2_live/__init__.py +0 -0
  82. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/ros2_live/echo_parser.py +0 -0
  83. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/ros2_live/echo_stream.py +0 -0
  84. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/ros2_live/parsers.py +0 -0
  85. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/ros2_live/process_tree.py +0 -0
  86. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/ros2_mock/__init__.py +0 -0
  87. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/adapters/ros2_mock/adapter.py +0 -0
  88. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/config/__init__.py +0 -0
  89. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/config/settings.py +0 -0
  90. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/constants.py +0 -0
  91. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/models/__init__.py +0 -0
  92. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/server/__init__.py +0 -0
  93. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/server/app.py +0 -0
  94. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/services/__init__.py +0 -0
  95. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/services/bag_stats.py +0 -0
  96. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/services/factory.py +0 -0
  97. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/services/inspector.py +0 -0
  98. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/services/sample_budget.py +0 -0
  99. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/telemetry/__init__.py +0 -0
  100. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/telemetry/client.py +0 -0
  101. {topicforge-0.6.0 → topicforge-0.6.1}/src/topicforge/tools/__init__.py +0 -0
  102. {topicforge-0.6.0 → topicforge-0.6.1}/tests/__init__.py +0 -0
  103. {topicforge-0.6.0 → topicforge-0.6.1}/tests/conftest.py +0 -0
  104. {topicforge-0.6.0 → topicforge-0.6.1}/tests/fixtures/ros2_echo/image_noarr.yaml +0 -0
  105. {topicforge-0.6.0 → topicforge-0.6.1}/tests/fixtures/ros2_echo/image_trunc4.yaml +0 -0
  106. {topicforge-0.6.0 → topicforge-0.6.1}/tests/fixtures/ros2_echo/scan_edge.yaml +0 -0
  107. {topicforge-0.6.0 → topicforge-0.6.1}/tests/fixtures/ros2_echo/scan_noarr.yaml +0 -0
  108. {topicforge-0.6.0 → topicforge-0.6.1}/tests/fixtures/ros2_echo/scan_trunc128.yaml +0 -0
  109. {topicforge-0.6.0 → topicforge-0.6.1}/tests/fixtures/ros2_echo/scan_trunc3.yaml +0 -0
  110. {topicforge-0.6.0 → topicforge-0.6.1}/tests/fixtures/ros2_echo/string_latched.yaml +0 -0
  111. {topicforge-0.6.0 → topicforge-0.6.1}/tests/fixtures/ros2_echo/twist.yaml +0 -0
  112. {topicforge-0.6.0 → topicforge-0.6.1}/tests/fixtures/ros2_topic_info_verbose_cmd_vel.txt +0 -0
  113. {topicforge-0.6.0 → topicforge-0.6.1}/tests/fixtures/ros2_topic_info_verbose_parameter_events.txt +0 -0
  114. {topicforge-0.6.0 → topicforge-0.6.1}/tests/fixtures/ros2_topic_info_verbose_scan.txt +0 -0
  115. {topicforge-0.6.0 → topicforge-0.6.1}/tests/fixtures/ros2_topic_info_verbose_tf.txt +0 -0
  116. {topicforge-0.6.0 → topicforge-0.6.1}/tests/fixtures/ros2_topic_info_verbose_tf_static.txt +0 -0
  117. {topicforge-0.6.0 → topicforge-0.6.1}/tests/integration/__init__.py +0 -0
  118. {topicforge-0.6.0 → topicforge-0.6.1}/tests/integration/ros2/Dockerfile +0 -0
  119. {topicforge-0.6.0 → topicforge-0.6.1}/tests/integration/ros2/__init__.py +0 -0
  120. {topicforge-0.6.0 → topicforge-0.6.1}/tests/integration/ros2/entrypoint.sh +0 -0
  121. {topicforge-0.6.0 → topicforge-0.6.1}/tests/integration/ros2/publisher.py +0 -0
  122. {topicforge-0.6.0 → topicforge-0.6.1}/tests/integration/ros2/run_bench.py +0 -0
  123. {topicforge-0.6.0 → topicforge-0.6.1}/tests/integration/test_real_bus.py +0 -0
  124. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_analyze_bag_multi_format.py +0 -0
  125. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_bag_service.py +0 -0
  126. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_bag_stats.py +0 -0
  127. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_composite_adapter.py +0 -0
  128. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_config.py +0 -0
  129. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_cyclone_adapter.py +0 -0
  130. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_dds_cross_vendor.py +0 -0
  131. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_dds_helpers.py +0 -0
  132. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_dds_inactive_reason.py +0 -0
  133. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_dds_introspection.py +0 -0
  134. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_dds_qos_normalization.py +0 -0
  135. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_dds_schemas.py +0 -0
  136. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_discovery_tracker.py +0 -0
  137. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_dust_adapter.py +0 -0
  138. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_echo_parser.py +0 -0
  139. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_echo_stream.py +0 -0
  140. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_endpoints.py +0 -0
  141. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_example_node_spec.py +0 -0
  142. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_factory.py +0 -0
  143. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_fast_adapter.py +0 -0
  144. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_history_and_hints.py +0 -0
  145. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_honest_outputs.py +0 -0
  146. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_inspector.py +0 -0
  147. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_lifecycle_buffer.py +0 -0
  148. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_live_adapter_graph.py +0 -0
  149. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_live_adapter_parse.py +0 -0
  150. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_live_adapter_subprocess.py +0 -0
  151. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_metrics_buffer.py +0 -0
  152. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_opendds_adapter.py +0 -0
  153. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_peek_bag_samples.py +0 -0
  154. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_process_tree.py +0 -0
  155. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_qos_analyzer.py +0 -0
  156. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_qos_endpoints.py +0 -0
  157. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_qos_scan.py +0 -0
  158. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_sample_options.py +0 -0
  159. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_telemetry.py +0 -0
  160. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_tools_integration.py +0 -0
  161. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_topic_metrics.py +0 -0
  162. {topicforge-0.6.0 → topicforge-0.6.1}/tests/test_xtypes.py +0 -0
@@ -5,6 +5,37 @@ All notable changes to TopicForge are documented in this file.
5
5
  The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.1] - 2026-10-06
9
+
10
+ ### Added
11
+
12
+ - A Claude plugin bundle in `plugin/`: the TopicForge MCP server (started with
13
+ `uvx`, version pinned) and two skills, `diagnose-dds-bus` and
14
+ `inspect-ros2-robot`. Install with `claude plugin marketplace add
15
+ yaniswav/TopicForge`.
16
+ - [docs/VALIDATION.md](docs/VALIDATION.md): the OmniSim team's external validation
17
+ against a simulated robot's ground truth, published as approved by them.
18
+ - `MessageSample.recorded_ns`: the bag record time of a `peek_bag_samples` sample.
19
+ - `MessageSample.stamp_source` value `recorded`: `timestamp_ns` is the bag record
20
+ time because the message has no top-level header.
21
+ - `HealthReport.sim_clock_published` (live ROS 2 only, else null): whether `/clock`
22
+ has a publisher, a hint that nodes may run on simulated time.
23
+
24
+ ### Changed
25
+
26
+ - `peek_bag_samples` `timestamp_ns` is now `header.stamp` when the message has a
27
+ top-level header (`stamp_source` `header`), else the bag record time (`stamp_source`
28
+ `recorded`). It used to be the bag record time for every message, with
29
+ `stamp_source` null. The tool description says it returns the first `count`
30
+ messages, not the last.
31
+ - CI: `actions/checkout` v7, `actions/setup-python` v7, `actions/cache` v6. Jobs
32
+ that run tests or the demos use `ubuntu-24.04` instead of `ubuntu-latest`.
33
+
34
+ ### Fixed
35
+
36
+ - `peek_bag_samples` payloads no longer carry `__msgtype__` in every nested object
37
+ nor a top-level `_msgtype`; the message type stays at the sample level.
38
+
8
39
  ## [0.6.0] - 2026-10-05
9
40
 
10
41
  `sample_messages` (live) rewritten after the OmniSim team's report (count
@@ -1263,7 +1294,8 @@ Initial MVP release of TopicForge: ROS Topic Inspector & Bag Analyzer MCP server
1263
1294
  - The write path (publishing, commanding robots) is intentionally out of scope for the MVP.
1264
1295
  - `analyze_bag` in live mode parses `ros2 bag info` text output; deeper anomaly detection remains mock-only for now.
1265
1296
 
1266
- [Unreleased]: https://github.com/yaniswav/TopicForge/compare/v0.6.0...HEAD
1297
+ [Unreleased]: https://github.com/yaniswav/TopicForge/compare/v0.6.1...HEAD
1298
+ [0.6.1]: https://github.com/yaniswav/TopicForge/compare/v0.6.0...v0.6.1
1267
1299
  [0.6.0]: https://github.com/yaniswav/TopicForge/compare/v0.5.6...v0.6.0
1268
1300
  [0.5.6]: https://github.com/yaniswav/TopicForge/compare/v0.5.5...v0.5.6
1269
1301
  [0.5.5]: https://github.com/yaniswav/TopicForge/compare/v0.5.4...v0.5.5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: topicforge
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: ROS Topic Inspector & Bag Analyzer MCP server for AI agents
5
5
  Project-URL: Homepage, https://github.com/yaniswav/TopicForge
6
6
  Project-URL: Repository, https://github.com/yaniswav/TopicForge
@@ -158,6 +158,8 @@ Samples with comments are in [`.env.example`](.env.example). Any invalid value s
158
158
 
159
159
  ## Limitations
160
160
 
161
+ External validation against a simulated robot's ground truth: [docs/VALIDATION.md](docs/VALIDATION.md).
162
+
161
163
  - DDS validation is partial. The Cyclone adapter has run against a real bus, with Cyclone and Dust DDS participants, on Windows and in CI on Ubuntu and Windows (`.github/workflows/demo.yml`). The Fast DDS adapter has never run against a bus, and no RTI, OpenDDS, CoreDX or OpenSplice participant has been observed by this project. The multi-vendor claim rests on the RTPS protocol guarantee, not on a recorded cross-vendor run.
162
164
  - User-topic payloads are not decoded. `peek_dds_samples` on a user topic returns count 0 and a note that the topic is announced on the bus; no traffic is read. `topic_metrics` therefore has data only for the builtin discovery topics and says so in its `status`. It is a discovery-layer probe, not a publish-rate monitor.
163
165
  - Liveliness at runtime is not observed. A writer that is alive but silent (a hung process whose lease is still renewed) looks healthy, because TopicForge reads discovery, not data. An opt-in data probe is planned. A crash and a clean leave cannot be told apart, and `lost_ns` is an upper bound of the death.
@@ -166,7 +168,7 @@ Samples with comments are in [`.env.example`](.env.example). Any invalid value s
166
168
  - DDS Security is not handled. A participant without credentials sees an empty secure bus. `detect_qos_mismatches` checks Partition, type name, Reliability, Durability, Deadline, Liveliness, LatencyBudget, Ownership (kind), DestinationOrder and DataRepresentation (History as a risk); Presentation, XTypes assignability and runtime behavior are not checked, and the result lists them in `policies_unchecked`. It returns a `MismatchScan` envelope: read `reports` for the mismatches.
167
169
  - Fast DDS serves no `list_endpoints`.
168
170
  - `sample_messages` (live) streams `ros2 topic echo` until `count` messages arrive or `timeout_s` (1..45, default 10, for the whole call) runs out, and returns what arrived with a `note` (`N of M messages within T s`, saying whether a publisher exists). QoS is matched to the publishers, so latched topics work. The payload has nested named fields (`payload.header.stamp.sec`); `timestamp_ns` is `header.stamp` (the publisher's clock, sim time on a simulation) or 0 for headerless types, with `stamp_source` and `received_ns` (wall clock when the CLI printed it). Arrays are cut at 128 elements by default; `max_array_length` (1..65536, or null for no cut) and `arrays_summary_only` change that, and a cut is listed under `_truncated_fields`. `nan` and `inf` come back as strings. `count` above 50 is capped with a note.
169
- - `analyze_bag` (live) parses `ros2 bag info` text for the totals and counts; anomaly detection is mock-only. Per-topic times, rates (`(n - 1) / span`) and `latched` are added when the bag can be read locally (`.db3` with the standard library, `.mcap` with `rosbags`), else rates fall back to count / bag duration (`frequency_basis`). `peek_bag_samples` reads the file itself, through `rosbags`, and is served only by the ROS2 CLI adapter or the mock; bags that embed no message definitions (Humble `.db3`) are decoded with the Humble definitions, or the distro the bag records, and `note` says so. Without `ros2`, bag tools return fixtures: check `health_check` for `mode: "mock"` before trusting bag output.
171
+ - `analyze_bag` (live) parses `ros2 bag info` text for the totals and counts; anomaly detection is mock-only. Per-topic times, rates (`(n - 1) / span`) and `latched` are added when the bag can be read locally (`.db3` with the standard library, `.mcap` with `rosbags`), else rates fall back to count / bag duration (`frequency_basis`). `peek_bag_samples` reads the file itself, through `rosbags`, returns the first `count` messages in recording order (not the last), and is served only by the ROS2 CLI adapter or the mock; its `timestamp_ns` is the message's `header.stamp` (`stamp_source` `header`) or, without a top-level header, the bag record time (`stamp_source` `recorded`), and `recorded_ns` is always the bag record time; bags that embed no message definitions (Humble `.db3`) are decoded with the Humble definitions, or the distro the bag records, and `note` says so. Without `ros2`, bag tools return fixtures: check `health_check` for `mode: "mock"` before trusting bag output. `health_check` also reports `sim_clock_published` (live only): true when `/clock` has a publisher, a hint that header stamps may be simulation time.
170
172
  - Synchronous handlers: the tools run on the MCP event loop; on Windows a hung `ros2` launcher can block the server.
171
173
  - No streaming or push subscriptions: tools are strictly request/response.
172
174
 
@@ -189,7 +191,7 @@ When on, each tool call emits one event with exactly six fields:
189
191
  | `tool_name` | `"list_topics"` | One of the twelve tools, never argument values |
190
192
  | `latency_ms` | `12.34` | Handler wall-clock duration, 2 decimals |
191
193
  | `mode` | `"mock"` | Mode of the adapter actually serving: `mock` or `live` |
192
- | `version` | `"0.6.0"` | TopicForge server version |
194
+ | `version` | `"0.6.1"` | TopicForge server version |
193
195
  | `session_id` | `"a1b2c3..."` | Random UUID per process, never persisted |
194
196
  | `success` | `true` | Whether the handler returned or raised |
195
197
 
@@ -98,6 +98,8 @@ Samples with comments are in [`.env.example`](.env.example). Any invalid value s
98
98
 
99
99
  ## Limitations
100
100
 
101
+ External validation against a simulated robot's ground truth: [docs/VALIDATION.md](docs/VALIDATION.md).
102
+
101
103
  - DDS validation is partial. The Cyclone adapter has run against a real bus, with Cyclone and Dust DDS participants, on Windows and in CI on Ubuntu and Windows (`.github/workflows/demo.yml`). The Fast DDS adapter has never run against a bus, and no RTI, OpenDDS, CoreDX or OpenSplice participant has been observed by this project. The multi-vendor claim rests on the RTPS protocol guarantee, not on a recorded cross-vendor run.
102
104
  - User-topic payloads are not decoded. `peek_dds_samples` on a user topic returns count 0 and a note that the topic is announced on the bus; no traffic is read. `topic_metrics` therefore has data only for the builtin discovery topics and says so in its `status`. It is a discovery-layer probe, not a publish-rate monitor.
103
105
  - Liveliness at runtime is not observed. A writer that is alive but silent (a hung process whose lease is still renewed) looks healthy, because TopicForge reads discovery, not data. An opt-in data probe is planned. A crash and a clean leave cannot be told apart, and `lost_ns` is an upper bound of the death.
@@ -106,7 +108,7 @@ Samples with comments are in [`.env.example`](.env.example). Any invalid value s
106
108
  - DDS Security is not handled. A participant without credentials sees an empty secure bus. `detect_qos_mismatches` checks Partition, type name, Reliability, Durability, Deadline, Liveliness, LatencyBudget, Ownership (kind), DestinationOrder and DataRepresentation (History as a risk); Presentation, XTypes assignability and runtime behavior are not checked, and the result lists them in `policies_unchecked`. It returns a `MismatchScan` envelope: read `reports` for the mismatches.
107
109
  - Fast DDS serves no `list_endpoints`.
108
110
  - `sample_messages` (live) streams `ros2 topic echo` until `count` messages arrive or `timeout_s` (1..45, default 10, for the whole call) runs out, and returns what arrived with a `note` (`N of M messages within T s`, saying whether a publisher exists). QoS is matched to the publishers, so latched topics work. The payload has nested named fields (`payload.header.stamp.sec`); `timestamp_ns` is `header.stamp` (the publisher's clock, sim time on a simulation) or 0 for headerless types, with `stamp_source` and `received_ns` (wall clock when the CLI printed it). Arrays are cut at 128 elements by default; `max_array_length` (1..65536, or null for no cut) and `arrays_summary_only` change that, and a cut is listed under `_truncated_fields`. `nan` and `inf` come back as strings. `count` above 50 is capped with a note.
109
- - `analyze_bag` (live) parses `ros2 bag info` text for the totals and counts; anomaly detection is mock-only. Per-topic times, rates (`(n - 1) / span`) and `latched` are added when the bag can be read locally (`.db3` with the standard library, `.mcap` with `rosbags`), else rates fall back to count / bag duration (`frequency_basis`). `peek_bag_samples` reads the file itself, through `rosbags`, and is served only by the ROS2 CLI adapter or the mock; bags that embed no message definitions (Humble `.db3`) are decoded with the Humble definitions, or the distro the bag records, and `note` says so. Without `ros2`, bag tools return fixtures: check `health_check` for `mode: "mock"` before trusting bag output.
111
+ - `analyze_bag` (live) parses `ros2 bag info` text for the totals and counts; anomaly detection is mock-only. Per-topic times, rates (`(n - 1) / span`) and `latched` are added when the bag can be read locally (`.db3` with the standard library, `.mcap` with `rosbags`), else rates fall back to count / bag duration (`frequency_basis`). `peek_bag_samples` reads the file itself, through `rosbags`, returns the first `count` messages in recording order (not the last), and is served only by the ROS2 CLI adapter or the mock; its `timestamp_ns` is the message's `header.stamp` (`stamp_source` `header`) or, without a top-level header, the bag record time (`stamp_source` `recorded`), and `recorded_ns` is always the bag record time; bags that embed no message definitions (Humble `.db3`) are decoded with the Humble definitions, or the distro the bag records, and `note` says so. Without `ros2`, bag tools return fixtures: check `health_check` for `mode: "mock"` before trusting bag output. `health_check` also reports `sim_clock_published` (live only): true when `/clock` has a publisher, a hint that header stamps may be simulation time.
110
112
  - Synchronous handlers: the tools run on the MCP event loop; on Windows a hung `ros2` launcher can block the server.
111
113
  - No streaming or push subscriptions: tools are strictly request/response.
112
114
 
@@ -129,7 +131,7 @@ When on, each tool call emits one event with exactly six fields:
129
131
  | `tool_name` | `"list_topics"` | One of the twelve tools, never argument values |
130
132
  | `latency_ms` | `12.34` | Handler wall-clock duration, 2 decimals |
131
133
  | `mode` | `"mock"` | Mode of the adapter actually serving: `mock` or `live` |
132
- | `version` | `"0.6.0"` | TopicForge server version |
134
+ | `version` | `"0.6.1"` | TopicForge server version |
133
135
  | `session_id` | `"a1b2c3..."` | Random UUID per process, never persisted |
134
136
  | `success` | `true` | Whether the handler returned or raised |
135
137
 
@@ -15,7 +15,7 @@ How to get a working ROS2 environment to point TopicForge at, and how to wire it
15
15
  ```bash
16
16
  python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
17
17
  pip install topicforge
18
- python -m topicforge --version # -> topicforge 0.6.0
18
+ python -m topicforge --version # -> topicforge 0.6.1
19
19
  TOPICFORGE_MODE=mock python -m topicforge # blocks on stdio; MCP clients spawn it
20
20
  ```
21
21
 
@@ -41,7 +41,7 @@ python3 -m venv ~/topicforge-venv && source ~/topicforge-venv/bin/activate
41
41
  pip install topicforge # Ubuntu 22.04 ships Python 3.10, which is enough
42
42
  ```
43
43
 
44
- Then run live mode with three terminals. In the first, `ros2 run demo_nodes_cpp talker` publishes `/chatter` at about 1 Hz. In the second, `ros2 topic list` should show `/chatter`. In the third, source ROS2, activate the venv and run `TOPICFORGE_MODE=live python -m topicforge`. The startup line reads `topicforge 0.6.0 ready (mode=live, requested_mode=live, adapter=ros2_cli, telemetry=off)`; `mode=mock` means `ros2` was not found and the server fell back to fixtures.
44
+ Then run live mode with three terminals. In the first, `ros2 run demo_nodes_cpp talker` publishes `/chatter` at about 1 Hz. In the second, `ros2 topic list` should show `/chatter`. In the third, source ROS2, activate the venv and run `TOPICFORGE_MODE=live python -m topicforge`. The startup line reads `topicforge 0.6.1 ready (mode=live, requested_mode=live, adapter=ros2_cli, telemetry=off)`; `mode=mock` means `ros2` was not found and the server fell back to fixtures.
45
45
 
46
46
  ## Linux native
47
47
 
@@ -0,0 +1,15 @@
1
+ # External validation
2
+
3
+ ## OmniSim (October 2026)
4
+
5
+ The OmniSim team ran TopicForge against a simulated robot and compared its answers with the simulator's ground truth.
6
+
7
+ Setup: [OmniSim v9.1.3](https://github.com/omnilink-tech/omnisim/releases/tag/v9.1.3), a simulated Clearpath Husky with a 541-beam SICK LMS111 lidar in a room with walls at known positions, robot stationary. ROS 2 Humble on Ubuntu 22.04 (WSL2), Fast DDS. TopicForge 0.5.3 in live mode for the ROS 2 tools, and 0.5.5 with the Cyclone backend for the DDS tools.
8
+
9
+ What matched:
10
+ - Every lidar scan TopicForge sampled was bit-identical to the simulator's raw data on the beams it delivered.
11
+ - `list_topics` and `get_topic_info` returned the same names, types and publisher/subscriber counts as the `ros2` CLI.
12
+ - `analyze_bag` returned the same duration and message counts as `ros2 bag info` and an independent read of the bag.
13
+ - With the Cyclone backend, TopicForge saw all seven Fast DDS participants of the ROS 2 graph without extra configuration, and reported participants leaving within about 10 ms of the actual time.
14
+
15
+ What it found: seven discrepancies on TopicForge's side, including lidar arrays cut at 128 values, one message per `sample_messages` call, zero timestamps on simulated time, and Humble bags that `peek_bag_samples` could not decode. All seven are addressed in 0.5.6 and 0.6.0 (full arrays through a `max_array_length` option) and covered by a ROS 2 Humble/Jazzy test bench; the OmniSim run itself has not been repeated on these versions yet. The bag recorded during the run is part of TopicForge's test fixtures (`tests/fixtures/bags/omnisim_humble/`).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yanis ETHVIGNOT
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,60 @@
1
+ # TopicForge plugin for Claude
2
+
3
+ TopicForge is a read-only MCP server for ROS 2 and DDS. This plugin bundles the server with two short skills so Claude can inspect a robot stack and diagnose a DDS bus. It cannot publish, command a robot, or change QoS: the server has no write path.
4
+
5
+ ## What is included
6
+
7
+ - MCP server `topicforge`, started with `uvx` from the `topicforge` package on PyPI (version pinned to 0.6.1). It exposes twelve read-only tools: `health_check`, `list_topics`, `get_topic_info`, `sample_messages`, `analyze_bag`, `peek_bag_samples`, `list_participants`, `list_endpoints`, `detect_qos_mismatches`, `participant_events`, `peek_dds_samples`, `topic_metrics`.
8
+ - Skill `diagnose-dds-bus`: what to call, and in which order, when nodes do not talk, a topic gets no data, or a node crashed or restarts.
9
+ - Skill `inspect-ros2-robot`: listing topics, sampling messages and reading bags, including large arrays and simulation time.
10
+
11
+ ## Requirements
12
+
13
+ - [`uv`](https://docs.astral.sh/uv/) on PATH, which provides `uvx`. The first start downloads the package and the Cyclone DDS binding, so it can take a little while.
14
+ - A local MCP server: it runs on your machine, in Claude Code and in Cowork sessions that run on your computer. It does not run in claude.ai chat.
15
+ - ROS 2 on PATH is optional. Without it the ROS 2 tools fall back to fixtures (a fictional demo robot) and `health_check` reports `mode: mock`. The DDS tools work without ROS 2.
16
+
17
+ ## Install
18
+
19
+ From a marketplace, once the plugin is listed:
20
+
21
+ ```
22
+ /plugin install topicforge
23
+ ```
24
+
25
+ Meanwhile, from this repository (Claude Code):
26
+
27
+ ```
28
+ claude plugin marketplace add yaniswav/TopicForge
29
+ claude plugin install topicforge@topicforge
30
+ ```
31
+
32
+ Or load the folder for one session: `claude --plugin-dir ./plugin`.
33
+
34
+ ## Configuration
35
+
36
+ Claude Code asks for two options when the plugin is enabled:
37
+
38
+ | Option | Default | Meaning |
39
+ | --- | --- | --- |
40
+ | `dds_backend` | `cyclone` | `cyclone` joins the DDS bus as a read-only participant. `mock` serves fixtures. `auto` picks the best available. |
41
+ | `dds_domain_id` | `0` | The one DDS domain observed (0 to 232). Fixed at startup. |
42
+
43
+ The server runs with `TOPICFORGE_MODE=auto`: live ROS 2 when `ros2` is on PATH, fixtures otherwise. Cowork does not prompt for options and uses the defaults. To use other settings there, edit `.mcp.json`.
44
+
45
+ Without DDS, set `dds_backend` to `mock`, or use the standalone install `uvx topicforge`.
46
+
47
+ ## Privacy
48
+
49
+ The server reads the local ROS 2 graph and the DDS discovery traffic on the machine and network it runs on, and returns the result to the Claude session. It makes no outbound network calls. Anonymous telemetry exists but is off by default and is not enabled by this plugin. See the main [README](https://github.com/yaniswav/TopicForge#telemetry) for the telemetry contract and [SECURITY.md](https://github.com/yaniswav/TopicForge/blob/main/SECURITY.md).
50
+
51
+ ## Limits
52
+
53
+ TopicForge sees what DDS discovery announces. It cannot see whether data flows, a writer that is alive but silent, other DDS domains, or secured (DDS Security) endpoints. The skills tell Claude to say so.
54
+
55
+ ## Links
56
+
57
+ - Project: https://github.com/yaniswav/TopicForge
58
+ - Site: https://topicforge.horizonvista.xyz
59
+ - Issues: https://github.com/yaniswav/TopicForge/issues
60
+ - License: MIT
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "topicforge"
7
- version = "0.6.0"
7
+ version = "0.6.1"
8
8
  description = "ROS Topic Inspector & Bag Analyzer MCP server for AI agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,5 +1,5 @@
1
1
  """TopicForge: ROS Topic Inspector & Bag Analyzer MCP server."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.6.1"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -59,8 +59,18 @@ def iter_field_names(sample: Any) -> list[str]:
59
59
  """List field names on a dynamic-type sample.
60
60
 
61
61
  Tries `__dataclass_fields__`, `__fields__`, `__slots__`, then public
62
- `__dict__` keys. Returns an empty list when none apply.
62
+ `__dict__` keys. Returns an empty list when none apply. Dunder names
63
+ such as the `__msgtype__` marker that `rosbags` messages carry are
64
+ not data and are left out.
63
65
  """
66
+ return [name for name in _raw_field_names(sample) if not _is_dunder(name)]
67
+
68
+
69
+ def _is_dunder(name: str) -> bool:
70
+ return name.startswith("__") and name.endswith("__")
71
+
72
+
73
+ def _raw_field_names(sample: Any) -> list[str]:
64
74
  fields = getattr(sample, "__dataclass_fields__", None)
65
75
  if fields:
66
76
  return list(fields)
@@ -81,6 +81,11 @@ class CompositeAdapter:
81
81
  def analyze_bag(self, path: str) -> BagAnalysis:
82
82
  return self._ros.analyze_bag(path)
83
83
 
84
+ def sim_clock_published(self) -> bool | None:
85
+ """The ROS 2 half's `/clock` probe, `None` when it has none."""
86
+ probe = getattr(self._ros, "sim_clock_published", None)
87
+ return probe() if callable(probe) else None
88
+
84
89
  # ----- DDS surface -> DDS adapter -----
85
90
 
86
91
  def observer_status(self) -> dict[str, Any] | None:
@@ -309,6 +309,16 @@ class Ros2CliAdapter:
309
309
  analysis = analysis.model_copy(update={"note": note})
310
310
  return analysis
311
311
 
312
+ def sim_clock_published(self) -> bool | None:
313
+ """Whether `/clock` has a publisher on the graph; `None` when the CLI cannot tell."""
314
+ try:
315
+ text = self._run([self._exe, "topic", "info", "/clock"])
316
+ except AdapterError as exc:
317
+ return False if "unknown topic" in str(exc).lower() else None
318
+ if "unknown topic" in text.lower():
319
+ return False
320
+ return parse_pub_sub_counts(text)[0] > 0
321
+
312
322
  def _graph_counts(self) -> dict[str, tuple[int, int]] | None:
313
323
  """`{topic: (pubs, subs)}` from `ros2 topic list -v`, or `None` when unavailable."""
314
324
  try:
@@ -602,13 +602,13 @@ MOCK_BAG_SAMPLES: dict[str, list[MessageSample]] = {
602
602
  MessageSample(
603
603
  topic="/cmd_vel",
604
604
  message_type="geometry_msgs/msg/Twist",
605
- timestamp_ns=0,
606
- stamp_source="none",
605
+ timestamp_ns=_BASE_TS_NS + i * 100_000_000,
606
+ stamp_source="recorded",
607
+ recorded_ns=_BASE_TS_NS + i * 100_000_000,
607
608
  payload={
608
609
  "_decode_status": "full",
609
610
  "linear": {"x": 0.20 + i * 0.01, "y": 0.0, "z": 0.0},
610
611
  "angular": {"x": 0.0, "y": 0.0, "z": 0.05 * i},
611
- "_msgtype": "geometry_msgs/msg/Twist",
612
612
  },
613
613
  )
614
614
  for i in range(5)
@@ -618,11 +618,18 @@ MOCK_BAG_SAMPLES: dict[str, list[MessageSample]] = {
618
618
  topic="/odom",
619
619
  message_type="nav_msgs/msg/Odometry",
620
620
  timestamp_ns=_BASE_TS_NS + i * 100_000_000,
621
+ stamp_source="header",
622
+ recorded_ns=_BASE_TS_NS + i * 100_000_000 + 2_000_000,
621
623
  payload={
622
624
  "_decode_status": "full",
623
- "header": {"frame_id": "odom"},
625
+ "header": {
626
+ "stamp": {
627
+ "sec": (_BASE_TS_NS + i * 100_000_000) // 1_000_000_000,
628
+ "nanosec": (_BASE_TS_NS + i * 100_000_000) % 1_000_000_000,
629
+ },
630
+ "frame_id": "odom",
631
+ },
624
632
  "pose": {"position": {"x": 0.1 * i, "y": 0.0, "z": 0.0}},
625
- "_msgtype": "nav_msgs/msg/Odometry",
626
633
  },
627
634
  )
628
635
  for i in range(3)
@@ -801,16 +801,30 @@ class MessageSample(BaseModel):
801
801
  "`geometry_msgs/Twist`). It is the publisher's clock, not the "
802
802
  "arrival time: on simulated time it is sim time since the "
803
803
  "simulation started (a few seconds, not a date) and 0 is a valid "
804
- "value. Use `received_ns` for when the CLI printed the message. "
805
- "Mock samples with a header carry synthetic increasing values."
804
+ "value. From `peek_bag_samples` a message without a header gets "
805
+ "the bag record time instead (`stamp_source` `recorded`), also "
806
+ "given in `recorded_ns`. Use `received_ns` for when the CLI "
807
+ "printed the message. Mock samples with a header carry synthetic "
808
+ "increasing values."
806
809
  )
807
810
  )
808
- stamp_source: Literal["header", "none"] | None = Field(
811
+ stamp_source: Literal["header", "none", "recorded"] | None = Field(
809
812
  default=None,
810
813
  description=(
811
814
  "Where `timestamp_ns` comes from: `header` (the message's "
812
- "`header.stamp`) or `none` (headerless message, `timestamp_ns` is "
813
- "0). `None` when the backend does not say."
815
+ "`header.stamp`), `none` (headerless live message, `timestamp_ns` "
816
+ "is 0) or `recorded` (headerless message from a bag, "
817
+ "`timestamp_ns` is the bag record time). `None` when the backend "
818
+ "does not say."
819
+ ),
820
+ )
821
+ recorded_ns: int | None = Field(
822
+ default=None,
823
+ description=(
824
+ "Nanoseconds at which the recorder wrote this message into the "
825
+ "bag (the bag's own record time, wall clock unless the recorder "
826
+ "ran on simulated time). `peek_bag_samples` only; `None` for live "
827
+ "samples, where `received_ns` plays that role."
814
828
  ),
815
829
  )
816
830
  received_ns: int | None = Field(
@@ -1173,6 +1187,17 @@ class HealthReport(BaseModel):
1173
1187
  "use `list_endpoints` for topics and wiring there."
1174
1188
  ),
1175
1189
  )
1190
+ sim_clock_published: bool | None = Field(
1191
+ default=None,
1192
+ description=(
1193
+ "Whether `/clock` has at least one publisher on the ROS 2 graph. "
1194
+ "True suggests nodes may run on simulated time (`use_sim_time`), "
1195
+ "so `header.stamp` values are sim time, not wall time (compare "
1196
+ "with `received_ns`). It is a hint: a publisher on `/clock` does "
1197
+ "not prove a given node follows it. `None` in mock mode or when "
1198
+ "the `ros2` CLI could not answer."
1199
+ ),
1200
+ )
1176
1201
  payload_decoding: Literal["disabled", "enabled"] = Field(
1177
1202
  default="disabled",
1178
1203
  description=(
@@ -356,11 +356,14 @@ def _peek_with_rosbags(
356
356
  if len(samples) >= count:
357
357
  break
358
358
  payload = _decode_bag_message(reader, connection, raw)
359
+ header_ns = _header_stamp_ns(payload)
359
360
  samples.append(
360
361
  MessageSample(
361
362
  topic=topic,
362
363
  message_type=message_type,
363
- timestamp_ns=int(timestamp),
364
+ timestamp_ns=int(timestamp) if header_ns is None else header_ns,
365
+ stamp_source="recorded" if header_ns is None else "header",
366
+ recorded_ns=int(timestamp),
364
367
  payload=_cap_arrays(payload, capped),
365
368
  )
366
369
  )
@@ -379,6 +382,18 @@ def _peek_with_rosbags(
379
382
  return samples, " ".join(notes) or None
380
383
 
381
384
 
385
+ def _header_stamp_ns(payload: dict[str, Any]) -> int | None:
386
+ """`header.stamp` of a decoded message in nanoseconds, or `None` without a top-level header."""
387
+ header = payload.get("header")
388
+ stamp = header.get("stamp") if isinstance(header, dict) else None
389
+ if not isinstance(stamp, dict):
390
+ return None
391
+ sec, nanosec = stamp.get("sec"), stamp.get("nanosec")
392
+ if isinstance(sec, int) and isinstance(nanosec, int):
393
+ return sec * 1_000_000_000 + nanosec
394
+ return None
395
+
396
+
382
397
  def _cap_arrays(payload: dict[str, Any], capped: set[str], prefix: str = "") -> dict[str, Any]:
383
398
  """Cut lists over `_MAX_ARRAY_ELEMENTS`, recording the field paths in `capped`."""
384
399
  out: dict[str, Any] = {}
@@ -406,7 +421,4 @@ def _decode_bag_message(reader: Any, connection: Any, raw: bytes) -> dict[str, A
406
421
 
407
422
  from topicforge.adapters.common.cdr_decoder import decode_dynamic_sample
408
423
 
409
- decoded = decode_dynamic_sample(deserialized, max_array_elements=_MAX_ARRAY_ELEMENTS)
410
- if isinstance(decoded, dict):
411
- decoded.setdefault("_msgtype", getattr(connection, "msgtype", "<unknown>"))
412
- return decoded
424
+ return decode_dynamic_sample(deserialized, max_array_elements=_MAX_ARRAY_ELEMENTS)
@@ -67,6 +67,7 @@ class HealthService:
67
67
  middleware_available=_middleware_available(dds_backend, self._settings),
68
68
  ros_backend=ros_backend,
69
69
  ros_tools_available=ros_backend != "none",
70
+ sim_clock_published=_sim_clock_published(self._adapter),
70
71
  )
71
72
 
72
73
 
@@ -84,6 +85,21 @@ def _observer_status(adapter: MiddlewareAdapter) -> dict[str, Any]:
84
85
  return {}
85
86
 
86
87
 
88
+ def _sim_clock_published(adapter: MiddlewareAdapter) -> bool | None:
89
+ """`/clock` publisher hint if the adapter can probe it (live ROS 2), else `None`.
90
+
91
+ Not part of the protocol, and a failure must not break `health_check`.
92
+ """
93
+ probe = getattr(adapter, "sim_clock_published", None)
94
+ if not callable(probe):
95
+ return None
96
+ try:
97
+ result = probe()
98
+ except Exception:
99
+ return None
100
+ return result if isinstance(result, bool) else None
101
+
102
+
87
103
  def _backends_from_adapter_name(name: str) -> tuple[RosBackendTag, DdsBackendTag]:
88
104
  """Split an adapter tag into its ROS2 and DDS halves.
89
105
 
@@ -550,7 +550,12 @@ def register_tools(
550
550
  "graph), this reads **offline bag content** for post-mortem "
551
551
  "analysis. Supported formats: MCAP "
552
552
  "(`.mcap`), ROS 2 rosbag2 SQLite (`.db3`), ROS 1 legacy chunked "
553
- "binary (`.bag`), detected from the file extension. Returns a "
553
+ "binary (`.bag`), detected from the file extension. Returns the "
554
+ "**first** `count` messages of the topic in recording order, not "
555
+ "the last ones. **Clocks**: `timestamp_ns` is the message's own "
556
+ "`header.stamp` (`stamp_source` `header`) when it has a top-level "
557
+ "header, else the bag record time (`stamp_source` `recorded`); "
558
+ "`recorded_ns` is always the bag record time. Returns a "
554
559
  "`SampleResult` in the same shape as `peek_dds_samples`: each "
555
560
  "sample's `payload` carries a `_decode_status` annotation (`full` /"
556
561
  " `partial` / `raw`). `count` defaults to 5 and is silently clamped"
@@ -283,3 +283,27 @@ def test_a_short_latched_result_hints_count_one(adapter: Ros2CliAdapter) -> None
283
283
  assert result.count == 1
284
284
  assert result.note is not None and "fewer than requested" in result.note
285
285
  assert "latched" in result.note
286
+
287
+
288
+ def test_peek_bag_samples_names_the_clock_of_each_timestamp(
289
+ adapter: Ros2CliAdapter, bag: Path
290
+ ) -> None:
291
+ scan = adapter.peek_bag_samples(str(bag), "/scan", 1).samples[0]
292
+ assert scan.stamp_source == "header"
293
+ assert scan.recorded_ns is not None
294
+ assert "__msgtype__" not in str(scan.payload) and "_msgtype" not in str(scan.payload)
295
+
296
+ text = adapter.peek_bag_samples(str(bag), "/robot_description_lite", 1).samples[0]
297
+ assert text.stamp_source == "recorded"
298
+ assert text.timestamp_ns == text.recorded_ns
299
+
300
+
301
+ def test_health_reports_the_sim_clock_publisher(adapter: Ros2CliAdapter) -> None:
302
+ from topicforge.config import Settings
303
+ from topicforge.services import HealthService
304
+
305
+ assert adapter.sim_clock_published() is True
306
+ settings = Settings(
307
+ mode="live", log_level="INFO", ros2_executable="ros2", telemetry_enabled=False
308
+ )
309
+ assert HealthService(settings, adapter).report().sim_clock_published is True
@@ -148,3 +148,19 @@ def test_peek_without_embedded_definitions_note_is_absent_for_self_describing_ba
148
148
  bag = _write_mcap_bag(tmp_path)
149
149
  result = BagService().peek_samples(str(bag), "/fast", 1)
150
150
  assert result.note is None or "no message definitions" not in result.note
151
+
152
+
153
+ def test_peek_labels_the_clock_of_each_timestamp(bag_path: str) -> None:
154
+ scan = BagService().peek_samples(bag_path, "/scan", 1).samples[0]
155
+ header = scan.payload["header"]["stamp"]
156
+ assert scan.stamp_source == "header"
157
+ assert scan.timestamp_ns == header["sec"] * 1_000_000_000 + header["nanosec"]
158
+ assert scan.recorded_ns is not None
159
+
160
+ log = BagService().peek_samples(bag_path, "/rosout", 1).samples[0]
161
+ assert log.stamp_source == "header" or log.timestamp_ns == log.recorded_ns
162
+
163
+
164
+ def test_peek_payload_has_no_message_type_markers(bag_path: str) -> None:
165
+ payload = BagService().peek_samples(bag_path, "/scan", 1).samples[0].payload
166
+ assert "msgtype" not in str(payload)
@@ -267,3 +267,10 @@ def test_cap_reaches_arrays_inside_nested_samples() -> None:
267
267
 
268
268
  decoded = decode_dynamic_sample(Outer(Inner(np.zeros(9000))), max_array_elements=100)
269
269
  assert len(decoded["inner"]["ranges"]) == 101 # type: ignore[index]
270
+
271
+
272
+ def test_iter_field_names_skips_dunder_markers() -> None:
273
+ from dataclasses import make_dataclass
274
+
275
+ msg = make_dataclass("Msg", [("x", int), ("__msgtype__", str)])(x=1, __msgtype__="a/msg/B")
276
+ assert iter_field_names(msg) == ["x"]
@@ -215,3 +215,59 @@ def test_report_leaves_tracker_fields_empty_without_an_observer() -> None:
215
215
  report = HealthService(_settings(), _adapter("mock")).report()
216
216
 
217
217
  assert report.observer_started_ns is None and report.tracker_errors is None
218
+
219
+
220
+ def test_sim_clock_is_unknown_in_mock_mode() -> None:
221
+ assert HealthService(_settings(), MockAdapter()).report().sim_clock_published is None
222
+
223
+
224
+ class _ClockAdapter(_NamedAdapter):
225
+ def __init__(self, outcome: object) -> None:
226
+ super().__init__("ros2_cli")
227
+ self._outcome = outcome
228
+
229
+ def sim_clock_published(self) -> bool | None:
230
+ if isinstance(self._outcome, Exception):
231
+ raise self._outcome
232
+ return self._outcome # type: ignore[return-value]
233
+
234
+
235
+ @pytest.mark.parametrize(
236
+ ("outcome", "expected"), [(True, True), (False, False), (None, None), (RuntimeError(), None)]
237
+ )
238
+ def test_sim_clock_hint_comes_from_the_adapter_and_never_breaks_health(
239
+ outcome: object, expected: bool | None
240
+ ) -> None:
241
+ adapter: Any = _ClockAdapter(outcome)
242
+ assert HealthService(_settings(), adapter).report().sim_clock_published is expected
243
+
244
+
245
+ def test_cli_adapter_reads_publisher_count_of_clock(monkeypatch: pytest.MonkeyPatch) -> None:
246
+ from topicforge.adapters.ros2_live import Ros2CliAdapter
247
+
248
+ adapter = Ros2CliAdapter()
249
+ outputs = {
250
+ "Type: rosgraph_msgs/msg/Clock\nPublisher count: 1\nSubscription count: 0\n": True,
251
+ "Type: rosgraph_msgs/msg/Clock\nPublisher count: 0\nSubscription count: 2\n": False,
252
+ }
253
+ for text, expected in outputs.items():
254
+ monkeypatch.setattr(adapter, "_run", lambda cmd, timeout=8.0, t=text: t)
255
+ assert adapter.sim_clock_published() is expected
256
+
257
+
258
+ def test_cli_adapter_clock_probe_failure_modes(monkeypatch: pytest.MonkeyPatch) -> None:
259
+ from topicforge.adapters.base import AdapterError
260
+ from topicforge.adapters.ros2_live import Ros2CliAdapter
261
+
262
+ adapter = Ros2CliAdapter()
263
+
264
+ def unknown(cmd: list[str], timeout: float = 8.0) -> str:
265
+ raise AdapterError("failed (exit 1): Unknown topic '/clock'")
266
+
267
+ def timed_out(cmd: list[str], timeout: float = 8.0) -> str:
268
+ raise AdapterError("timed out after 8.0s")
269
+
270
+ monkeypatch.setattr(adapter, "_run", unknown)
271
+ assert adapter.sim_clock_published() is False
272
+ monkeypatch.setattr(adapter, "_run", timed_out)
273
+ assert adapter.sim_clock_published() is None
@@ -54,7 +54,9 @@ def test_timeout_bounds_the_topic_lookup_too(monkeypatch: pytest.MonkeyPatch) ->
54
54
  _stub_info(monkeypatch, delay_s=0.4)
55
55
  seen = _stub_echo(monkeypatch, EchoRun())
56
56
  Ros2CliAdapter().sample_messages("/imu", count=1, timeout_s=5)
57
- assert 4.0 < seen[0]["deadline_s"] <= 4.6
57
+ # Upper bound leaves room for the ~16 ms Windows clock tick: a 0.4 s sleep can
58
+ # measure as 0.39 s there.
59
+ assert 4.0 < seen[0]["deadline_s"] <= 4.65
58
60
 
59
61
 
60
62
  def test_the_topic_lookup_never_waits_longer_than_timeout_s(