diff --git a/docs/source/device/add_device.rst b/docs/source/device/add_device.rst index e85aba119f..c152350a31 100644 --- a/docs/source/device/add_device.rst +++ b/docs/source/device/add_device.rst @@ -127,6 +127,9 @@ Use ``direction = "push"`` when Teleop **pushes** a typed table to a plugin (e.g ``HapticCommand.endpoint`` samples on one collection (e.g. ``HapticCommandReaderTracker`` for Manus haptics) stay hand-written — see :doc:`../references/generated_trackers`. +A device that describes itself once (a layout, a calibration) rather than in every frame declares +that description as the tracker's ``metadata``; see :ref:`tracker-metadata`. + For what the generator emits, which trackers it covers today, and which shapes are not supported yet, see :doc:`../references/generated_trackers`. diff --git a/docs/source/references/generated_trackers.rst b/docs/source/references/generated_trackers.rst index 0ccd8e0249..5babf0955a 100644 --- a/docs/source/references/generated_trackers.rst +++ b/docs/source/references/generated_trackers.rst @@ -73,6 +73,41 @@ Python edit at all. ``.pyi`` stubs need no work either: :code-file:`src/core/python/generate_stubs.py` derives them from the built module. +.. _tracker-metadata: + +Trackers with metadata +---------------------- + +A device that describes itself once rather than every frame (a tactile glove's taxel layout, for +example) declares that description as the tracker's ``metadata`` table: + +.. code-block:: toml + + [[tracker]] + name = "tactile" + table = "TactileData" + metadata = "TactileShape" + +The metadata table is the ``root_type`` of a schema of its own, named after the table in snake case +(``tactile_shape.fbs``), with no Record wrapper: MCAP records the table itself. Like the data table, +it needs a hand-written ``schema/python/*_bindings.h`` before ``get_metadata()`` can return it to +Python. Only ``direction = "pull"`` supports ``metadata``. + +- **Producer.** Pushes the encoded table on ``tracker_metadata_collection_id(collection_id)`` + (``/metadata``), tensor ``TRACKER_METADATA_TENSOR_IDENTIFIER``, in samples of at most + ``TRACKER_METADATA_MAX_SIZE`` bytes; see :code-file:`src/core/schema/cpp/inc/schema/tracker_metadata.hpp`. + It pushes when it starts and periodically after that, always the same encoded table, so a consumer + that starts later still receives it. A C++ producer does this with a ``SchemaPusher``, a Python one + with a ``TensorPushTracker``, either sized to the encoded table. +- **Live.** The first metadata received is adopted, recorded once on ``_metadata``, and + returned by ``get_metadata()`` for the rest of the session. ``get_data()`` publishes and records + data only while the producer's metadata is confirmed: from a sample equal to the adopted one + until the metadata collection goes away, so a restarted producer's data waits for its own + metadata. A sample that differs from the adopted one, or is not a valid table, is an error the + session update throws. +- **Replay.** Reads the recorded metadata when the session opens, and publishes data only from a + recording that holds it. + Generated today --------------- diff --git a/src/core/codegen/manifest.py b/src/core/codegen/manifest.py index bb5f15ad35..6728ec7946 100644 --- a/src/core/codegen/manifest.py +++ b/src/core/codegen/manifest.py @@ -172,6 +172,17 @@ def resolve_tracker_entry( f"tracker '{raw['name']}': record must be a boolean, got {type(record).__name__}" ) + metadata = resolved.get("metadata") + if metadata is not None: + if not isinstance(metadata, str) or not metadata: + raise ValueError( + f"tracker '{raw['name']}': metadata must name a table, got {metadata!r}" + ) + if resolved["direction"] != "pull": + raise ValueError( + f"tracker '{raw['name']}': metadata requires direction=pull" + ) + if resolved["direction"] == "pull" and record: for key in ("mcap_channels", "replay_channels"): if key not in resolved: diff --git a/src/core/codegen/template_renderer.py b/src/core/codegen/template_renderer.py index 05cde6f9fd..69a71bb0e0 100644 --- a/src/core/codegen/template_renderer.py +++ b/src/core/codegen/template_renderer.py @@ -63,6 +63,11 @@ def template_values(ctx: TrackerGenContext) -> dict[str, str]: "PYTHON_ACCESSOR": ctx.python_accessor, "TENSOR_IDENTIFIER_EXPR": _tensor_identifier_expr(ctx), "HAS_TENSOR_IDENTIFIER": "1" if ctx.facade_tensor_constant else "0", + "HAS_METADATA": "1" if ctx.metadata else "0", + # Only referenced inside `#if @HAS_METADATA@` blocks. + "METADATA": ctx.metadata.table if ctx.metadata else "", + "METADATA_SCHEMA": ctx.metadata.schema if ctx.metadata else "", + "METADATA_CHANNEL": ctx.metadata.channel if ctx.metadata else "", } if ctx.mcap_channels is not None: values["RECORDING_CHANNELS"] = ", ".join(f'"{c}"' for c in ctx.mcap_channels) diff --git a/src/core/codegen/templates.py b/src/core/codegen/templates.py index 241df46576..e755929b98 100644 --- a/src/core/codegen/templates.py +++ b/src/core/codegen/templates.py @@ -5,6 +5,7 @@ from __future__ import annotations +import re from dataclasses import dataclass from typing import Any @@ -19,6 +20,22 @@ def _header() -> str: ) +def camel_to_snake(value: str) -> str: + """Convert CamelCase to snake_case (TactileShape -> tactile_shape).""" + return re.sub( + r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", "_", value + ).lower() + + +@dataclass(frozen=True, slots=True) +class MetadataGenContext: + """The ``metadata`` table, the ``.fbs`` it is the root of, and its MCAP sub-channel.""" + + table: str + schema: str + channel: str + + @dataclass(frozen=True, slots=True) class TrackerGenContext: """Resolved manifest entry plus derived C++/file names for codegen.""" @@ -39,6 +56,7 @@ class TrackerGenContext: facade_tensor_constant: bool mcap_channels: tuple[str, ...] | None replay_channels: tuple[str, ...] | None + metadata: MetadataGenContext | None header: str base_header: str live_impl_file: str @@ -82,6 +100,17 @@ def enrich_context(entry: dict[str, Any]) -> TrackerGenContext: mcap_channels = tuple(mcap_raw) if mcap_raw is not None else None replay_channels = tuple(replay_raw) if replay_raw is not None else None + metadata_table = entry.get("metadata") + metadata = ( + MetadataGenContext( + table=metadata_table, + schema=camel_to_snake(metadata_table), + channel=f"{entry['channel']}_metadata", + ) + if metadata_table is not None + else None + ) + return TrackerGenContext( name=name, table=table, @@ -99,6 +128,7 @@ def enrich_context(entry: dict[str, Any]) -> TrackerGenContext: facade_tensor_constant=bool(entry["facade_tensor_constant"]), mcap_channels=mcap_channels, replay_channels=replay_channels, + metadata=metadata, header=header, base_header=base, live_impl_file=live_impl_file, diff --git a/src/core/codegen/templates/fragments/pybind_pull.template b/src/core/codegen/templates/fragments/pybind_pull.template index d4ead323cf..f059b299c4 100644 --- a/src/core/codegen/templates/fragments/pybind_pull.template +++ b/src/core/codegen/templates/fragments/pybind_pull.template @@ -6,4 +6,13 @@ [](const core::@CLASS@& self, const core::ITrackerSession& session) { return to_python(self.get_data(session)); }, py::arg("session"), - "Get the current state (None when no data available)"); + "Get the current state (None when no data available)") +#if @HAS_METADATA@ + .def( + "get_metadata", + [](const core::@CLASS@& self, const core::ITrackerSession& session) + { return to_python(self.get_metadata(session)); }, + py::arg("session"), + "Get the first metadata received from the producer (None until it arrives, then fixed for the session)") +#endif + ; diff --git a/src/core/codegen/templates/fragments/recording_traits_pull.template b/src/core/codegen/templates/fragments/recording_traits_pull.template index 0a5fa6021e..cfb8d97dc2 100644 --- a/src/core/codegen/templates/fragments/recording_traits_pull.template +++ b/src/core/codegen/templates/fragments/recording_traits_pull.template @@ -2,4 +2,7 @@ struct @TRAITS@ { static constexpr std::array recording_channels = { @RECORDING_CHANNELS@ }; static constexpr std::array replay_channels = { @REPLAY_CHANNELS@ }; +#if @HAS_METADATA@ + static constexpr std::array metadata_channels = { "@METADATA_CHANNEL@" }; +#endif }; diff --git a/src/core/codegen/templates/fragments/replay_factory_pull.template b/src/core/codegen/templates/fragments/replay_factory_pull.template index 99aacb45b8..514331a84a 100644 --- a/src/core/codegen/templates/fragments/replay_factory_pull.template +++ b/src/core/codegen/templates/fragments/replay_factory_pull.template @@ -1,4 +1,11 @@ std::unique_ptr<@IFACE@> ReplayDeviceIOFactory::create_@NAME@_tracker_impl(const @CLASS@* tracker) { +#if @HAS_METADATA@ + // Each McapTrackerViewers iterates a reader of its own, so the metadata is read through a second one. + auto metadata = @REPLAY_IMPL@::read_metadata(open_reader(filename_), get_name(tracker), recorded_schemas_); + return std::make_unique<@REPLAY_IMPL@>( + open_reader(filename_), get_name(tracker), recorded_schemas_, std::move(metadata)); +#else return std::make_unique<@REPLAY_IMPL@>(open_reader(filename_), get_name(tracker), recorded_schemas_); +#endif } diff --git a/src/core/codegen/templates/pull/deviceio_base.hpp.template b/src/core/codegen/templates/pull/deviceio_base.hpp.template index 261d844ba8..1937984b16 100644 --- a/src/core/codegen/templates/pull/deviceio_base.hpp.template +++ b/src/core/codegen/templates/pull/deviceio_base.hpp.template @@ -7,9 +7,15 @@ namespace core { struct @FB_TABLE@; +#if @HAS_METADATA@ +struct @METADATA@; +#endif class @IFACE@ : public ITrackerImpl { public: virtual const Serialized<@FB_TABLE@>& get_data() const = 0; +#if @HAS_METADATA@ + virtual const Serialized<@METADATA@>& get_metadata() const = 0; +#endif }; } // namespace core diff --git a/src/core/codegen/templates/pull/facade.cpp.template b/src/core/codegen/templates/pull/facade.cpp.template index 657a00c431..98fcde580a 100644 --- a/src/core/codegen/templates/pull/facade.cpp.template +++ b/src/core/codegen/templates/pull/facade.cpp.template @@ -14,4 +14,10 @@ const Serialized<@FB_TABLE@>& @CLASS@::get_data(const ITrackerSession& session) { return static_cast(session.get_tracker_impl(*this)).get_data(); } +#if @HAS_METADATA@ +const Serialized<@METADATA@>& @CLASS@::get_metadata(const ITrackerSession& session) const +{ + return static_cast(session.get_tracker_impl(*this)).get_metadata(); +} +#endif } // namespace core diff --git a/src/core/codegen/templates/pull/facade.hpp.template b/src/core/codegen/templates/pull/facade.hpp.template index 150e1b847c..e9ce3974b5 100644 --- a/src/core/codegen/templates/pull/facade.hpp.template +++ b/src/core/codegen/templates/pull/facade.hpp.template @@ -4,6 +4,9 @@ #pragma once #include #include +#if @HAS_METADATA@ +#include +#endif #include #include @@ -25,6 +28,12 @@ public: return TRACKER_NAME; } const Serialized<@FB_TABLE@>& get_data(const ITrackerSession& session) const; +#if @HAS_METADATA@ + //! The first metadata received from the producer: empty until it arrives, then fixed for the session. + //! get_data() is empty until the producer's metadata is confirmed. A different one from the + //! producer makes the session update throw. + const Serialized<@METADATA@>& get_metadata(const ITrackerSession& session) const; +#endif const std::string& collection_id() const { return collection_id_; diff --git a/src/core/codegen/templates/pull/live.cpp.template b/src/core/codegen/templates/pull/live.cpp.template index 528dedf35a..75019d00e9 100644 --- a/src/core/codegen/templates/pull/live.cpp.template +++ b/src/core/codegen/templates/pull/live.cpp.template @@ -4,6 +4,9 @@ #include "@LIVE_IMPL_FILE@.hpp" #include +#if @HAS_METADATA@ +#include +#endif #include namespace core @@ -20,6 +23,18 @@ SchemaTrackerConfig make_@NAME@_tensor_config(const @CLASS@* tracker) return cfg; } } // namespace +#if @HAS_METADATA@ +@MCAP_CHANNELS_TYPE@::@MCAP_CHANNELS_TYPE@(mcap::McapWriter& writer, + std::string_view base_name, + const std::vector& sub_channels) + : data(writer, base_name, sub_channels), + metadata(writer, + base_name, + std::vector(@TRAITS@::metadata_channels.begin(), @TRAITS@::metadata_channels.end())) +{ + // No-op: members set in the initializer list. +} +#endif std::unique_ptr<@MCAP_CHANNELS_TYPE@> @LIVE_IMPL@::create_mcap_channels(mcap::McapWriter& writer, std::string_view base_name) { @@ -31,17 +46,38 @@ std::unique_ptr<@MCAP_CHANNELS_TYPE@> @LIVE_IMPL@::create_mcap_channels(mcap::Mc const @CLASS@* tracker, std::unique_ptr<@MCAP_CHANNELS_TYPE@> mcap_channels) : mcap_channels_(std::move(mcap_channels)), +#if @HAS_METADATA@ + m_schema_reader(handles, make_@NAME@_tensor_config(tracker), mcap_channels_ ? &mcap_channels_->data : nullptr, 0, 1), + m_metadata_reader(handles, tracker->collection_id(), mcap_channels_ ? &mcap_channels_->metadata : nullptr) +#else m_schema_reader(handles, make_@NAME@_tensor_config(tracker), mcap_channels_.get(), 0, 1) +#endif { // No-op: members set in the initializer list. } void @LIVE_IMPL@::update(int64_t /*monotonic_time_ns*/) { +#if @HAS_METADATA@ + // Metadata first, so it is recorded ahead of the data it lets through on the same tick. + const bool publish = m_metadata_reader.update(); + m_schema_reader.update(m_tracked, publish); + if (!publish) + { + m_tracked.reset(); + } +#else m_schema_reader.update(m_tracked); +#endif } const Serialized<@FB_TABLE@>& @LIVE_IMPL@::get_data() const { return m_tracked; } +#if @HAS_METADATA@ +const Serialized<@METADATA@>& @LIVE_IMPL@::get_metadata() const +{ + return m_metadata_reader.get(); +} +#endif } // namespace core diff --git a/src/core/codegen/templates/pull/live.hpp.template b/src/core/codegen/templates/pull/live.hpp.template index 7ee3154082..486845fe46 100644 --- a/src/core/codegen/templates/pull/live.hpp.template +++ b/src/core/codegen/templates/pull/live.hpp.template @@ -3,8 +3,14 @@ #pragma once #include +#if @HAS_METADATA@ +#include +#endif #include #include +#if @HAS_METADATA@ +#include +#endif #include #include @@ -13,7 +19,18 @@ #include namespace core { +#if @HAS_METADATA@ +// The data and metadata channels, created together so the factory hands over one object. +struct @MCAP_CHANNELS_TYPE@ +{ + @MCAP_CHANNELS_TYPE@(mcap::McapWriter& writer, std::string_view base_name, const std::vector& sub_channels); + + McapTrackerChannels<@RECORD_TYPE@> data; + McapTrackerChannels<@METADATA@> metadata; +}; +#else using @MCAP_CHANNELS_TYPE@ = McapTrackerChannels<@RECORD_TYPE@>; +#endif using @SCHEMA_TRACKER_TYPE@ = SchemaTracker<@RECORD_TYPE@, @FB_TABLE@>; class @LIVE_IMPL@ : public @IFACE@ { @@ -29,10 +46,16 @@ public: std::unique_ptr<@MCAP_CHANNELS_TYPE@> mcap_channels); void update(int64_t monotonic_time_ns) override; const Serialized<@FB_TABLE@>& get_data() const override; +#if @HAS_METADATA@ + const Serialized<@METADATA@>& get_metadata() const override; +#endif private: std::unique_ptr<@MCAP_CHANNELS_TYPE@> mcap_channels_; @SCHEMA_TRACKER_TYPE@ m_schema_reader; +#if @HAS_METADATA@ + SchemaMetadataTracker<@METADATA@> m_metadata_reader; +#endif Serialized<@FB_TABLE@> m_tracked; }; } // namespace core diff --git a/src/core/codegen/templates/pull/replay.cpp.template b/src/core/codegen/templates/pull/replay.cpp.template index 76cb40adb8..9f2c6e0e99 100644 --- a/src/core/codegen/templates/pull/replay.cpp.template +++ b/src/core/codegen/templates/pull/replay.cpp.template @@ -4,6 +4,9 @@ #include "@REPLAY_IMPL_FILE@.hpp" #include +#if @HAS_METADATA@ +#include +#endif #include #include @@ -11,9 +14,26 @@ namespace core { +#if @HAS_METADATA@ +Serialized<@METADATA@> @REPLAY_IMPL@::read_metadata(std::unique_ptr reader, + std::string_view base_name, + const RecordedSchemas& recorded) +{ + // Live records only the metadata it adopts, so the first message is the only one. + McapTrackerViewers<@METADATA@> viewers( + std::move(reader), base_name, + std::vector(@TRAITS@::metadata_channels.begin(), @TRAITS@::metadata_channels.end()), recorded); + return viewers.read(0); +} +@REPLAY_IMPL@::@REPLAY_IMPL@(std::unique_ptr reader, + std::string_view base_name, + const RecordedSchemas& recorded, + Serialized<@METADATA@> metadata) +#else @REPLAY_IMPL@::@REPLAY_IMPL@(std::unique_ptr reader, std::string_view base_name, const RecordedSchemas& recorded) +#endif : mcap_viewers_(std::make_unique<@MCAP_VIEWERS_TYPE@>( std::move(reader), base_name, @@ -21,19 +41,41 @@ namespace core recorded)), logger_(isaaccapture::Logger::get("isaaccapture.core.@REPLAY_IMPL@")), no_data_message_("no data (EOF or gap), base_name=" + std::string(base_name)) +#if @HAS_METADATA@ + , metadata_(std::move(metadata)) +#endif { +#if @HAS_METADATA@ + if (!metadata_) + { + logger_->warn("recording has no {} for base_name={}; its data is not published", + @METADATA@::GetFullyQualifiedName(), base_name); + } +#else // No-op: members set in the initializer list. +#endif } const Serialized<@FB_TABLE@>& @REPLAY_IMPL@::get_data() const { return tracked_; } +#if @HAS_METADATA@ +const Serialized<@METADATA@>& @REPLAY_IMPL@::get_metadata() const +{ + return metadata_; +} +#endif void @REPLAY_IMPL@::update(int64_t /*monotonic_time_ns*/) { auto record = mcap_viewers_->read(0); if (record) { +#if @HAS_METADATA@ + // Live publishes data only with its metadata. + tracked_ = metadata_ ? record.narrow(record->data()) : Serialized<@FB_TABLE@>(); +#else tracked_ = record.narrow(record->data()); +#endif warned_no_data_ = false; } else diff --git a/src/core/codegen/templates/pull/replay.hpp.template b/src/core/codegen/templates/pull/replay.hpp.template index 9762ee3f23..2afcebc471 100644 --- a/src/core/codegen/templates/pull/replay.hpp.template +++ b/src/core/codegen/templates/pull/replay.hpp.template @@ -5,6 +5,9 @@ #include #include #include +#if @HAS_METADATA@ +#include +#endif #include #include @@ -17,11 +20,25 @@ using @MCAP_VIEWERS_TYPE@ = McapTrackerViewers<@RECORD_TYPE@>; class @REPLAY_IMPL@ : public @IFACE@ { public: +#if @HAS_METADATA@ + //! The metadata the recording holds for `base_name`, or an empty handle when it holds none. + static Serialized<@METADATA@> read_metadata(std::unique_ptr reader, + std::string_view base_name, + const RecordedSchemas& recorded); + @REPLAY_IMPL@(std::unique_ptr reader, + std::string_view base_name, + const RecordedSchemas& recorded, + Serialized<@METADATA@> metadata); +#else @REPLAY_IMPL@(std::unique_ptr reader, std::string_view base_name, const RecordedSchemas& recorded); +#endif void update(int64_t monotonic_time_ns) override; const Serialized<@FB_TABLE@>& get_data() const override; +#if @HAS_METADATA@ + const Serialized<@METADATA@>& get_metadata() const override; +#endif private: Serialized<@FB_TABLE@> tracked_; @@ -29,5 +46,8 @@ private: std::shared_ptr logger_; std::string no_data_message_; bool warned_no_data_ = false; +#if @HAS_METADATA@ + Serialized<@METADATA@> metadata_; +#endif }; } // namespace core diff --git a/src/core/codegen/test_manifest.py b/src/core/codegen/test_manifest.py index 651deb4292..5fdc5aaf51 100644 --- a/src/core/codegen/test_manifest.py +++ b/src/core/codegen/test_manifest.py @@ -207,5 +207,68 @@ def test_header_override_preserves_file_stems(self) -> None: self.assertEqual(ctx.traits, "OakRecordingTraits") +class MetadataTest(unittest.TestCase): + """The optional ``metadata`` key. The C++ it renders is compiled by tests/cpp/core/tracker_metadata.""" + + def _context(self, **overrides): + from templates import enrich_context + + raw = {"name": "tactile", "table": "TactileData", **overrides} + return enrich_context(resolve_tracker_entry(raw, load_defaults(DEFAULTS_PATH))) + + def test_unset_by_default(self) -> None: + self.assertIsNone(self._context().metadata) + + def test_names_derive_from_the_table_and_channel(self) -> None: + from templates import MetadataGenContext + + self.assertEqual( + self._context(metadata="TactileShape").metadata, + MetadataGenContext("TactileShape", "tactile_shape", "tactile_metadata"), + ) + self.assertEqual( + self._context(metadata="TactileShape", channel="glove").metadata.channel, + "glove_metadata", + ) + + def test_schema_stem_splits_acronyms_and_digits(self) -> None: + from templates import camel_to_snake + + self.assertEqual(camel_to_snake("Se3TrackerPose"), "se3_tracker_pose") + self.assertEqual(camel_to_snake("HTTPHeader"), "http_header") + + def test_invalid_values_are_rejected(self) -> None: + for overrides in ( + {"metadata": ""}, + {"metadata": 1}, + {"metadata": "TactileShape", "direction": "push"}, + ): + with self.subTest(**overrides), self.assertRaises(ValueError) as ctx: + self._context(**overrides) + self.assertIn("metadata", str(ctx.exception)) + + def test_every_placeholder_is_filled(self) -> None: + from generate_trackers import ( + _live_factory_template, + _render_fragment, + _replay_factory_template, + render_for_entry, + ) + + for metadata in (None, "TactileShape"): + ctx = self._context(**({"metadata": metadata} if metadata else {})) + files = render_for_entry(ctx) + for fragment in ( + _live_factory_template(ctx), + _replay_factory_template(ctx), + "pybind_pull.template", + "recording_traits_pull.template", + ): + files[fragment] = _render_fragment(fragment, ctx) + for name, text in files.items(): + with self.subTest(metadata=metadata, file=name): + self.assertNotRegex(text, r"@[A-Z_]+@") + + if __name__ == "__main__": unittest.main() diff --git a/src/core/deviceio_trackers/AGENTS.md b/src/core/deviceio_trackers/AGENTS.md index 8f0ca5f0cd..2925871ea3 100644 --- a/src/core/deviceio_trackers/AGENTS.md +++ b/src/core/deviceio_trackers/AGENTS.md @@ -16,8 +16,9 @@ in **[`trackers.toml`](trackers.toml)** and generated at configure time by [`../codegen/generate_trackers.py`](../codegen/generate_trackers.py); the facade `.hpp`/`.cpp` land under `${CMAKE_BINARY_DIR}/generated/trackers/`, **not** in `cpp/`. -- **Adding one is a manifest edit**, not a new source file: `name` and `table` are required, every - other key has a default in [`defaults.toml`](defaults.toml) — most derive from `name` through +- **Adding one is a manifest edit**, not a new source file: `name` and `table` are required, the + optional `header` and `metadata` have no default, and every other key has one in + [`defaults.toml`](defaults.toml) — most derive from `name` through `%placeholder%` substitution, a few (`direction`, `record`, `max_flatbuffer_size`, …) are plain constants. Override a key only where the convention genuinely does not hold (`se3_tracker` sets `class`; `generic_3axis_pedal` diff --git a/src/core/live_trackers/cpp/CMakeLists.txt b/src/core/live_trackers/cpp/CMakeLists.txt index fad1087cae..6f2788cbe3 100644 --- a/src/core/live_trackers/cpp/CMakeLists.txt +++ b/src/core/live_trackers/cpp/CMakeLists.txt @@ -5,6 +5,7 @@ cmake_minimum_required(VERSION 3.20) add_library(live_trackers STATIC schema_tracker_base.cpp + schema_metadata_tracker.cpp live_deviceio_factory.cpp live_head_tracker_impl.cpp live_hand_tracker_impl.cpp @@ -19,6 +20,7 @@ add_library(live_trackers STATIC ${GENERATED_TRACKER_LIVE_SOURCES} inc/live_trackers/schema_tracker_base.hpp inc/live_trackers/schema_tracker.hpp + inc/live_trackers/schema_metadata_tracker.hpp inc/live_trackers/live_deviceio_factory.hpp live_head_tracker_impl.hpp live_hand_tracker_impl.hpp diff --git a/src/core/live_trackers/cpp/inc/live_trackers/schema_metadata_tracker.hpp b/src/core/live_trackers/cpp/inc/live_trackers/schema_metadata_tracker.hpp new file mode 100644 index 0000000000..dadf1b51f8 --- /dev/null +++ b/src/core/live_trackers/cpp/inc/live_trackers/schema_metadata_tracker.hpp @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include "schema_tracker_base.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace core +{ + +/*! + * @brief Config of the SchemaMetadataTracker for the data collection `collection_id`. + * @throws std::invalid_argument when the metadata collection's name does not fit + * XR_MAX_TENSOR_IDENTIFIER_SIZE. + */ +SchemaTrackerConfig schema_metadata_tracker_config(const std::string& collection_id); + +//! Whether two metadata samples hold the same table. Each producer zero-pads its samples to a +//! size of its choosing, so the padding is not compared. +bool same_tracker_metadata(std::span a, std::span b); + +/*! + * @brief Reads the metadata of a generated tracker whose manifest entry sets `metadata`. + * + * The first metadata received is adopted, recorded, and kept for the life of this tracker. The + * tracker's data may be published and recorded while the producer's metadata is confirmed: from a + * sample equal to the adopted one until the metadata collection goes away. A sample that differs + * from the adopted one, or is not a valid table, makes update() throw. + */ +template +class SchemaMetadataTracker : public SchemaTrackerBase +{ +public: + using Channels = McapTrackerChannels; + + /*! + * @param collection_id The data collection; see schema_metadata_tracker_config(). + * @param mcap_channels Non-owning; channel 0 receives the adopted metadata. Null when not recording. + */ + SchemaMetadataTracker(const OpenXRSessionHandles& handles, const std::string& collection_id, Channels* mcap_channels) + : SchemaTrackerBase(handles, schema_metadata_tracker_config(collection_id)), mcap_channels_(mcap_channels) + { + } + + /*! + * @brief Reads the pending metadata. Call once per tick, before reading the data. + * @return Whether the data may be published and recorded this tick. + * @throws std::runtime_error when a sample is not a valid MetadataT, or differs from the + * adopted one. + */ + bool update() + { + std::vector samples; + const bool present = read_all_samples(samples); + for (auto& sample : samples) + { + accept(sample); + } + + if (!present) + { + // The producer is gone; whichever comes next confirms its own metadata. + confirmed_ = false; + } + else if (!samples.empty()) + { + confirmed_ = true; + } + return confirmed_; + } + + //! Empty until the first metadata arrives, then fixed. + const Serialized& get() const + { + return metadata_; + } + +private: + //! Adopts the first sample and checks every later one against it. + void accept(SampleResult& sample) + { + const std::string& collection_id = config().collection_id; + flatbuffers::Verifier verifier(sample.buffer.data(), sample.buffer.size()); + if (!verifier.VerifyBuffer(nullptr)) + { + throw std::runtime_error(collection_id + ": metadata sample is not a valid " + + MetadataT::GetFullyQualifiedName()); + } + + if (!metadata_) + { + const int64_t log_time_ns = sample.timestamp.available_time_local_common_clock(); + metadata_ = Serialized::adopt(std::move(sample.buffer)); + if (mcap_channels_ != nullptr) + { + mcap_channels_->write(0, metadata_, log_time_ns); + } + logger_->info( + "{}: adopted {} B of {}", collection_id, metadata_.buffer().size(), MetadataT::GetFullyQualifiedName()); + return; + } + + if (!same_tracker_metadata(metadata_.buffer(), sample.buffer)) + { + throw std::runtime_error(collection_id + ": received a " + MetadataT::GetFullyQualifiedName() + + " that differs from the one adopted"); + } + } + + Channels* mcap_channels_; + Serialized metadata_; + bool confirmed_ = false; + std::shared_ptr logger_ = isaaccapture::Logger::get("isaaccapture.core.SchemaMetadataTracker"); +}; + +} // namespace core diff --git a/src/core/live_trackers/cpp/inc/live_trackers/schema_tracker.hpp b/src/core/live_trackers/cpp/inc/live_trackers/schema_tracker.hpp index 9f57084bda..3adde0b5e3 100644 --- a/src/core/live_trackers/cpp/inc/live_trackers/schema_tracker.hpp +++ b/src/core/live_trackers/cpp/inc/live_trackers/schema_tracker.hpp @@ -65,12 +65,13 @@ class SchemaTracker : public SchemaTrackerBase * @param out Receives the final sample of this tick when any were read; left * untouched when the collection is present but produced nothing (the * last-known sample is retained); emptied when the collection is absent. + * @param record False reads the samples without writing them to MCAP. * @throws std::runtime_error On critical OpenXR/tensor API failures propagated * from SchemaTrackerBase. * @note Missing collection, temporary collection loss, and "no new sample" * are treated as common non-fatal conditions and do not throw. */ - void update(Serialized& out) + void update(Serialized& out, bool record = true) { samples_.clear(); bool present = read_all_samples(samples_); @@ -84,7 +85,7 @@ class SchemaTracker : public SchemaTrackerBase return; } - if (mcap_channels_) + if (mcap_channels_ && record) { Serialized last_record; for (const auto& sample : samples_) diff --git a/src/core/live_trackers/cpp/schema_metadata_tracker.cpp b/src/core/live_trackers/cpp/schema_metadata_tracker.cpp new file mode 100644 index 0000000000..1315d6a170 --- /dev/null +++ b/src/core/live_trackers/cpp/schema_metadata_tracker.cpp @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include "inc/live_trackers/schema_metadata_tracker.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace core +{ + +SchemaTrackerConfig schema_metadata_tracker_config(const std::string& collection_id) +{ + std::string metadata_collection_id = tracker_metadata_collection_id(collection_id); + // A pusher truncates a longer name, so the reader would wait for a collection that never appears. + if (metadata_collection_id.size() >= XR_MAX_TENSOR_IDENTIFIER_SIZE) + { + throw std::invalid_argument("metadata collection id '" + metadata_collection_id + + "' does not fit XR_MAX_TENSOR_IDENTIFIER_SIZE"); + } + return SchemaTrackerConfig{ .collection_id = std::move(metadata_collection_id), + .max_flatbuffer_size = TRACKER_METADATA_MAX_SIZE, + .tensor_identifier = std::string(TRACKER_METADATA_TENSOR_IDENTIFIER), + .localized_name = "Tracker metadata" }; +} + +bool same_tracker_metadata(std::span a, std::span b) +{ + if (a.size() > b.size()) + { + std::swap(a, b); + } + return std::equal(a.begin(), a.end(), b.begin()) && + std::all_of(b.begin() + a.size(), b.end(), [](uint8_t byte) { return byte == 0; }); +} + +} // namespace core diff --git a/src/core/mcap/cpp/inc/mcap/tracker_channels.hpp b/src/core/mcap/cpp/inc/mcap/tracker_channels.hpp index 152766ac4a..701421f4a7 100644 --- a/src/core/mcap/cpp/inc/mcap/tracker_channels.hpp +++ b/src/core/mcap/cpp/inc/mcap/tracker_channels.hpp @@ -38,8 +38,9 @@ inline std::string mcap_topic(std::string_view base_name, const std::string& sub /** * @brief Type-safe MCAP channel writer for FlatBuffer record types. * - * @tparam RecordT The FlatBuffer record wrapper type (e.g. HeadPoseRecord). - * Must expose BinarySchema; records are encoded by pack_record(). + * @tparam RecordT The FlatBuffer root written to the channels: a record wrapper (e.g. + * HeadPoseRecord, encoded by pack_record()), or a tracker's metadata table, + * which has no timestamp of its own. Must expose BinarySchema. * * The factory creates a unique_ptr> only when recording * is active and passes it to the impl. Impls null-check before calling write(). @@ -73,6 +74,13 @@ class McapTrackerChannels * */ void write(size_t channel_index, const Serialized& record) + { + const auto* timestamp = record ? record->timestamp() : nullptr; + write(channel_index, record, timestamp != nullptr ? timestamp->available_time_local_common_clock() : 0); + } + + //! Writes `record` at `log_time_ns`, for a root with no timestamp of its own. + void write(size_t channel_index, const Serialized& record, int64_t log_time_ns) { if (channel_index >= channel_ids_.size()) { @@ -87,11 +95,9 @@ class McapTrackerChannels throw std::invalid_argument("McapTrackerChannels: write called with a record that owns no buffer"); } - const auto* timestamp = record->timestamp(); mcap::Message msg; msg.channelId = channel_ids_[channel_index]; - msg.logTime = - timestamp != nullptr ? static_cast(timestamp->available_time_local_common_clock()) : 0; + msg.logTime = static_cast(log_time_ns); msg.publishTime = msg.logTime; msg.sequence = sequence_++; msg.data = reinterpret_cast(bytes.data()); @@ -194,7 +200,8 @@ Serialized::TableType> publish_and_record(Mca /** * @brief Type-safe MCAP channel reader returning owning handles over the recorded records. * - * @tparam RecordT The FlatBuffer record wrapper stored in MCAP (e.g. HeadPoseRecord). + * @tparam RecordT The FlatBuffer root stored in MCAP: a record wrapper (e.g. HeadPoseRecord), or a + * tracker's metadata table. * * Read-side counterpart to McapTrackerChannels. Owns an McapReader and iterates * all registered sub-channels through a single LinearMessageView. Each message is diff --git a/src/core/schema/cpp/inc/schema/tracker_metadata.hpp b/src/core/schema/cpp/inc/schema/tracker_metadata.hpp new file mode 100644 index 0000000000..a453b2a33c --- /dev/null +++ b/src/core/schema/cpp/inc/schema/tracker_metadata.hpp @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Wire contract for tracker metadata: a table fixed per producer, pushed as samples of a companion +// collection next to the data collection. The producer pushes it when it starts and periodically +// after that, so a consumer that joins late still receives it. + +#pragma once + +#include +#include +#include + +namespace core +{ + +//! Largest metadata sample a tracker accepts. A producer sizes its metadata collection to its +//! encoded table, up to this. +inline constexpr size_t TRACKER_METADATA_MAX_SIZE = 68864; + +//! Collection the metadata of the tracker reading `collection_id` is pushed on. +inline std::string tracker_metadata_collection_id(std::string_view collection_id) +{ + return std::string(collection_id) + "/metadata"; +} + +//! Name of the one tensor in a metadata collection. +inline constexpr std::string_view TRACKER_METADATA_TENSOR_IDENTIFIER = "metadata"; + +} // namespace core diff --git a/src/core/schema/python/CMakeLists.txt b/src/core/schema/python/CMakeLists.txt index d8fc43bfd8..05a7ca0b5c 100644 --- a/src/core/schema/python/CMakeLists.txt +++ b/src/core/schema/python/CMakeLists.txt @@ -27,6 +27,7 @@ pybind11_add_module(schema_py schema_module.cpp spacemouse_bindings.h steering_wheel_bindings.h + tracker_metadata_bindings.h ) target_link_libraries(schema_py diff --git a/src/core/schema/python/schema_module.cpp b/src/core/schema/python/schema_module.cpp index c76db6cc8a..2462f22a42 100644 --- a/src/core/schema/python/schema_module.cpp +++ b/src/core/schema/python/schema_module.cpp @@ -29,6 +29,7 @@ #include "spacemouse_bindings.h" #include "steering_wheel_bindings.h" #include "timestamp_bindings.h" +#include "tracker_metadata_bindings.h" namespace py = pybind11; @@ -60,6 +61,9 @@ PYBIND11_MODULE(_schema, m) // Bind OGLO tactile glove types (OgloGloveSample table). core::bind_oglo_tactile(m); + // Bind the helpers for producers of tracker metadata. + core::bind_tracker_metadata(m); + // Bind joint-state types (JointState, JointStateOutput tables) for generic joint-space devices. core::bind_joint_state(m); diff --git a/src/core/schema/python/tracker_metadata_bindings.h b/src/core/schema/python/tracker_metadata_bindings.h new file mode 100644 index 0000000000..e16908cea8 --- /dev/null +++ b/src/core/schema/python/tracker_metadata_bindings.h @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Python helpers for producers of tracker metadata (see schema/tracker_metadata.hpp). A Python +// producer pushes the encoded table with a TensorPushTracker on tracker_metadata_collection_id(). + +#pragma once + +#include +#include + +#include + +namespace py = pybind11; + +namespace core +{ + +inline void bind_tracker_metadata(py::module& m) +{ + m.attr("TRACKER_METADATA_MAX_SIZE") = TRACKER_METADATA_MAX_SIZE; + m.attr("TRACKER_METADATA_TENSOR_IDENTIFIER") = std::string(TRACKER_METADATA_TENSOR_IDENTIFIER); + + m.def( + "tracker_metadata_collection_id", + [](const std::string& collection_id) { return tracker_metadata_collection_id(collection_id); }, + py::arg("collection_id"), "Collection the metadata of the tracker reading `collection_id` is pushed on."); +} + +} // namespace core diff --git a/src/python/isaaccapture/schema/__init__.py b/src/python/isaaccapture/schema/__init__.py index f1e7d1348d..7c3fce996d 100644 --- a/src/python/isaaccapture/schema/__init__.py +++ b/src/python/isaaccapture/schema/__init__.py @@ -52,6 +52,10 @@ # OGLO tactile glove types. OgloGloveSample, OgloGloveSampleRecord, + # Tracker metadata, for producers of a tracker's metadata. + TRACKER_METADATA_MAX_SIZE, + TRACKER_METADATA_TENSOR_IDENTIFIER, + tracker_metadata_collection_id, # Joint-state types (generic joint-space devices: leader arms, exoskeletons, ...). JointState, JointStateOutput, @@ -180,6 +184,10 @@ def __getattr__(name: str): # OGLO tactile glove types. "OgloGloveSample", "OgloGloveSampleRecord", + # Tracker metadata. + "TRACKER_METADATA_MAX_SIZE", + "TRACKER_METADATA_TENSOR_IDENTIFIER", + "tracker_metadata_collection_id", # Joint-state types (generic joint-space devices). "JointState", "JointStateOutput", diff --git a/tests/cpp/core/CMakeLists.txt b/tests/cpp/core/CMakeLists.txt index e2f5e22590..56c3b9100b 100644 --- a/tests/cpp/core/CMakeLists.txt +++ b/tests/cpp/core/CMakeLists.txt @@ -6,5 +6,6 @@ add_subdirectory(mcap) add_subdirectory(deviceio_trackers) add_subdirectory(replay_deviceio_session) add_subdirectory(live_trackers) +add_subdirectory(tracker_metadata) add_subdirectory(log_bridge) add_subdirectory(plugin_manager) diff --git a/tests/cpp/core/tracker_metadata/CMakeLists.txt b/tests/cpp/core/tracker_metadata/CMakeLists.txt new file mode 100644 index 0000000000..90e73162a8 --- /dev/null +++ b/tests/cpp/core/tracker_metadata/CMakeLists.txt @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +cmake_minimum_required(VERSION 3.20) + +# Generates the tracker in trackers.toml, whose metadata table is fbs/, and compiles it into the +# test, so the metadata half of the tracker templates is built and run. +include(${CMAKE_SOURCE_DIR}/cmake/GenerateFlatBuffers.cmake) + +generate_flatbuffer_headers( + TEST_SCHEMA_HEADERS + ${CMAKE_CURRENT_SOURCE_DIR}/fbs + ${CMAKE_CURRENT_BINARY_DIR}/generated/schema + TEST_SCHEMA_BFBS +) + +set(_TRACKERS_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated/trackers") +execute_process( + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_SOURCE_DIR}/src/core/codegen/generate_trackers.py" + --manifest "${CMAKE_CURRENT_SOURCE_DIR}/trackers.toml" + --defaults "${CMAKE_SOURCE_DIR}/src/core/deviceio_trackers/defaults.toml" + --out-dir "${_TRACKERS_DIR}" + --emit-cmake "${_TRACKERS_DIR}/generated_sources.cmake" + --prune-stale + RESULT_VARIABLE _tracker_gen_rc + OUTPUT_VARIABLE _tracker_gen_out + ERROR_VARIABLE _tracker_gen_err) +if(NOT _tracker_gen_rc EQUAL 0) + message(FATAL_ERROR "tracker codegen failed:\n${_tracker_gen_out}\n${_tracker_gen_err}") +endif() +# The generator and templates are configure dependencies already (cmake/GenerateTrackers.cmake). +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/trackers.toml") + +add_executable(tracker_metadata_tests + test_tracker_metadata.cpp + fake_tensor_runtime.hpp + ${_TRACKERS_DIR}/deviceio_trackers/metadata_test_tracker.cpp + ${_TRACKERS_DIR}/live_trackers/live_metadata_test_tracker_impl.cpp + ${_TRACKERS_DIR}/replay_trackers/replay_metadata_test_tracker_impl.cpp + ${TEST_SCHEMA_HEADERS} +) + +# Ahead of the production generated directories, so reads the +# traits of this manifest. +target_include_directories(tracker_metadata_tests BEFORE PRIVATE + ${CMAKE_CURRENT_BINARY_DIR}/generated + ${_TRACKERS_DIR} + ${_TRACKERS_DIR}/deviceio_trackers/inc + ${_TRACKERS_DIR}/live_trackers + ${_TRACKERS_DIR}/replay_trackers + ${_TRACKERS_DIR}/inc +) + +target_link_libraries(tracker_metadata_tests PRIVATE + deviceio::live_trackers + deviceio::replay_trackers + # Provides the MCAP_IMPLEMENTATION symbols, as for live_trackers_tests. + deviceio::deviceio_session + mcap_test_support + Catch2::Catch2WithMain +) + +message(STATUS "tracker_metadata_tests target enabled with Catch2") +catch_discover_tests(tracker_metadata_tests) diff --git a/tests/cpp/core/tracker_metadata/fake_tensor_runtime.hpp b/tests/cpp/core/tracker_metadata/fake_tensor_runtime.hpp new file mode 100644 index 0000000000..1aca73e27d --- /dev/null +++ b/tests/cpp/core/tracker_metadata/fake_tensor_runtime.hpp @@ -0,0 +1,237 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +// The XR_NVX1_tensor_data reader functions and timespec conversions SchemaTrackerBase loads, +// served from memory, so a live tracker impl runs without an OpenXR runtime. XrTime is +// nanoseconds on the monotonic clock. One instance at a time. + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace tracker_metadata_test +{ + +class FakeTensorRuntime +{ +public: + FakeTensorRuntime() + { + if (s_current != nullptr) + { + throw std::logic_error("FakeTensorRuntime: one instance at a time"); + } + s_current = this; + } + + ~FakeTensorRuntime() + { + s_current = nullptr; + } + + FakeTensorRuntime(const FakeTensorRuntime&) = delete; + FakeTensorRuntime& operator=(const FakeTensorRuntime&) = delete; + + core::OpenXRSessionHandles handles() const + { + core::OpenXRSessionHandles handles; + handles.instance = reinterpret_cast(1); + handles.session = reinterpret_cast(1); + handles.space = reinterpret_cast(1); + handles.xrGetInstanceProcAddr = &get_instance_proc_addr; + return handles; + } + + //! Creates `collection_id` with samples of `sample_size` bytes, as a producer starting up does. + void create(const std::string& collection_id, uint32_t sample_size) + { + m_collections.push_back(std::make_shared(Collection{ collection_id, sample_size })); + ++m_generation; + } + + //! Removes `collection_id`, as a producer going away does. + void destroy(const std::string& collection_id) + { + auto it = find(collection_id); + (*it)->destroyed = true; + m_collections.erase(it); + ++m_generation; + } + + //! Pushes `payload`, zero-padded to the collection's sample size, as received at `time_ns`. + void push(const std::string& collection_id, std::span payload, int64_t time_ns) + { + Collection& collection = **find(collection_id); + if (payload.size() > collection.sample_size) + { + throw std::invalid_argument("FakeTensorRuntime: payload larger than the sample size"); + } + std::vector bytes(collection.sample_size, 0); + std::copy(payload.begin(), payload.end(), bytes.begin()); + collection.samples.push_back({ collection.next_index++, std::move(bytes), time_ns }); + } + +private: + struct Sample + { + int64_t index; + std::vector bytes; + int64_t time_ns; + }; + + struct Collection + { + std::string id; + uint32_t sample_size; + std::vector samples{}; + int64_t next_index = 0; + bool destroyed = false; + }; + + using Listed = std::vector>; + + std::vector>::iterator find(const std::string& collection_id) + { + auto it = std::find_if(m_collections.begin(), m_collections.end(), + [&](const auto& collection) { return collection->id == collection_id; }); + if (it == m_collections.end()) + { + throw std::invalid_argument("FakeTensorRuntime: no collection " + collection_id); + } + return it; + } + + //! The collections `list` saw at its last update; a reader holds indices into this. + static Listed& listed(XrTensorListNV list) + { + return s_current->m_lists.at(reinterpret_cast(list)); + } + + static XrResult XRAPI_CALL latest_generation(XrSession, uint64_t* generation) + { + *generation = s_current->m_generation; + return XR_SUCCESS; + } + + static XrResult XRAPI_CALL create_list(XrSession, const XrCreateTensorListInfoNV*, XrTensorListNV* list) + { + const uintptr_t handle = s_current->m_next_list++; + s_current->m_lists[handle] = {}; + *list = reinterpret_cast(handle); + return XR_SUCCESS; + } + + static XrResult XRAPI_CALL update_list(XrTensorListNV list) + { + listed(list) = s_current->m_collections; + return XR_SUCCESS; + } + + static XrResult XRAPI_CALL list_properties(XrTensorListNV list, XrSystemTensorListPropertiesNV* properties) + { + properties->generationNumber = s_current->m_generation; + properties->tensorCollectionCount = static_cast(listed(list).size()); + return XR_SUCCESS; + } + + static XrResult XRAPI_CALL collection_properties(XrTensorListNV list, + uint32_t index, + XrTensorCollectionPropertiesNV* properties) + { + const Collection& collection = *listed(list).at(index); + if (collection.destroyed) + { + return XR_ERROR_TENSOR_LOST_NV; + } + std::strncpy(properties->data.identifier, collection.id.c_str(), XR_MAX_TENSOR_IDENTIFIER_SIZE - 1); + properties->data.tensorCount = 1; + properties->data.totalSampleSize = collection.sample_size; + properties->sampleBatchStride = collection.sample_size; + return XR_SUCCESS; + } + + //! Writes the first sample at or after the requested index, one per call. + static XrResult XRAPI_CALL get_data(XrTensorListNV list, const XrTensorDataRetrievalInfoNV* info, XrTensorDataNV* data) + { + const Collection& collection = *listed(list).at(info->tensorCollectionIndex); + if (collection.destroyed) + { + return XR_ERROR_TENSOR_LOST_NV; + } + data->writtenSampleCount = 0; + for (const Sample& sample : collection.samples) + { + if (sample.index < info->startSampleIndex) + { + continue; + } + std::memcpy(data->buffer, sample.bytes.data(), sample.bytes.size()); + data->metadataArray[0].sampleIndex = sample.index; + data->metadataArray[0].timestamp = sample.time_ns; + data->metadataArray[0].arrivalTimestamp = sample.time_ns; + data->metadataArray[0].rawDeviceTimestamp = static_cast(sample.time_ns); + data->writtenSampleCount = 1; + break; + } + return XR_SUCCESS; + } + + static XrResult XRAPI_CALL destroy_list(XrTensorListNV list) + { + s_current->m_lists.erase(reinterpret_cast(list)); + return XR_SUCCESS; + } + + static XrResult XRAPI_CALL timespec_to_time(XrInstance, const struct timespec* ts, XrTime* time) + { + *time = static_cast(ts->tv_sec) * 1000000000 + ts->tv_nsec; + return XR_SUCCESS; + } + + static XrResult XRAPI_CALL time_to_timespec(XrInstance, XrTime time, struct timespec* ts) + { + ts->tv_sec = static_cast(time / 1000000000); + ts->tv_nsec = static_cast(time % 1000000000); + return XR_SUCCESS; + } + + static XrResult XRAPI_CALL get_instance_proc_addr(XrInstance, const char* name, PFN_xrVoidFunction* function) + { + static const std::map functions = { + { "xrGetTensorListLatestGenerationNV", reinterpret_cast(&latest_generation) }, + { "xrCreateTensorListNV", reinterpret_cast(&create_list) }, + { "xrUpdateTensorListNV", reinterpret_cast(&update_list) }, + { "xrGetTensorListPropertiesNV", reinterpret_cast(&list_properties) }, + { "xrGetTensorCollectionPropertiesNV", reinterpret_cast(&collection_properties) }, + { "xrGetTensorDataNV", reinterpret_cast(&get_data) }, + { "xrDestroyTensorListNV", reinterpret_cast(&destroy_list) }, + { "xrConvertTimespecTimeToTimeKHR", reinterpret_cast(×pec_to_time) }, + { "xrConvertTimeToTimespecTimeKHR", reinterpret_cast(&time_to_timespec) }, + }; + const auto it = functions.find(name); + *function = it != functions.end() ? it->second : nullptr; + return it != functions.end() ? XR_SUCCESS : XR_ERROR_FUNCTION_UNSUPPORTED; + } + + inline static FakeTensorRuntime* s_current = nullptr; + + std::vector> m_collections; + std::map m_lists; + uintptr_t m_next_list = 1; + uint64_t m_generation = 1; +}; + +} // namespace tracker_metadata_test diff --git a/tests/cpp/core/tracker_metadata/fbs/test_tracker_metadata.fbs b/tests/cpp/core/tracker_metadata/fbs/test_tracker_metadata.fbs new file mode 100644 index 0000000000..4b6379a730 --- /dev/null +++ b/tests/cpp/core/tracker_metadata/fbs/test_tracker_metadata.fbs @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Metadata table of the tracker in tests/cpp/core/tracker_metadata/trackers.toml. + +namespace core; + +table TestTrackerMetadata { + label: string (id: 0); +} + +root_type TestTrackerMetadata; diff --git a/tests/cpp/core/tracker_metadata/test_tracker_metadata.cpp b/tests/cpp/core/tracker_metadata/test_tracker_metadata.cpp new file mode 100644 index 0000000000..44bdd45385 --- /dev/null +++ b/tests/cpp/core/tracker_metadata/test_tracker_metadata.cpp @@ -0,0 +1,386 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// The metadata half of a generated tracker: SchemaMetadataTracker's helpers, when live publishes +// and records the data, and how replay reads the recording back. The tracker is generated from +// trackers.toml next to this file; on timespec platforms, a fake tensor runtime stands in for OpenXR. + +#if defined(XR_USE_TIMESPEC) +# include "fake_tensor_runtime.hpp" +#endif +#include "live_metadata_test_tracker_impl.hpp" +#include "mcap_test_support.hpp" +#include "replay_metadata_test_tracker_impl.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +using namespace mcap_test; + +constexpr std::string_view BASE_NAME = "pedals"; + +core::Serialized make_metadata(const std::string& label) +{ + core::TestTrackerMetadataT native; + native.label = label; + return core::pack(native); +} + +core::Serialized make_record(float left_pedal, int64_t time_ns) +{ + core::Generic3AxisPedalOutputT native; + native.left_pedal = left_pedal; + return core::pack_record( + &native, core::DeviceDataTimestamp(time_ns, time_ns, time_ns)); +} + +std::unique_ptr open_writer(const std::string& path) +{ + auto writer = std::make_unique(); + mcap::McapWriterOptions options("teleop-test"); + options.compression = mcap::Compression::None; + REQUIRE(writer->open(path, options).ok()); + return writer; +} + +//! Writes what a live session of the tracker records: the metadata, when there is one, then one +//! sample on the tracked channel replay reads. +void write_recording(const std::string& path, const core::Serialized& metadata) +{ + auto writer = open_writer(path); + auto channels = core::LiveMetadataTestTrackerImpl::create_mcap_channels(*writer, BASE_NAME); + if (metadata) + { + channels->metadata.write(0, metadata, 5); + } + channels->data.write(1, make_record(0.25f, 10)); + writer->close(); +} + +//! Builds the replay impl the way the replay factory does. +std::unique_ptr open_replay(const std::string& path) +{ + const auto recorded = recorded_schemas(path); + auto metadata = core::ReplayMetadataTestTrackerImpl::read_metadata(open_reader(path), BASE_NAME, recorded); + return std::make_unique( + open_reader(path), BASE_NAME, recorded, std::move(metadata)); +} + +#if defined(XR_USE_TIMESPEC) + +using tracker_metadata_test::FakeTensorRuntime; + +//! The left pedal of what `get_data()` publishes, or nullopt when it publishes nothing. +std::optional published(const core::Serialized& data) +{ + return data ? std::optional(data->left_pedal()) : std::nullopt; +} + +//! Every message recorded on `sub_channel`, in order. +template +std::vector> read_channel(const std::string& path, std::string_view sub_channel) +{ + const auto recorded = recorded_schemas(path); + core::McapTrackerViewers viewers(open_reader(path), BASE_NAME, { std::string(sub_channel) }, recorded); + std::vector> messages; + while (auto message = viewers.read(0)) + { + messages.push_back(std::move(message)); + } + return messages; +} + +std::vector recorded_left_pedals(const std::string& path, std::string_view sub_channel) +{ + std::vector values; + for (const auto& record : read_channel(path, sub_channel)) + { + values.push_back(record->data()->left_pedal()); + } + return values; +} + +const std::string DATA_COLLECTION = "pedals"; +const std::string METADATA_COLLECTION = core::tracker_metadata_collection_id(DATA_COLLECTION); + +//! A producer and the live impl reading it, with the fake runtime between them. +struct LiveSession +{ + FakeTensorRuntime runtime; + std::shared_ptr tracker = std::make_shared(DATA_COLLECTION); + std::unique_ptr writer; + std::unique_ptr impl; + int64_t now_ns = 0; + + //! Records to `recording_path` unless it is empty. + explicit LiveSession(const std::string& recording_path = {}) + { + std::unique_ptr channels; + if (!recording_path.empty()) + { + writer = open_writer(recording_path); + channels = core::LiveMetadataTestTrackerImpl::create_mcap_channels(*writer, BASE_NAME); + } + impl = std::make_unique(runtime.handles(), tracker.get(), std::move(channels)); + } + + void start_data() + { + runtime.create(DATA_COLLECTION, static_cast(tracker->max_flatbuffer_size())); + } + + void start_metadata(uint32_t sample_size = core::TRACKER_METADATA_MAX_SIZE) + { + runtime.create(METADATA_COLLECTION, sample_size); + } + + void push_data(float left_pedal) + { + core::Generic3AxisPedalOutputT native; + native.left_pedal = left_pedal; + runtime.push(DATA_COLLECTION, core::pack(native).buffer(), ++now_ns); + } + + void push_metadata(const core::Serialized& metadata) + { + runtime.push(METADATA_COLLECTION, metadata.buffer(), ++now_ns); + } + + //! Runs one tick and returns what it published. + std::optional step() + { + impl->update(++now_ns); + return published(impl->get_data()); + } + + void finish_recording() + { + impl.reset(); + writer->close(); + } +}; + +#endif + +} // namespace + +TEST_CASE("The metadata of a collection is read from /metadata", "[unit][tracker_metadata]") +{ + const auto config = core::schema_metadata_tracker_config("tactile"); + CHECK(config.collection_id == core::tracker_metadata_collection_id("tactile")); + CHECK(config.collection_id == "tactile/metadata"); + CHECK(config.tensor_identifier == core::TRACKER_METADATA_TENSOR_IDENTIFIER); + CHECK(config.max_flatbuffer_size == core::TRACKER_METADATA_MAX_SIZE); + + const std::string longest(XR_MAX_TENSOR_IDENTIFIER_SIZE - 1 - std::string_view("/metadata").size(), 'c'); + CHECK_NOTHROW(core::schema_metadata_tracker_config(longest)); + CHECK_THROWS_AS(core::schema_metadata_tracker_config(longest + "c"), std::invalid_argument); +} + +TEST_CASE("Metadata samples compare equal up to their zero padding", "[unit][tracker_metadata]") +{ + const std::vector table{ 4, 0, 7, 9 }; + std::vector padded = table; + padded.resize(16, 0); + + CHECK(core::same_tracker_metadata(table, table)); + CHECK(core::same_tracker_metadata(table, padded)); + CHECK(core::same_tracker_metadata(padded, table)); + + std::vector other_padding = padded; + other_padding.back() = 1; + CHECK_FALSE(core::same_tracker_metadata(table, other_padding)); + + std::vector other_table = table; + other_table[2] = 8; + CHECK_FALSE(core::same_tracker_metadata(table, other_table)); +} + +TEST_CASE("A metadata table is recorded at the log time it is given", "[unit][tracker_metadata]") +{ + const auto path = temp_mcap_path("test_tracker_metadata"); + const TempFileCleanup cleanup(path); + write_recording(path, make_metadata("glove")); + + auto reader = open_reader(path); + const auto on_problem = [](const mcap::Status& status) { FAIL(status.message); }; + int metadata_messages = 0; + for (const auto& view : reader->readMessages(on_problem)) + { + if (view.channel->topic != core::mcap_topic(BASE_NAME, core::MetadataTestRecordingTraits::metadata_channels[0])) + { + continue; + } + ++metadata_messages; + CHECK(view.schema->name == core::TestTrackerMetadata::GetFullyQualifiedName()); + CHECK(view.message.logTime == 5); + } + CHECK(metadata_messages == 1); +} + +TEST_CASE("Replay publishes the data with the metadata it was recorded with", "[unit][tracker_metadata]") +{ + const auto path = temp_mcap_path("test_tracker_metadata"); + const TempFileCleanup cleanup(path); + write_recording(path, make_metadata("glove")); + + auto replay = open_replay(path); + REQUIRE(replay->get_metadata()); + CHECK(replay->get_metadata()->label()->str() == "glove"); + + replay->update(0); + REQUIRE(replay->get_data()); + CHECK(replay->get_data()->left_pedal() == 0.25f); +} + +TEST_CASE("Replay withholds data recorded without metadata", "[unit][tracker_metadata]") +{ + const auto path = temp_mcap_path("test_tracker_metadata"); + const TempFileCleanup cleanup(path); + write_recording(path, core::Serialized()); + + auto replay = open_replay(path); + CHECK_FALSE(replay->get_metadata()); + + replay->update(0); + CHECK_FALSE(replay->get_data()); +} + +#if defined(XR_USE_TIMESPEC) + +TEST_CASE("Live publishes and records data only once the producer's metadata arrives", "[unit][tracker_metadata]") +{ + const auto path = temp_mcap_path("test_tracker_metadata"); + const TempFileCleanup cleanup(path); + { + LiveSession live(path); + live.start_data(); + live.push_data(0.1f); + CHECK_FALSE(live.step()); + CHECK_FALSE(live.impl->get_metadata()); + + live.start_metadata(); + live.push_metadata(make_metadata("glove")); + live.push_data(0.2f); + CHECK(live.step() == 0.2f); + REQUIRE(live.impl->get_metadata()); + CHECK(live.impl->get_metadata()->label()->str() == "glove"); + + live.push_metadata(make_metadata("glove")); + live.push_data(0.3f); + CHECK(live.step() == 0.3f); + live.finish_recording(); + } + + using Traits = core::MetadataTestRecordingTraits; + const auto metadata = read_channel(path, Traits::metadata_channels[0]); + REQUIRE(metadata.size() == 1); + CHECK(metadata[0]->label()->str() == "glove"); + CHECK(recorded_left_pedals(path, Traits::recording_channels[0]) == std::vector{ 0.2f, 0.3f }); + CHECK(recorded_left_pedals(path, Traits::recording_channels[1]) == std::vector{ 0.2f, 0.3f }); + + auto replay = open_replay(path); + REQUIRE(replay->get_metadata()); + CHECK(replay->get_metadata()->label()->str() == "glove"); + replay->update(0); + CHECK(published(replay->get_data()) == 0.2f); + replay->update(0); + CHECK(published(replay->get_data()) == 0.3f); +} + +TEST_CASE("Metadata that arrives before the data collection lets its first sample through", "[unit][tracker_metadata]") +{ + LiveSession live; + live.start_metadata(); + live.push_metadata(make_metadata("glove")); + CHECK_FALSE(live.step()); + + live.start_data(); + live.push_data(0.5f); + CHECK(live.step() == 0.5f); +} + +TEST_CASE("A restarted producer's data waits for its metadata, however it is padded", "[unit][tracker_metadata]") +{ + const auto metadata = make_metadata("glove"); + LiveSession live; + live.start_metadata(); + live.start_data(); + live.push_metadata(metadata); + live.push_data(0.1f); + CHECK(live.step() == 0.1f); + + live.runtime.destroy(METADATA_COLLECTION); + live.runtime.destroy(DATA_COLLECTION); + CHECK_FALSE(live.step()); + + live.start_data(); + live.push_data(0.2f); + CHECK_FALSE(live.step()); + + live.start_metadata(static_cast(metadata.buffer().size())); + live.push_metadata(metadata); + live.push_data(0.3f); + CHECK(live.step() == 0.3f); +} + +TEST_CASE("Different metadata is an error naming its collection", "[unit][tracker_metadata]") +{ + using Catch::Matchers::ContainsSubstring; + + LiveSession live; + live.start_metadata(); + live.start_data(); + live.push_metadata(make_metadata("glove")); + live.push_data(0.1f); + CHECK(live.step() == 0.1f); + + SECTION("in a tick of its own") + { + live.push_metadata(make_metadata("other")); + } + SECTION("followed by the adopted one in the same tick") + { + live.push_metadata(make_metadata("other")); + live.push_metadata(make_metadata("glove")); + } + live.push_data(0.2f); + CHECK_THROWS_WITH(live.step(), ContainsSubstring(METADATA_COLLECTION) && ContainsSubstring("differs")); +} + +TEST_CASE("A metadata sample that is not a valid table is an error naming its collection", "[unit][tracker_metadata]") +{ + LiveSession live; + live.start_metadata(); + const std::vector garbage{ 0xff, 0xff, 0xff, 0x7f }; + live.runtime.push(METADATA_COLLECTION, garbage, 1); + CHECK_THROWS_WITH(live.step(), Catch::Matchers::ContainsSubstring(METADATA_COLLECTION)); +} + +TEST_CASE("A metadata collection with samples over TRACKER_METADATA_MAX_SIZE is an error", "[unit][tracker_metadata]") +{ + LiveSession live; + live.start_metadata(static_cast(core::TRACKER_METADATA_MAX_SIZE) + 1); + CHECK_THROWS_WITH(live.step(), Catch::Matchers::ContainsSubstring(METADATA_COLLECTION)); +} + +#endif diff --git a/tests/cpp/core/tracker_metadata/trackers.toml b/tests/cpp/core/tracker_metadata/trackers.toml new file mode 100644 index 0000000000..fa66ea75da --- /dev/null +++ b/tests/cpp/core/tracker_metadata/trackers.toml @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# A tracker with metadata, generated for tracker_metadata_tests alone. Its metadata table is +# fbs/test_tracker_metadata.fbs. + +[[tracker]] +name = "metadata_test" +table = "Generic3AxisPedalOutput" +schema = "pedals" +metadata = "TestTrackerMetadata" diff --git a/tests/python/core/schema/test_tracker_metadata.py b/tests/python/core/schema/test_tracker_metadata.py new file mode 100644 index 0000000000..25a72d56a7 --- /dev/null +++ b/tests/python/core/schema/test_tracker_metadata.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for the tracker metadata helpers in isaaccapture.schema.""" + +from isaaccapture.schema import ( + TRACKER_METADATA_MAX_SIZE, + TRACKER_METADATA_TENSOR_IDENTIFIER, + tracker_metadata_collection_id, +) + + +def test_metadata_collection_is_named_after_the_data_collection(): + assert tracker_metadata_collection_id("tactile") == "tactile/metadata" + + +def test_wire_contract(): + assert TRACKER_METADATA_TENSOR_IDENTIFIER == "metadata" + assert TRACKER_METADATA_MAX_SIZE == 68864