websocket-proxy 0.1.1__tar.gz

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.
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.3
2
+ Name: websocket-proxy
3
+ Version: 0.1.1
4
+ Summary: Foxglove WebSocket proxy with message transformations
5
+ Keywords: websocket,foxglove,robotics,ros,ros2,proxy,compression
6
+ Author: Marko Bausch
7
+ License: GPL-3.0
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Internet
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Classifier: Typing :: Typed
21
+ Requires-Dist: numpy>=2.0.0
22
+ Requires-Dist: pointcloud2>=0.2.3
23
+ Requires-Dist: websockets>=15.0.1
24
+ Requires-Dist: typing-extensions>=4.12.2
25
+ Requires-Dist: mcap-ros2-support-fast
26
+ Requires-Dist: robo-ws-bridge
27
+ Requires-Dist: rich>=14.2.0
28
+ Requires-Dist: av>=12.0.0
29
+ Requires-Python: >=3.10
30
+ Project-URL: Homepage, https://github.com/mrkbac/robotic-tools
31
+ Project-URL: Issues, https://github.com/mrkbac/robotic-tools/issues
32
+ Project-URL: Repository, https://github.com/mrkbac/robotic-tools
33
+ Description-Content-Type: text/markdown
34
+
35
+ # websocket-proxy
36
+
37
+ A proxy for [Foxglove Bridge](https://github.com/foxglove/foxglove-sdk/tree/main/ros/src/foxglove_bridge) that converts messages just-in-time and forwards them via the Foxglove Bridge protocol.
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ # Run directly without installing
43
+ uvx --from websocket-proxy bridge ws://localhost:8765
44
+
45
+ # Or add to your project
46
+ uv add websocket-proxy
47
+ ```
48
+
49
+ ## Features
50
+
51
+ - Converts `sensor_msgs/msg/CompressedImage` and `sensor_msgs/msg/Image` to `foxglove_msgs/msg/CompressedVideo`
52
+ - Using ffmpeg: h264, h265, vp9, av1
53
+ - Downscaling support
54
+ - Converts `sensor_msgs/msg/PointCloud2` to `point_cloud_interfaces/msg/CompressedPointCloud2` using [cloudini](https://github.com/facontidavide/cloudini)
55
+ - Downsampling support (dropping random points, voxelize grid)
56
+ - Throttling support for all topics
57
+ - Prevents flooding traffic by awaiting Ping/Pong
58
+
59
+ ## Usage
60
+
61
+ ```bash
62
+ bridge <source-ws>
63
+ ```
64
+
65
+ This starts the bridge connecting to `<source-ws>` and opens the proxy at `ws://0.0.0.0:8766`.
66
+
67
+ Default settings:
68
+ - Image downsampling to HD and h264 compression
69
+ - Cloudini point cloud compression
70
+ - Throttling to 1Hz
@@ -0,0 +1,36 @@
1
+ # websocket-proxy
2
+
3
+ A proxy for [Foxglove Bridge](https://github.com/foxglove/foxglove-sdk/tree/main/ros/src/foxglove_bridge) that converts messages just-in-time and forwards them via the Foxglove Bridge protocol.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # Run directly without installing
9
+ uvx --from websocket-proxy bridge ws://localhost:8765
10
+
11
+ # Or add to your project
12
+ uv add websocket-proxy
13
+ ```
14
+
15
+ ## Features
16
+
17
+ - Converts `sensor_msgs/msg/CompressedImage` and `sensor_msgs/msg/Image` to `foxglove_msgs/msg/CompressedVideo`
18
+ - Using ffmpeg: h264, h265, vp9, av1
19
+ - Downscaling support
20
+ - Converts `sensor_msgs/msg/PointCloud2` to `point_cloud_interfaces/msg/CompressedPointCloud2` using [cloudini](https://github.com/facontidavide/cloudini)
21
+ - Downsampling support (dropping random points, voxelize grid)
22
+ - Throttling support for all topics
23
+ - Prevents flooding traffic by awaiting Ping/Pong
24
+
25
+ ## Usage
26
+
27
+ ```bash
28
+ bridge <source-ws>
29
+ ```
30
+
31
+ This starts the bridge connecting to `<source-ws>` and opens the proxy at `ws://0.0.0.0:8766`.
32
+
33
+ Default settings:
34
+ - Image downsampling to HD and h264 compression
35
+ - Cloudini point cloud compression
36
+ - Throttling to 1Hz
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "websocket-proxy"
3
+ version = "0.1.1"
4
+ description = "Foxglove WebSocket proxy with message transformations"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = {text = "GPL-3.0"}
8
+ authors = [
9
+ {name = "Marko Bausch"}
10
+ ]
11
+ keywords = ["websocket", "foxglove", "robotics", "ros", "ros2", "proxy", "compression"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "Intended Audience :: Science/Research",
16
+ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Internet",
24
+ "Topic :: Scientific/Engineering",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = [
28
+ "numpy>=2.0.0",
29
+ "pointcloud2>=0.2.3",
30
+ "websockets>=15.0.1",
31
+ "typing-extensions>=4.12.2",
32
+ "mcap-ros2-support-fast",
33
+ "robo-ws-bridge",
34
+ "rich>=14.2.0",
35
+ "av>=12.0.0",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/mrkbac/robotic-tools"
40
+ Repository = "https://github.com/mrkbac/robotic-tools"
41
+ Issues = "https://github.com/mrkbac/robotic-tools/issues"
42
+
43
+ [project.scripts]
44
+ bridge = "websocket_proxy.__main__:main"
45
+
46
+ [build-system]
47
+ requires = ["uv_build>=0.8.9,<0.9.0"]
48
+ build-backend = "uv_build"
49
+
50
+ [tool.uv.sources]
51
+ mcap-ros2-support-fast = { workspace = true }
52
+ robo-ws-bridge = { workspace = true }
53
+
54
+ [dependency-groups]
55
+ example = [
56
+ "foxglove-sdk>=0.15.3",
57
+ ]
@@ -0,0 +1,13 @@
1
+ """Fox Bridge - Foxglove WebSocket Proxy with Message Transformations"""
2
+
3
+ from websocket_proxy.proxy import ProxyBridge
4
+ from websocket_proxy.transformers import TransformerRegistry
5
+ from websocket_proxy.transformers.image_to_video import ImageToVideoTransformer
6
+ from websocket_proxy.transformers.pointcloud_voxel import PointCloudVoxelTransformer
7
+
8
+ __all__ = [
9
+ "ImageToVideoTransformer",
10
+ "PointCloudVoxelTransformer",
11
+ "ProxyBridge",
12
+ "TransformerRegistry",
13
+ ]
@@ -0,0 +1,228 @@
1
+ import argparse
2
+ import asyncio
3
+ import contextlib
4
+ import logging
5
+ import signal
6
+ import sys
7
+
8
+ from rich.console import Console
9
+ from rich.logging import RichHandler
10
+
11
+ from websocket_proxy.dashboard import DashboardRenderer
12
+ from websocket_proxy.proxy import ProxyBridge
13
+ from websocket_proxy.transformers import TransformerRegistry
14
+ from websocket_proxy.transformers.image_to_video import ImageToVideoTransformer
15
+ from websocket_proxy.transformers.pointcloud_voxel import PointCloudVoxelTransformer
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def parse_args() -> argparse.Namespace:
21
+ """Parse command line arguments."""
22
+ parser = argparse.ArgumentParser(
23
+ description="Foxglove WebSocket proxy - forwards topics with optional transformations"
24
+ )
25
+ parser.add_argument(
26
+ "source_ws",
27
+ help="WebSocket URL of the upstream Foxglove bridge (e.g., ws://localhost:8765)",
28
+ )
29
+ parser.add_argument(
30
+ "--port",
31
+ type=int,
32
+ default=8766,
33
+ help="Port to listen on for downstream clients (default: 8766)",
34
+ )
35
+ parser.add_argument(
36
+ "--host",
37
+ default="0.0.0.0", # noqa: S104
38
+ help="Host to listen on for downstream clients (default: 0.0.0.0)",
39
+ )
40
+ parser.add_argument(
41
+ "--verbose",
42
+ "-v",
43
+ action="store_true",
44
+ help="Enable verbose debug logging",
45
+ )
46
+ parser.add_argument(
47
+ "--throttle-hz",
48
+ type=float,
49
+ default=1.0,
50
+ help="Topic throttle rate in Hz (default: 1.0; set to 0 to disable)",
51
+ )
52
+ parser.add_argument(
53
+ "--max-message-size",
54
+ type=int,
55
+ default=0,
56
+ help="Maximum websocket message size in bytes (<=0 disables limit, default: unlimited)",
57
+ )
58
+
59
+ parser.add_argument(
60
+ "--image-codec",
61
+ default="h264",
62
+ help="Video codec to use for image compression (default: h264)",
63
+ )
64
+ parser.add_argument(
65
+ "--image-quality",
66
+ type=int,
67
+ default=23,
68
+ help="CRF/quality value for image compression (lower is higher quality, default: 23)",
69
+ )
70
+ parser.add_argument(
71
+ "--image-preset",
72
+ default="fast",
73
+ help="Encoder preset for image compression (default: fast)",
74
+ )
75
+ parser.add_argument(
76
+ "--image-max-dimension",
77
+ type=int,
78
+ default=480,
79
+ help="Maximum width/height used when downscaling images before encoding (default: 480)",
80
+ )
81
+ parser.add_argument(
82
+ "--image-disable-hw",
83
+ dest="image_use_hardware",
84
+ action="store_false",
85
+ help="Disable hardware acceleration for image compression",
86
+ )
87
+
88
+ parser.add_argument(
89
+ "--pointcloud-voxel-size",
90
+ type=float,
91
+ default=0.1,
92
+ help="Voxel size (in meters) for point cloud downsampling (default: 0.1)",
93
+ )
94
+ parser.add_argument(
95
+ "--pointcloud-keep-nans",
96
+ dest="pointcloud_skip_nans",
97
+ action="store_false",
98
+ help="Keep NaN points when voxelizing point clouds (default: drop NaNs)",
99
+ )
100
+
101
+ parser.add_argument(
102
+ "--no-dashboard",
103
+ action="store_true",
104
+ help="Disable the live dashboard display",
105
+ )
106
+ parser.add_argument(
107
+ "--dashboard-refresh-rate",
108
+ type=float,
109
+ default=1.0,
110
+ help="Dashboard refresh rate in seconds (default: 1.0)",
111
+ )
112
+
113
+ parser.set_defaults(image_use_hardware=True, pointcloud_skip_nans=True)
114
+ return parser.parse_args()
115
+
116
+
117
+ async def main_async(args: argparse.Namespace) -> None:
118
+ """Async main function."""
119
+ # Create shared console for dashboard and logging
120
+ console = Console()
121
+
122
+ # Create transformer registry and register transformers (BEFORE configuring logging)
123
+ registry = TransformerRegistry()
124
+
125
+ # Register image to video transformer
126
+ image_transformer = ImageToVideoTransformer(
127
+ codec=args.image_codec,
128
+ quality=args.image_quality,
129
+ preset=args.image_preset,
130
+ use_hardware=args.image_use_hardware,
131
+ max_dimension=args.image_max_dimension,
132
+ )
133
+ registry.register(image_transformer)
134
+
135
+ pointcloud_transformer = PointCloudVoxelTransformer(
136
+ voxel_size=args.pointcloud_voxel_size,
137
+ skip_nans=args.pointcloud_skip_nans,
138
+ )
139
+ registry.register(pointcloud_transformer)
140
+
141
+ # Create proxy bridge with transformers
142
+ bridge = ProxyBridge(
143
+ upstream_url=args.source_ws,
144
+ listen_host=args.host,
145
+ listen_port=args.port,
146
+ transformer_registry=registry,
147
+ default_throttle_hz=args.throttle_hz,
148
+ max_message_size=args.max_message_size if args.max_message_size > 0 else None,
149
+ )
150
+
151
+ # Create dashboard if enabled (with shared console for logging integration)
152
+ dashboard = None
153
+ if not args.no_dashboard:
154
+ dashboard = DashboardRenderer(
155
+ bridge, refresh_rate=args.dashboard_refresh_rate, console=console
156
+ )
157
+ # Start dashboard BEFORE configuring logging
158
+ dashboard.start_sync()
159
+
160
+ # NOW configure logging with Rich handler (after dashboard is started)
161
+ logging.basicConfig(
162
+ level=logging.DEBUG if args.verbose else logging.INFO,
163
+ format="%(message)s",
164
+ datefmt="[%X]",
165
+ handlers=[
166
+ RichHandler(
167
+ console=console,
168
+ rich_tracebacks=True,
169
+ tracebacks_show_locals=args.verbose,
170
+ )
171
+ ],
172
+ )
173
+
174
+ logger.info("Registered transformers:")
175
+ for transformer in registry.get_all_transformers():
176
+ logger.info(f" {transformer.get_input_schema()} -> {transformer.get_output_schema()}")
177
+
178
+ # Setup signal handlers for graceful shutdown
179
+ loop = asyncio.get_running_loop()
180
+
181
+ def signal_handler() -> None:
182
+ logger.info("Received shutdown signal")
183
+ asyncio.create_task(bridge.stop()) # noqa: RUF006
184
+
185
+ for sig in (signal.SIGTERM, signal.SIGINT):
186
+ loop.add_signal_handler(sig, signal_handler)
187
+
188
+ try:
189
+ if dashboard:
190
+ # Dashboard already started above (before logging config)
191
+ # Just create a background task for dashboard updates
192
+ dashboard_task = asyncio.create_task(dashboard.run_updates())
193
+
194
+ try:
195
+ # Start proxy (this will block until stop() is called)
196
+ await bridge.start()
197
+ finally:
198
+ # Cancel dashboard updates
199
+ dashboard_task.cancel()
200
+ with contextlib.suppress(asyncio.CancelledError):
201
+ await dashboard_task
202
+ await dashboard.stop()
203
+ else:
204
+ # No dashboard - just start proxy
205
+ await bridge.start()
206
+ except KeyboardInterrupt:
207
+ logger.info("Keyboard interrupt received")
208
+ except Exception:
209
+ logger.exception("Unexpected error in proxy bridge")
210
+ sys.exit(1)
211
+ finally:
212
+ await bridge.stop()
213
+ if dashboard:
214
+ await dashboard.stop()
215
+
216
+
217
+ def main() -> None:
218
+ """Main entry point."""
219
+ args = parse_args()
220
+
221
+ try:
222
+ asyncio.run(main_async(args))
223
+ except KeyboardInterrupt:
224
+ logger.info("Exiting")
225
+
226
+
227
+ if __name__ == "__main__":
228
+ main()
@@ -0,0 +1,245 @@
1
+ """Rich-based dashboard for websocket proxy server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from datetime import datetime, timedelta, timezone
8
+ from typing import TYPE_CHECKING
9
+
10
+ from rich.console import Console, Group
11
+ from rich.live import Live
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+ if TYPE_CHECKING:
17
+ from .metrics import MetricsCollector
18
+ from .proxy import ProxyBridge
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _format_duration(seconds: float) -> str:
24
+ """Format duration in seconds to human-readable string."""
25
+ if seconds < 60:
26
+ return f"{seconds:.0f}s"
27
+ if seconds < 3600:
28
+ return f"{seconds // 60:.0f}m {seconds % 60:.0f}s"
29
+ hours = seconds // 3600
30
+ minutes = (seconds % 3600) // 60
31
+ return f"{hours:.0f}h {minutes:.0f}m"
32
+
33
+
34
+ def _format_rate(rate: float) -> str:
35
+ """Format rate (msgs/sec) to human-readable string."""
36
+ if rate < 0.01:
37
+ return "0.00"
38
+ if rate < 1:
39
+ return f"{rate:.2f}"
40
+ if rate < 10:
41
+ return f"{rate:.1f}"
42
+ return f"{rate:.0f}"
43
+
44
+
45
+ def _format_bandwidth(bytes_per_sec: float) -> str:
46
+ """Format bandwidth to human-readable string."""
47
+ if bytes_per_sec < 1024:
48
+ return f"{bytes_per_sec:.0f} B/s"
49
+ if bytes_per_sec < 1024 * 1024:
50
+ return f"{bytes_per_sec / 1024:.1f} KB/s"
51
+ return f"{bytes_per_sec / (1024 * 1024):.2f} MB/s"
52
+
53
+
54
+ def _format_bytes(byte_count: int) -> str:
55
+ """Format byte count to human-readable string."""
56
+ if byte_count < 1024:
57
+ return f"{byte_count} B"
58
+ if byte_count < 1024 * 1024:
59
+ return f"{byte_count / 1024:.1f} KB"
60
+ if byte_count < 1024 * 1024 * 1024:
61
+ return f"{byte_count / (1024 * 1024):.1f} MB"
62
+ return f"{byte_count / (1024 * 1024 * 1024):.2f} GB"
63
+
64
+
65
+ def _format_timestamp(dt: datetime | None) -> str:
66
+ """Format timestamp to relative time string."""
67
+ if dt is None:
68
+ return "never"
69
+
70
+ now = datetime.now(timezone.utc)
71
+ diff = now - dt
72
+
73
+ if diff < timedelta(seconds=1):
74
+ return "just now"
75
+ if diff < timedelta(seconds=60):
76
+ return f"{diff.seconds}s ago"
77
+ if diff < timedelta(minutes=60):
78
+ return f"{diff.seconds // 60}m ago"
79
+ return dt.strftime("%H:%M:%S")
80
+
81
+
82
+ class DashboardRenderer:
83
+ """Renders a live dashboard for the proxy server using Rich."""
84
+
85
+ def __init__(self, proxy: ProxyBridge, refresh_rate: float, console: Console) -> None:
86
+ self.proxy = proxy
87
+ self.metrics: MetricsCollector = proxy.metrics
88
+ self.refresh_rate = refresh_rate
89
+ self.console = console
90
+ self._live: Live | None = None
91
+
92
+ def _create_header_panel(self) -> Panel:
93
+ """Create the header panel with global stats."""
94
+ uptime = _format_duration(self.metrics.get_uptime())
95
+ total_clients = len(self.metrics.clients)
96
+
97
+ header_text = Text()
98
+ header_text.append("Foxglove WebSocket Proxy Dashboard", style="bold cyan")
99
+ header_text.append("\n\n")
100
+ header_text.append("Uptime: ", style="bold")
101
+ header_text.append(uptime)
102
+ header_text.append(" | Connected Clients: ", style="bold")
103
+ header_text.append(str(total_clients), style="green" if total_clients > 0 else "dim")
104
+
105
+ # Add upstream metrics
106
+ header_text.append("\n\n")
107
+ header_text.append("Upstream Status: ", style="bold")
108
+ if self.metrics.upstream_connected:
109
+ header_text.append("● Connected", style="green bold")
110
+ else:
111
+ header_text.append("● Disconnected", style="red bold")
112
+
113
+ header_text.append(" | Topics: ", style="bold")
114
+ header_text.append(str(self.metrics.upstream_topic_count), style="cyan")
115
+
116
+ header_text.append(" | Transformed: ", style="bold")
117
+ header_text.append(str(self.metrics.transformed_channel_count), style="magenta")
118
+
119
+ header_text.append("\n")
120
+ header_text.append("Messages Received: ", style="bold")
121
+ header_text.append(str(self.metrics.upstream_messages_received), style="green")
122
+
123
+ header_text.append(" | Throttled: ", style="bold")
124
+ throttled_style = "red" if self.metrics.upstream_messages_throttled > 0 else "dim"
125
+ header_text.append(str(self.metrics.upstream_messages_throttled), style=throttled_style)
126
+
127
+ return Panel(header_text, border_style="blue")
128
+
129
+ def _create_clients_table(self) -> Table:
130
+ """Create the clients table."""
131
+ table = Table(
132
+ title="Connected Clients",
133
+ title_style="bold magenta",
134
+ show_header=True,
135
+ header_style="bold",
136
+ show_lines=False,
137
+ expand=False,
138
+ )
139
+
140
+ table.add_column("ID", style="cyan", no_wrap=True)
141
+ table.add_column("Remote Address", style="blue", no_wrap=True)
142
+ table.add_column("Connected", style="green", no_wrap=True)
143
+ table.add_column("Msg/s", justify="right", style="yellow")
144
+ table.add_column("Bandwidth", justify="right", style="yellow")
145
+ table.add_column("Msgs", justify="right", style="white")
146
+ table.add_column("Bytes", justify="right", style="yellow")
147
+ table.add_column("Subs", justify="center", style="cyan")
148
+ table.add_column("Errors", justify="center", style="red")
149
+ table.add_column("Last Msg", style="dim", no_wrap=True)
150
+
151
+ # Sort clients by connection time (oldest first)
152
+ sorted_clients = sorted(
153
+ self.metrics.clients.values(),
154
+ key=lambda c: c.connected_at,
155
+ )
156
+
157
+ for client in sorted_clients:
158
+ client_id_short = client.client_id.replace("client_", "")[:12]
159
+ duration = _format_duration(client.connected_duration)
160
+ msg_rate = _format_rate(client.get_message_rate())
161
+ bandwidth = _format_bandwidth(client.get_bandwidth())
162
+ bytes_send = _format_bytes(client.bytes_sent)
163
+ last_msg = _format_timestamp(client.last_message_at)
164
+
165
+ # Style errors in red if > 0
166
+ errors_str = str(client.errors)
167
+ errors_style = "red bold" if client.errors > 0 else "dim"
168
+
169
+ table.add_row(
170
+ client_id_short,
171
+ client.remote_address,
172
+ duration,
173
+ msg_rate,
174
+ bandwidth,
175
+ str(client.messages_sent),
176
+ bytes_send,
177
+ str(client.subscription_count),
178
+ Text(errors_str, style=errors_style),
179
+ last_msg,
180
+ )
181
+
182
+ if not sorted_clients:
183
+ table.add_row(
184
+ Text("No clients connected", style="dim italic"),
185
+ "",
186
+ "",
187
+ "",
188
+ "",
189
+ "",
190
+ "",
191
+ "",
192
+ "",
193
+ "",
194
+ )
195
+
196
+ return table
197
+
198
+ def _create_layout(self) -> Panel:
199
+ """Create the full dashboard layout."""
200
+ header = self._create_header_panel()
201
+ clients_table = self._create_clients_table()
202
+
203
+ # Group everything together
204
+ layout = Group(
205
+ header,
206
+ "", # Spacer
207
+ clients_table,
208
+ )
209
+
210
+ return Panel(layout, border_style="bright_blue", padding=(1, 2))
211
+
212
+ def start_sync(self) -> None:
213
+ """Start the live dashboard (synchronous - starts the Live display)."""
214
+ self._live = Live(
215
+ self._create_layout(),
216
+ console=self.console,
217
+ refresh_per_second=1 / self.refresh_rate,
218
+ screen=False,
219
+ auto_refresh=True, # Enable auto-refresh so updates are visible
220
+ )
221
+ self._live.start()
222
+ # Do an initial render
223
+ self._live.update(self._create_layout())
224
+
225
+ async def run_updates(self) -> None:
226
+ """Run the dashboard update loop (call after start_sync)."""
227
+ if self._live is None:
228
+ return
229
+
230
+ while True:
231
+ try:
232
+ self._live.update(self._create_layout())
233
+ await asyncio.sleep(self.refresh_rate)
234
+ except asyncio.CancelledError:
235
+ break
236
+ except (RuntimeError, ValueError, KeyError, IndexError) as e:
237
+ # Log errors during rendering but continue
238
+ logger.debug("Dashboard rendering error: %s", e)
239
+ await asyncio.sleep(self.refresh_rate)
240
+
241
+ async def stop(self) -> None:
242
+ """Stop the live dashboard."""
243
+ if self._live:
244
+ self._live.stop()
245
+ self._live = None