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 +5 -0
- topicforge/__main__.py +68 -0
- topicforge/adapters/__init__.py +5 -0
- topicforge/adapters/base.py +44 -0
- topicforge/adapters/ros2_live/__init__.py +5 -0
- topicforge/adapters/ros2_live/adapter.py +283 -0
- topicforge/adapters/ros2_mock/__init__.py +5 -0
- topicforge/adapters/ros2_mock/adapter.py +68 -0
- topicforge/adapters/ros2_mock/fixtures.py +172 -0
- topicforge/config/__init__.py +5 -0
- topicforge/config/settings.py +68 -0
- topicforge/models/__init__.py +19 -0
- topicforge/models/schemas.py +158 -0
- topicforge/server/__init__.py +5 -0
- topicforge/server/app.py +43 -0
- topicforge/services/__init__.py +7 -0
- topicforge/services/factory.py +39 -0
- topicforge/services/health.py +32 -0
- topicforge/services/inspector.py +92 -0
- topicforge/tools/__init__.py +5 -0
- topicforge/tools/handlers.py +146 -0
- topicforge-0.1.0.dist-info/METADATA +292 -0
- topicforge-0.1.0.dist-info/RECORD +26 -0
- topicforge-0.1.0.dist-info/WHEEL +4 -0
- topicforge-0.1.0.dist-info/entry_points.txt +2 -0
- topicforge-0.1.0.dist-info/licenses/LICENSE +21 -0
topicforge/__init__.py
ADDED
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,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,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,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
|
+
)
|