diff --git a/src/plugins/sensing/app/main.cpp b/src/plugins/sensing/app/main.cpp index 19a4dee0ad..530c438dd4 100644 --- a/src/plugins/sensing/app/main.cpp +++ b/src/plugins/sensing/app/main.cpp @@ -124,44 +124,50 @@ static std::string default_platform_config() static void print_usage(const char* program_name) { - std::cout << "Usage: " << program_name << " --livekit-url=URL --livekit-token-file=PATH --add-stream=... [options]\n" - << "\n" - << "Publishes each SENSING sensor as a LiveKit camera track: SIPL or Argus capture, hardware H.264,\n" - << "sent as it is.\n" - << "\n" - << "Streams (repeat for each sensor):\n" - << " --add-stream=sensor=N,livekit=NAME camera N (SIPL or Argus device), published as track NAME\n" - << " --list-sensors print the cameras, then exit\n" - << " --camera-backend=auto|sipl|argus auto picks the one backend that matches (default);\n" - << " (a backend must also be built in)\n" - << " --argus-mode=N Argus sensor mode index (default: 0)\n" - << "\n" - << "Platform:\n" - << " --platform-config=PATH platform JSON (default: configs/shw5g.json beside this\n" - << " binary)\n" - << " --config-name=NAME named config inside it (default: SHW5G_2)\n" - << " --link-masks='M M' one mask per deserializer (default: '0x0000 0x1100')\n" - << " --nito-dir=DIR ISP tuning directory (default: /var/nvidia/nvcam/settings/sipl)\n" - << "\n" - << "LiveKit:\n" - << " --livekit-url=URL server, e.g. wss://livekit.example.org\n" - << " --livekit-token-file=PATH file holding the join token; it names the room and the\n" - << " participant identity\n" - << " --livekit-fps=N publication rate cap (default: 30)\n" - << " --livekit-scale-div=N reduce every track by this integer divisor (default: 1)\n" - << " --livekit-bitrate=N target bitrate of each track's encoder in bps (default: 8000000)\n" - << " --gop=N IDR period in frames (default: fps*5)\n" - << "\n" - << "Other:\n" - << " --gpu-id=N CUDA device index (default: 0)\n" - << " --isp0-buffers=N ISP0 buffers per sensor (default: 6)\n" - << " --full-range full-range YUV\n" - << " --swap-uv swap the chroma planes\n" - << "\n" - << "Example:\n" - << " " << program_name << " --livekit-url=wss://livekit.example.org \\\n" - << " --livekit-token-file=join.token --add-stream=sensor=0,livekit=sensing-left \\\n" - << " --add-stream=sensor=1,livekit=sensing-right\n"; + std::cout + << "Usage: " << program_name << " --livekit-url=URL --livekit-token-file=PATH --add-stream=... [options]\n" + << "\n" + << "Publishes each SENSING sensor as a LiveKit camera track: SIPL or Argus capture, hardware H.264,\n" + << "sent as it is.\n" + << "\n" + << "Streams (repeat for each sensor):\n" + << " --add-stream=sensor=N,livekit=NAME camera N (SIPL or Argus device), published as track NAME\n" + << " --list-sensors print the cameras, then exit\n" + << " --camera-backend=auto|sipl|argus auto picks the one backend that matches (default);\n" + << " (a backend must also be built in)\n" + << " --argus-mode=N Argus sensor mode index (default: 0)\n" + << "\n" + << "Platform:\n" + << " --platform-config=PATH platform JSON (default: configs/shw5g.json beside this\n" + << " binary)\n" + << " --config-name=NAME named config inside it (default: SHW5G_2)\n" + << " --link-masks='M M' one mask per deserializer (default: '0x0000 0x1100')\n" + << " --nito-dir=DIR ISP tuning directory (default: /var/nvidia/nvcam/settings/sipl)\n" + << "\n" + << "LiveKit:\n" + << " --livekit-url=URL server, e.g. wss://livekit.example.org\n" + << " --livekit-token-file=PATH file holding the join token; it names the room and the\n" + << " participant identity\n" + << " --livekit-fps=N publication rate cap (default: 30)\n" + << " --livekit-scale-div=N reduce every track by this integer divisor (default: 1)\n" + << " --timestamp-basis=B what each frame's timestamp is: monotonic_us (default) is this host's\n" + << " monotonic clock when the frame was ready to encode; capture_monotonic_us\n" + << " (UNVERIFIED on hardware) is the sensor's capture event on that clock and fails\n" + << " the stream if it cannot be placed; processing_unix_us is Unix processing time.\n" + << " The streams file on the PC must declare the same basis.\n" + << " --livekit-bitrate=N target bitrate of each track's encoder in bps (default: 8000000)\n" + << " --gop=N IDR period in frames (default: fps*5)\n" + << "\n" + << "Other:\n" + << " --gpu-id=N CUDA device index (default: 0)\n" + << " --isp0-buffers=N ISP0 buffers per sensor (default: 6)\n" + << " --full-range full-range YUV\n" + << " --swap-uv swap the chroma planes\n" + << "\n" + << "Example:\n" + << " " << program_name << " --livekit-url=wss://livekit.example.org \\\n" + << " --livekit-token-file=join.token --add-stream=sensor=0,livekit=sensing-left \\\n" + << " --add-stream=sensor=1,livekit=sensing-right\n"; } // ============================================================================= @@ -238,6 +244,22 @@ try { camera_config.livekit.fps = static_cast(std::stoul(arg.substr(14))); } + else if (arg.find("--timestamp-basis=") == 0) + { + const std::string basis = arg.substr(18); + if (basis == "monotonic_us") + camera_config.livekit.timestamp_basis = TimestampBasis::FrameReadyMonotonic; + else if (basis == "capture_monotonic_us") + camera_config.livekit.timestamp_basis = TimestampBasis::SensorCaptureMonotonic; + else if (basis == "processing_unix_us") + camera_config.livekit.timestamp_basis = TimestampBasis::ProcessingUnix; + else + { + std::cerr << "Error: --timestamp-basis must be monotonic_us, capture_monotonic_us or processing_unix_us." + << std::endl; + return 1; + } + } else if (arg.find("--livekit-bitrate=") == 0) { camera_config.livekit.bitrate_bps = static_cast(std::stoul(arg.substr(18))); diff --git a/src/plugins/sensing/core/CMakeLists.txt b/src/plugins/sensing/core/CMakeLists.txt index 2f1998a3da..a117c017e5 100644 --- a/src/plugins/sensing/core/CMakeLists.txt +++ b/src/plugins/sensing/core/CMakeLists.txt @@ -5,6 +5,7 @@ add_library(sensing_core STATIC sensing_camera.cpp camera_backend.cpp + sensor_clock.cpp capture_factory.cpp jetson_encoder.cpp livekit_video_output.cpp @@ -16,6 +17,7 @@ add_library(sensing_core STATIC inc/sensing_core/capture_factory.hpp inc/sensing_core/jetson_encoder.hpp inc/sensing_core/sensing_camera.hpp + inc/sensing_core/sensor_clock.hpp inc/sensing_core/sensing_types.hpp inc/sensing_core/sipl_camera.hpp ${JETSON_MMAPI_SOURCES} diff --git a/src/plugins/sensing/core/argus_capture.cpp b/src/plugins/sensing/core/argus_capture.cpp index 91410f1507..f8d29568ba 100644 --- a/src/plugins/sensing/core/argus_capture.cpp +++ b/src/plugins/sensing/core/argus_capture.cpp @@ -552,6 +552,7 @@ struct ArgusCapture::Impl : std::enable_shared_from_this if (metadata) { frame.sensor_time = metadata->getSensorTimestamp(); + frame.sensor_domain = SensorTimeDomain::MonotonicRawNs; frame.sequence = metadata->getCaptureId(); } else diff --git a/src/plugins/sensing/core/inc/sensing_core/camera_capture.hpp b/src/plugins/sensing/core/inc/sensing_core/camera_capture.hpp index 9108512630..d16dfba181 100644 --- a/src/plugins/sensing/core/inc/sensing_core/camera_capture.hpp +++ b/src/plugins/sensing/core/inc/sensing_core/camera_capture.hpp @@ -6,6 +6,8 @@ #pragma once +#include "sensor_clock.hpp" + #include #include #include @@ -120,6 +122,11 @@ struct CaptureFrame /// The backend's own sensor timestamp, unscaled and in its own clock domain; never convert it to another /// domain here. Zero when the backend reports none. uint64_t sensor_time = 0; + /// What `sensor_time` counts; the claim is checked at run time by SensorClockMap, not trusted. + SensorTimeDomain sensor_domain = SensorTimeDomain::None; + /// CLOCK_MONOTONIC ns of the sensor's capture event, set by the LiveKit output when it publishes capture + /// time; zero otherwise. + uint64_t capture_monotonic_ns = 0; std::variant pixels; FrameLease lease; }; diff --git a/src/plugins/sensing/core/inc/sensing_core/sensing_types.hpp b/src/plugins/sensing/core/inc/sensing_core/sensing_types.hpp index b88d0e29cc..d8c68ec693 100644 --- a/src/plugins/sensing/core/inc/sensing_core/sensing_types.hpp +++ b/src/plugins/sensing/core/inc/sensing_core/sensing_types.hpp @@ -25,6 +25,17 @@ struct StreamConfig }; /// Where and how the tracks are published. +/// The clock and event a published frame's application timestamp is read from. +enum class TimestampBasis +{ + /// CLOCK_MONOTONIC when the frame was ready to encode (`monotonic_us`); recordable. + FrameReadyMonotonic, + /// The sensor's capture event converted to CLOCK_MONOTONIC (`capture_monotonic_us`); unverified on hardware. + SensorCaptureMonotonic, + /// CLOCK_REALTIME when the frame finished conversion (`processing_unix_us`); not recordable. + ProcessingUnix, +}; + struct LiveKitConfig { std::string url; @@ -36,6 +47,8 @@ struct LiveKitConfig uint32_t scale_div = 1; /// Target bitrate of each track's hardware encoder; the VBR ceiling is half as much again. uint32_t bitrate_bps = 8'000'000; + /// What each frame's application timestamp is; the receiving side's streams file must declare the same basis. + TimestampBasis timestamp_basis = TimestampBasis::FrameReadyMonotonic; }; struct SensingConfig diff --git a/src/plugins/sensing/core/inc/sensing_core/sensor_clock.hpp b/src/plugins/sensing/core/inc/sensing_core/sensor_clock.hpp new file mode 100644 index 0000000000..0872f7aeba --- /dev/null +++ b/src/plugins/sensing/core/inc/sensing_core/sensor_clock.hpp @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#pragma once + +#include +#include +#include +#include +#include + +namespace plugins +{ +namespace sensing +{ + +/// What a backend's `CaptureFrame::sensor_time` counts. +enum class SensorTimeDomain +{ + /// The backend reports no sensor time. + None, + /// SIPL `frameCaptureTSC`: ticks of the ARM generic counter (assumed; checked at run time by SensorClockMap). + GenericCounterTicks, + /// Argus `getSensorTimestamp()`: nanoseconds on CLOCK_MONOTONIC_RAW (assumed; checked at run time). + MonotonicRawNs, +}; + +/// A source clock reading paired with CLOCK_MONOTONIC, read back to back. +struct ClockPair +{ + /// The source clock's value (counter ticks or raw ns) at the midpoint of the bracket. + int64_t source = 0; + /// CLOCK_MONOTONIC ns at the same midpoint. + int64_t monotonic_ns = 0; + /// Width of the bracket the pair was read in; the pair is no more certain than this. + int64_t bracket_ns = 0; +}; + +/// Reads the ARM generic counter (CNTVCT_EL0) and CLOCK_MONOTONIC back to back, keeping the narrowest of a few +/// attempts. Throws where the counter is not readable (not aarch64). +ClockPair read_counter_pair(); +/// The generic counter's frequency in Hz (CNTFRQ_EL0). +int64_t read_counter_hz(); +/// Reads CLOCK_MONOTONIC_RAW and CLOCK_MONOTONIC back to back, keeping the narrowest of a few attempts. +ClockPair read_raw_pair(); + +/** + * @brief Places a backend's sensor timestamp on this machine's CLOCK_MONOTONIC. + * + * One anchor pair (source clock, CLOCK_MONOTONIC) is read through a supported clock API and refreshed at a low + * rate, never per frame; a frame's capture time is the anchor plus the source-clock difference. The source's units + * are an assumption, not a fact (see SensorTimeDomain), so every conversion is checked: a capture time that is in + * the future of the frame's ready time, or more than `max_age_ns` before it, means the assumed domain or rate is + * wrong and the conversion throws. There is no fallback to a host-side stamp. + * + * Not thread-safe; one instance per capture thread. + */ +class SensorClockMap +{ +public: + /// Reads one pair; injectable so the mapping is testable off-target. + using PairReader = std::function; + + struct Config + { + SensorTimeDomain domain = SensorTimeDomain::None; + /// Counter frequency in Hz; required for GenericCounterTicks. + int64_t counter_hz = 0; + /// Anchor refresh period. + int64_t refresh_ns = 1'000'000'000; + /// Largest plausible gap between capture and the frame being ready. + int64_t max_age_ns = 2'000'000'000; + }; + + SensorClockMap(Config config, PairReader reader) : m_config(config), m_reader(std::move(reader)) + { + if (m_config.domain == SensorTimeDomain::None) + throw std::invalid_argument("SensorClockMap needs a sensor time domain"); + if (m_config.domain == SensorTimeDomain::GenericCounterTicks && m_config.counter_hz <= 0) + throw std::invalid_argument("SensorClockMap needs the counter frequency for counter ticks"); + } + + /** + * @brief CLOCK_MONOTONIC ns of the sensor's capture event. + * @param sensor_time The backend's raw sensor timestamp. + * @param ready_monotonic_ns CLOCK_MONOTONIC when the frame was ready (the plausibility reference). + * @param now_monotonic_ns CLOCK_MONOTONIC now, to decide when the anchor is due. + * @throws std::runtime_error when the value is not plausible for the assumed domain. + */ + int64_t to_monotonic_ns(uint64_t sensor_time, int64_t ready_monotonic_ns, int64_t now_monotonic_ns) + { + if (!m_have_anchor || now_monotonic_ns - m_anchor_taken_ns >= m_config.refresh_ns) + { + m_anchor = m_reader(); + m_have_anchor = true; + m_anchor_taken_ns = now_monotonic_ns; + } + const int64_t delta = to_ns(static_cast(sensor_time) - m_anchor.source); + const int64_t capture = m_anchor.monotonic_ns + delta; + if (capture > ready_monotonic_ns || ready_monotonic_ns - capture > m_config.max_age_ns) + { + throw std::runtime_error( + "sensor timestamp " + std::to_string(sensor_time) + " maps to " + + std::to_string(capture - ready_monotonic_ns) + + " ns relative to the frame being ready: the assumed sensor clock domain or rate is wrong"); + } + return capture; + } + + /// Uncertainty of the current anchor (its bracket); zero before the first conversion. + int64_t anchor_uncertainty_ns() const + { + return m_have_anchor ? m_anchor.bracket_ns : 0; + } + +private: + int64_t to_ns(int64_t source_delta) const + { + if (m_config.domain == SensorTimeDomain::MonotonicRawNs) + return source_delta; + // Split into whole seconds and remainder so the product cannot overflow 64 bits. + const int64_t hz = m_config.counter_hz; + const int64_t secs = source_delta / hz; + const int64_t rem = source_delta % hz; + return secs * 1'000'000'000 + rem * 1'000'000'000 / hz; + } + + Config m_config; + PairReader m_reader; + ClockPair m_anchor{}; + int64_t m_anchor_taken_ns = 0; + bool m_have_anchor = false; +}; + +} // namespace sensing +} // namespace plugins diff --git a/src/plugins/sensing/core/jetson_encoder.cpp b/src/plugins/sensing/core/jetson_encoder.cpp index b61665ac83..4546e5ee1f 100644 --- a/src/plugins/sensing/core/jetson_encoder.cpp +++ b/src/plugins/sensing/core/jetson_encoder.cpp @@ -6,9 +6,12 @@ #include "rgba_to_nv12.cuh" #include +#include #include +#include #include +#include #include #include #include @@ -47,6 +50,12 @@ struct JetsonEncoder::Impl EncoderConfig config; NvVideoEncoder* encoder = nullptr; + // SENSING_ENCODER_TIMESTAMP_PROBE=1 logs the first units' returned stamps beside the last stamp submitted, to + // show whether the driver copies the OUTPUT stamp to the unit. Diagnostic only: nothing consumes the result. + const bool timestamp_probe = std::getenv("SENSING_ENCODER_TIMESTAMP_PROBE") != nullptr; + std::atomic last_submitted_us{ 0 }; + std::atomic probe_logged{ 0 }; + // DMA-BUF input: a wrapper per output buffer carrying the fd the encoder reads, and the lease that keeps the // capture buffer alive until the plane returns that index. Members destroy after ~Impl deleted the encoder. struct DmaInput : NvBuffer @@ -141,6 +150,12 @@ struct JetsonEncoder::Impl unit.timestamp_ns = static_cast(v4l2_buf->timestamp.tv_sec) * 1000000000LL + static_cast(v4l2_buf->timestamp.tv_usec) * 1000LL; unit.keyframe = (v4l2_buf->flags & V4L2_BUF_FLAG_KEYFRAME) != 0; + if (self->timestamp_probe && self->probe_logged.fetch_add(1) < 30) + { + isaaccapture::Logger::get("isaaccapture.plugins.sensing.JetsonEncoder") + ->info("[ts-probe] unit stamp_us={} last_submitted_us={}", unit.timestamp_ns / 1000, + self->last_submitted_us.load()); + } std::lock_guard lock(self->mutex); self->ready.push_back(std::move(unit)); } @@ -302,6 +317,7 @@ bool JetsonEncoder::submit(uintptr_t rgba_device_ptr, std::size_t row_pitch_byte // Microsecond granularity is all a timeval carries, well below the frame interval. v4l2_buf.timestamp.tv_sec = static_cast(timestamp_ns / 1000000000LL); v4l2_buf.timestamp.tv_usec = static_cast((timestamp_ns % 1000000000LL) / 1000LL); + m_impl->last_submitted_us.store(timestamp_ns / 1000); check(enc->output_plane.qBuffer(v4l2_buf, nullptr), "output qBuffer"); return true; @@ -334,6 +350,7 @@ bool JetsonEncoder::submit_dmabuf(int dmabuf_fd, int64_t timestamp_ns, FrameLeas v4l2_buf.m.planes[0].bytesused = 1; v4l2_buf.timestamp.tv_sec = static_cast(timestamp_ns / 1000000000LL); v4l2_buf.timestamp.tv_usec = static_cast((timestamp_ns % 1000000000LL) / 1000LL); + m_impl->last_submitted_us.store(timestamp_ns / 1000); check(m_impl->encoder->output_plane.qBuffer(v4l2_buf, &input), "output qBuffer"); m_impl->dma_leases[index] = std::move(lease); diff --git a/src/plugins/sensing/core/livekit_video_output.cpp b/src/plugins/sensing/core/livekit_video_output.cpp index 8dd38d371d..78b46bda1c 100644 --- a/src/plugins/sensing/core/livekit_video_output.cpp +++ b/src/plugins/sensing/core/livekit_video_output.cpp @@ -4,6 +4,7 @@ #include "livekit_video_output.hpp" #include "inc/sensing_core/jetson_encoder.hpp" +#include "inc/sensing_core/sensor_clock.hpp" #include "rgba_box_downscale.cuh" #include @@ -17,6 +18,7 @@ #include #include #include +#include #include namespace plugins @@ -50,6 +52,7 @@ struct LiveKitVideoOutput::Impl size_t pitch = 0; uint64_t timestamp_ns = 0; uint64_t unix_ns = 0; + uint64_t capture_monotonic_ns = 0; uint64_t capture_tsc = 0; uint64_t sequence = 0; }; @@ -166,9 +169,28 @@ struct LiveKitVideoOutput::Impl if (failed_flag.load()) return; - // Pace on the capture clock, so poll jitter does not drop a frame that is due. A tenth of a - // period of slack lets 60 fps capture feed a 30 fps track every second capture. - if (have_last && frame.monotonic_ns < last_accepted_ns + period_ns * 9 / 10) + // Pace on the clock the frame is stamped with: the sensor's capture time when that is the published basis, + // otherwise the time the camera backend handed the frame over. Either way it is not the poll time, so poll + // jitter does not drop a frame that is due. + uint64_t pace_ns = frame.monotonic_ns; + if (config.timestamp_basis == TimestampBasis::SensorCaptureMonotonic) + { + try + { + frame.capture_monotonic_ns = capture_time_ns(frame); + } + catch (const std::exception& e) + { + fail(e.what()); + return; + } + pace_ns = frame.capture_monotonic_ns; + } + // A clock that steps back would otherwise hold every frame until it caught up: restart pacing instead. + if (have_last && pace_ns < last_accepted_ns) + have_last = false; + // A tenth of a period of slack lets 60 fps capture feed a 30 fps track every second capture. + if (have_last && pace_ns < last_accepted_ns + period_ns * 9 / 10) { ++paced_out; return; @@ -176,7 +198,7 @@ struct LiveKitVideoOutput::Impl if (dma) { - const uint64_t accepted_ns = frame.monotonic_ns; + const uint64_t accepted_ns = pace_ns; // A frame still waiting is replaced by this newer one. Its lease is released after the lock is dropped: // that is a camera API call, and must not run inside the section the worker waits on. std::optional replaced_frame; @@ -226,6 +248,7 @@ struct LiveKitVideoOutput::Impl slot.timestamp_ns = frame.monotonic_ns; slot.unix_ns = frame.unix_ns; + slot.capture_monotonic_ns = frame.capture_monotonic_ns; slot.capture_tsc = frame.sensor_time; slot.sequence = frame.sequence; pending = target; @@ -237,7 +260,7 @@ struct LiveKitVideoOutput::Impl } ++snapshots; have_last = true; - last_accepted_ns = frame.monotonic_ns; + last_accepted_ns = pace_ns; wake.notify_one(); } @@ -361,8 +384,8 @@ struct LiveKitVideoOutput::Impl ++encoder_busy; return; } - track_in_flight( - static_cast(slot.sequence), slot.capture_tsc, slot.timestamp_ns / 1000, slot.unix_ns / 1000); + track_in_flight(static_cast(slot.sequence), slot.capture_tsc, + application_stamp_us(slot.timestamp_ns, slot.capture_monotonic_ns, slot.unix_ns)); } // Worker thread: a leased DMA-BUF frame goes to the encoder as it is, and its lease stays with the encoder. @@ -379,14 +402,54 @@ struct LiveKitVideoOutput::Impl ++encoder_busy; return; } - track_in_flight( - static_cast(frame.sequence), frame.sensor_time, frame.monotonic_ns / 1000, frame.unix_ns / 1000); + track_in_flight(static_cast(frame.sequence), frame.sensor_time, + application_stamp_us(frame.monotonic_ns, frame.capture_monotonic_ns, frame.unix_ns)); + } + + /// Polling thread: the frame's capture event on CLOCK_MONOTONIC. The sensor clock's anchor is built from the + /// first frame's declared domain. + uint64_t capture_time_ns(const CaptureFrame& frame) + { + if (frame.sensor_domain == SensorTimeDomain::None || frame.sensor_time == 0) + throw std::runtime_error("LiveKit output: the camera backend reports no sensor timestamp to publish"); + if (!clock_map) + { + SensorClockMap::Config map; + map.domain = frame.sensor_domain; + if (frame.sensor_domain == SensorTimeDomain::GenericCounterTicks) + map.counter_hz = read_counter_hz(); + const bool counter = frame.sensor_domain == SensorTimeDomain::GenericCounterTicks; + clock_map.emplace(map, counter ? SensorClockMap::PairReader(read_counter_pair) : + SensorClockMap::PairReader(read_raw_pair)); + } + struct timespec now + { + }; + clock_gettime(CLOCK_MONOTONIC, &now); + const int64_t now_ns = static_cast(now.tv_sec) * 1'000'000'000LL + now.tv_nsec; + return static_cast( + clock_map->to_monotonic_ns(frame.sensor_time, static_cast(frame.monotonic_ns), now_ns)); + } + + /// The application timestamp published with a frame, in microseconds. + uint64_t application_stamp_us(uint64_t ready_monotonic_ns, uint64_t capture_monotonic_ns, uint64_t unix_ns) const + { + switch (config.timestamp_basis) + { + case TimestampBasis::FrameReadyMonotonic: + return ready_monotonic_ns / 1000; + case TimestampBasis::SensorCaptureMonotonic: + return capture_monotonic_ns / 1000; + case TimestampBasis::ProcessingUnix: + return unix_ns / 1000; + } + throw std::logic_error("unknown timestamp basis"); } // Worker thread: remember the capture a submitted frame came from, for the unit that comes back. - void track_in_flight(uint32_t sequence, uint64_t capture_tsc, uint64_t monotonic_us, uint64_t unix_us) + void track_in_flight(uint32_t sequence, uint64_t capture_tsc, uint64_t unix_us) { - in_encoder.push_back({ sequence, capture_tsc, monotonic_us, unix_us }); + in_encoder.push_back({ sequence, capture_tsc, unix_us }); } // Worker thread: one encoded unit becomes one published frame, with the sequence and capture time @@ -424,11 +487,13 @@ struct LiveKitVideoOutput::Impl if (awaiting_keyframe && !unit.keyframe) return; + // The transport timestamp is the encoder's own unit stamp; the application time travels in user_timestamp_us. + const int64_t stamp_us = unit.timestamp_ns / 1000; EncodedVideoFrame frame; frame.data = unit.data.data(); frame.size = unit.data.size(); frame.keyframe = unit.keyframe; - frame.timestamp_us = meta.monotonic_us; + frame.timestamp_us = static_cast(stamp_us); frame.frame_id = meta.sequence; frame.capture_tsc = meta.capture_tsc; frame.user_timestamp_us = meta.unix_us; @@ -455,9 +520,7 @@ struct LiveKitVideoOutput::Impl { uint32_t sequence = 0; uint64_t capture_tsc = 0; - /// CLOCK_MONOTONIC when the frame was handed over, for the frame's transport timestamp. - uint64_t monotonic_us = 0; - /// Unix time when the frame was handed over, for the frame's application timestamp. + /// The frame's application timestamp: capture time when publishing it, else processing-time Unix. uint64_t unix_us = 0; }; @@ -465,6 +528,7 @@ struct LiveKitVideoOutput::Impl uint32_t max_bitrate = 0; // Worker thread only. + std::optional clock_map; ///< Polling thread only. std::deque in_encoder; ///< Submitted to the encoder, no unit back yet. int64_t last_unit_index = 0; ///< The encoder's own number of the last unit returned. bool have_unit_index = false; diff --git a/src/plugins/sensing/core/livekit_video_output.hpp b/src/plugins/sensing/core/livekit_video_output.hpp index 30bfd79f58..a0d624beec 100644 --- a/src/plugins/sensing/core/livekit_video_output.hpp +++ b/src/plugins/sensing/core/livekit_video_output.hpp @@ -4,6 +4,7 @@ #pragma once #include "inc/sensing_core/camera_capture.hpp" +#include "inc/sensing_core/sensing_types.hpp" #include @@ -34,6 +35,9 @@ struct LiveKitOutputConfig /// IDR period in frames; 0 defers to the encoder's default. uint32_t gop = 0; bool full_range = false; + /// What each frame's application timestamp is. SensorCaptureMonotonic fails the output on a frame whose sensor + /// time cannot be placed on CLOCK_MONOTONIC. + TimestampBasis timestamp_basis = TimestampBasis::FrameReadyMonotonic; }; struct LiveKitOutputStats diff --git a/src/plugins/sensing/core/sensing_camera.cpp b/src/plugins/sensing/core/sensing_camera.cpp index dbbaa049a4..141e713c22 100644 --- a/src/plugins/sensing/core/sensing_camera.cpp +++ b/src/plugins/sensing/core/sensing_camera.cpp @@ -34,6 +34,7 @@ LiveKitOutputConfig make_output_config(const SensingConfig& config, const Camera output.input = sensor.pixels; output.gop = config.gop; output.full_range = config.full_range; + output.timestamp_basis = config.livekit.timestamp_basis; return output; } diff --git a/src/plugins/sensing/core/sensor_clock.cpp b/src/plugins/sensing/core/sensor_clock.cpp new file mode 100644 index 0000000000..043ec70441 --- /dev/null +++ b/src/plugins/sensing/core/sensor_clock.cpp @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include "inc/sensing_core/sensor_clock.hpp" + +#include + +namespace plugins +{ +namespace sensing +{ + +namespace +{ + +int64_t clock_ns(clockid_t id) +{ + timespec ts{}; + clock_gettime(id, &ts); + return static_cast(ts.tv_sec) * 1'000'000'000LL + ts.tv_nsec; +} + +#if defined(__aarch64__) +int64_t read_counter() +{ + uint64_t v; + asm volatile("isb; mrs %0, cntvct_el0" : "=r"(v)::"memory"); + return static_cast(v); +} +#endif + +/// The narrowest of several back-to-back reads: a descheduled read has a wide bracket and is discarded. +template +ClockPair narrowest_pair(ReadSource read_source) +{ + constexpr int kAttempts = 8; + ClockPair best{}; + best.bracket_ns = INT64_MAX; + for (int i = 0; i < kAttempts; ++i) + { + const int64_t before = clock_ns(CLOCK_MONOTONIC); + const int64_t source = read_source(); + const int64_t after = clock_ns(CLOCK_MONOTONIC); + if (after - before < best.bracket_ns) + best = { source, before + (after - before) / 2, after - before }; + } + return best; +} + +} // namespace + +ClockPair read_counter_pair() +{ +#if defined(__aarch64__) + return narrowest_pair(read_counter); +#else + throw std::runtime_error("the ARM generic counter can only be read on aarch64"); +#endif +} + +int64_t read_counter_hz() +{ +#if defined(__aarch64__) + uint64_t hz; + asm volatile("mrs %0, cntfrq_el0" : "=r"(hz)); + return static_cast(hz); +#else + throw std::runtime_error("the ARM generic counter can only be read on aarch64"); +#endif +} + +ClockPair read_raw_pair() +{ + return narrowest_pair([] { return clock_ns(CLOCK_MONOTONIC_RAW); }); +} + +} // namespace sensing +} // namespace plugins diff --git a/src/plugins/sensing/core/sipl_capture.cpp b/src/plugins/sensing/core/sipl_capture.cpp index 9576b5db44..38d6fff994 100644 --- a/src/plugins/sensing/core/sipl_capture.cpp +++ b/src/plugins/sensing/core/sipl_capture.cpp @@ -43,6 +43,7 @@ std::optional SiplCapture::acquire_latest(uint32_t camera_id) frame.monotonic_ns = view->timestamp_ns; frame.unix_ns = view->unix_ns; frame.sensor_time = view->capture_tsc; + frame.sensor_domain = SensorTimeDomain::GenericCounterTicks; frame.pixels = CudaRgbaPixels{ view->ptr, view->pitch }; return frame; } diff --git a/src/plugins/sensing/livekit/inc/sensing_livekit/clock_sync.hpp b/src/plugins/sensing/livekit/inc/sensing_livekit/clock_sync.hpp new file mode 100644 index 0000000000..dfefbed700 --- /dev/null +++ b/src/plugins/sensing/livekit/inc/sensing_livekit/clock_sync.hpp @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// The publisher's half of the PC's four-timestamp clock exchange: parse a request, build the reply. All times are +// CLOCK_MONOTONIC nanoseconds, the clock the PC maps. Wire format (little-endian), shared with +// examples/egocap/backend/egocap_web/livekit_clock_sync.py and the egocap emulator: +// request, 13 bytes: 'Q', u32 sequence, i64 t1 (the requester's clock) +// reply, 29 bytes: 'R', u32 sequence, i64 t1 (both echoed), i64 t2 (receipt), i64 t3 (reply send) +// No LiveKit type appears here, so the format is testable on its own. + +#pragma once + +#include +#include +#include +#include +#include + +namespace plugins +{ +namespace sensing +{ + +inline constexpr const char* kClockSyncTopic = "isaaccapture.clock-sync"; +inline constexpr std::size_t kClockSyncRequestSize = 13; +inline constexpr std::size_t kClockSyncReplySize = 29; +/// Byte offset of t3 in a reply, so it can be written as late as possible, just before the send. +inline constexpr std::size_t kClockSyncReplyT3Offset = 21; + +struct ClockSyncRequest +{ + std::uint32_t sequence = 0; + std::int64_t t1 = 0; +}; + +inline std::int64_t monotonic_ns() +{ + timespec ts{}; + clock_gettime(CLOCK_MONOTONIC, &ts); + return static_cast(ts.tv_sec) * 1'000'000'000LL + ts.tv_nsec; +} + +inline void put_le(std::vector& out, std::size_t at, std::uint64_t value, std::size_t bytes) +{ + for (std::size_t i = 0; i < bytes; ++i) + out[at + i] = static_cast((value >> (8 * i)) & 0xFF); +} + +inline std::uint64_t get_le(const std::vector& in, std::size_t at, std::size_t bytes) +{ + std::uint64_t value = 0; + for (std::size_t i = 0; i < bytes; ++i) + value |= static_cast(in[at + i]) << (8 * i); + return value; +} + +/// The request in `data`, or nullopt for anything that is not exactly a request. +inline std::optional parse_clock_sync_request(const std::vector& data) +{ + if (data.size() != kClockSyncRequestSize || data[0] != 'Q') + return std::nullopt; + ClockSyncRequest request; + request.sequence = static_cast(get_le(data, 1, 4)); + request.t1 = static_cast(get_le(data, 5, 8)); + return request; +} + +inline std::vector build_clock_sync_reply(const ClockSyncRequest& request, std::int64_t t2, std::int64_t t3) +{ + std::vector reply(kClockSyncReplySize); + reply[0] = 'R'; + put_le(reply, 1, request.sequence, 4); + put_le(reply, 5, static_cast(request.t1), 8); + put_le(reply, 13, static_cast(t2), 8); + put_le(reply, kClockSyncReplyT3Offset, static_cast(t3), 8); + return reply; +} + +/// Writes t3 into an already built reply. +inline void set_clock_sync_reply_t3(std::vector& reply, std::int64_t t3) +{ + put_le(reply, kClockSyncReplyT3Offset, static_cast(t3), 8); +} + +} // namespace sensing +} // namespace plugins diff --git a/src/plugins/sensing/livekit/livekit_session.cpp b/src/plugins/sensing/livekit/livekit_session.cpp index b3ad56fe3e..929721ecc4 100644 --- a/src/plugins/sensing/livekit/livekit_session.cpp +++ b/src/plugins/sensing/livekit/livekit_session.cpp @@ -3,16 +3,22 @@ #include "inc/sensing_livekit/livekit_session.hpp" +#include "inc/sensing_livekit/clock_sync.hpp" + #include #include #include #include #include +#include +#include #include #include #include #include +#include +#include #include #include @@ -68,12 +74,79 @@ struct LiveKitSessionImpl : livekit::RoomDelegate { ~LiveKitSessionImpl() override { + stop_clock_sync(); // The members below are destroyed before the room: stop callbacks reaching them, then leave the // room while the SDK is still up (its reference is released after the room). room.setDelegate(nullptr); room.disconnect(); } + // A clock-sync request from the PC or the web page. t2 is read first, before anything else runs; the reply is + // sent from a small thread so this SDK callback never waits on a send, and t3 is written just before it. + void onUserPacketReceived(livekit::Room&, const livekit::UserDataPacketEvent& event) override + { + const std::int64_t t2 = monotonic_ns(); + if (event.topic != kClockSyncTopic || event.participant == nullptr) + return; + const auto request = parse_clock_sync_request(event.data); + if (!request) + return; + { + std::lock_guard lock(sync_mutex); + // Bounded: a flood drops the oldest request, whose requester would discard a late reply anyway. + if (sync_queue.size() >= kMaxPendingSync) + sync_queue.pop_front(); + sync_queue.push_back({ event.participant->identity(), *request, t2 }); + } + sync_wake.notify_one(); + } + + void start_clock_sync() + { + sync_thread = std::thread([this] { run_clock_sync(); }); + } + + void stop_clock_sync() + { + { + std::lock_guard lock(sync_mutex); + sync_stopping = true; + } + sync_wake.notify_all(); + if (sync_thread.joinable()) + sync_thread.join(); + } + + void run_clock_sync() + { + for (;;) + { + PendingSync pending; + { + std::unique_lock lock(sync_mutex); + sync_wake.wait(lock, [this] { return sync_stopping || !sync_queue.empty(); }); + if (sync_stopping) + return; + pending = std::move(sync_queue.front()); + sync_queue.pop_front(); + } + auto reply = build_clock_sync_reply(pending.request, pending.t2, 0); + try + { + const auto local = room.localParticipant().lock(); + if (!local) + continue; + // t3 as late as possible: nothing but the call follows it. + set_clock_sync_reply_t3(reply, monotonic_ns()); + local->publishData(reply, false, { pending.identity }, kClockSyncTopic); + } + catch (const std::exception& e) + { + logger->warn("clock-sync reply failed: {}", e.what()); + } + } + } + void onDisconnected(livekit::Room&, const livekit::DisconnectedEvent& event) override { if (event.reason == livekit::DisconnectReason::ClientInitiated) @@ -102,6 +175,19 @@ struct LiveKitSessionImpl : livekit::RoomDelegate } } + struct PendingSync + { + std::string identity; + ClockSyncRequest request; + std::int64_t t2 = 0; + }; + static constexpr std::size_t kMaxPendingSync = 16; + std::mutex sync_mutex; + std::condition_variable sync_wake; + std::deque sync_queue; + bool sync_stopping = false; + std::thread sync_thread; + // Declared before the room, so destroyed after it. LiveKitSdkReference sdk; livekit::Room room; @@ -280,6 +366,7 @@ std::unique_ptr LiveKitSession::connect(const std::string& url, " (check the URL, that the server is reachable and that the token is valid)"); } impl->logger->info("Joined LiveKit room at {}", url); + impl->start_clock_sync(); return std::unique_ptr(new LiveKitSession(std::move(impl))); } diff --git a/src/plugins/sensing/tests/CMakeLists.txt b/src/plugins/sensing/tests/CMakeLists.txt index 33819ad40b..eaf7b80670 100644 --- a/src/plugins/sensing/tests/CMakeLists.txt +++ b/src/plugins/sensing/tests/CMakeLists.txt @@ -25,3 +25,25 @@ target_include_directories(sensing_buffer_owner_tests PRIVATE ${CMAKE_CURRENT_SO target_link_libraries(sensing_buffer_owner_tests PRIVATE Catch2::Catch2WithMain Threads::Threads) catch_discover_tests(sensing_buffer_owner_tests) + +# The clock-sync wire format is header-only and LiveKit-free, so the test needs only its include path. +add_executable(sensing_clock_sync_tests + test_clock_sync.cpp +) + +target_include_directories(sensing_clock_sync_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../livekit/inc) + +target_link_libraries(sensing_clock_sync_tests PRIVATE Catch2::Catch2WithMain) + +catch_discover_tests(sensing_clock_sync_tests) + +# The sensor-clock mapping is header-only and injectable, so it needs no SDK. +add_executable(sensing_sensor_clock_tests + test_sensor_clock.cpp +) + +target_include_directories(sensing_sensor_clock_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/..) + +target_link_libraries(sensing_sensor_clock_tests PRIVATE Catch2::Catch2WithMain) + +catch_discover_tests(sensing_sensor_clock_tests) diff --git a/src/plugins/sensing/tests/test_clock_sync.cpp b/src/plugins/sensing/tests/test_clock_sync.cpp new file mode 100644 index 0000000000..db0176b7ec --- /dev/null +++ b/src/plugins/sensing/tests/test_clock_sync.cpp @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include "sensing_livekit/clock_sync.hpp" + +#include + +#include +#include +#include +#include + +using plugins::sensing::build_clock_sync_reply; +using plugins::sensing::ClockSyncRequest; +using plugins::sensing::kClockSyncReplySize; +using plugins::sensing::kClockSyncRequestSize; +using plugins::sensing::monotonic_ns; +using plugins::sensing::parse_clock_sync_request; +using plugins::sensing::set_clock_sync_reply_t3; + +namespace +{ + +using Bytes = std::vector; + +Bytes from_hex(const std::string& hex) +{ + Bytes out; + for (std::size_t i = 0; i + 1 < hex.size(); i += 2) + out.push_back(static_cast(std::stoul(hex.substr(i, 2), nullptr, 16))); + return out; +} + +// Golden bytes produced by egocap_web.livekit_clock_sync.encode_request and +// tools/livekit_orin_emulator.build_sync_reply for the inputs named beside each constant. +// sequence 1, t1 1, t2 2, t3 3 +const char* const kSimpleRequest = "51010000000100000000000000"; +const char* const kSimpleReply = "5201000000010000000000000002000000000000000300000000000000"; +// sequence 0xFFFFFFFF, t1 -1234567890123, t2 INT64_MAX, t3 INT64_MIN +const char* const kExtremeRequest = "51ffffffff35fb048ee0feffff"; +const char* const kExtremeReply = "52ffffffff35fb048ee0feffffffffffffffffff7f0000000000000080"; + +} // namespace + +TEST_CASE("parse_clock_sync_request accepts a well-formed request", "[clock_sync]") +{ + auto request = parse_clock_sync_request(from_hex(kSimpleRequest)); + REQUIRE(request.has_value()); + CHECK(request->sequence == 1); + CHECK(request->t1 == 1); + + request = parse_clock_sync_request(from_hex(kExtremeRequest)); + REQUIRE(request.has_value()); + CHECK(request->sequence == 0xFFFFFFFFu); + CHECK(request->t1 == -1234567890123LL); +} + +TEST_CASE("parse_clock_sync_request rejects anything else", "[clock_sync]") +{ + for (std::size_t size : { std::size_t{ 0 }, std::size_t{ 12 }, std::size_t{ 14 }, std::size_t{ 29 } }) + { + Bytes data(size, 0); + if (size > 0) + data[0] = 'Q'; + CHECK_FALSE(parse_clock_sync_request(data).has_value()); + } + + Bytes wrong_kind = from_hex(kSimpleRequest); + wrong_kind[0] = 'X'; + CHECK_FALSE(parse_clock_sync_request(wrong_kind).has_value()); + + CHECK_FALSE(parse_clock_sync_request(from_hex(kSimpleReply)).has_value()); + + Bytes reply_kind_at_request_size = from_hex(kSimpleRequest); + reply_kind_at_request_size[0] = 'R'; + CHECK_FALSE(parse_clock_sync_request(reply_kind_at_request_size).has_value()); +} + +TEST_CASE("build_clock_sync_reply lays out the documented fields", "[clock_sync]") +{ + const Bytes reply = build_clock_sync_reply(ClockSyncRequest{ 1, 1 }, 2, 3); + REQUIRE(reply.size() == kClockSyncReplySize); + CHECK(reply[0] == 'R'); + CHECK(reply == from_hex(kSimpleReply)); + + // Explicit offsets, little-endian. + CHECK(reply[1] == 1); + CHECK(reply[5] == 1); + CHECK(reply[13] == 2); + CHECK(reply[21] == 3); +} + +TEST_CASE("build_clock_sync_reply handles large and negative values", "[clock_sync]") +{ + const ClockSyncRequest request{ 0xFFFFFFFFu, -1234567890123LL }; + const Bytes reply = build_clock_sync_reply( + request, std::numeric_limits::max(), std::numeric_limits::min()); + CHECK(reply == from_hex(kExtremeReply)); +} + +TEST_CASE("clock sync request and reply round trip through parse", "[clock_sync]") +{ + const Bytes request_bytes = from_hex(kExtremeRequest); + REQUIRE(request_bytes.size() == kClockSyncRequestSize); + const auto request = parse_clock_sync_request(request_bytes); + REQUIRE(request.has_value()); + const Bytes reply = build_clock_sync_reply( + *request, std::numeric_limits::max(), std::numeric_limits::min()); + CHECK(reply == from_hex(kExtremeReply)); +} + +TEST_CASE("set_clock_sync_reply_t3 changes only the t3 bytes", "[clock_sync]") +{ + const Bytes before = build_clock_sync_reply(ClockSyncRequest{ 7, -5 }, 11, 13); + Bytes after = before; + set_clock_sync_reply_t3(after, 0x0102030405060708LL); + + REQUIRE(after.size() == before.size()); + for (std::size_t i = 0; i < before.size(); ++i) + { + if (i >= 21 && i <= 28) + CHECK(after[i] == static_cast(0x08 - (i - 21))); + else + CHECK(after[i] == before[i]); + } + CHECK(after == build_clock_sync_reply(ClockSyncRequest{ 7, -5 }, 11, 0x0102030405060708LL)); +} + +TEST_CASE("monotonic_ns does not go backwards", "[clock_sync]") +{ + const std::int64_t a = monotonic_ns(); + const std::int64_t b = monotonic_ns(); + CHECK(a > 0); + CHECK(b >= a); +} diff --git a/src/plugins/sensing/tests/test_sensor_clock.cpp b/src/plugins/sensing/tests/test_sensor_clock.cpp new file mode 100644 index 0000000000..9a0bd06a5f --- /dev/null +++ b/src/plugins/sensing/tests/test_sensor_clock.cpp @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#include "core/inc/sensing_core/sensor_clock.hpp" + +#include + +using plugins::sensing::ClockPair; +using plugins::sensing::SensorClockMap; +using plugins::sensing::SensorTimeDomain; + +namespace +{ +constexpr int64_t kHz = 31'250'000; // 32 ns per tick +constexpr int64_t kMs = 1'000'000; + +SensorClockMap counter_map(int& reads, int64_t tick0 = 1'000'000, int64_t mono0 = 50'000 * kMs) +{ + SensorClockMap::Config cfg; + cfg.domain = SensorTimeDomain::GenericCounterTicks; + cfg.counter_hz = kHz; + return SensorClockMap(cfg, + [&reads, tick0, mono0] + { + ++reads; + return ClockPair{ tick0, mono0, 1000 }; + }); +} +} // namespace + +TEST_CASE("counter ticks convert through the anchor at the counter rate") +{ + int reads = 0; + auto map = counter_map(reads); + // 100 ms after the anchor = 3,125,000 ticks. + const int64_t ready = 50'200 * kMs; + REQUIRE(map.to_monotonic_ns(1'000'000 + 3'125'000, ready, ready) == 50'100 * kMs); +} + +TEST_CASE("a capture before the anchor converts with a negative delta") +{ + int reads = 0; + auto map = counter_map(reads); + const int64_t ready = 50'010 * kMs; + // 3,125 ticks before the anchor = 100 us. + REQUIRE(map.to_monotonic_ns(1'000'000 - 3'125, ready, ready) == 50'000 * kMs - 100'000); +} + +TEST_CASE("raw nanoseconds convert one to one") +{ + SensorClockMap::Config cfg; + cfg.domain = SensorTimeDomain::MonotonicRawNs; + SensorClockMap map(cfg, [] { return ClockPair{ 7'000 * kMs, 7'002 * kMs, 100 }; }); + REQUIRE(map.to_monotonic_ns(7'010 * kMs, 7'050 * kMs, 7'050 * kMs) == 7'012 * kMs); +} + +TEST_CASE("the anchor is refreshed at the configured rate, not per frame") +{ + int reads = 0; + auto map = counter_map(reads); + const int64_t t = 50'100 * kMs; + for (int i = 0; i < 30; ++i) + map.to_monotonic_ns(1'000'000 + 3'125'000, t, t + i * 10 * kMs); + REQUIRE(reads == 1); + map.to_monotonic_ns(1'000'000 + 3'125'000, 51'200 * kMs, 51'200 * kMs); + REQUIRE(reads == 2); +} + +TEST_CASE("a capture in the future of the ready time is rejected") +{ + int reads = 0; + auto map = counter_map(reads); + REQUIRE_THROWS_AS(map.to_monotonic_ns(1'000'000 + 3'125'000, 50'050 * kMs, 50'050 * kMs), std::runtime_error); +} + +TEST_CASE("a capture implausibly old is rejected, as with the wrong rate") +{ + int reads = 0; + auto map = counter_map(reads); + // Treating nanoseconds as ticks puts the capture far from the anchor. + REQUIRE_THROWS_AS(map.to_monotonic_ns(1'000'000 + 400'000'000, 50'100 * kMs, 50'100 * kMs), std::runtime_error); + int more = 0; + auto old_map = counter_map(more, 200'000'000); + // 199,999,999 ticks before the anchor is about 6.4 s. + REQUIRE_THROWS_AS(old_map.to_monotonic_ns(1, 50'100 * kMs, 50'100 * kMs), std::runtime_error); +} + +TEST_CASE("a domain is required, and counter ticks need a rate") +{ + SensorClockMap::Config cfg; + auto reader = [] { return ClockPair{}; }; + REQUIRE_THROWS_AS(SensorClockMap(cfg, reader), std::invalid_argument); + cfg.domain = SensorTimeDomain::GenericCounterTicks; + REQUIRE_THROWS_AS(SensorClockMap(cfg, reader), std::invalid_argument); +}