topicforge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
topicforge/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """TopicForge — ROS Topic Inspector & Bag Analyzer MCP server."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
topicforge/__main__.py ADDED
@@ -0,0 +1,68 @@
1
+ """Console entrypoint: `python -m topicforge` and the `topicforge` script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+
9
+ from topicforge import __version__
10
+ from topicforge.config import load_settings
11
+ from topicforge.server import build_app
12
+
13
+
14
+ def _build_arg_parser() -> argparse.ArgumentParser:
15
+ parser = argparse.ArgumentParser(
16
+ prog="topicforge",
17
+ description=(
18
+ "ROS Topic Inspector & Bag Analyzer MCP server. "
19
+ "Runs on stdio so MCP clients (Claude Desktop, Claude Code, etc.) "
20
+ "can spawn it directly."
21
+ ),
22
+ epilog=(
23
+ "Configuration is read from environment variables: "
24
+ "TOPICFORGE_MODE (mock|live|auto, default auto), "
25
+ "TOPICFORGE_LOG_LEVEL (DEBUG|INFO|WARNING|ERROR, default INFO), "
26
+ "TOPICFORGE_ROS2_BIN (default 'ros2')."
27
+ ),
28
+ )
29
+ parser.add_argument(
30
+ "--version",
31
+ action="version",
32
+ version=f"topicforge {__version__}",
33
+ )
34
+ return parser
35
+
36
+
37
+ def main(argv: list[str] | None = None) -> int:
38
+ """Build the MCP app from environment settings and run it on stdio."""
39
+ _build_arg_parser().parse_args(argv)
40
+
41
+ try:
42
+ settings = load_settings()
43
+ except ValueError as exc:
44
+ print(f"topicforge: configuration error: {exc}", file=sys.stderr)
45
+ return 2
46
+
47
+ logging.basicConfig(
48
+ level=settings.log_level,
49
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
50
+ stream=sys.stderr,
51
+ )
52
+ log = logging.getLogger("topicforge")
53
+ log.info("starting (mode=%s)", settings.effective_mode)
54
+
55
+ app = build_app(settings)
56
+ try:
57
+ app.run()
58
+ except KeyboardInterrupt:
59
+ log.info("interrupted by user")
60
+ return 0
61
+ except Exception:
62
+ log.exception("topicforge crashed while serving MCP requests")
63
+ return 1
64
+ return 0
65
+
66
+
67
+ if __name__ == "__main__":
68
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """Adapters — the only layer that knows how to talk to a specific backend."""
2
+
3
+ from topicforge.adapters.base import AdapterError, RosAdapter
4
+
5
+ __all__ = ["AdapterError", "RosAdapter"]
@@ -0,0 +1,44 @@
1
+ """Adapter protocol.
2
+
3
+ Adapters are the *only* place in the codebase that may know how to talk to a
4
+ specific backend (mock fixtures, the `ros2` CLI, or a future `rclpy` adapter).
5
+ Services depend on this protocol; tools depend on services. That separation
6
+ is what makes the codebase testable without ROS2 installed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Literal, Protocol, runtime_checkable
12
+
13
+ from topicforge.models import BagAnalysis, MessageSample, TopicInfo
14
+
15
+ AdapterName = Literal["mock", "live"]
16
+
17
+
18
+ class AdapterError(RuntimeError):
19
+ """Raised when an adapter cannot fulfill a request.
20
+
21
+ Carries a clear, user-facing message; tool handlers translate this into
22
+ a structured error envelope returned to the MCP client.
23
+ """
24
+
25
+
26
+ @runtime_checkable
27
+ class RosAdapter(Protocol):
28
+ """Uniform read-only interface over a ROS2 environment.
29
+
30
+ Implementations must be safe to construct lazily — `is_available()` is
31
+ the contract for "can this adapter actually serve requests right now?".
32
+ """
33
+
34
+ name: AdapterName
35
+
36
+ def is_available(self) -> bool: ...
37
+
38
+ def list_topics(self) -> list[TopicInfo]: ...
39
+
40
+ def get_topic_info(self, topic: str) -> TopicInfo: ...
41
+
42
+ def sample_messages(self, topic: str, count: int) -> list[MessageSample]: ...
43
+
44
+ def analyze_bag(self, path: str) -> BagAnalysis: ...
@@ -0,0 +1,5 @@
1
+ """Live adapter — thin wrappers over the `ros2` CLI."""
2
+
3
+ from topicforge.adapters.ros2_live.adapter import Ros2CliAdapter
4
+
5
+ __all__ = ["Ros2CliAdapter"]
@@ -0,0 +1,283 @@
1
+ """Live adapter — defensive wrappers over the `ros2` CLI.
2
+
3
+ Why CLI and not `rclpy`?
4
+ * `rclpy` is hard to depend on portably — distro-pinned, requires a sourced
5
+ setup file, and ships with the ROS2 install rather than from PyPI.
6
+ * The `ros2` CLI is stable, widely available wherever ROS2 is installed,
7
+ and trivial to mock in tests by stubbing `subprocess.run`.
8
+ * A richer `rclpy`-backed adapter can ship later behind the same protocol.
9
+ See `# TODO(roadmap): rclpy-backed adapter` below.
10
+
11
+ Public methods never raise raw subprocess errors. They raise `AdapterError`
12
+ with a message that is safe to surface to an MCP client.
13
+
14
+ The pure parsers at module level are split out from the adapter class so
15
+ they can be unit-tested without a running ROS2 install.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ from pathlib import Path
25
+
26
+ from topicforge.adapters.base import AdapterError, AdapterName
27
+ from topicforge.models import BagAnalysis, BagTopicStats, MessageSample, TopicInfo
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+ _DEFAULT_TIMEOUT_SEC = 8.0
32
+ _SAMPLE_TIMEOUT_SEC = 3.0
33
+
34
+
35
+ class Ros2CliAdapter:
36
+ """Adapter that shells out to the `ros2` CLI."""
37
+
38
+ name: AdapterName = "live"
39
+
40
+ def __init__(self, executable: str = "ros2") -> None:
41
+ self._exe = executable
42
+
43
+ def is_available(self) -> bool:
44
+ return shutil.which(self._exe) is not None
45
+
46
+ # ----------------------------- topics --------------------------------
47
+
48
+ def list_topics(self) -> list[TopicInfo]:
49
+ out = self._run([self._exe, "topic", "list", "-t"])
50
+ topics: list[TopicInfo] = []
51
+ for name, msg_type in parse_topic_list(out):
52
+ pub_count, sub_count = self._safe_counts(name)
53
+ topics.append(
54
+ TopicInfo(
55
+ name=name,
56
+ message_type=msg_type,
57
+ publisher_count=pub_count,
58
+ subscriber_count=sub_count,
59
+ )
60
+ )
61
+ return topics
62
+
63
+ def get_topic_info(self, topic: str) -> TopicInfo:
64
+ out = self._run([self._exe, "topic", "info", topic, "--verbose"])
65
+ info = parse_topic_info(out, fallback_name=topic)
66
+ if info is None:
67
+ raise AdapterError(f"Topic not found or empty info: {topic!r}")
68
+ return info
69
+
70
+ def sample_messages(self, topic: str, count: int) -> list[MessageSample]:
71
+ # `ros2 topic echo` blocks indefinitely; --once + bounded timeout is the
72
+ # safe MVP shape. Richer windowed sampling is a roadmap item.
73
+ # TODO(roadmap): rclpy-backed adapter — windowed echo, time-range,
74
+ # better deserialization of complex message payloads.
75
+ if count <= 0:
76
+ return []
77
+
78
+ info = self.get_topic_info(topic)
79
+ try:
80
+ out = self._run(
81
+ [self._exe, "topic", "echo", "--once", topic],
82
+ timeout=_SAMPLE_TIMEOUT_SEC,
83
+ )
84
+ except AdapterError as exc:
85
+ log.info("sample_messages on %s returned no data: %s", topic, exc)
86
+ return []
87
+
88
+ return [
89
+ MessageSample(
90
+ topic=topic,
91
+ message_type=info.message_type,
92
+ timestamp_ns=0,
93
+ payload=parse_echo_yaml(out),
94
+ )
95
+ ]
96
+
97
+ # ------------------------------ bag ----------------------------------
98
+
99
+ def analyze_bag(self, path: str) -> BagAnalysis:
100
+ bag_path = Path(path)
101
+ if not bag_path.exists():
102
+ raise AdapterError(f"Bag path does not exist: {path}")
103
+
104
+ out = self._run([self._exe, "bag", "info", str(bag_path)])
105
+ return parse_bag_info(out, fallback_path=str(bag_path))
106
+
107
+ # ---------------------------- internals ------------------------------
108
+
109
+ def _safe_counts(self, topic: str) -> tuple[int, int]:
110
+ """Return (pub_count, sub_count) for a topic, defaulting to (0, 0).
111
+
112
+ Failing to fetch counts for a single topic must not break `list_topics`.
113
+ """
114
+ try:
115
+ text = self._run([self._exe, "topic", "info", topic])
116
+ except AdapterError:
117
+ return (0, 0)
118
+ return parse_pub_sub_counts(text)
119
+
120
+ def _run(self, cmd: list[str], timeout: float = _DEFAULT_TIMEOUT_SEC) -> str:
121
+ # Resolve the executable to a full path so Windows .cmd/.bat shims work
122
+ # without shell=True.
123
+ resolved = shutil.which(cmd[0]) if cmd[0] == self._exe else cmd[0]
124
+ if resolved is None:
125
+ raise AdapterError(f"`{self._exe}` not found on PATH. Source your ROS2 setup file.")
126
+ full_cmd = [resolved, *cmd[1:]]
127
+
128
+ log.debug("ros2 cmd: %s", " ".join(full_cmd))
129
+ try:
130
+ result = subprocess.run(
131
+ full_cmd,
132
+ capture_output=True,
133
+ text=True,
134
+ timeout=timeout,
135
+ check=False,
136
+ )
137
+ except FileNotFoundError as exc:
138
+ raise AdapterError(
139
+ f"`{self._exe}` not found on PATH. Source your ROS2 setup file."
140
+ ) from exc
141
+ except subprocess.TimeoutExpired as exc:
142
+ raise AdapterError(f"`{' '.join(cmd)}` timed out after {timeout}s") from exc
143
+
144
+ if result.returncode != 0:
145
+ stderr_tail = ""
146
+ if result.stderr:
147
+ lines = [line for line in result.stderr.strip().splitlines() if line]
148
+ if lines:
149
+ stderr_tail = lines[-1]
150
+ raise AdapterError(
151
+ f"`{' '.join(cmd)}` failed (exit {result.returncode}): {stderr_tail or 'no stderr'}"
152
+ )
153
+ return result.stdout
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Pure parsers — unit-testable without ROS2 present.
158
+ # ---------------------------------------------------------------------------
159
+
160
+ _LIST_LINE = re.compile(r"^(\S+)\s+\[(.+)\]\s*$")
161
+ _TYPE_LINE = re.compile(r"^\s*Type:\s*(.+)$")
162
+ _PUB_COUNT = re.compile(r"^\s*Publisher count:\s*(\d+)\s*$")
163
+ _SUB_COUNT = re.compile(r"^\s*Subscription count:\s*(\d+)\s*$")
164
+ _BAG_DUR = re.compile(r"Duration:\s*([\d.]+)\s*s")
165
+ _BAG_COUNT = re.compile(r"Messages:\s*(\d+)")
166
+ _BAG_STORAGE = re.compile(r"Storage id:\s*(\S+)")
167
+ _BAG_TOPIC = re.compile(
168
+ r"Topic:\s*(\S+)\s*\|\s*Type:\s*(\S+)\s*\|\s*Count:\s*(\d+)\s*\|\s*Serialization Format:"
169
+ )
170
+
171
+
172
+ def parse_topic_list(stdout: str) -> list[tuple[str, str]]:
173
+ """Parse `ros2 topic list -t` output. Returns (name, type) pairs."""
174
+ pairs: list[tuple[str, str]] = []
175
+ for raw in stdout.splitlines():
176
+ line = raw.strip()
177
+ if not line:
178
+ continue
179
+ m = _LIST_LINE.match(line)
180
+ if m:
181
+ pairs.append((m.group(1), m.group(2)))
182
+ return pairs
183
+
184
+
185
+ def parse_pub_sub_counts(stdout: str) -> tuple[int, int]:
186
+ """Parse publisher / subscription counts from `ros2 topic info` output."""
187
+ pub = sub = 0
188
+ for line in stdout.splitlines():
189
+ if m := _PUB_COUNT.match(line):
190
+ pub = int(m.group(1))
191
+ elif m := _SUB_COUNT.match(line):
192
+ sub = int(m.group(1))
193
+ return pub, sub
194
+
195
+
196
+ def parse_topic_info(stdout: str, *, fallback_name: str) -> TopicInfo | None:
197
+ """Parse `ros2 topic info <topic> --verbose` output into a TopicInfo.
198
+
199
+ Returns None if no message type was found, which the adapter treats as
200
+ "topic not found".
201
+ """
202
+ msg_type: str | None = None
203
+ pub = sub = 0
204
+ for line in stdout.splitlines():
205
+ if m := _TYPE_LINE.match(line):
206
+ msg_type = m.group(1).strip()
207
+ elif m := _PUB_COUNT.match(line):
208
+ pub = int(m.group(1))
209
+ elif m := _SUB_COUNT.match(line):
210
+ sub = int(m.group(1))
211
+ if msg_type is None:
212
+ return None
213
+ return TopicInfo(
214
+ name=fallback_name,
215
+ message_type=msg_type,
216
+ publisher_count=pub,
217
+ subscriber_count=sub,
218
+ )
219
+
220
+
221
+ # TODO(roadmap): rclpy-backed adapter will return native typed payloads
222
+ # and make this parser obsolete. See docs/product-plan.md Phase 1.
223
+ def parse_echo_yaml(stdout: str) -> dict[str, object]:
224
+ """Best-effort parse of `ros2 topic echo --once` YAML-ish output.
225
+
226
+ We deliberately avoid a hard YAML dependency for the MVP — instead we emit
227
+ a flat `{key: value}` dict (top-level keys only) plus the raw text under
228
+ a reserved `_raw_text` key. LLMs can still reason over the raw text, and
229
+ downstream tools can upgrade this parser without changing the contract.
230
+
231
+ `_raw_text` is intentionally not dunder-named (no leading/trailing `__`):
232
+ a dunder key signals Python special-attribute semantics, which this is
233
+ not. A single leading underscore is enough to flag it as parser metadata
234
+ while keeping it a plain dict key.
235
+ """
236
+ flat: dict[str, object] = {}
237
+ for raw in stdout.splitlines():
238
+ line = raw.rstrip()
239
+ stripped = line.lstrip()
240
+ if not stripped or stripped.startswith("#"):
241
+ continue
242
+ # Top-level keys only (no indentation).
243
+ if line == stripped and ":" in line:
244
+ key, _, value = line.partition(":")
245
+ flat[key.strip()] = value.strip()
246
+ flat["_raw_text"] = stdout
247
+ return flat
248
+
249
+
250
+ def parse_bag_info(stdout: str, *, fallback_path: str) -> BagAnalysis:
251
+ """Parse `ros2 bag info <path>` text output into a BagAnalysis."""
252
+ duration = 0.0
253
+ msg_count = 0
254
+ storage: str | None = None
255
+ topics: list[BagTopicStats] = []
256
+
257
+ for line in stdout.splitlines():
258
+ if m := _BAG_DUR.search(line):
259
+ duration = float(m.group(1))
260
+ if m := _BAG_COUNT.search(line):
261
+ msg_count = int(m.group(1))
262
+ if m := _BAG_STORAGE.search(line):
263
+ storage = m.group(1)
264
+ if m := _BAG_TOPIC.search(line):
265
+ name, msg_type, count = m.group(1), m.group(2), int(m.group(3))
266
+ freq = (count / duration) if duration > 0 else None
267
+ topics.append(
268
+ BagTopicStats(
269
+ name=name,
270
+ message_type=msg_type,
271
+ message_count=count,
272
+ frequency_hz=freq,
273
+ )
274
+ )
275
+
276
+ return BagAnalysis(
277
+ path=fallback_path,
278
+ storage_format=storage,
279
+ duration_seconds=duration,
280
+ message_count=msg_count,
281
+ topics=topics,
282
+ anomalies=[],
283
+ )
@@ -0,0 +1,5 @@
1
+ """Deterministic mock adapter for development, tests, and demos."""
2
+
3
+ from topicforge.adapters.ros2_mock.adapter import MockAdapter
4
+
5
+ __all__ = ["MockAdapter"]
@@ -0,0 +1,68 @@
1
+ """Mock adapter — deterministic fixtures for development, tests, and demos.
2
+
3
+ Always available. Outputs are stable across runs so tests can assert on
4
+ exact values.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import PurePosixPath, PureWindowsPath
10
+
11
+ from topicforge.adapters.base import AdapterError, AdapterName
12
+ from topicforge.adapters.ros2_mock import fixtures
13
+ from topicforge.models import BagAnalysis, MessageSample, TopicInfo
14
+
15
+ # Extensions the live `ros2 bag info` accepts. The mock mirrors this list so
16
+ # a test that passes `/tmp/demo.txt` fails in mock the same way it would in
17
+ # live — otherwise mock mode would hide a real-world UX problem until the
18
+ # first ROS2 install.
19
+ _BAG_EXTENSIONS: frozenset[str] = frozenset({".mcap", ".db3", ".bag"})
20
+
21
+
22
+ class MockAdapter:
23
+ name: AdapterName = "mock"
24
+
25
+ def is_available(self) -> bool:
26
+ return True
27
+
28
+ def list_topics(self) -> list[TopicInfo]:
29
+ return list(fixtures.MOCK_TOPICS)
30
+
31
+ def get_topic_info(self, topic: str) -> TopicInfo:
32
+ for t in fixtures.MOCK_TOPICS:
33
+ if t.name == topic:
34
+ return t
35
+ raise AdapterError(f"Unknown topic: {topic!r}")
36
+
37
+ def sample_messages(self, topic: str, count: int) -> list[MessageSample]:
38
+ if count < 0:
39
+ raise AdapterError("count must be >= 0")
40
+ # Validate the topic exists first so the error is the same as `get_topic_info`.
41
+ self.get_topic_info(topic)
42
+ return fixtures.mock_samples_for(topic, count)
43
+
44
+ def analyze_bag(self, path: str) -> BagAnalysis:
45
+ _reject_non_bag_path(path)
46
+ # The fixture is frozen; produce a copy with the caller's path.
47
+ return fixtures.MOCK_BAG_ANALYSIS.model_copy(update={"path": path})
48
+
49
+
50
+ def _reject_non_bag_path(path: str) -> None:
51
+ """Reject paths the live `ros2 bag info` would obviously refuse.
52
+
53
+ Accepts: `.mcap` / `.db3` / `.bag` files, and any extensionless path
54
+ (which could legitimately be a `rosbag2_*` directory). Rejects every
55
+ other extension so mock demos surface the same shape of error a
56
+ real ROS2 install would produce on, e.g., `/tmp/note.txt`.
57
+ """
58
+ # `PurePosixPath` handles `/tmp/foo.mcap`; `PureWindowsPath` handles
59
+ # `C:\demos\foo.mcap`. The longest suffix wins.
60
+ posix_suffix = PurePosixPath(path).suffix.lower()
61
+ win_suffix = PureWindowsPath(path).suffix.lower()
62
+ suffix = posix_suffix or win_suffix
63
+ if suffix and suffix not in _BAG_EXTENSIONS:
64
+ raise AdapterError(
65
+ f"path does not look like a ROS2 bag (got suffix {suffix!r}); "
66
+ f"expected one of {sorted(_BAG_EXTENSIONS)} or a "
67
+ "`rosbag2_*` directory"
68
+ )
@@ -0,0 +1,172 @@
1
+ """Deterministic fake-robot fixtures used by `MockAdapter`.
2
+
3
+ These model a small differential-drive mobile robot with a 2D LIDAR, an
4
+ RGB camera, and a TF tree. The data is rich enough to make demos and
5
+ screenshots believable, and stable enough for tests to assert on exact
6
+ values.
7
+
8
+ If you change a value here, expect to update tests under `tests/`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from topicforge.models import BagAnalysis, BagTopicStats, MessageSample, TopicInfo
14
+
15
+ MOCK_TOPICS: tuple[TopicInfo, ...] = (
16
+ TopicInfo(
17
+ name="/cmd_vel",
18
+ message_type="geometry_msgs/msg/Twist",
19
+ publisher_count=1,
20
+ subscriber_count=1,
21
+ qos_reliability="reliable",
22
+ ),
23
+ TopicInfo(
24
+ name="/odom",
25
+ message_type="nav_msgs/msg/Odometry",
26
+ publisher_count=1,
27
+ subscriber_count=2,
28
+ qos_reliability="reliable",
29
+ ),
30
+ TopicInfo(
31
+ name="/scan",
32
+ message_type="sensor_msgs/msg/LaserScan",
33
+ publisher_count=1,
34
+ subscriber_count=1,
35
+ qos_reliability="best_effort",
36
+ ),
37
+ TopicInfo(
38
+ name="/tf",
39
+ message_type="tf2_msgs/msg/TFMessage",
40
+ publisher_count=3,
41
+ subscriber_count=2,
42
+ qos_reliability="reliable",
43
+ ),
44
+ TopicInfo(
45
+ name="/camera/image_raw",
46
+ message_type="sensor_msgs/msg/Image",
47
+ publisher_count=1,
48
+ subscriber_count=1,
49
+ qos_reliability="best_effort",
50
+ ),
51
+ )
52
+
53
+
54
+ _BASE_TS_NS = 1_700_000_000_000_000_000
55
+
56
+
57
+ _MOCK_SAMPLES: dict[str, list[MessageSample]] = {
58
+ "/cmd_vel": [
59
+ MessageSample(
60
+ topic="/cmd_vel",
61
+ message_type="geometry_msgs/msg/Twist",
62
+ timestamp_ns=_BASE_TS_NS + i * 100_000_000,
63
+ payload={
64
+ "linear": {"x": 0.20 + i * 0.01, "y": 0.0, "z": 0.0},
65
+ "angular": {"x": 0.0, "y": 0.0, "z": 0.05 * i},
66
+ },
67
+ )
68
+ for i in range(5)
69
+ ],
70
+ "/odom": [
71
+ MessageSample(
72
+ topic="/odom",
73
+ message_type="nav_msgs/msg/Odometry",
74
+ timestamp_ns=_BASE_TS_NS + i * 100_000_000,
75
+ payload={
76
+ "header": {"frame_id": "odom", "stamp_sec": 1_700_000_000 + i},
77
+ "pose": {"position": {"x": 0.1 * i, "y": 0.0, "z": 0.0}},
78
+ "twist": {"linear": {"x": 0.2}, "angular": {"z": 0.0}},
79
+ },
80
+ )
81
+ for i in range(5)
82
+ ],
83
+ "/scan": [
84
+ MessageSample(
85
+ topic="/scan",
86
+ message_type="sensor_msgs/msg/LaserScan",
87
+ timestamp_ns=_BASE_TS_NS + i * 50_000_000,
88
+ payload={
89
+ "header": {"frame_id": "laser", "stamp_sec": 1_700_000_000 + i},
90
+ "angle_min": -3.14,
91
+ "angle_max": 3.14,
92
+ "range_min": 0.05,
93
+ "range_max": 12.0,
94
+ "ranges_summary": {"min": 0.32, "max": 11.5, "n": 720},
95
+ },
96
+ )
97
+ for i in range(3)
98
+ ],
99
+ "/tf": [
100
+ MessageSample(
101
+ topic="/tf",
102
+ message_type="tf2_msgs/msg/TFMessage",
103
+ timestamp_ns=_BASE_TS_NS + i * 100_000_000,
104
+ payload={
105
+ "transforms": [
106
+ {"frame_id": "odom", "child_frame_id": "base_link"},
107
+ {"frame_id": "base_link", "child_frame_id": "laser"},
108
+ ]
109
+ },
110
+ )
111
+ for i in range(2)
112
+ ],
113
+ "/camera/image_raw": [
114
+ MessageSample(
115
+ topic="/camera/image_raw",
116
+ message_type="sensor_msgs/msg/Image",
117
+ timestamp_ns=_BASE_TS_NS,
118
+ payload={
119
+ "header": {"frame_id": "camera", "stamp_sec": 1_700_000_000},
120
+ "width": 640,
121
+ "height": 480,
122
+ "encoding": "rgb8",
123
+ "data_summary": "<binary 921600 bytes elided>",
124
+ },
125
+ )
126
+ ],
127
+ }
128
+
129
+
130
+ def mock_samples_for(topic: str, count: int) -> list[MessageSample]:
131
+ """Return up to `count` deterministic samples for `topic`. Empty if unknown."""
132
+ return list(_MOCK_SAMPLES.get(topic, [])[:count])
133
+
134
+
135
+ MOCK_BAG_ANALYSIS = BagAnalysis(
136
+ path="<mock>",
137
+ storage_format="mcap",
138
+ duration_seconds=42.5,
139
+ message_count=1287,
140
+ topics=[
141
+ BagTopicStats(
142
+ name="/cmd_vel",
143
+ message_type="geometry_msgs/msg/Twist",
144
+ message_count=425,
145
+ frequency_hz=10.0,
146
+ ),
147
+ BagTopicStats(
148
+ name="/odom",
149
+ message_type="nav_msgs/msg/Odometry",
150
+ message_count=425,
151
+ frequency_hz=10.0,
152
+ ),
153
+ BagTopicStats(
154
+ name="/scan",
155
+ message_type="sensor_msgs/msg/LaserScan",
156
+ message_count=425,
157
+ frequency_hz=10.0,
158
+ ),
159
+ BagTopicStats(
160
+ name="/tf",
161
+ message_type="tf2_msgs/msg/TFMessage",
162
+ message_count=12,
163
+ frequency_hz=0.28,
164
+ ),
165
+ ],
166
+ # TODO(roadmap): bag anomaly detection — replace these canned strings with
167
+ # output from a real anomaly detector (clock jumps, frame drops, TF gaps).
168
+ anomalies=[
169
+ "/scan: 3 frames dropped between t=10.1s and t=10.4s",
170
+ "/tf: static transforms only — no dynamic updates during recording",
171
+ ],
172
+ )
@@ -0,0 +1,5 @@
1
+ """Configuration & runtime mode resolution."""
2
+
3
+ from topicforge.config.settings import Mode, ResolvedMode, Settings, load_settings
4
+
5
+ __all__ = ["Mode", "ResolvedMode", "Settings", "load_settings"]