rosight 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.
- rosight/__init__.py +5 -0
- rosight/__main__.py +6 -0
- rosight/app.py +277 -0
- rosight/app.tcss +70 -0
- rosight/cli.py +87 -0
- rosight/config.py +153 -0
- rosight/ros/__init__.py +17 -0
- rosight/ros/backend.py +600 -0
- rosight/ros/introspection.py +199 -0
- rosight/ros/qos.py +143 -0
- rosight/ros/stats.py +146 -0
- rosight/utils/__init__.py +1 -0
- rosight/utils/datatable.py +133 -0
- rosight/utils/formatting.py +88 -0
- rosight/utils/path.py +110 -0
- rosight/utils/ringbuffer.py +119 -0
- rosight/version.py +3 -0
- rosight/widgets/__init__.py +1 -0
- rosight/widgets/actions_panel.py +136 -0
- rosight/widgets/bags_panel.py +192 -0
- rosight/widgets/command_palette.py +61 -0
- rosight/widgets/help_screen.py +97 -0
- rosight/widgets/interfaces_panel.py +184 -0
- rosight/widgets/message_tree.py +145 -0
- rosight/widgets/nodes_panel.py +157 -0
- rosight/widgets/params_panel.py +133 -0
- rosight/widgets/plot_panel.py +195 -0
- rosight/widgets/plot_view.py +186 -0
- rosight/widgets/services_panel.py +153 -0
- rosight/widgets/status_bar.py +58 -0
- rosight/widgets/tf_panel.py +128 -0
- rosight/widgets/topics_panel.py +317 -0
- rosight-0.1.0.dist-info/METADATA +196 -0
- rosight-0.1.0.dist-info/RECORD +37 -0
- rosight-0.1.0.dist-info/WHEEL +4 -0
- rosight-0.1.0.dist-info/entry_points.txt +2 -0
- rosight-0.1.0.dist-info/licenses/LICENSE +21 -0
rosight/__init__.py
ADDED
rosight/__main__.py
ADDED
rosight/app.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Top-level Textual application.
|
|
2
|
+
|
|
3
|
+
Holds the :class:`RosBackend`, the panel registry, and the global key
|
|
4
|
+
bindings. Each panel is implemented as a self-contained widget under
|
|
5
|
+
``rosight.widgets`` and registered here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from textual.app import App, ComposeResult
|
|
13
|
+
from textual.binding import Binding
|
|
14
|
+
from textual.containers import Vertical
|
|
15
|
+
from textual.reactive import reactive
|
|
16
|
+
from textual.widgets import Footer, Header, TabbedContent, TabPane
|
|
17
|
+
|
|
18
|
+
from rosight.config import Config, load_user_state, save_user_state
|
|
19
|
+
from rosight.ros.backend import RosBackend, RosUnavailable
|
|
20
|
+
from rosight.widgets.actions_panel import ActionsPanel
|
|
21
|
+
from rosight.widgets.bags_panel import BagsPanel
|
|
22
|
+
from rosight.widgets.command_palette import CommandPalette
|
|
23
|
+
from rosight.widgets.help_screen import HelpScreen
|
|
24
|
+
from rosight.widgets.interfaces_panel import InterfacesPanel
|
|
25
|
+
from rosight.widgets.nodes_panel import NodesPanel
|
|
26
|
+
from rosight.widgets.params_panel import ParamsPanel
|
|
27
|
+
from rosight.widgets.plot_panel import PlotPanel
|
|
28
|
+
from rosight.widgets.services_panel import ServicesPanel
|
|
29
|
+
from rosight.widgets.status_bar import StatusBar
|
|
30
|
+
from rosight.widgets.tf_panel import TfPanel
|
|
31
|
+
from rosight.widgets.topics_panel import TopicsPanel
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RosightApp(App[int]):
|
|
37
|
+
"""Main Textual application."""
|
|
38
|
+
|
|
39
|
+
CSS_PATH = "app.tcss"
|
|
40
|
+
TITLE = "Rosight"
|
|
41
|
+
SUB_TITLE = "ROS 2 TUI"
|
|
42
|
+
|
|
43
|
+
BINDINGS = [
|
|
44
|
+
Binding("q,ctrl+c", "quit", "Quit"),
|
|
45
|
+
Binding("?", "help", "Help"),
|
|
46
|
+
Binding(":", "command", "Command"),
|
|
47
|
+
Binding("r", "refresh", "Refresh"),
|
|
48
|
+
Binding("1", "tab('topics')", "Messages", show=False),
|
|
49
|
+
Binding("2", "tab('nodes')", "Nodes", show=False),
|
|
50
|
+
Binding("3", "tab('services')", "Services", show=False),
|
|
51
|
+
Binding("4", "tab('actions')", "Actions", show=False),
|
|
52
|
+
Binding("5", "tab('params')", "Params", show=False),
|
|
53
|
+
Binding("6", "tab('plot')", "Plot", show=False),
|
|
54
|
+
Binding("7", "tab('tf')", "TF", show=False),
|
|
55
|
+
Binding("8", "tab('bags')", "Bags", show=False),
|
|
56
|
+
Binding("9", "tab('interfaces')", "Interfaces", show=False),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
backend_ok: reactive[bool] = reactive(False)
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
config: Config | None = None,
|
|
64
|
+
*,
|
|
65
|
+
ros: RosBackend | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
super().__init__()
|
|
68
|
+
self.config: Config = config or Config()
|
|
69
|
+
self._owns_ros = ros is None
|
|
70
|
+
self.ros: RosBackend = ros or RosBackend(
|
|
71
|
+
domain_id=self.config.ros.domain_id,
|
|
72
|
+
default_depth=self.config.ros.queue_depth,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# --------------- compose / lifecycle ---------------
|
|
76
|
+
|
|
77
|
+
def compose(self) -> ComposeResult:
|
|
78
|
+
yield Header(show_clock=True)
|
|
79
|
+
with TabbedContent(initial="topics", id="main-tabs"):
|
|
80
|
+
with TabPane("Messages", id="topics"):
|
|
81
|
+
yield TopicsPanel()
|
|
82
|
+
with TabPane("Nodes", id="nodes"):
|
|
83
|
+
yield NodesPanel()
|
|
84
|
+
with TabPane("Services", id="services"):
|
|
85
|
+
yield ServicesPanel()
|
|
86
|
+
with TabPane("Actions", id="actions"):
|
|
87
|
+
yield ActionsPanel()
|
|
88
|
+
with TabPane("Params", id="params"):
|
|
89
|
+
yield ParamsPanel()
|
|
90
|
+
with TabPane("Plot", id="plot"):
|
|
91
|
+
yield PlotPanel()
|
|
92
|
+
with TabPane("TF", id="tf"):
|
|
93
|
+
yield TfPanel()
|
|
94
|
+
with TabPane("Bags", id="bags"):
|
|
95
|
+
yield BagsPanel()
|
|
96
|
+
with TabPane("Interfaces", id="interfaces"):
|
|
97
|
+
yield InterfacesPanel()
|
|
98
|
+
with Vertical(id="bottom-bar"):
|
|
99
|
+
yield StatusBar(id="status")
|
|
100
|
+
yield Footer()
|
|
101
|
+
|
|
102
|
+
async def on_mount(self) -> None:
|
|
103
|
+
# Restore the theme the user last picked via Ctrl+P → Change theme.
|
|
104
|
+
# Failures are silent: if the state file is missing or names a theme
|
|
105
|
+
# Textual no longer ships, we just stay on the default.
|
|
106
|
+
try:
|
|
107
|
+
state = load_user_state()
|
|
108
|
+
saved = state.get("theme")
|
|
109
|
+
if isinstance(saved, str) and self.get_theme(saved) is not None:
|
|
110
|
+
self.theme = saved
|
|
111
|
+
except Exception:
|
|
112
|
+
log.debug("could not restore theme", exc_info=True)
|
|
113
|
+
|
|
114
|
+
# Persist on every subsequent theme change.
|
|
115
|
+
try:
|
|
116
|
+
self.theme_changed_signal.subscribe(self, self._on_theme_changed)
|
|
117
|
+
except Exception:
|
|
118
|
+
log.debug("could not subscribe to theme_changed_signal", exc_info=True)
|
|
119
|
+
|
|
120
|
+
# Try to start the ROS backend; downgrade to a banner on failure.
|
|
121
|
+
if self._owns_ros:
|
|
122
|
+
try:
|
|
123
|
+
self.ros.start()
|
|
124
|
+
self.backend_ok = True
|
|
125
|
+
except RosUnavailable as e:
|
|
126
|
+
self.backend_ok = False
|
|
127
|
+
self.notify(str(e), title="ROS 2 unavailable", severity="warning", timeout=10)
|
|
128
|
+
except Exception:
|
|
129
|
+
log.exception("ROS backend failed to start")
|
|
130
|
+
self.backend_ok = False
|
|
131
|
+
self.notify(
|
|
132
|
+
"ROS backend failed to start — see logs.",
|
|
133
|
+
title="error",
|
|
134
|
+
severity="error",
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
self.backend_ok = self.ros.started
|
|
138
|
+
|
|
139
|
+
self.set_interval(self.config.ui.discovery_period, self._refresh_status)
|
|
140
|
+
|
|
141
|
+
def _on_theme_changed(self, theme) -> None:
|
|
142
|
+
"""Persist the chosen theme so it survives restarts."""
|
|
143
|
+
try:
|
|
144
|
+
name = getattr(theme, "name", None) or self.theme
|
|
145
|
+
if not isinstance(name, str):
|
|
146
|
+
return
|
|
147
|
+
state = load_user_state()
|
|
148
|
+
state["theme"] = name
|
|
149
|
+
save_user_state(state)
|
|
150
|
+
except Exception:
|
|
151
|
+
log.debug("could not persist theme", exc_info=True)
|
|
152
|
+
|
|
153
|
+
async def on_unmount(self) -> None:
|
|
154
|
+
if self._owns_ros:
|
|
155
|
+
try:
|
|
156
|
+
self.ros.stop()
|
|
157
|
+
except Exception:
|
|
158
|
+
log.exception("ros stop failed")
|
|
159
|
+
|
|
160
|
+
# --------------- actions ---------------
|
|
161
|
+
|
|
162
|
+
def action_help(self) -> None:
|
|
163
|
+
self.push_screen(HelpScreen())
|
|
164
|
+
|
|
165
|
+
def action_command(self) -> None:
|
|
166
|
+
self.push_screen(CommandPalette(), self._on_command_submitted)
|
|
167
|
+
|
|
168
|
+
def action_refresh(self) -> None:
|
|
169
|
+
self.push_status("refresh requested")
|
|
170
|
+
# Each panel polls on a timer; nothing else needed.
|
|
171
|
+
|
|
172
|
+
def action_tab(self, tab_id: str) -> None:
|
|
173
|
+
try:
|
|
174
|
+
self.query_one(TabbedContent).active = tab_id
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
# --------------- helpers exposed to panels ---------------
|
|
179
|
+
|
|
180
|
+
def push_status(self, msg: str) -> None:
|
|
181
|
+
try:
|
|
182
|
+
bar = self.query_one("#status", StatusBar)
|
|
183
|
+
bar.message = msg
|
|
184
|
+
except Exception:
|
|
185
|
+
log.info(msg)
|
|
186
|
+
log.info("status: %s", msg)
|
|
187
|
+
|
|
188
|
+
def add_plot_series(self, topic: str, field_path: str) -> None:
|
|
189
|
+
"""Forward field-selected events from the topics panel to plot."""
|
|
190
|
+
try:
|
|
191
|
+
panel = self.query_one(PlotPanel)
|
|
192
|
+
panel.add_series(topic, field_path)
|
|
193
|
+
self.action_tab("plot")
|
|
194
|
+
except Exception:
|
|
195
|
+
log.exception("could not add plot series")
|
|
196
|
+
|
|
197
|
+
# --------------- background tickers ---------------
|
|
198
|
+
|
|
199
|
+
def _refresh_status(self) -> None:
|
|
200
|
+
bar = self.query_one("#status", StatusBar)
|
|
201
|
+
bar.backend_ok = self.backend_ok and self.ros.started
|
|
202
|
+
bar.domain_id = self.ros.domain_id
|
|
203
|
+
if not self.ros.started:
|
|
204
|
+
return
|
|
205
|
+
try:
|
|
206
|
+
bar.topics = len(self.ros.list_topics())
|
|
207
|
+
bar.nodes = len(self.ros.list_nodes())
|
|
208
|
+
bar.services = len(self.ros.list_services())
|
|
209
|
+
bar.actions = len(self.ros.list_actions())
|
|
210
|
+
bar.subs = len(self.ros.active_subscriptions())
|
|
211
|
+
except Exception:
|
|
212
|
+
log.debug("status refresh failed", exc_info=True)
|
|
213
|
+
|
|
214
|
+
# --------------- command palette ---------------
|
|
215
|
+
|
|
216
|
+
def _on_command_submitted(self, raw: str | None) -> None:
|
|
217
|
+
if not raw:
|
|
218
|
+
return
|
|
219
|
+
parts = raw.strip().split()
|
|
220
|
+
cmd, args = parts[0], parts[1:]
|
|
221
|
+
if cmd in ("q", "quit", "exit"):
|
|
222
|
+
self.exit(0)
|
|
223
|
+
elif cmd == "topic" and args:
|
|
224
|
+
self.action_tab("topics")
|
|
225
|
+
try:
|
|
226
|
+
tp = self.query_one(TopicsPanel)
|
|
227
|
+
tp.filter_text = args[0]
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
elif cmd == "node" and args:
|
|
231
|
+
self.action_tab("nodes")
|
|
232
|
+
elif cmd == "param" and len(args) >= 1:
|
|
233
|
+
self.action_tab("params")
|
|
234
|
+
elif cmd == "plot" and len(args) >= 2:
|
|
235
|
+
topic, path = args[0], args[1]
|
|
236
|
+
self.add_plot_series(topic, path)
|
|
237
|
+
elif cmd == "record":
|
|
238
|
+
self.action_tab("bags")
|
|
239
|
+
elif cmd == "help":
|
|
240
|
+
self.action_help()
|
|
241
|
+
elif cmd == "domain" and args:
|
|
242
|
+
self._switch_domain(args[0])
|
|
243
|
+
else:
|
|
244
|
+
self.push_status(f"unknown command: {raw}")
|
|
245
|
+
|
|
246
|
+
def _switch_domain(self, raw_id: str) -> None:
|
|
247
|
+
"""Re-init the rclpy backend on a new ROS_DOMAIN_ID.
|
|
248
|
+
|
|
249
|
+
Tears down the executor + node + context and reconnects on the
|
|
250
|
+
target domain. Any subscriptions are lost — panels rediscover and
|
|
251
|
+
the user can re-subscribe.
|
|
252
|
+
"""
|
|
253
|
+
try:
|
|
254
|
+
new_id = int(raw_id)
|
|
255
|
+
except ValueError:
|
|
256
|
+
self.notify(f"invalid domain id: {raw_id!r}", severity="warning", title="Domain")
|
|
257
|
+
return
|
|
258
|
+
if not 0 <= new_id <= 232: # ROS_DOMAIN_ID range
|
|
259
|
+
self.notify(
|
|
260
|
+
f"domain id {new_id} outside valid range 0..232",
|
|
261
|
+
severity="warning",
|
|
262
|
+
title="Domain",
|
|
263
|
+
)
|
|
264
|
+
return
|
|
265
|
+
try:
|
|
266
|
+
self.ros.set_domain_id(new_id)
|
|
267
|
+
self.backend_ok = self.ros.started
|
|
268
|
+
self.notify(
|
|
269
|
+
f"now on ROS_DOMAIN_ID={new_id}\n(subscriptions cleared)",
|
|
270
|
+
title="Domain",
|
|
271
|
+
)
|
|
272
|
+
except RosUnavailable as e:
|
|
273
|
+
self.backend_ok = False
|
|
274
|
+
self.notify(str(e), title="ROS 2 unavailable", severity="warning")
|
|
275
|
+
except Exception as e:
|
|
276
|
+
log.exception("set_domain_id failed")
|
|
277
|
+
self.notify(f"failed to switch domain: {e}", severity="error")
|
rosight/app.tcss
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* rosight — Textual stylesheet */
|
|
2
|
+
|
|
3
|
+
Screen {
|
|
4
|
+
background: $background;
|
|
5
|
+
color: $text;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
Header {
|
|
9
|
+
dock: top;
|
|
10
|
+
height: 1;
|
|
11
|
+
background: $primary 30%;
|
|
12
|
+
color: $text;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
#bottom-bar {
|
|
16
|
+
dock: bottom;
|
|
17
|
+
height: 2;
|
|
18
|
+
layout: vertical;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Footer {
|
|
22
|
+
dock: initial;
|
|
23
|
+
height: 1;
|
|
24
|
+
background: $primary 20%;
|
|
25
|
+
color: $text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
TabbedContent {
|
|
29
|
+
height: 1fr;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
TabPane {
|
|
33
|
+
padding: 0;
|
|
34
|
+
height: 1fr;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
DataTable > .datatable--header {
|
|
38
|
+
background: $primary 40%;
|
|
39
|
+
color: $text;
|
|
40
|
+
text-style: bold;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
DataTable > .datatable--cursor {
|
|
44
|
+
background: $accent 35%;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
Tree {
|
|
48
|
+
background: $surface;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
Input {
|
|
52
|
+
background: $surface;
|
|
53
|
+
border: tall $primary 30%;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
Input:focus {
|
|
57
|
+
border: tall $accent;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Static.dim {
|
|
61
|
+
color: $text-muted;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#detail-header {
|
|
65
|
+
text-style: bold;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ToastRack > Toast {
|
|
69
|
+
background: $primary 70%;
|
|
70
|
+
}
|
rosight/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Command-line entry point for ``rosight``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rosight.config import Config, config_path, load_config
|
|
11
|
+
from rosight.version import __version__
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _setup_logging(level: str, log_file: Path | None) -> None:
|
|
15
|
+
handlers: list[logging.Handler] = []
|
|
16
|
+
if log_file is not None:
|
|
17
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
|
|
19
|
+
logging.basicConfig(
|
|
20
|
+
level=level.upper(),
|
|
21
|
+
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
22
|
+
handlers=handlers or [logging.NullHandler()],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
27
|
+
p = argparse.ArgumentParser(
|
|
28
|
+
prog="rosight",
|
|
29
|
+
description="lazygit-style terminal UI for ROS 2",
|
|
30
|
+
)
|
|
31
|
+
p.add_argument(
|
|
32
|
+
"-c",
|
|
33
|
+
"--config",
|
|
34
|
+
type=Path,
|
|
35
|
+
help=f"path to config TOML (default: {config_path()})",
|
|
36
|
+
)
|
|
37
|
+
p.add_argument("--domain-id", type=int, help="override ROS_DOMAIN_ID")
|
|
38
|
+
p.add_argument("--node-name", default="rosight", help="rclpy node name")
|
|
39
|
+
p.add_argument(
|
|
40
|
+
"--log-level",
|
|
41
|
+
default="INFO",
|
|
42
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
43
|
+
)
|
|
44
|
+
p.add_argument(
|
|
45
|
+
"--log-file",
|
|
46
|
+
type=Path,
|
|
47
|
+
help="path to a log file (default: stderr suppressed)",
|
|
48
|
+
)
|
|
49
|
+
p.add_argument(
|
|
50
|
+
"--no-ros",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="skip ROS 2 initialisation (useful for UI-only debugging)",
|
|
53
|
+
)
|
|
54
|
+
p.add_argument("-V", "--version", action="version", version=f"rosight {__version__}")
|
|
55
|
+
return p.parse_args(argv)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main(argv: list[str] | None = None) -> int:
|
|
59
|
+
args = parse_args(argv)
|
|
60
|
+
_setup_logging(args.log_level, args.log_file)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
cfg: Config = load_config(args.config)
|
|
64
|
+
except ValueError as e:
|
|
65
|
+
print(f"config error: {e}", file=sys.stderr)
|
|
66
|
+
return 2
|
|
67
|
+
|
|
68
|
+
if args.domain_id is not None:
|
|
69
|
+
from dataclasses import replace
|
|
70
|
+
|
|
71
|
+
cfg = replace(cfg, ros=replace(cfg.ros, domain_id=args.domain_id))
|
|
72
|
+
|
|
73
|
+
# Defer Textual import to keep --version fast.
|
|
74
|
+
from rosight.app import RosightApp
|
|
75
|
+
from rosight.ros.backend import RosBackend
|
|
76
|
+
|
|
77
|
+
ros = None
|
|
78
|
+
if args.no_ros:
|
|
79
|
+
ros = RosBackend(node_name=args.node_name, domain_id=cfg.ros.domain_id)
|
|
80
|
+
# leave it un-started; the app will downgrade gracefully.
|
|
81
|
+
|
|
82
|
+
app = RosightApp(config=cfg, ros=ros)
|
|
83
|
+
return app.run() or 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
raise SystemExit(main())
|
rosight/config.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""User configuration loading.
|
|
2
|
+
|
|
3
|
+
Configuration lives at ``$XDG_CONFIG_HOME/rosight/config.toml`` (falling back
|
|
4
|
+
to ``~/.config/rosight/config.toml``). Missing files are not an error — the
|
|
5
|
+
defaults are applied automatically.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass, field, replace
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from platformdirs import user_config_dir
|
|
17
|
+
|
|
18
|
+
if sys.version_info >= (3, 11):
|
|
19
|
+
import tomllib
|
|
20
|
+
else: # pragma: no cover
|
|
21
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
CONFIG_FILENAME = "config.toml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class PlotConfig:
|
|
29
|
+
window_seconds: float = 30.0
|
|
30
|
+
max_points: int = 5_000
|
|
31
|
+
refresh_hz: float = 15.0
|
|
32
|
+
show_legend: bool = True
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class StatsConfig:
|
|
37
|
+
window_seconds: float = 5.0
|
|
38
|
+
max_samples: int = 4096
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class UIConfig:
|
|
43
|
+
theme: str = "rosight-dark"
|
|
44
|
+
refresh_hz: float = 10.0
|
|
45
|
+
discovery_period: float = 1.0 # how often to refresh topic/node lists
|
|
46
|
+
vim_keys: bool = True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class RosConfig:
|
|
51
|
+
domain_id: int | None = None # None -> inherit ROS_DOMAIN_ID
|
|
52
|
+
qos_profile: str = "auto" # auto | sensor_data | reliable | best_effort
|
|
53
|
+
queue_depth: int = 10
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class Config:
|
|
58
|
+
ui: UIConfig = field(default_factory=UIConfig)
|
|
59
|
+
plot: PlotConfig = field(default_factory=PlotConfig)
|
|
60
|
+
stats: StatsConfig = field(default_factory=StatsConfig)
|
|
61
|
+
ros: RosConfig = field(default_factory=RosConfig)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_dict(cls, data: dict[str, Any]) -> Config:
|
|
65
|
+
"""Build a Config from a parsed TOML dict, ignoring unknown keys."""
|
|
66
|
+
return cls(
|
|
67
|
+
ui=_merge(UIConfig(), data.get("ui", {})),
|
|
68
|
+
plot=_merge(PlotConfig(), data.get("plot", {})),
|
|
69
|
+
stats=_merge(StatsConfig(), data.get("stats", {})),
|
|
70
|
+
ros=_merge(RosConfig(), data.get("ros", {})),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _merge(default: Any, overrides: dict[str, Any]) -> Any:
|
|
75
|
+
"""Replace dataclass fields with values from a mapping; ignore unknowns."""
|
|
76
|
+
if not overrides:
|
|
77
|
+
return default
|
|
78
|
+
valid = {f.name for f in default.__dataclass_fields__.values()} # type: ignore[attr-defined]
|
|
79
|
+
filtered = {k: v for k, v in overrides.items() if k in valid}
|
|
80
|
+
return replace(default, **filtered)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def config_path() -> Path:
|
|
84
|
+
"""Return the resolved config file path (does not require it to exist)."""
|
|
85
|
+
base = os.environ.get("ROSIGHT_CONFIG")
|
|
86
|
+
if base:
|
|
87
|
+
return Path(base).expanduser()
|
|
88
|
+
return Path(user_config_dir("rosight")) / CONFIG_FILENAME
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_config(path: Path | None = None) -> Config:
|
|
92
|
+
"""Load configuration from ``path`` (or the default location).
|
|
93
|
+
|
|
94
|
+
Returns defaults if the file does not exist. Raises ``ValueError`` only
|
|
95
|
+
if the file exists but is malformed.
|
|
96
|
+
"""
|
|
97
|
+
p = path or config_path()
|
|
98
|
+
if not p.exists():
|
|
99
|
+
return Config()
|
|
100
|
+
try:
|
|
101
|
+
with p.open("rb") as f:
|
|
102
|
+
data = tomllib.load(f)
|
|
103
|
+
except tomllib.TOMLDecodeError as e:
|
|
104
|
+
raise ValueError(f"Invalid TOML in {p}: {e}") from e
|
|
105
|
+
return Config.from_dict(data)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Runtime user state (theme choice, ...). Kept separate from ``config.toml``
|
|
110
|
+
# so the user's hand-written config is never rewritten by the app.
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
STATE_FILENAME = "state.toml"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def state_path() -> Path:
|
|
117
|
+
return Path(user_config_dir("rosight")) / STATE_FILENAME
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_user_state() -> dict[str, Any]:
|
|
121
|
+
"""Return the persisted user-state dict (theme, etc.). Empty on miss."""
|
|
122
|
+
p = state_path()
|
|
123
|
+
if not p.exists():
|
|
124
|
+
return {}
|
|
125
|
+
try:
|
|
126
|
+
with p.open("rb") as f:
|
|
127
|
+
return tomllib.load(f)
|
|
128
|
+
except Exception:
|
|
129
|
+
return {}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def save_user_state(state: dict[str, Any]) -> None:
|
|
133
|
+
"""Atomically replace the state file with ``state``.
|
|
134
|
+
|
|
135
|
+
Best-effort: failure to write (read-only FS, etc.) is silently swallowed
|
|
136
|
+
so we never crash the app over a cosmetic preference.
|
|
137
|
+
"""
|
|
138
|
+
p = state_path()
|
|
139
|
+
try:
|
|
140
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
tmp = p.with_suffix(p.suffix + ".tmp")
|
|
142
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
143
|
+
for k, v in state.items():
|
|
144
|
+
if isinstance(v, str):
|
|
145
|
+
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
|
146
|
+
f.write(f'{k} = "{escaped}"\n')
|
|
147
|
+
elif isinstance(v, bool):
|
|
148
|
+
f.write(f"{k} = {'true' if v else 'false'}\n")
|
|
149
|
+
elif isinstance(v, (int, float)):
|
|
150
|
+
f.write(f"{k} = {v}\n")
|
|
151
|
+
tmp.replace(p)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
rosight/ros/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""ROS 2 backend abstractions used by the TUI.
|
|
2
|
+
|
|
3
|
+
Importing this package does **not** import ``rclpy``. The backend lazily
|
|
4
|
+
imports rclpy inside :class:`RosBackend.start` so the rest of the package
|
|
5
|
+
(and the test suite) can run without a ROS 2 installation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from rosight.ros.backend import RosBackend, RosUnavailable, ros_available
|
|
9
|
+
from rosight.ros.stats import BandwidthMonitor, RateMonitor
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BandwidthMonitor",
|
|
13
|
+
"RateMonitor",
|
|
14
|
+
"RosBackend",
|
|
15
|
+
"RosUnavailable",
|
|
16
|
+
"ros_available",
|
|
17
|
+
]
|