API reference

Generated from the source. The guide is the place to start; this is the page to come back to for a signature.

The client

class dynrec.client.Recorder(recorder=None, *, timeout=DEFAULT_TIMEOUT, discovery_timeout=DEFAULT_DISCOVERY_TIMEOUT, domain_id=None, node_name=None)[source]

Bases: object

A connected rosbag2_dynamic_recorder, and every operation it offers.

With one recorder running there is nothing to configure:

with Recorder() as rec:
    rec.set_topics(['/scan', '/odom'])
    print(rec.status().subscribed_topics)

Name one explicitly when several are running, or when the script knows which it wants:

rec = Recorder('/robot/left_recorder')

Every method blocks until the recorder has answered and raises on refusal, so a script reads top to bottom and needs no return-code checking. Safe to call from any thread, including from inside a callback of the caller’s own node – see the module docstring for why that is the point rather than a detail.

discovered

Node names of every recorder seen while connecting, for error messages and for a caller that wants to know what else is out there.

name

The recorder this client talks to, fully qualified.

close()[source]

Drop the client’s node and stop its thread. Idempotent.

status()[source]

Everything the recorder knows about itself, right now, as a Status.

A one-shot call. A script that reacts to changes should use on_status() instead, which needs no polling and delivers the recorder’s own view as it changes.

topics()[source]

The topics being recorded, sorted. Bare names, nothing else.

profiles()[source]

The configured profiles and which one is active, as a Profiles.

is_paused()[source]

True while arriving messages are being discarded. Subscriptions stay up either way.

add(topics=(), *, regex='', exclude_regex='')[source]

Start recording these topics, leaving everything else alone.

Name a type as ‘/scan:sensor_msgs/msg/LaserScan’ to record a topic whose publisher has not started yet; without one the type is read from the graph, which cannot work before the publisher exists. From a script that is the common case, since the script often starts the publisher itself.

regex is a search over topic names on the graph, in the dialect of ros2 bag record -e: ‘camera’ finds ‘/robot/camera/image’, so anchor it (‘^/robot/’) when a prefix is what you mean. exclude_regex is applied after names and matches are combined, so it filters explicitly named topics too.

remove(topics=(), *, regex='', exclude_regex='')[source]

Stop recording these topics, leaving everything else alone.

Messages already written are kept; the bag simply stops gaining new ones, leaving a sparse channel that the recorder’s own events explain.

regex here matches what is currently being recorded, not the graph – only a recorded topic can be dropped, and matching the graph would silently do nothing for one that has since left it.

set_topics(topics=(), *, regex='', exclude_regex='')[source]

Record exactly these topics and nothing else.

The one to reach for when switching modes: topics common to the old and the new set are never torn down and keep recording without a gap, which is the guarantee this whole project is built around.

Passing nothing is a valid request that stops recording every topic without closing the bag – unlike add() and remove(), which refuse an empty selection because for them it could only ever be a mistake.

profile(name)[source]

Apply a named profile: record exactly the topics it lists.

Routed through the same code as set_topics() inside the recorder, so it inherits the same guarantee about topics common to both sets.

pause()[source]

Stop writing messages. Subscriptions stay up and arriving messages are discarded.

The recorder writes a PauseEvent into the bag, so the hole this leaves across every topic is explained from inside the bag rather than looking like the crash it otherwise resembles.

resume(at=None, *, mode='node', topic='')[source]

Start writing messages again, now or at a scheduled time.

Returns the epoch time it was scheduled for, or None when it happened immediately.

mode picks the clock the time is compared against. ‘node’ fires on a timer and works on a robot that has gone quiet; ‘publish’ and ‘receive’ are evaluated as messages arrive and therefore cannot fire without traffic – which is a feature when you mean “when data resumes” and a trap when you mean “in five minutes”.

toggle()[source]

Flip between paused and recording. Returns True if the recorder is now paused.

Costs a second round trip to ask, because a toggle whose outcome you have to guess is not much use to a script.

split(at=None, *, mode='node', topic='')[source]

Roll over to a new bag file, now or at a scheduled time.

Returns the epoch time it was scheduled for, or None when it happened immediately. A schedule is cleared by stop(), so one queued here cannot fire against a later bag.

snapshot()[source]

Flush the in-memory buffer to disk. Only meaningful in snapshot mode.

Raises CallFailed otherwise, since the service reports a bare false: the near-certain cause is a recorder not started with snapshot_mode:=true, in which case there is no buffer to flush because messages are already being written as they arrive.

stop()[source]

Close the bag. The node keeps running, and record() opens a new one.

record(uri='', at=None)[source]

Open a new bag after a stop, restoring the previous topic selection.

Returns the epoch time it was scheduled for, or None when it started immediately. uri defaults to the one the recorder was started with. Unlike resume() and split() there is no mode to choose: Record carries no mode field, so a scheduled start is node time by definition and fires on a timer whether or not messages are arriving.

on_status(callback)[source]

Call callback(status) whenever the recorder publishes its status.

The reason to prefer this library over shelling out to ros2 dynrec: no polling, and the first call arrives immediately even mid-recording, because the recorder latches the topic.

Callbacks run on this client’s own executor thread. Keep them short, and do not call back into a blocking method of this same client from inside one – that would be the deadlock the whole design avoids everywhere else. Raise anything and it is logged and swallowed, so one bad callback cannot stop the others or kill the thread.

Returns a handle with .cancel(), usable as a context manager.

on_event(callback)[source]

Call callback(event) for every subscription change, pause and self-inflicted stop.

All four streams, flattened into one Event, because they are only meaningful together: one explains a channel that stops, another a bag that stops, the other two a recording the recorder ended itself, for the disk or for a cap on the bag. They arrive on separate subscriptions, so if order matters, sort by event.stamp rather than trusting the order they were delivered in.

Same callback rules and same handle as on_status().

wait_for(predicate, timeout=None)[source]

Block until predicate(status) is true, and return that Status.

Written against the latched status topic rather than a polling loop, so the current state is evaluated immediately: rec.wait_for(lambda s: not s.recording) returns at once if the recorder is already stopped, instead of hanging until something else changes.

Raises CallTimeout if timeout passes first.

dynrec.client.discover(timeout=DEFAULT_DISCOVERY_TIMEOUT, domain_id=None)[source]

Every recorder currently visible on the graph, sorted, as node names.

For the fleet case: a script controlling two recorders needs their names before it can build a Recorder for each, and Recorder() deliberately refuses to guess between them.

dynrec.client.DEFAULT_TIMEOUT = 10.0

Seconds to wait for a service to appear and then to reply. Generous because adding a topic costs ~0.5s inside the recorder and a set_topics over a dozen topics is slower still.

dynrec.client.DEFAULT_DISCOVERY_TIMEOUT = 5.0

Seconds to wait for a recorder to show up on the graph before giving up on finding one. Longer than the CLI’s one second of spin: a script is often started by the same launch file as the recorder, so a couple of seconds of discovery lag is ordinary rather than exceptional.

Reading a bag back

Most tools report a rate averaged over the whole bag, which describes a channel that stopped early as a slow sensor. These read the recorder’s own events to measure each channel over the time it was actually being recorded.

Reading a recorded bag back, and answering what each channel really did.

mcap info and ros2 bag info compute a channel’s rate as its count over the whole bag, so a topic that published at 20 Hz for a quarter of the bag reads as 5 Hz. Dividing by the channel’s own first-to-last span fixes a channel that stopped, but not one with a hole in the middle; only the recorder’s own events locate those holes.

When the bag does not carry what is needed, the answer is unknown rather than a plausible number: ChannelStats.rate is None, and basis says which evidence was available.

dynrec.bag.UNEXPLAINED_GAP_SECONDS = 1.0

How long a simultaneous, unexplained hole must be to be reported. Below this, scheduling jitter across a handful of topics would raise a permanent false alarm.

class dynrec.bag.ChannelStats(topic, message_type, count, first, last, recorded_seconds, active_seconds, rate, averaged_rate, basis, bag_seconds=0.0)[source]

Bases: object

What one topic in the bag actually did.

Parameters:
  • topic (str)

  • message_type (str)

  • count (int)

  • first (float)

  • last (float)

  • recorded_seconds (float | None)

  • active_seconds (float | None)

  • rate (float | None)

  • averaged_rate (float)

  • basis (str)

  • bag_seconds (float)

topic: str
message_type: str
count: int
first: float

Epoch seconds of the first and last message on this channel, on the recorder’s clock.

last: float
recorded_seconds: float | None

Seconds this channel was subscribed and the recorder was not paused. None when the bag does not carry the events needed to work it out.

active_seconds: float | None

The same, trimmed to the span in which messages actually arrived. A large difference from recorded_seconds is itself a finding: a sensor that went quiet while still subscribed.

rate: float | None

Messages per second over active_seconds: the rate the publisher actually ran at. None when it cannot be determined.

averaged_rate: float

Count over the whole bag duration, as mcap info and ros2 bag info report it.

basis: str

events when subscription events located the windows and the rate is trustworthy; span when no event names this topic and its own first-to-last span was used, which is wrong for a channel with an interior hole; unknown when there was no evidence at all.

bag_seconds: float = 0.0

The whole bag’s duration, so sparse has something to compare against.

property sparse

True when this channel was not live for the whole bag.

class dynrec.bag.BagSummary(uri, start, end, channels=<factory>, pause_windows=<factory>, events=<factory>, unexplained_gaps=<factory>, low_disk_stop=None, bag_size_limit_stop=None, warnings=<factory>)[source]

Bases: object

A whole bag, described in terms of what was being recorded when.

Parameters:
  • uri (str)

  • start (float)

  • end (float)

  • channels (List[ChannelStats])

  • pause_windows (List[Tuple[float, float]])

  • events (List[Event])

  • unexplained_gaps (List[Tuple[float, float]])

  • low_disk_stop (Event | None)

  • bag_size_limit_stop (Event | None)

  • warnings (List[str])

uri: str
start: float
end: float
channels: List[ChannelStats]
pause_windows: List[Tuple[float, float]]

Windows during which the recorder was paused, from the PauseEvents in the bag.

events: List[Event]

Every recorder event in the bag, oldest first.

unexplained_gaps: List[Tuple[float, float]]

Holes shared by every live channel that no PauseEvent accounts for: a crash, a stall, or a pause recorded with record_pause_events:=false.

low_disk_stop: Event | None = None

The LowDiskEvent if the recorder stopped itself for free space, else None. None also when the event was not recorded (record_low_disk_events:=false).

bag_size_limit_stop: Event | None = None

The BagSizeLimitEvent if the recorder stopped itself at max_bag_size, else None. Same caveat (record_bag_size_limit_events:=false).

warnings: List[str]

Things the reader could not establish, worded for a reader of the report.

property duration
as_dict()[source]

The report as plain data, times relative to the bag’s start, unknown values as None.

dynrec.bag.merge_windows(windows)[source]

Collapse overlapping or touching intervals into the smallest equivalent set, dropping empty ones.

dynrec.bag.intersect_windows(first, second)[source]

The intervals covered by both sets.

dynrec.bag.overlap(first, second)[source]

Seconds two intervals share.

dynrec.bag.subscribed_windows(events, topic, bag_end)[source]

When topic was subscribed, from the subscription events in the bag. Still subscribed at the end means subscribed until bag_end.

dynrec.bag.pause_windows(events, bag_end=None)[source]

When the recorder was paused, from the pause events in the bag. Still paused at the end means paused until bag_end, or, without one, until the last event.

dynrec.bag.live_seconds(windows, pauses)[source]

Seconds covered by windows with every paused interval taken out.

dynrec.bag.rate_over(count, seconds)[source]

Messages per second, or None when there is no interval to divide by.

dynrec.bag.unexplained_gaps(channel_gaps, pauses, threshold=UNEXPLAINED_GAP_SECONDS)[source]

Holes that every channel shares and no pause accounts for.

channel_gaps is one list of (start, end) per channel. A hole in one channel is what unsubscribing looks like; a hole in every channel at once is what a pause looks like, so one with no PauseEvent behind it is worth reporting.

dynrec.bag.gaps_in(stamps, threshold)[source]

Intervals between consecutive messages at least threshold long.

dynrec.bag.describe(uri, storage_id='')[source]

Read a bag and report what each channel really did.

Leave storage_id empty to let rosbag2 detect it from the bag’s metadata.

Needs rosbag2_py, imported here so that everything above stays importable without ROS.

Results

What the calls hand back: plain data, not ROS messages. These types enforce the rule that a number the recorder cannot vouch for is None, never a zero.

What the calls hand back: plain data, not ROS messages.

The rule the CLI’s –json and the browser UI rely on is enforced here, once: a number the recorder cannot vouch for is None, never a convenient zero. A raw messages_missed reads 0 on a middleware that cannot count gaps at all.

No ROS import: the from_msg constructors only read attributes, so any object carrying the right fields will do in a test.

dynrec.results.SUBSCRIPTION_ACTIONS = {0: 'subscribed', 1: 'unsubscribed'}

Maps the uint8 action constants onto words, because a script comparing against 0 and 1 is one constant rename away from silently inverting.

dynrec.results.STOP_ACTIONS = {0: 'stopped'}

Shared by LowDiskEvent and BagSizeLimitEvent: a self-inflicted stop is their only action.

dynrec.results.SCHEDULE_ACTIONS = {0: 'resume', 1: 'split', 2: 'record'}

ScheduledAction’s action and mode constants. The mode words are the ones mode= takes.

class dynrec.results.Scheduled(action, at, mode, topic='')[source]

Bases: object

An operation the recorder will perform later: one entry of Status.schedules.

Parameters:
  • action (str)

  • at (float)

  • mode (str)

  • topic (str)

action: str

‘resume’, ‘split’ or ‘record’.

at: float

When it fires, as epoch seconds on the clock mode names. For ‘node’ that is the recorder’s clock, the one Status.recording_started is on.

mode: str

‘node’ fires on a timer; ‘publish’ and ‘receive’ wait for a message stamped that late.

topic: str = ''

For ‘publish’ and ‘receive’: the topic whose messages are checked, or ‘’ for any topic.

classmethod from_msg(msg)[source]
class dynrec.results.TopicLoss(topic, missed, lost_in_transport, lost_in_recorder)[source]

Bases: object

What one topic has lost in the current bag: one entry of Status.topic_losses.

Parameters:
  • topic (str)

  • missed (int | None)

  • lost_in_transport (int)

  • lost_in_recorder (int)

topic: str
missed: int | None

From sequence-number gaps. None where the middleware supplies no sequence numbers, as for Status.messages_missed.

lost_in_transport: int
lost_in_recorder: int
classmethod from_msg(msg, sequence_ok)[source]
class dynrec.results.Status(recorder, uri, storage_id, recording, paused, snapshot_mode, recording_started, elapsed_seconds, subscribed_topics, active_profile, messages_written, messages_missed, messages_lost_in_transport, messages_lost_in_recorder, messages_lost_reported, write_errors, bag_splits, bag_size_bytes, sequence_numbers_available, free_space_bytes=0, total_space_bytes=0, min_free_space=0, min_free_space_percent=0.0, stopped_for_low_disk=False, max_bag_size=0, stopped_for_max_bag_size=False, use_sim_time=False, waiting_for_clock=False, schedules=<factory>, topic_losses=<factory>, stamp=0.0)[source]

Bases: object

What the recorder is doing, as of one moment.

The clock caveat worth carrying into any script: recording_started and every event stamp are on the recorder’s clock, not this process’s. Compare them with each other, never with time.time() unless you know the two clocks agree.

Parameters:
  • recorder (str)

  • uri (str)

  • storage_id (str)

  • recording (bool)

  • paused (bool)

  • snapshot_mode (bool)

  • recording_started (float)

  • elapsed_seconds (float)

  • subscribed_topics (List[str])

  • active_profile (str)

  • messages_written (int)

  • messages_missed (int | None)

  • messages_lost_in_transport (int)

  • messages_lost_in_recorder (int)

  • messages_lost_reported (int)

  • write_errors (int)

  • bag_splits (int)

  • bag_size_bytes (int)

  • sequence_numbers_available (bool)

  • free_space_bytes (int)

  • total_space_bytes (int)

  • min_free_space (int)

  • min_free_space_percent (float)

  • stopped_for_low_disk (bool)

  • max_bag_size (int)

  • stopped_for_max_bag_size (bool)

  • use_sim_time (bool)

  • waiting_for_clock (bool)

  • schedules (List[Scheduled])

  • topic_losses (List[TopicLoss])

  • stamp (float)

recorder: str
uri: str
storage_id: str
recording: bool
paused: bool
snapshot_mode: bool
recording_started: float
elapsed_seconds: float
subscribed_topics: List[str]
active_profile: str
messages_written: int
messages_missed: int | None

None when the middleware supplies no publication sequence numbers, i.e. when the recorder genuinely cannot tell. Never zero in that case – zero would be a claim it cannot make.

messages_lost_in_transport: int

Losses the transport reported before delivery: a floor, not a total, since the transport can drop messages without reporting them. When this climbs, look at the publisher, its QoS, or the network.

messages_lost_in_recorder: int

Messages that reached the recorder and were then dropped by the writer: cache overflow because the disk could not keep up, or a failed storage write. Known-lost with certainty, and the remedy is local – cache size or duration, storage preset, topic set, or the disk.

messages_lost_reported: int

The two above summed, for callers that only want a total. Inherits the transport caveat.

write_errors: int
bag_splits: int
bag_size_bytes: int

Bytes flushed to disk, which lags what has been captured because the writer caches. Reads 0 early in a healthy recording; use messages_written for liveness.

sequence_numbers_available: bool
free_space_bytes: int = 0

Bytes available to the recorder on the filesystem holding the bag, or 0 when it could not be determined. This is what the free-space guard compares against.

total_space_bytes: int = 0

Total size of that filesystem in bytes, or 0 when it could not be determined.

min_free_space: int = 0

Configured minimum free bytes that stops recording. 0 when not set.

min_free_space_percent: float = 0.0

Configured minimum free space as a percentage of the filesystem. 0.0 when not set.

stopped_for_low_disk: bool = False

True when the recorder stopped itself because free space fell below the configured minimum. Cleared when ~/record opens a new bag.

max_bag_size: int = 0

Configured cap on the bag directory in bytes, across every split. 0 when not set.

stopped_for_max_bag_size: bool = False

True when the recorder stopped itself because the bag grew past max_bag_size. Cleared when ~/record opens a new bag.

use_sim_time: bool = False

True when messages are stamped with the node clock driven by /clock, so the bag’s timeline is the simulation’s rather than the wall clock’s.

waiting_for_clock: bool = False

True under use_sim_time until the first /clock message: no bag is open yet, and every call that needs one is refused until then.

schedules: List[Scheduled]

Resumes, splits and records queued for later, at most one of each, in that order. Includes schedules any client set. There is no call to cancel one; scheduling again replaces it.

topic_losses: List[TopicLoss]

The loss figures broken down by topic, for each topic that has lost anything in this bag, sorted by name. Empty when nothing has been lost.

stamp: float = 0.0

When the recorder built this status, as epoch seconds on its clock: that clock’s reading as of now, give or take delivery. elapsed_seconds stops counting at a stop; this does not.

classmethod from_msg(msg, recorder='')[source]
as_dict()[source]

Plain data for logging or JSON, with the unknown-versus-zero rule intact.

class dynrec.results.TopicChange(subscribed=<factory>, unsubscribed=<factory>, unavailable=<factory>, not_subscribed=<factory>)[source]

Bases: object

The outcome of a call that changed what is being recorded.

One type for all four of them, because a caller that switches between add and set_topics should not have to switch result types too; the lists a given call cannot fill stay empty.

unavailable is the field to actually read. The services report a partial success – two topics added, one absent from the graph – as success, so nothing raises, and this list is the only thing that says you are recording less than you asked for.

Parameters:
  • subscribed (List[str])

  • unsubscribed (List[str])

  • unavailable (List[str])

  • not_subscribed (List[str])

subscribed: List[str]

Topics being recorded after the call. add and set_topics fill it; the others do not.

unsubscribed: List[str]

Topics no longer being recorded because of this call.

unavailable: List[str]

Requested topics that could not be subscribed: absent from the graph, invalid, or of ambiguous type. Empty is the only reassuring value.

not_subscribed: List[str]

For remove: topics that were not being recorded in the first place, so nothing happened.

classmethod from_msg(response)[source]
property complete

True when everything asked for happened – nothing was unavailable.

class dynrec.results.Profiles(profiles=<factory>, active='')[source]

Bases: object

The configured profiles, and which one matches what is being recorded.

active is derived by the recorder from the live topic set on every reply rather than remembered, so it cannot claim a profile that a manual topic change has since broken. An empty active after any hand-edited selection is the normal, truthful answer.

Parameters:
  • profiles (Dict[str, List[str]])

  • active (str)

profiles: Dict[str, List[str]]
active: str = ''
classmethod from_msg(response)[source]
property names
class dynrec.results.Event(kind, action, stamp, reason, node_name, topic='', topic_type='', free_space_bytes=0, total_space_bytes=0, bag_size_bytes=0, max_bag_size=0, closed_file='', opened_file='')[source]

Bases: object

One thing that happened to the recording, from one of five event streams.

All five are flattened into one shape because a caller watching for “what changed” wants them interleaved, and because they are only meaningful together: a subscription event explains a channel that stops, a pause event explains a bag that stops, a split event explains where one file ends and the next begins, and a low-disk or bag-size-limit event explains a recording the recorder ended itself, to protect the filesystem or to honour a cap on the bag.

stamp is on the recorder’s clock. Sort by it rather than by arrival: the streams come in on separate subscriptions, so delivery order is not the order things happened.

Parameters:
  • kind (str)

  • action (str)

  • stamp (float)

  • reason (str)

  • node_name (str)

  • topic (str)

  • topic_type (str)

  • free_space_bytes (int)

  • total_space_bytes (int)

  • bag_size_bytes (int)

  • max_bag_size (int)

  • closed_file (str)

  • opened_file (str)

kind: str

‘subscription’, ‘pause’, ‘split’, ‘low_disk’ or ‘bag_size_limit’.

action: str

‘subscribed’, ‘unsubscribed’, ‘paused’, ‘resumed’, ‘split’ or ‘stopped’.

stamp: float

Epoch seconds on the recorder’s clock.

reason: str

What caused it, e.g. ‘service:set_topics’, ‘schedule:resume’, ‘startup’, ‘timer’.

node_name: str
topic: str = ''

Empty for a pause, low-disk or bag-size-limit event, which affect the whole recording rather than one topic.

topic_type: str = ''
free_space_bytes: int = 0

For a low-disk event: bytes available on the recorder’s filesystem at the check that fired, and its total size. Zero on the other kinds, which have no filesystem figure to carry.

total_space_bytes: int = 0
bag_size_bytes: int = 0

For a bag-size-limit event: the bag’s size on disk at the check that fired, and the limit it exceeded. Zero on the other kinds.

max_bag_size: int = 0
closed_file: str = ''

For a split event: the file that was closed and the file recording continues in. Empty on the other kinds.

opened_file: str = ''
classmethod from_subscription_msg(msg)[source]
classmethod from_pause_msg(msg)[source]
classmethod from_file_split_msg(msg)[source]
classmethod from_low_disk_msg(msg)[source]
classmethod from_bag_size_limit_msg(msg)[source]
dynrec.results.EVENT_STREAMS = {'/events/bag_size_limit': ('BagSizeLimitEvent', <bound method Event.from_bag_size_limit_msg of <class 'dynrec.results.Event'>>), '/events/file_split': ('FileSplitEvent', <bound method Event.from_file_split_msg of <class 'dynrec.results.Event'>>), '/events/low_disk': ('LowDiskEvent', <bound method Event.from_low_disk_msg of <class 'dynrec.results.Event'>>), '/events/pause': ('PauseEvent', <bound method Event.from_pause_msg of <class 'dynrec.results.Event'>>), '/events/subscription_change': ('SubscriptionChangeEvent', <bound method Event.from_subscription_msg of <class 'dynrec.results.Event'>>)}

The recorder’s event streams: topic suffix under the recorder’s name, the message type in rosbag2_dynamic_recorder_interfaces.msg, and the Event constructor for it. The client subscribes to every row and the bag reader decodes every row, so a new stream is one row here.

Errors

What can go wrong, as a family a caller can catch at whatever granularity it needs.

Everything raised by this library derives from DynrecError, so a script that only wants to log and carry on catches one thing. The distinctions below exist because they call for different responses: a recorder that is not there yet is worth retrying, a call the recorder refused is not.

exception dynrec.errors.DynrecError[source]

Bases: Exception

Base class for every failure this library raises.

exception dynrec.errors.RecorderNotFound[source]

Bases: DynrecError

No recorder on the graph, or none under the requested name.

exception dynrec.errors.AmbiguousRecorder(message, recorders=())[source]

Bases: DynrecError

Several recorders are running and none was named.

Guessing would be worse than failing: the two recorders on a robot are usually recording different things, and picking the wrong one is silent.

recorders

The recorder node names that were found, so a caller can choose without re-discovering.

exception dynrec.errors.ServiceUnavailable[source]

Bases: DynrecError

The recorder is known but the service did not appear in time.

exception dynrec.errors.CallTimeout[source]

Bases: DynrecError

The service was reached but did not reply in time.

Distinct from ServiceUnavailable on purpose: this one means the recorder is up and accepting calls but did not finish this one, which points at a busy or wedged recorder rather than at a name or a domain mismatch.

exception dynrec.errors.CallFailed(message, return_code=None, error_string='', response=None)[source]

Bases: DynrecError

The recorder refused the call.

Carries the service’s own return_code and error_string, because the recorder’s message is almost always more specific than anything this library could say on its behalf.

Also carries the raw response, when there was one. A refusal can still leave useful data on the response – a set_topics that failed every topic still lists them in unavailable_topics – and a CLI that prints those before reporting the error needs them to survive the raise.

exception dynrec.errors.InvalidRequest[source]

Bases: DynrecError, ValueError

The request could not be built from what the caller passed.

Also a ValueError, since that is what a caller checking its own arguments will reach for, and every instance of this really is a mistake in the calling code rather than a recorder fault.

Discovery

How a recorder is identified on the graph. It takes a graph listing as an argument rather than fetching one, which makes the rule testable without a ROS graph.

Finding the recorder on the graph, and deciding which one is meant.

Takes a graph listing as an argument rather than fetching one, so the rule can be tested without a ROS graph – the same split ros2 dynrec made, and for the same reason: this is the part with judgement in it.

dynrec.discovery.GET_STATUS_TYPE = 'rosbag2_dynamic_recorder_interfaces/srv/GetStatus'

The service every recorder offers, used to identify one on the graph.

dynrec.discovery.recorder_nodes(service_names_and_types)[source]

Fully qualified names of the recorders visible on the graph, sorted.

A recorder is identified by offering ~/get_status of our own type, not by node name. A recorder launched under a different name or pushed into a namespace is therefore still found, and an unrelated node that happens to be called rosbag2_dynamic_recorder is not.

dynrec.discovery.node_name(requested)[source]

A recorder name as the graph spells it: one leading slash, whatever the caller typed.

dynrec.discovery.choose_recorder(discovered, requested=None)[source]

Pick the recorder to talk to, or raise explaining why that is not possible.

A requested name is trusted even when discovery did not turn it up: graph discovery can lag, and refusing a name that is about to appear would be worse than trying it and timing out.

Scheduling

Command-line-shaped times, turned into what the scheduled services take.

Deliberately the same dialect ros2 dynrec –at accepts, so a value that worked in a shell command keeps working when the shell command becomes a script. Standard library only.

dynrec.schedule.TIME_MODES = {'node': 0, 'publish': 1, 'receive': 2}

Mode names accepted by mode=, mapping to the constants shared by Resume and SplitBagfile.

node fires on a timer and therefore works on a robot that has gone quiet; publish and receive are evaluated as messages arrive and cannot fire without traffic. That difference is the whole reason the choice exists, so it is worth knowing before picking one.

dynrec.schedule.NANOSECONDS_PER_SECOND = 1000000000

One second in nanoseconds. Shared by every conversion between the two, inside and outside this module, so the scale is written once.

dynrec.schedule.parse_time(value, now=None)[source]

Parse an at= argument into (sec, nanosec) for a builtin_interfaces/Time.

Accepts, besides a datetime or a number of epoch seconds:

+30s, +5m, +1h relative to now 14:05 today at that local wall-clock time, tomorrow if it has already passed 2026-09-03T14:05 an ISO 8601 instant, local time unless it carries an offset 1756900000.5 raw epoch seconds

The relative and wall-clock forms are resolved against this machine’s clock, while the recorder compares against its own node clock. On one machine, or a clock-synced fleet, those agree; across a robot whose clock has drifted they do not, which is why the absolute forms exist and why a script running off-robot should prefer them.

dynrec.schedule.parse_mode(value)[source]

Map a mode name to its numeric constant, or pass a constant through.

dynrec.schedule.set_stamp(stamp, at)[source]

Set a builtin_interfaces/Time to at (anything parse_time() reads). Returns the epoch time, or None when at is None and the stamp is left at zero, which the services read as “now”.

dynrec.schedule.apply_schedule(request, at, mode, tracking_topic, time_field, mode_field)[source]

Fill a scheduled request’s time, mode and tracking topic. Returns the epoch time, or None for an immediate request.

Topic specifications

Turning what a caller writes into the parallel arrays the services take.

No ROS import: this is string handling, and keeping it that way is what makes the awkward case below testable in isolation.

dynrec.topics.parse_topic_specs(values)[source]

Split TOPIC or TOPIC:TYPE items into (topics, topic_types).

An explicit type is not decoration: the recorder only consults the graph when the type is empty, so naming it is the way to record a topic whose publisher has not started yet – which from a script is the common case, since the script often starts the publisher itself.

A plain string is accepted as well as a list, because add(‘/scan’) is what anyone writes first and iterating it into fourteen single-character topics would be a baffling way to fail.

dynrec.topics.require_a_selection(topics, regex)[source]

Refuse a call that names nothing and matches nothing.

Without this an empty add() would be a silent no-op reported as success, and a supervisor script has no way to notice that – the same reason ros2 dynrec add refuses it.

Service types

The service and message types the recorder offers, resolved for the installed rosbag2.

The recorder reuses rosbag2_interfaces types wherever it can, so that a client written against ros2 bag record can drive it. But those definitions moved between distros: rosbag2 0.34 (Rolling) gave Record, Resume and SplitBagfile a scheduled form, gave Stop a return code, and added MessagesLostEvent; Jazzy (0.26) and Kilted (0.32) have the earlier shapes, and Kilted has no Record at all. rosbag2_dynamic_recorder_interfaces carries a field-identical copy of each, and the recorder’s CMake offers the stock type where the installed one matches and the copy where it does not.

This module makes the same decision, from the same evidence, on the client side: a stock type is used when it has the field that distinguishes the 0.34 shape, and the copy otherwise. Import the names from here rather than from rosbag2_interfaces.srv directly, and they agree with whatever recorder was built against the same install.

dynrec.services.type_name(interface)[source]

The package/kind/Name string for one of the types above, as ros2 service call wants.

The module also exposes IsPaused, Pause, Record, Resume, Snapshot, SplitBagfile, Stop, TogglePaused and MessagesLostEvent: the class the recorder built on the same install offers each under, stock or copy. Recorder uses them internally; take them from here rather than from rosbag2_interfaces when calling the services by hand.