Skip to content

Commit e1dfddc

Browse files
feat: Add ~/save_snapshot_delayed service with delay, blocking mode, reason tracking, and full documentation (#10)
* Initial plan * feat: Add save_snapshot_delayed service with optional delay before snapshot - Add rosbag_blackbox_interfaces package with SaveSnapshotDelayed.srv service definition (float64 delay request, bool success + string message response) - Update rosbag_blackbox package.xml to depend on rosbag_blackbox_interfaces - Add ~/save_snapshot_delayed service to RingBufferNode that returns immediately and runs the snapshot in a background ThreadPoolExecutor after the specified delay - Add 8 pure-Python tests for the delayed snapshot logic Co-authored-by: marc-hanheide <1153084+marc-hanheide@users.noreply.github.com> * 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> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: marc-hanheide <1153084+marc-hanheide@users.noreply.github.com>
1 parent a447546 commit e1dfddc

9 files changed

Lines changed: 665 additions & 12 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: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@
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>
1616
<exec_depend>rosbag2_py</exec_depend>
1717
<exec_depend>rosbag2_storage_sqlite3</exec_depend>
1818
<exec_depend>rosidl_runtime_py</exec_depend>
1919
<exec_depend>std_srvs</exec_depend>
20+
<exec_depend>rosbag_blackbox_interfaces</exec_depend>
2021
<exec_depend>sensor_msgs</exec_depend>
2122
<exec_depend>python3-yaml</exec_depend>
2223

src/rosbag_blackbox/rosbag_blackbox/ringbuffer_node.py

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
A ``std_srvs/srv/Trigger`` service triggers a snapshot: the buffered
2020
messages are written to a rosbag file.
2121
22+
A second service (``~/save_snapshot_delayed``) accepts an optional
23+
``delay`` (seconds). It returns immediately and performs the snapshot
24+
in a background thread once the delay has elapsed.
25+
2226
Topics are **dynamically discovered** at runtime, mirroring the
2327
behaviour of the ``ros2 bag record`` CLI. Pattern matching follows
2428
the zenoh-selector convention (``*`` = one segment, ``**`` = any depth).
@@ -44,7 +48,9 @@
4448
is written into each snapshot.
4549
"""
4650

51+
import concurrent.futures
4752
import os
53+
import time
4854
from datetime import datetime
4955
from typing import Any, Dict, List, Optional, Set
5056

@@ -59,13 +65,18 @@
5965
ReliabilityPolicy,
6066
)
6167
from rclpy.serialization import serialize_message
68+
from rosbag_blackbox_interfaces.srv import SaveSnapshotDelayed
6269
from rosidl_runtime_py.utilities import get_message
6370
from std_srvs.srv import Trigger
6471

6572
import rosbag2_py
6673

6774
from rosbag_blackbox.ring_buffer import FileRingBuffer, RingBuffer
68-
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+
)
6980
from rosbag_blackbox.topic_filter import find_matching_config, matches_pattern
7081

7182

@@ -224,6 +235,18 @@ def __init__(self) -> None:
224235
self._on_save_snapshot,
225236
)
226237

238+
# Background executor for delayed snapshots ---------------------------
239+
self._snapshot_executor = concurrent.futures.ThreadPoolExecutor(
240+
max_workers=4, thread_name_prefix='snapshot_bg'
241+
)
242+
243+
# Service to trigger a delayed snapshot (returns immediately) ----------
244+
self._delayed_snapshot_srv = self.create_service(
245+
SaveSnapshotDelayed,
246+
'~/save_snapshot_delayed',
247+
self._on_save_snapshot_delayed,
248+
)
249+
227250
# Periodic topic discovery --------------------------------------------
228251
self._discovery_timer = self.create_timer(2.0, self._discover_topics)
229252

@@ -564,11 +587,88 @@ def _on_auto_snapshot(self) -> None:
564587
except Exception as exc: # noqa: BLE001
565588
self.get_logger().warning(f'Auto-snapshot skipped: {exc}')
566589

567-
def _write_snapshot(self, trigger: str = 'manual') -> str:
590+
def _on_save_snapshot_delayed(
591+
self,
592+
request: SaveSnapshotDelayed.Request,
593+
response: SaveSnapshotDelayed.Response,
594+
) -> SaveSnapshotDelayed.Response:
595+
"""Schedule a snapshot after an optional delay.
596+
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.
600+
601+
:param request: Service request with ``delay`` (float64, seconds),
602+
``blocking`` (bool), and ``reason`` (string).
603+
:param response: Service response with ``success`` and ``message``.
604+
:returns: Populated response.
605+
"""
606+
delay = request.delay
607+
if delay < 0.0:
608+
response.success = False
609+
response.message = f'Invalid delay {delay}s: must be >= 0.'
610+
self.get_logger().error(response.message)
611+
return response
612+
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
633+
634+
if delay > 0.0:
635+
response.message = f'Snapshot scheduled in {delay:.3g}s'
636+
else:
637+
response.message = 'Snapshot scheduled immediately'
638+
response.success = True
639+
self.get_logger().info(response.message)
640+
return response
641+
642+
def _write_snapshot_background(
643+
self,
644+
delay: float = 0.0,
645+
reason: Optional[str] = None,
646+
) -> str:
647+
"""Write a snapshot in a background thread, optionally after a delay.
648+
649+
: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`.
653+
"""
654+
if delay > 0.0:
655+
time.sleep(delay)
656+
path = self._write_snapshot(trigger='manual', reason=reason)
657+
self.get_logger().info(f'Delayed snapshot saved: {path}')
658+
return path
659+
660+
def _write_snapshot(
661+
self,
662+
trigger: str = 'manual',
663+
reason: Optional[str] = None,
664+
) -> str:
568665
"""Serialise the ring buffer to a new rosbag directory.
569666
570667
:param trigger: ``'manual'`` when called from the service,
571668
``'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.
572672
:returns: Absolute path of the created bag directory.
573673
:raises ValueError: When the buffer is empty.
574674
"""
@@ -634,6 +734,16 @@ def _write_snapshot(self, trigger: str = 'manual') -> str:
634734
f'{len(topic_types)} topics → {bag_path}'
635735
)
636736

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+
637747
# Write node parameters sidecar if collection is configured -----------
638748
if self._param_patterns and self._node_params_cache:
639749
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

0 commit comments

Comments
 (0)