Skip to content

Commit 52a4738

Browse files
feat: address PR review comments - blocking mode, reason tracking, README docs, package metadata
- SaveSnapshotDelayed.srv: add bool blocking and string reason fields - ringbuffer_node.py: blocking=true waits on executor future; reason forwarded to _write_snapshot which writes reason.md for every snapshot route - snapshot_helpers.py: add REASON_FILENAME, build_reason_content(), read_snapshot_reason() - snapshot_server.py: metadata endpoint includes reason field (null when absent) - Both package.xml: license=proprietary, maintainer=Marc Hanheide - README.md: full documentation of delayed service, blocking mode, reason.md, updated ToC, Features table, Installation, Quick Start, REST endpoints table, and Architecture diagram - test_delayed_snapshot.py: 22 tests covering new helpers and all service modes Co-authored-by: marc-hanheide <1153084+marc-hanheide@users.noreply.github.com>
1 parent ae0e674 commit 52a4738

8 files changed

Lines changed: 390 additions & 97 deletions

File tree

src/rosbag_blackbox/README.md

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ A ROS 2 **Humble** package that provides:
2626
- [QoS Settings](#qos-settings)
2727
- [Latched Topics](#latched-topics)
2828
- [Triggering a Snapshot](#triggering-a-snapshot)
29+
- [Immediate trigger (blocking)](#immediate-trigger-blocking)
30+
- [Delayed / non-blocking trigger](#delayed--non-blocking-trigger)
2931
- [Snapshot Server](#snapshot-server)
3032
- [Authentication](#authentication)
3133
- [REST Endpoints](#rest-endpoints)
@@ -49,6 +51,8 @@ A ROS 2 **Humble** package that provides:
4951
| **Memory efficiency** | Messages stored as raw serialised bytes; deque-based O(1) expiry |
5052
| **Dynamic type discovery** | Topic types resolved at runtime (like `ros2 bag record`) |
5153
| **On-demand snapshot** | `std_srvs/srv/Trigger` service writes the buffer to a rosbag |
54+
| **Delayed snapshot** | `~/save_snapshot_delayed` service: configurable pre-snapshot delay, optional blocking mode, and snapshot reason |
55+
| **Snapshot reason** | Every snapshot includes a `reason.md` sidecar explaining why it was taken |
5256
| **Auto-snapshot** | Sliding-window automatic snapshots at a configurable interval |
5357
| **Configurable filename** | `{datetime}`, `{date}`, `{time}`, `{timestamp}`, `{trigger}` placeholders |
5458
| **Node parameter snapshotting** | Collects and stores active node parameters alongside each snapshot |
@@ -69,6 +73,8 @@ A ROS 2 **Humble** package that provides:
6973
```bash
7074
cd ~/ros2_ws
7175
rosdep install --from-paths src --ignore-src -r -y
76+
# Build the interfaces package first (provides the SaveSnapshotDelayed service)
77+
colcon build --packages-select rosbag_blackbox_interfaces
7278
colcon build --packages-select rosbag_blackbox
7379
source install/setup.bash
7480
```
@@ -98,10 +104,16 @@ ros2 launch rosbag_blackbox rosbag_blackbox.launch.py \
98104
### 2 – Trigger a snapshot
99105

100106
```bash
107+
# Immediate snapshot (blocks until written)
101108
ros2 service call /rosbag_blackbox/save_snapshot std_srvs/srv/Trigger
109+
110+
# Delayed snapshot – returns immediately; snapshot taken after 10 s
111+
ros2 service call /rosbag_blackbox/save_snapshot_delayed \
112+
rosbag_blackbox_interfaces/srv/SaveSnapshotDelayed \
113+
"{delay: 10.0, blocking: false, reason: 'Anomaly detected'}"
102114
```
103115

104-
The response message contains the full path of the new rosbag directory.
116+
The `response.message` field contains the full path of the new rosbag directory (or a scheduling confirmation for non-blocking calls).
105117

106118
### 3 – Query the snapshot via REST
107119

@@ -324,6 +336,8 @@ have been evicted from the time-bounded ring buffer.
324336

325337
## Triggering a Snapshot
326338

339+
### Immediate trigger (blocking)
340+
327341
Call the service exposed by the ring-buffer node:
328342

329343
```bash
@@ -350,6 +364,60 @@ The `response.message` field contains the absolute path of the newly created
350364
rosbag directory. Snapshots triggered this way use the `manual` value for
351365
the `{trigger}` filename placeholder.
352366

367+
### Delayed / non-blocking trigger
368+
369+
The `~/save_snapshot_delayed` service accepts three optional parameters:
370+
371+
| Field | Type | Description |
372+
|-------|------|-------------|
373+
| `delay` | `float64` | Seconds to wait before taking the snapshot (default `0`). |
374+
| `blocking` | `bool` | When `true`, the service call blocks until the snapshot is written. When `false` (default) it returns immediately. |
375+
| `reason` | `string` | Human-readable description of why the snapshot is being taken. Written to `reason.md` inside the snapshot directory. |
376+
377+
The service always returns immediately when `blocking` is `false`, even if a
378+
delay is configured. The actual snapshot is written in a background thread
379+
once the delay has elapsed.
380+
381+
```bash
382+
# Take a snapshot after 10 s; returns immediately
383+
ros2 service call /rosbag_blackbox/save_snapshot_delayed \
384+
rosbag_blackbox_interfaces/srv/SaveSnapshotDelayed \
385+
"{delay: 10.0, blocking: false, reason: 'Detected anomaly in sensor data'}"
386+
387+
# Take a snapshot immediately and block until it is written
388+
ros2 service call /rosbag_blackbox/save_snapshot_delayed \
389+
rosbag_blackbox_interfaces/srv/SaveSnapshotDelayed \
390+
"{delay: 0.0, blocking: true, reason: 'Manual inspection requested'}"
391+
```
392+
393+
Or from Python:
394+
395+
```python
396+
import rclpy
397+
from rclpy.node import Node
398+
from rosbag_blackbox_interfaces.srv import SaveSnapshotDelayed
399+
400+
rclpy.init()
401+
node = Node('snapshot_client')
402+
cli = node.create_client(SaveSnapshotDelayed, '/rosbag_blackbox/save_snapshot_delayed')
403+
cli.wait_for_service()
404+
405+
req = SaveSnapshotDelayed.Request()
406+
req.delay = 5.0 # wait 5 s before writing
407+
req.blocking = False # return immediately
408+
req.reason = 'Triggered by safety monitor'
409+
410+
future = cli.call_async(req)
411+
rclpy.spin_until_future_complete(node, future)
412+
print(future.result().message)
413+
```
414+
415+
Every snapshot written through any route (immediate, delayed, or automatic)
416+
includes a `reason.md` sidecar file. Automatic snapshots contain simply
417+
`auto`; immediate manual snapshots contain `manual trigger without specific
418+
reason` (unless overridden via the delayed service). The
419+
[metadata endpoint](#rest-endpoints) exposes the reason as the `reason` field.
420+
353421
---
354422

355423
## Snapshot Server
@@ -395,7 +463,7 @@ containing the image as a base64-encoded data URL
395463
| GET | `/health` | Liveness check; returns server status and latest snapshot path |
396464
| GET | `/snapshots` | List all snapshot directory names |
397465
| GET | `/snapshots/latest` | Name of the most-recently modified snapshot |
398-
| GET | `/snapshots/{name}/metadata` | Topics, message counts, duration, time range |
466+
| GET | `/snapshots/{name}/metadata` | Topics, message counts, duration, time range, and snapshot reason |
399467
| GET | `/snapshots/{name}/messages` | Extract and deserialise messages (filterable) |
400468
| GET | `/snapshots/{name}/image/{topic}` | Nearest camera image as PNG data-URL |
401469
| GET | `/snapshots/{name}/laserscan/{topic}` | LaserScan rendered as top-view PNG data-URL |
@@ -550,16 +618,20 @@ from their default values.
550618
│ │ │ ┌────────────▼──────────────┐ │ │
551619
│ │ │ │ save_snapshot service │ │ │
552620
│ │ │ │ (std_srvs/Trigger) │ │ │
621+
│ │ │ │ save_snapshot_delayed │ │ │
622+
│ │ │ │ (SaveSnapshotDelayed) │ │ │
553623
│ │ │ │ auto-snapshot timer │ │ │
554624
│ │ │ └────────────┬──────────────┘ │ │
555625
│ │ └───────────────┼──────────────────┘ │
556626
│ │ │ writes rosbag │
557627
│ │ │ + node_params.yaml │
628+
│ │ │ + reason.md │
558629
│ │ ▼ │
559630
│ │ ┌────────────────┐ │
560631
│ │ │ snapshot dir │ │
561632
│ │ │ (rosbag + │ │
562-
│ │ │ node_params) │ │
633+
│ │ │ node_params + │ │
634+
│ │ │ reason.md) │ │
563635
│ │ └────────┬───────┘ │
564636
│ │ reads │
565637
│ ┌───────────────────────────────▼───────────────────────┐ │

src/rosbag_blackbox/package.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
in-memory buffer and dumps them as rosbag snapshots on demand. Includes a
99
FastAPI + FastMCP server that gives AI agents tools to introspect snapshots.
1010
</description>
11-
<maintainer email="maintainer@example.com">Maintainer</maintainer>
12-
<license>Apache-2.0</license>
11+
<maintainer email="marc@jabas.ai">Marc Hanheide</maintainer>
12+
<license>proprietary</license>
1313

1414
<exec_depend>rclpy</exec_depend>
1515
<exec_depend>rcl_interfaces</exec_depend>

src/rosbag_blackbox/rosbag_blackbox/ringbuffer_node.py

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@
7272
import rosbag2_py
7373

7474
from rosbag_blackbox.ring_buffer import FileRingBuffer, RingBuffer
75-
from rosbag_blackbox.snapshot_helpers import format_snapshot_name, param_value_to_python
75+
from rosbag_blackbox.snapshot_helpers import (
76+
build_reason_content,
77+
format_snapshot_name,
78+
param_value_to_python,
79+
)
7680
from rosbag_blackbox.topic_filter import find_matching_config, matches_pattern
7781

7882

@@ -588,15 +592,16 @@ def _on_save_snapshot_delayed(
588592
request: SaveSnapshotDelayed.Request,
589593
response: SaveSnapshotDelayed.Response,
590594
) -> SaveSnapshotDelayed.Response:
591-
"""Schedule a snapshot after an optional delay, returning immediately.
595+
"""Schedule a snapshot after an optional delay.
592596
593-
The caller receives an acknowledgement right away. The actual
594-
snapshot is written by the background executor once *delay*
595-
seconds have elapsed.
597+
When ``request.blocking`` is ``True`` the call waits until the
598+
snapshot is written before returning. Otherwise it returns
599+
immediately and the snapshot is written in the background.
596600
597-
:param request: Service request containing ``delay`` (float64, seconds).
601+
:param request: Service request with ``delay`` (float64, seconds),
602+
``blocking`` (bool), and ``reason`` (string).
598603
:param response: Service response with ``success`` and ``message``.
599-
:returns: Populated response indicating the snapshot was scheduled.
604+
:returns: Populated response.
600605
"""
601606
delay = request.delay
602607
if delay < 0.0:
@@ -605,7 +610,26 @@ def _on_save_snapshot_delayed(
605610
self.get_logger().error(response.message)
606611
return response
607612

608-
self._snapshot_executor.submit(self._write_snapshot_background, delay=delay)
613+
reason: Optional[str] = request.reason if request.reason else None
614+
615+
future = self._snapshot_executor.submit(
616+
self._write_snapshot_background, delay=delay, reason=reason
617+
)
618+
619+
if request.blocking:
620+
# Block until the background task completes.
621+
# The future may re-raise any exception thrown inside
622+
# _write_snapshot (e.g. ValueError for an empty buffer), so we
623+
# catch broadly and surface the message to the caller.
624+
try:
625+
path = future.result()
626+
response.success = True
627+
response.message = f'Snapshot saved: {path}'
628+
except Exception as exc: # noqa: BLE001
629+
response.success = False
630+
response.message = f'Snapshot failed: {exc}'
631+
self.get_logger().info(response.message)
632+
return response
609633

610634
if delay > 0.0:
611635
response.message = f'Snapshot scheduled in {delay:.3g}s'
@@ -615,24 +639,36 @@ def _on_save_snapshot_delayed(
615639
self.get_logger().info(response.message)
616640
return response
617641

618-
def _write_snapshot_background(self, delay: float = 0.0) -> None:
642+
def _write_snapshot_background(
643+
self,
644+
delay: float = 0.0,
645+
reason: Optional[str] = None,
646+
) -> str:
619647
"""Write a snapshot in a background thread, optionally after a delay.
620648
621649
:param delay: Seconds to wait before taking the snapshot.
650+
:param reason: Optional human-readable explanation for the snapshot.
651+
:returns: Absolute path of the created bag directory.
652+
:raises: Re-raises any exception from :meth:`_write_snapshot`.
622653
"""
623654
if delay > 0.0:
624655
time.sleep(delay)
625-
try:
626-
path = self._write_snapshot(trigger='manual')
627-
self.get_logger().info(f'Delayed snapshot saved: {path}')
628-
except Exception as exc: # noqa: BLE001
629-
self.get_logger().error(f'Delayed snapshot failed: {exc}')
656+
path = self._write_snapshot(trigger='manual', reason=reason)
657+
self.get_logger().info(f'Delayed snapshot saved: {path}')
658+
return path
630659

631-
def _write_snapshot(self, trigger: str = 'manual') -> str:
660+
def _write_snapshot(
661+
self,
662+
trigger: str = 'manual',
663+
reason: Optional[str] = None,
664+
) -> str:
632665
"""Serialise the ring buffer to a new rosbag directory.
633666
634667
:param trigger: ``'manual'`` when called from the service,
635668
``'auto'`` when called from the periodic timer.
669+
:param reason: Optional human-readable explanation for why this
670+
snapshot was taken. Written to ``reason.md`` inside the bag
671+
directory. When *None* or empty a default message is used.
636672
:returns: Absolute path of the created bag directory.
637673
:raises ValueError: When the buffer is empty.
638674
"""
@@ -698,6 +734,16 @@ def _write_snapshot(self, trigger: str = 'manual') -> str:
698734
f'{len(topic_types)} topics → {bag_path}'
699735
)
700736

737+
# Write reason.md sidecar ---------------------------------------------
738+
reason_path = os.path.join(bag_path, 'reason.md')
739+
try:
740+
with open(reason_path, 'w') as fh:
741+
fh.write(build_reason_content(trigger, reason))
742+
except OSError as exc:
743+
self.get_logger().warning(
744+
f'Failed to write reason.md: {exc}'
745+
)
746+
701747
# Write node parameters sidecar if collection is configured -----------
702748
if self._param_patterns and self._node_params_cache:
703749
params_path = os.path.join(bag_path, 'node_params.yaml')

src/rosbag_blackbox/rosbag_blackbox/snapshot_helpers.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@
2020

2121
import os
2222
from datetime import datetime
23-
from typing import Any, Dict
23+
from typing import Any, Dict, Optional
2424

2525

2626
#: Name of the YAML sidecar file written alongside the rosbag.
2727
NODE_PARAMS_FILENAME = 'node_params.yaml'
2828

29+
#: Name of the Markdown file that records why a snapshot was taken.
30+
REASON_FILENAME = 'reason.md'
31+
2932

3033
def format_snapshot_name(pattern: str, now: datetime, trigger: str) -> str:
3134
"""Return a snapshot directory name from *pattern*.
@@ -109,3 +112,48 @@ def param_value_to_python(pval: Any) -> Any:
109112
if t == ParameterType.PARAMETER_STRING_ARRAY:
110113
return list(pval.string_array_value)
111114
return None # PARAMETER_NOT_SET or unknown
115+
116+
117+
def build_reason_content(trigger: str, reason: Optional[str] = None) -> str:
118+
"""Return the content to write into ``reason.md``.
119+
120+
Output format depends on the inputs:
121+
122+
* When *reason* is provided (non-empty), returns a Markdown document::
123+
124+
# Reason for snapshot
125+
126+
{reason}
127+
128+
* When no *reason* is given and *trigger* is ``'auto'``, returns ``'auto\\n'``.
129+
* When no *reason* is given and *trigger* is ``'manual'``, returns
130+
``'manual trigger without specific reason\\n'``.
131+
132+
:param trigger: ``'auto'`` or ``'manual'``.
133+
:param reason: Human-readable explanation, or ``None`` / empty string
134+
to fall back to a default message.
135+
:returns: String ready to be written to disk.
136+
"""
137+
if reason:
138+
return f'# Reason for snapshot\n\n{reason}\n'
139+
if trigger == 'auto':
140+
return 'auto\n'
141+
return 'manual trigger without specific reason\n'
142+
143+
144+
def read_snapshot_reason(bag_path: str) -> Optional[str]:
145+
"""Read the ``reason.md`` sidecar from *bag_path*.
146+
147+
:param bag_path: Path to the snapshot bag directory.
148+
:returns: The raw content of ``reason.md``, or ``None`` when the file
149+
does not exist. Never raises – errors are swallowed so that
150+
callers can degrade gracefully.
151+
"""
152+
reason_file = os.path.join(bag_path, REASON_FILENAME)
153+
if not os.path.exists(reason_file):
154+
return None
155+
try:
156+
with open(reason_file) as fh:
157+
return fh.read()
158+
except Exception: # noqa: BLE001
159+
return None

src/rosbag_blackbox/rosbag_blackbox/snapshot_server.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
* ``GET /snapshots/latest``
3131
Return the name of the most-recently modified snapshot.
3232
* ``GET /snapshots/{name}/metadata``
33-
Full bag metadata: topics, message counts, duration, time range.
33+
Full bag metadata: topics, message counts, duration, time range, and
34+
snapshot reason (from ``reason.md`` if present).
3435
* ``GET /snapshots/{name}/messages``
3536
Extract and deserialise messages with optional filters.
3637
* ``GET /snapshots/{name}/rosout``
@@ -67,7 +68,7 @@
6768
from rclpy.serialization import deserialize_message
6869
from rosidl_runtime_py.utilities import get_message
6970

70-
from rosbag_blackbox.snapshot_helpers import read_node_params
71+
from rosbag_blackbox.snapshot_helpers import read_node_params, read_snapshot_reason
7172

7273
# ---------------------------------------------------------------------------
7374
# Optional deps – fail gracefully so the package still builds/imports.
@@ -950,7 +951,8 @@ def get_latest_rest() -> Dict[str, str]:
950951
'Object with ``path``, ``topics`` (list of recorded topics with '
951952
'type and serialisation format), ``topic_message_counts`` (dict '
952953
'mapping topic name → count), ``total_messages``, '
953-
'``duration_seconds``, ``start_time_ns``, ``end_time_ns``.'
954+
'``duration_seconds``, ``start_time_ns``, ``end_time_ns``, '
955+
'and ``reason`` (content of ``reason.md`` if present, else ``null``).'
954956
),
955957
)
956958
def get_metadata_rest(bag_name: str) -> Dict[str, Any]:
@@ -960,6 +962,10 @@ def get_metadata_rest(bag_name: str) -> Dict[str, Any]:
960962
each topic contains, the total recording duration, and the absolute
961963
start/end timestamps in nanoseconds since the Unix epoch.
962964
965+
The ``reason`` field contains the contents of ``reason.md`` (written
966+
at snapshot time) when available, or ``null`` when the file is not
967+
present.
968+
963969
Use this before calling ``/messages`` or any render endpoint to
964970
confirm that the desired topic exists and to select an appropriate
965971
time window.
@@ -968,7 +974,9 @@ def get_metadata_rest(bag_name: str) -> Dict[str, Any]:
968974
if not os.path.exists(path):
969975
raise HTTPException(404, f'Snapshot not found: {path}')
970976
try:
971-
return read_bag_metadata(path)
977+
meta = read_bag_metadata(path)
978+
meta['reason'] = read_snapshot_reason(path)
979+
return meta
972980
except Exception as exc: # noqa: BLE001
973981
raise HTTPException(500, str(exc)) from exc
974982

0 commit comments

Comments
 (0)