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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
diagnostics-sessions/
*.db3
*.mcap

# ROS folders
build/
install/
Expand Down
16 changes: 15 additions & 1 deletion waybionic_rviz_plugins/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ install(
)

install(
PROGRAMS scripts/temporary_diagnostics_publisher.py
PROGRAMS
scripts/temporary_diagnostics_publisher.py
scripts/diagnostics_recorder.py
DESTINATION lib/${PROJECT_NAME}
)

Expand All @@ -86,6 +88,18 @@ if(BUILD_TESTING)
TIMEOUT 60
)

ament_add_pytest_test(
test_diagnostics_recorder
test/test_diagnostics_recorder.py
TIMEOUT 60
)

ament_add_pytest_test(
test_diagnostics_recorder_roundtrip
test/test_diagnostics_recorder_roundtrip.py
TIMEOUT 120
)

# Built from sources directly so the stress test links neither Qt nor RViz and
# can run headless.
ament_add_gtest(test_ros_diagnostics_source
Expand Down
44 changes: 44 additions & 0 deletions waybionic_rviz_plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ waybionic_rviz_plugins/
plugin_description.xml # Registers DiagnosticsPanel
scripts/
temporary_diagnostics_publisher.py
diagnostics_recorder.py
include/waybionic_rviz_plugins/
diagnostics_contract.hpp # Normalized DiagnosticMessage model
diagnostics_source.hpp # DiagnosticsSource interface
Expand Down Expand Up @@ -139,6 +140,49 @@ DiagnosticsSource

`RosDiagnosticsSource` maps ROS diagnostic levels and fields into the internal `DiagnosticMessage` model before the Qt panel renders them. See `docs/DIAGNOSTICS_CONTRACT.md` for the full mapping Korede/backend should follow, and `docs/DIAGNOSTICS_BACKEND_INTEGRATION.md` for backend replacement guidance.

### Recording a diagnostics session

The recorder saves the original ROS messages in a standard rosbag2 session and
writes `metadata.json` beside the bag. It records `/diagnostics` by default;
additional topics must be named explicitly.

```bash
ros2 run waybionic_rviz_plugins diagnostics_recorder.py \
--duration 30 \
--output-directory ~/diagnostics-sessions/fault-001 \
--source-label mock \
--tested-commit "$(git rev-parse HEAD)"
```

The output directory must not already exist. Inspect or replay a session in an
isolated ROS domain so it cannot interfere with an active robot or publisher:

```bash
ROS_DOMAIN_ID=42 ros2 bag info ~/diagnostics-sessions/fault-001/bag
ROS_DOMAIN_ID=42 ros2 bag play ~/diagnostics-sessions/fault-001/bag
ROS_DOMAIN_ID=42 ros2 topic echo /diagnostics
```

Label replayed data as `recorded/mock` when sharing it. Replay does not require
the original diagnostics publisher to be running. Generated bag directories
should remain outside Git; the repository ignores local recording output.

For a complete temporary-publisher validation, record each mode in a separate
new directory. Use a duration long enough for the first rosbag2 startup on the
machine:

```bash
ros2 launch waybionic_rviz_plugins temporary_diagnostics_publisher.launch.py mode:=normal
ros2 run waybionic_rviz_plugins diagnostics_recorder.py --duration 30 \
--output-directory ~/diagnostics-sessions/normal \
--source-label mock
```

Repeat with `mode:=fault`, `mode:=stale`, and `mode:=cycle`. To preserve a
publisher message gap, stop the publisher with `Ctrl+C`, leave the recorder
running, restart the publisher, and then let the recorder finish. The recorder
does not insert samples during that gap.

Switching between mock and live replaces the active source while a ROS callback may still be running. `docs/DIAGNOSTICS_SOURCE_LIFECYCLE.md` documents the ownership rules that keep that handoff safe and the stress test that guards it.

## Platform Notes
Expand Down
3 changes: 3 additions & 0 deletions waybionic_rviz_plugins/package.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
<exec_depend>launch</exec_depend>
<exec_depend>launch_ros</exec_depend>
<exec_depend>rclpy</exec_depend>
<exec_depend>rosbag2_py</exec_depend>
<exec_depend>rosbag2_storage</exec_depend>
<exec_depend>rosbag2_storage_mcap</exec_depend>
<exec_depend>rviz2</exec_depend>

<test_depend>ament_cmake_gtest</test_depend>
Expand Down
211 changes: 211 additions & 0 deletions waybionic_rviz_plugins/scripts/diagnostics_recorder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""Record an explicit ROS 2 diagnostics session into a rosbag2 directory."""

import argparse
import json
import signal
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Sequence


DEFAULT_TOPIC = "/diagnostics"


def parse_args(arguments: Optional[Sequence[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Record selected ROS 2 topics and write session metadata."
)
parser.add_argument(
"--topic",
action="append",
dest="topics",
metavar="TOPIC",
help=(
"Topic to record; repeat for additional topics. Defaults to "
f"{DEFAULT_TOPIC}. Other topics must be named explicitly."
),
)
parser.add_argument(
"--duration",
type=float,
help="Stop after this many seconds; omit to record until Ctrl+C.",
)
parser.add_argument(
"--output-directory",
required=True,
type=Path,
help="New session directory to create; an existing directory is rejected.",
)
parser.add_argument(
"--source-label",
required=True,
choices=("mock", "live"),
help="Label describing whether the session source is mock or live.",
)
parser.add_argument(
"--tested-commit",
help="Optional commit identifier tested during this session.",
)
parsed = parser.parse_args(arguments)
if parsed.duration is not None and parsed.duration <= 0:
parser.error("--duration must be greater than zero")
parsed.topics = parsed.topics or [DEFAULT_TOPIC]
return parsed


def build_record_command(bag_directory: Path, topics: Sequence[str]) -> list[str]:
if not topics:
raise ValueError("at least one topic must be selected")
return [
"ros2",
"bag",
"record",
"--disable-keyboard-controls",
"--output",
str(bag_directory),
"--topics",
*topics,
]


def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()


def write_metadata(
session_directory: Path,
topics: Sequence[str],
source_label: str,
start_time: str,
end_time: str,
tested_commit: Optional[str],
message_count: int,
) -> None:
metadata = {
"topic": topics[0] if len(topics) == 1 else list(topics),
"start_time": start_time,
"end_time": end_time,
"source_label": source_label,
"message_count": message_count,
}
if tested_commit:
metadata["tested_commit"] = tested_commit
(session_directory / "metadata.json").write_text(
json.dumps(metadata, indent=2) + "\n", encoding="utf-8"
)


def count_recorded_messages(bag_directory: Path) -> int:
if not bag_directory.exists():
return 0
try:
import rosbag2_py
except ImportError as exc:
raise RuntimeError(
"rosbag2_py is required to finalize and validate the recording"
) from exc

reader = rosbag2_py.SequentialReader()
reader.open(
rosbag2_py.StorageOptions(uri=str(bag_directory), storage_id="mcap"),
rosbag2_py.ConverterOptions("", ""),
)
count = 0
while reader.has_next():
reader.read_next()
count += 1
return count


def stop_recorder(process: subprocess.Popen[bytes]) -> None:
if process.poll() is not None:
return
process.send_signal(signal.SIGINT)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()


def run(arguments: Optional[Sequence[str]] = None) -> int:
options = parse_args(arguments)
session_directory = options.output_directory
bag_directory = session_directory / "bag"

if session_directory.exists():
print(f"error: output directory already exists: {session_directory}", file=sys.stderr)
return 2
try:
session_directory.mkdir(parents=True)
except OSError as exc:
print(f"error: cannot create output directory: {exc}", file=sys.stderr)
return 2

start_time = utc_now()
command = build_record_command(bag_directory, options.topics)
try:
process = subprocess.Popen(command)
except FileNotFoundError:
print(
"error: missing dependency: the 'ros2' command is not available; "
"source the ROS 2 environment first",
file=sys.stderr,
)
return 1
except (OSError, subprocess.SubprocessError) as exc:
print(f"error: could not start ros2 bag record: {exc}", file=sys.stderr)
return 1

try:
if options.duration is None:
process.wait()
else:
process.wait(timeout=options.duration)
except subprocess.TimeoutExpired:
print("Recording duration reached; finalizing bag.")
except KeyboardInterrupt:
print("Stopping recording; finalizing bag.")
finally:
stop_recorder(process)

end_time = utc_now()
if process.returncode not in (0, 130, -signal.SIGINT):
print(
f"error: ros2 bag record failed with exit code {process.returncode}",
file=sys.stderr,
)
return 1

try:
message_count = count_recorded_messages(bag_directory)
write_metadata(
session_directory,
options.topics,
options.source_label,
start_time,
end_time,
options.tested_commit,
message_count,
)
except Exception as exc:
print(f"error: could not finalize recording: {exc}", file=sys.stderr)
return 1

if message_count == 0:
print("error: recording completed but contained no messages", file=sys.stderr)
return 1

print(f"Recorded {message_count} messages in {session_directory}")
return 0


if __name__ == "__main__":
raise SystemExit(run())
Loading
Loading