benchlab-pytools 3.0.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.
Files changed (184) hide show
  1. benchlab/__init__.py +14 -0
  2. benchlab/__main__.py +21 -0
  3. benchlab/config/README.md +325 -0
  4. benchlab/config/__init__.py +9 -0
  5. benchlab/config/config_client.py +631 -0
  6. benchlab/config/config_manager.py +495 -0
  7. benchlab/config/config_tool.py +358 -0
  8. benchlab/config/diff.py +163 -0
  9. benchlab/config/examples/complete_config.json +56 -0
  10. benchlab/config/examples/rgb_lighting.json +24 -0
  11. benchlab/config/examples/simple_fan.json +32 -0
  12. benchlab/config/schema.py +138 -0
  13. benchlab/core/README.md +154 -0
  14. benchlab/core/__init__.py +78 -0
  15. benchlab/core/config.py +145 -0
  16. benchlab/core/datasource.py +1209 -0
  17. benchlab/core/datasource_manager.py +418 -0
  18. benchlab/core/device_registry.py +206 -0
  19. benchlab/core/discovery.py +92 -0
  20. benchlab/core/infrastructure.py +309 -0
  21. benchlab/core/process_manager.py +356 -0
  22. benchlab/core/retry.py +85 -0
  23. benchlab/core/shared_serial.py +235 -0
  24. benchlab/core/statistics.py +224 -0
  25. benchlab/csv_log/README.md +206 -0
  26. benchlab/csv_log/__init__.py +5 -0
  27. benchlab/csv_log/csv_logger_enhanced.py +416 -0
  28. benchlab/csv_log/message_batcher.py +361 -0
  29. benchlab/csv_log/requirements.txt +3 -0
  30. benchlab/csv_log/smart_retry.py +466 -0
  31. benchlab/graph/README.md +117 -0
  32. benchlab/graph/__init__.py +5 -0
  33. benchlab/graph/app.py +353 -0
  34. benchlab/graph/requirements.txt +2 -0
  35. benchlab/graph/runner.py +42 -0
  36. benchlab/graph/ui.py +288 -0
  37. benchlab/hwinfo/README.md +135 -0
  38. benchlab/hwinfo/__init__.py +5 -0
  39. benchlab/hwinfo/hwinfo_export.py +386 -0
  40. benchlab/hwinfo/requirements.txt +1 -0
  41. benchlab/launcher.py +387 -0
  42. benchlab/link/.env.EXAMPLE +10 -0
  43. benchlab/link/README.md +172 -0
  44. benchlab/link/__init__.py +1 -0
  45. benchlab/link/link_main.py +479 -0
  46. benchlab/main.py +462 -0
  47. benchlab/menu.py +729 -0
  48. benchlab/menu_classic.py +410 -0
  49. benchlab/mqtt/README.md +201 -0
  50. benchlab/mqtt/__init__.py +5 -0
  51. benchlab/mqtt/mqtt.config_template +2 -0
  52. benchlab/mqtt/mqtt_publisher.py +540 -0
  53. benchlab/mqtt/requirements.txt +3 -0
  54. benchlab/prefs.py +67 -0
  55. benchlab/restapi/.env.example +17 -0
  56. benchlab/restapi/__init__.py +5 -0
  57. benchlab/restapi/favicon.ico +0 -0
  58. benchlab/restapi/readme.md +202 -0
  59. benchlab/restapi/requirements.txt +5 -0
  60. benchlab/restapi/telemetry_api.py +808 -0
  61. benchlab/sources.py +515 -0
  62. benchlab/tools.py +147 -0
  63. benchlab/tui/README.md +266 -0
  64. benchlab/tui/__init__.py +5 -0
  65. benchlab/tui/config.py +477 -0
  66. benchlab/tui/requirements.txt +2 -0
  67. benchlab/tui/tui_core.py +1298 -0
  68. benchlab/tui/tui_main.py +418 -0
  69. benchlab/vu/README.md +64 -0
  70. benchlab/vu/VU-Server/NOTICE.md +13 -0
  71. benchlab/vu/VU-Server/README.md +80 -0
  72. benchlab/vu/VU-Server/Running_from_source_on_linux.md +97 -0
  73. benchlab/vu/VU-Server/__init__.py +0 -0
  74. benchlab/vu/VU-Server/assets/vu1_hello_world.png +0 -0
  75. benchlab/vu/VU-Server/community_applications.md +13 -0
  76. benchlab/vu/VU-Server/config.yaml +9 -0
  77. benchlab/vu/VU-Server/database.py +256 -0
  78. benchlab/vu/VU-Server/dial_driver.py +534 -0
  79. benchlab/vu/VU-Server/dials/Comms_Hub_Gauge.py +78 -0
  80. benchlab/vu/VU-Server/dials/Comms_Hub_Server.py +100 -0
  81. benchlab/vu/VU-Server/dials/__init__.py +0 -0
  82. benchlab/vu/VU-Server/dials/base_logger.py +100 -0
  83. benchlab/vu/VU-Server/installer/inc/icon.ico +0 -0
  84. benchlab/vu/VU-Server/installer/install.nsi +148 -0
  85. benchlab/vu/VU-Server/make_version.py +40 -0
  86. benchlab/vu/VU-Server/pylintrc +628 -0
  87. benchlab/vu/VU-Server/requirements.txt +9 -0
  88. benchlab/vu/VU-Server/serial_driver.py +244 -0
  89. benchlab/vu/VU-Server/server.py +670 -0
  90. benchlab/vu/VU-Server/server_config.py +255 -0
  91. benchlab/vu/VU-Server/server_dial_handler.py +334 -0
  92. benchlab/vu/VU-Server/vu_notifications.py +24 -0
  93. benchlab/vu/VU-Server/www/android-chrome-192x192.png +0 -0
  94. benchlab/vu/VU-Server/www/android-chrome-512x512.png +0 -0
  95. benchlab/vu/VU-Server/www/apple-touch-icon.png +0 -0
  96. benchlab/vu/VU-Server/www/assets/css/demo.css +291 -0
  97. benchlab/vu/VU-Server/www/assets/css/demo.min.css +9 -0
  98. benchlab/vu/VU-Server/www/assets/css/demo.rtl.css +276 -0
  99. benchlab/vu/VU-Server/www/assets/css/demo.rtl.min.css +9 -0
  100. benchlab/vu/VU-Server/www/assets/css/tabler-flags.css +1084 -0
  101. benchlab/vu/VU-Server/www/assets/css/tabler-flags.min.css +9 -0
  102. benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.css +1084 -0
  103. benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.min.css +9 -0
  104. benchlab/vu/VU-Server/www/assets/css/tabler-payments.css +528 -0
  105. benchlab/vu/VU-Server/www/assets/css/tabler-payments.min.css +9 -0
  106. benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.css +528 -0
  107. benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.min.css +9 -0
  108. benchlab/vu/VU-Server/www/assets/css/tabler-social.css +10 -0
  109. benchlab/vu/VU-Server/www/assets/css/tabler-social.min.css +8 -0
  110. benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.css +10 -0
  111. benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.min.css +8 -0
  112. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.css +1504 -0
  113. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.min.css +9 -0
  114. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.css +1504 -0
  115. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.min.css +9 -0
  116. benchlab/vu/VU-Server/www/assets/css/tabler.css +25813 -0
  117. benchlab/vu/VU-Server/www/assets/css/tabler.min.css +13 -0
  118. benchlab/vu/VU-Server/www/assets/css/tabler.rtl.css +25777 -0
  119. benchlab/vu/VU-Server/www/assets/css/tabler.rtl.min.css +13 -0
  120. benchlab/vu/VU-Server/www/assets/css/theme.min.css +261 -0
  121. benchlab/vu/VU-Server/www/assets/img/header.png +0 -0
  122. benchlab/vu/VU-Server/www/assets/img/logo.png +0 -0
  123. benchlab/vu/VU-Server/www/assets/js/dark-theme.js +35 -0
  124. benchlab/vu/VU-Server/www/assets/js/dark-theme.min.js +9 -0
  125. benchlab/vu/VU-Server/www/assets/js/demo.js +132 -0
  126. benchlab/vu/VU-Server/www/assets/js/demo.min.js +9 -0
  127. benchlab/vu/VU-Server/www/assets/js/jquery.min.js +2 -0
  128. benchlab/vu/VU-Server/www/assets/js/tabler.esm.js +7482 -0
  129. benchlab/vu/VU-Server/www/assets/js/tabler.esm.min.js +15 -0
  130. benchlab/vu/VU-Server/www/assets/js/tabler.js +7543 -0
  131. benchlab/vu/VU-Server/www/assets/js/tabler.min.js +15 -0
  132. benchlab/vu/VU-Server/www/assets/js/vu1_api.js +68 -0
  133. benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_key_settings.js +167 -0
  134. benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_keys.js +138 -0
  135. benchlab/vu/VU-Server/www/assets/js/vu1_gui_dial.js +179 -0
  136. benchlab/vu/VU-Server/www/assets/js/vu1_gui_home.js +60 -0
  137. benchlab/vu/VU-Server/www/assets/js/vu1_gui_root.js +125 -0
  138. benchlab/vu/VU-Server/www/favicon-16x16.png +0 -0
  139. benchlab/vu/VU-Server/www/favicon-32x32.png +0 -0
  140. benchlab/vu/VU-Server/www/favicon.ico +0 -0
  141. benchlab/vu/VU-Server/www/favicon.png +0 -0
  142. benchlab/vu/VU-Server/www/index.html +47 -0
  143. benchlab/vu/VU-Server/www/views/api_keys.html +151 -0
  144. benchlab/vu/VU-Server/www/views/dial.html +286 -0
  145. benchlab/vu/VU-Server/www/views/footer.html +27 -0
  146. benchlab/vu/VU-Server/www/views/header.html +86 -0
  147. benchlab/vu/VU-Server/www/views/key_settings.html +132 -0
  148. benchlab/vu/VU-Server/www/views/start.html +124 -0
  149. benchlab/vu/__init__.py +5 -0
  150. benchlab/vu/assets/Barlow-Bold.ttf +0 -0
  151. benchlab/vu/assets/bl_dial_144x200.png +0 -0
  152. benchlab/vu/assets/bl_logo_144x200.png +0 -0
  153. benchlab/vu/devices.py +166 -0
  154. benchlab/vu/requirements.txt +24 -0
  155. benchlab/vu/sensors.py +18 -0
  156. benchlab/vu/vu_dial.config_template +90 -0
  157. benchlab/vu/vu_logo_gen.py +97 -0
  158. benchlab/vu/vu_server.config_template +5 -0
  159. benchlab/vu/vu_server_config.py +40 -0
  160. benchlab/vu/vu_server_manager.py +192 -0
  161. benchlab/vu/vu_tui.py +919 -0
  162. benchlab/vu/vu_updater.py +540 -0
  163. benchlab/wigidash/README.md +182 -0
  164. benchlab/wigidash/__init__.py +5 -0
  165. benchlab/wigidash/assets/Inter-Bold.ttf +0 -0
  166. benchlab/wigidash/assets/Inter.ttf +0 -0
  167. benchlab/wigidash/assets/benchlab.png +0 -0
  168. benchlab/wigidash/benchlab_fleet.py +150 -0
  169. benchlab/wigidash/benchlab_graph.py +517 -0
  170. benchlab/wigidash/benchlab_overview.py +426 -0
  171. benchlab/wigidash/benchlab_telemetry.py +105 -0
  172. benchlab/wigidash/benchlab_ui.py +190 -0
  173. benchlab/wigidash/benchlab_utils.py +182 -0
  174. benchlab/wigidash/requirements.txt +9 -0
  175. benchlab/wigidash/wigidash_device.py +541 -0
  176. benchlab/wigidash/wigidash_manager.py +342 -0
  177. benchlab/wigidash/wigidash_session.py +355 -0
  178. benchlab/wigidash/wigidash_usb.py +247 -0
  179. benchlab/wigidash/wigidash_widget.py +88 -0
  180. benchlab_pytools-3.0.0.dist-info/METADATA +239 -0
  181. benchlab_pytools-3.0.0.dist-info/RECORD +184 -0
  182. benchlab_pytools-3.0.0.dist-info/WHEEL +4 -0
  183. benchlab_pytools-3.0.0.dist-info/entry_points.txt +2 -0
  184. benchlab_pytools-3.0.0.dist-info/licenses/LICENSE +21 -0
benchlab/menu.py ADDED
@@ -0,0 +1,729 @@
1
+ """BENCHLAB PyTools v2 – Interactive Menu System (Textual).
2
+
3
+ A single full-screen TUI, replacing the old numbered-prompt flow (still
4
+ available as menu_classic.py, used as an automatic fallback if textual
5
+ isn't installed or the app can't start in the current terminal).
6
+
7
+ Flow:
8
+ 1. Launching straight into the picker screen -- no separate top-level
9
+ "what would you like to do" prompt first.
10
+ 2. Two tabs: "Run Tool(s)" (Tools checklist stacked above a Data Source
11
+ radio list, live-filtered to what the selected tool(s) support) and
12
+ "Data Provider" (run FastAPI or MQTT standalone, no consumer tool).
13
+ 3. Last-used tool(s)/source are pre-selected on open.
14
+ 4. Launch/Start, then remember the choice for next time (tools mode only).
15
+ 5. Any source-specific connection params (broker host, API port, etc.)
16
+ are prompted for afterward in the terminal, pre-filled from last use
17
+ -- kept out of the TUI screen itself to avoid duplicating input
18
+ validation in two places.
19
+ """
20
+
21
+ import logging
22
+ import os
23
+ import sys
24
+ from typing import Dict, List, Optional, Tuple, Union
25
+
26
+ from bootstrap import clear_screen
27
+ from .tools import CONSUMER_TOOLS
28
+ from .sources import (
29
+ check_and_setup_source,
30
+ check_mqtt_running,
31
+ start_mqtt_broker,
32
+ start_mqtt_source,
33
+ cleanup_all_services,
34
+ SERVICE_HTTP_DEFAULT_PORT,
35
+ )
36
+ from .launcher import launch_single_tool, launch_tools_concurrent
37
+ from .prefs import load_prefs, record_launch, get_source_params
38
+
39
+ logger = logging.getLogger("benchlab.launcher")
40
+
41
+ BANNER = r"""
42
+ ██████╗ ███████╗███╗ ██╗ ██████╗██╗ ██╗██╗ █████╗ ██████╗
43
+ ██╔══██╗██╔════╝████╗ ██║██╔════╝██║ ██║██║ ██╔══██╗██╔══██╗
44
+ ██████╔╝█████╗ ██╔██╗ ██║██║ ███████║██║ ███████║██████╔╝
45
+ ██╔══██╗██╔══╝ ██║╚██╗██║██║ ██╔══██║██║ ██╔══██║██╔══██╗
46
+ ██████╔╝███████╗██║ ╚████║╚██████╗██║ ██║███████╗██║ ██║██████╔╝
47
+ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝
48
+
49
+ ██████╗ ██╗ ██╗████████╗ ██████╗ ██████╗ ██╗ ███████╗
50
+ ██╔══██╗╚██╗ ██╔╝╚══██╔══╝██╔═══██╗██╔═══██╗██║ ██╔════╝
51
+ ██████╔╝ ╚████╔╝ ██║ ██║ ██║██║ ██║██║ ███████╗
52
+ ██╔═══╝ ╚██╔╝ ██║ ██║ ██║██║ ██║██║ ╚════██║
53
+ ██║ ██║ ██║ ╚██████╔╝╚██████╔╝███████╗███████║
54
+ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝
55
+ """
56
+
57
+ SOURCE_LABELS: Dict[str, str] = {
58
+ "direct": "Direct (serial port)",
59
+ "fastapi": "FastAPI server (Python)",
60
+ "fastapi_custom": "FastAPI server (custom URL)",
61
+ "mqtt": "MQTT (Python, experimental)",
62
+ "mqtt_custom": "MQTT (custom)",
63
+ "named_pipe": "BenchLab service - named pipe",
64
+ "service_http": (
65
+ f"BenchLab service - HTTP API (port {SERVICE_HTTP_DEFAULT_PORT})"
66
+ ),
67
+ }
68
+
69
+ SOURCE_ORDER = [
70
+ "direct",
71
+ "fastapi",
72
+ "fastapi_custom",
73
+ "mqtt",
74
+ "mqtt_custom",
75
+ "named_pipe",
76
+ "service_http"]
77
+
78
+ PROVIDER_LABELS: Dict[str, str] = {
79
+ "fastapi": "FastAPI Server - REST API + WebSocket on port 8000",
80
+ "mqtt": "MQTT Publisher - publish telemetry to MQTT broker",
81
+ }
82
+
83
+
84
+ def print_banner() -> None:
85
+ print(BANNER)
86
+
87
+
88
+ # ──────────────────────────────────────────────────────────────
89
+ # Source compatibility (pure logic, reused by the picker and tests)
90
+ # ──────────────────────────────────────────────────────────────
91
+
92
+ def _available_sources(
93
+ is_multi: bool,
94
+ supported_sources: Optional[List[str]]) -> List[Tuple[str, str]]:
95
+ """Return (source_id, label) pairs valid for the given tool selection."""
96
+ is_windows = sys.platform.startswith("win")
97
+
98
+ result = []
99
+ for src in SOURCE_ORDER:
100
+ if src == "direct" and is_multi:
101
+ continue
102
+ if src == "named_pipe" and not is_windows:
103
+ continue
104
+ if supported_sources is not None and src not in supported_sources:
105
+ continue
106
+ result.append((src, SOURCE_LABELS[src]))
107
+ return result
108
+
109
+
110
+ def _combined_supported_sources(tool_ids: List[str]) -> Optional[List[str]]:
111
+ """Intersection of supported_sources across selected tools.
112
+
113
+ None means "no restriction" (tool doesn't declare supported_sources,
114
+ or no tools selected yet).
115
+ """
116
+ restrictions = [
117
+ CONSUMER_TOOLS[tid]["supported_sources"]
118
+ for tid in tool_ids
119
+ if tid in CONSUMER_TOOLS
120
+ and CONSUMER_TOOLS[tid].get("supported_sources") is not None
121
+ ]
122
+ if not restrictions:
123
+ return None
124
+ combined = set(restrictions[0])
125
+ for r in restrictions[1:]:
126
+ combined &= set(r)
127
+ return list(combined)
128
+
129
+
130
+ # ──────────────────────────────────────────────────────────────
131
+ # Textual app: "Run Tool(s)" tab + "Data Provider" tab
132
+ # ──────────────────────────────────────────────────────────────
133
+
134
+ # Result shape returned by the app / _run_picker_screen:
135
+ # ("tools", tool_ids, source) -- launch consumer tool(s)
136
+ # ("provider", provider_type) -- start a standalone data provider
137
+ # None -- cancelled
138
+ PickerResult = Union[Tuple[str, List[str], str], Tuple[str, str], None]
139
+
140
+
141
+ def _build_launcher_app(
142
+ default_tool_ids: List[str],
143
+ default_source: Optional[str]):
144
+ """Construct (but don't run) the Textual LauncherApp. Split out from
145
+ _run_picker_screen so tests can inspect app state without a real
146
+ terminal (Textual apps can run headless via App.run_test())."""
147
+ from textual.app import App, ComposeResult
148
+ from textual.containers import Vertical
149
+ from textual.widgets import (
150
+ Footer,
151
+ SelectionList,
152
+ RadioSet,
153
+ RadioButton,
154
+ Button,
155
+ Static,
156
+ TabbedContent,
157
+ TabPane,
158
+ )
159
+ from textual.widgets.selection_list import Selection
160
+ from textual.binding import Binding
161
+
162
+ class LauncherApp(App):
163
+ CSS = """
164
+ Screen {
165
+ background: $surface;
166
+ }
167
+ #banner {
168
+ width: 100%;
169
+ content-align: center middle;
170
+ color: $accent;
171
+ margin-bottom: 1;
172
+ }
173
+ .panel {
174
+ border: round $primary;
175
+ padding: 0 1;
176
+ margin-bottom: 1;
177
+ }
178
+ .panel:focus-within {
179
+ border: round $accent;
180
+ }
181
+ #tools-panel {
182
+ height: 1fr;
183
+ }
184
+ #source-panel {
185
+ height: auto;
186
+ max-height: 40%;
187
+ }
188
+ #provider-panel {
189
+ height: auto;
190
+ }
191
+ #tools-summary, #provider-summary {
192
+ height: auto;
193
+ margin-bottom: 1;
194
+ color: $text-muted;
195
+ }
196
+ .launch-btn {
197
+ width: 100%;
198
+ margin-top: 1;
199
+ }
200
+ .launch-btn.-ready {
201
+ background: $success;
202
+ }
203
+ """
204
+
205
+ BINDINGS = [
206
+ Binding("q", "quit_app", "Quit"),
207
+ Binding("escape", "quit_app", "Quit"),
208
+ Binding("ctrl+l", "launch", "Launch"),
209
+ ]
210
+
211
+ TITLE = "BENCHLAB PyTools"
212
+
213
+ def __init__(self):
214
+ super().__init__()
215
+ self.result: PickerResult = None
216
+ self._default_tool_ids = default_tool_ids
217
+ self._default_source = default_source
218
+
219
+ def compose(self) -> ComposeResult:
220
+ yield Static(BANNER, id="banner")
221
+ with TabbedContent(id="mode-tabs"):
222
+ with TabPane("Run Tool(s)", id="tab-tools"):
223
+ with Vertical(id="tools-panel", classes="panel"):
224
+ yield Static("[b]Tools[/b] (space to toggle)")
225
+ tool_selections = [
226
+ Selection(
227
+ f"{t['name']} - {t['description']}",
228
+ tid,
229
+ tid in self._default_tool_ids)
230
+ for tid, t in CONSUMER_TOOLS.items()
231
+ ]
232
+ yield SelectionList[str](*tool_selections, id="tools")
233
+ with Vertical(id="source-panel", classes="panel"):
234
+ yield Static("[b]Data Source[/b]")
235
+ yield RadioSet(id="sources")
236
+ yield Static("", id="tools-summary")
237
+ yield Button(
238
+ "Launch", id="launch-btn", variant="primary",
239
+ classes="launch-btn")
240
+ with TabPane("Data Provider", id="tab-provider"):
241
+ with Vertical(classes="panel", id="provider-panel"):
242
+ yield Static(
243
+ "[b]Provider[/b] - runs standalone, "
244
+ "no consumer tool")
245
+ yield RadioSet(
246
+ RadioButton(
247
+ PROVIDER_LABELS["fastapi"], value=True),
248
+ RadioButton(PROVIDER_LABELS["mqtt"]),
249
+ id="providers",
250
+ )
251
+ yield Static("", id="provider-summary")
252
+ yield Button(
253
+ "Start Provider", id="provider-btn",
254
+ variant="primary", classes="launch-btn")
255
+ yield Footer()
256
+
257
+ async def on_mount(self) -> None:
258
+ await self._refresh_sources()
259
+ self._update_provider_summary()
260
+
261
+ # ── Tools tab ────────────────────────────────────────────
262
+
263
+ def _selected_tool_ids(self) -> List[str]:
264
+ return list(self.query_one("#tools", SelectionList).selected)
265
+
266
+ def _current_source_items(self) -> List[Tuple[str, str]]:
267
+ tool_ids = self._selected_tool_ids()
268
+ supported = _combined_supported_sources(tool_ids)
269
+ sources = _available_sources(
270
+ is_multi=len(tool_ids) > 1,
271
+ supported_sources=supported)
272
+ return sources
273
+
274
+ async def _refresh_sources(self) -> None:
275
+ sources = self._current_source_items()
276
+ radio_set = self.query_one("#sources", RadioSet)
277
+ self._source_ids = [s for s, _ in sources]
278
+
279
+ radio_set._pressed_button = None
280
+ await radio_set.remove_children()
281
+ if not sources:
282
+ await radio_set.mount(
283
+ RadioButton("(no compatible source)", disabled=True))
284
+ else:
285
+ preferred_idx = 0
286
+ if self._default_source in self._source_ids:
287
+ preferred_idx = self._source_ids.index(
288
+ self._default_source)
289
+ buttons = [RadioButton(label) for _, label in sources]
290
+ await radio_set.mount(*buttons)
291
+ # RadioSet only auto-selects a pre-checked button in its own
292
+ # one-time _on_mount scan; that doesn't re-run on a dynamic
293
+ # remount, and setting .value=True just posts an async
294
+ # Changed message we'd have to await-settle before reading
295
+ # pressed_index. Simpler and immediate: set the internal
296
+ # pointer directly, matching what _on_mount itself does.
297
+ with radio_set.prevent(RadioButton.Changed):
298
+ buttons[preferred_idx].value = True
299
+ radio_set._pressed_button = buttons[preferred_idx]
300
+ self._update_tools_summary()
301
+
302
+ def _current_source_id(self) -> Optional[str]:
303
+ radio_set = self.query_one("#sources", RadioSet)
304
+ idx = radio_set.pressed_index
305
+ if idx is None or idx < 0 or not getattr(
306
+ self, "_source_ids", None):
307
+ return None
308
+ if idx >= len(self._source_ids):
309
+ return None
310
+ return self._source_ids[idx]
311
+
312
+ def _update_tools_summary(self) -> None:
313
+ tool_ids = self._selected_tool_ids()
314
+ source = self._current_source_id()
315
+ tool_names = ", ".join(
316
+ CONSUMER_TOOLS[tid]["name"]
317
+ for tid in tool_ids) or "(none selected)"
318
+ source_label = SOURCE_LABELS.get(
319
+ source, "(none)") if source else "(none)"
320
+ summary = self.query_one("#tools-summary", Static)
321
+ summary.update(f"Tools: {tool_names} | Source: {source_label}")
322
+
323
+ btn = self.query_one("#launch-btn", Button)
324
+ ready = bool(tool_ids and source)
325
+ btn.disabled = not ready
326
+ btn.set_class(ready, "-ready")
327
+
328
+ async def on_selection_list_selected_changed(self, event) -> None:
329
+ if event.selection_list.id == "tools":
330
+ await self._refresh_sources()
331
+
332
+ def on_radio_set_changed(self, event) -> None:
333
+ if event.radio_set.id == "sources":
334
+ self._update_tools_summary()
335
+ elif event.radio_set.id == "providers":
336
+ self._update_provider_summary()
337
+
338
+ def on_button_pressed(self, event) -> None:
339
+ if event.button.id == "launch-btn":
340
+ self.action_launch()
341
+ elif event.button.id == "provider-btn":
342
+ self._start_provider()
343
+
344
+ def action_launch(self) -> None:
345
+ tool_ids = self._selected_tool_ids()
346
+ source = self._current_source_id()
347
+ if tool_ids and source:
348
+ self.result = ("tools", tool_ids, source)
349
+ self.exit()
350
+
351
+ # ── Data Provider tab ───────────────────────────────────
352
+
353
+ def _current_provider(self) -> str:
354
+ idx = self.query_one("#providers", RadioSet).pressed_index
355
+ return "mqtt" if idx == 1 else "fastapi"
356
+
357
+ def _update_provider_summary(self) -> None:
358
+ provider = self._current_provider()
359
+ summary = self.query_one("#provider-summary", Static)
360
+ summary.update(f"Provider: {PROVIDER_LABELS[provider]}")
361
+
362
+ def _start_provider(self) -> None:
363
+ self.result = ("provider", self._current_provider())
364
+ self.exit()
365
+
366
+ # ── Global actions ──────────────────────────────────────
367
+
368
+ def action_quit_app(self) -> None:
369
+ self.result = None
370
+ self.exit()
371
+
372
+ return LauncherApp()
373
+
374
+
375
+ def _run_picker_screen(
376
+ default_tool_ids: List[str],
377
+ default_source: Optional[str]) -> PickerResult:
378
+ """Run the full-screen picker. Returns a PickerResult (see above) or
379
+ None if cancelled.
380
+
381
+ Falls back to the sequential prompt-based picker (_sequential_pick) if
382
+ Textual's app can't start in this terminal.
383
+ """
384
+ app = _build_launcher_app(default_tool_ids, default_source)
385
+ try:
386
+ app.run()
387
+ except Exception as e:
388
+ logger.debug(
389
+ f"Textual picker failed ({e}); "
390
+ "falling back to sequential prompts")
391
+ return _sequential_pick(default_tool_ids, default_source)
392
+ except KeyboardInterrupt:
393
+ return None
394
+ return app.result
395
+
396
+
397
+ # ──────────────────────────────────────────────────────────────
398
+ # Sequential fallback (used if the full-screen app can't run)
399
+ # ──────────────────────────────────────────────────────────────
400
+
401
+ def _fuzzy_pick(items: List[Tuple[str, str]], title: str,
402
+ default: Optional[str] = None) -> Optional[str]:
403
+ """Single-select picker over (value, label) pairs, printing the full
404
+ option list up front. Accepts a number or enough of a label to match
405
+ exactly one option.
406
+ """
407
+ default_index = next(
408
+ (i for i, (value, _) in enumerate(
409
+ items, 1) if value == default), None)
410
+
411
+ print(f"\n{title}")
412
+ for i, (_, label) in enumerate(items, 1):
413
+ marker = " (last used)" if i == default_index else ""
414
+ print(f" {i}. {label}{marker}")
415
+ default_str = str(default_index) if default_index else ""
416
+ suffix = f" [{default_str}]" if default_str else ""
417
+ try:
418
+ answer = input(f"Choice{suffix}: ").strip() or default_str
419
+ except (EOFError, KeyboardInterrupt):
420
+ return None
421
+
422
+ if not answer:
423
+ return None
424
+
425
+ if answer.isdigit():
426
+ idx = int(answer)
427
+ if 1 <= idx <= len(items):
428
+ return items[idx - 1][0]
429
+ print(f" No option numbered {idx}.")
430
+ return None
431
+
432
+ labels = [label for _, label in items]
433
+ matches = [label for label in labels if answer.lower() in label.lower()]
434
+ if len(matches) == 1:
435
+ idx = labels.index(matches[0])
436
+ return items[idx][0]
437
+
438
+ print(f" No unambiguous match for {answer!r}.")
439
+ return None
440
+
441
+
442
+ def _multi_pick(items: List[Tuple[str, str]], title: str) -> List[str]:
443
+ """Comma-separated number list multi-select (plain input())."""
444
+ print(f"\n{title}")
445
+ for i, (_, label) in enumerate(items, 1):
446
+ print(f" {i}. {label}")
447
+ print(" Enter numbers separated by commas (e.g. 1,3), or 'all'.")
448
+ try:
449
+ answer = input("> ").strip().lower()
450
+ except (EOFError, KeyboardInterrupt):
451
+ return []
452
+ if not answer:
453
+ return []
454
+ if answer == "all":
455
+ return [value for value, _ in items]
456
+ selected = []
457
+ for part in answer.split(","):
458
+ part = part.strip()
459
+ if part.isdigit() and 1 <= int(part) <= len(items):
460
+ selected.append(items[int(part) - 1][0])
461
+ return selected
462
+
463
+
464
+ def _sequential_pick(
465
+ default_tool_ids: List[str],
466
+ default_source: Optional[str]) -> PickerResult:
467
+ """Fallback flow when the full-screen picker can't run: choose tools
468
+ vs. data-provider mode, then pick accordingly, via sequential prompts."""
469
+ mode_choice = _fuzzy_pick(
470
+ [
471
+ (
472
+ "tools",
473
+ "Run tool(s) - pick one or more consumer tools and a "
474
+ "data source",
475
+ ),
476
+ (
477
+ "provider",
478
+ "Data provider - run FastAPI or MQTT standalone for "
479
+ "other processes",
480
+ ),
481
+ ],
482
+ "What would you like to do?",
483
+ )
484
+ if mode_choice is None:
485
+ return None
486
+
487
+ if mode_choice == "provider":
488
+ provider = _fuzzy_pick(
489
+ list(
490
+ PROVIDER_LABELS.items()),
491
+ "Select a data provider:")
492
+ if provider is None:
493
+ return None
494
+ return ("provider", provider)
495
+
496
+ tool_values = [(tid, f"{t['name']} - {t['description']}")
497
+ for tid, t in CONSUMER_TOOLS.items()]
498
+
499
+ print("\n=== Select Tool(s) ===")
500
+ try:
501
+ sub_mode = input(
502
+ "Single tool or multiple? (s/m) [s]: ").strip().lower() or "s"
503
+ except (EOFError, KeyboardInterrupt):
504
+ return None
505
+
506
+ if sub_mode.startswith("m"):
507
+ tool_ids = _multi_pick(tool_values, "Select Tools")
508
+ if not tool_ids:
509
+ print(" No tools selected.")
510
+ return None
511
+ else:
512
+ tool_id = _fuzzy_pick(
513
+ tool_values,
514
+ "Select a tool:",
515
+ default=default_tool_ids[0] if default_tool_ids else None)
516
+ if tool_id is None:
517
+ return None
518
+ tool_ids = [tool_id]
519
+
520
+ supported = _combined_supported_sources(tool_ids)
521
+ sources = _available_sources(
522
+ is_multi=len(tool_ids) > 1,
523
+ supported_sources=supported)
524
+ if not sources:
525
+ print(" No compatible data source available for this selection.")
526
+ return None
527
+
528
+ valid_defaults = [s for s, _ in sources]
529
+ default = default_source if default_source in valid_defaults else None
530
+ source = _fuzzy_pick(sources, "Select a data source:", default=default)
531
+ if source is None:
532
+ return None
533
+
534
+ return ("tools", tool_ids, source)
535
+
536
+
537
+ # ──────────────────────────────────────────────────────────────
538
+ # Source connection params (pre-filled from remembered prefs)
539
+ # ──────────────────────────────────────────────────────────────
540
+
541
+ def _prompt_default(label: str, default: str) -> str:
542
+ try:
543
+ val = input(f" {label} [{default}]: ").strip()
544
+ except (EOFError, KeyboardInterrupt):
545
+ return default
546
+ return val or default
547
+
548
+
549
+ _last_source_params: Dict[str, Dict] = {}
550
+
551
+
552
+ def _setup_source(source_type: str) -> bool:
553
+ """Prompt for any source-specific params, then check/start the source."""
554
+ remembered = get_source_params(source_type)
555
+ setup_kwargs: Dict = {}
556
+
557
+ if source_type == "fastapi":
558
+ port = int(remembered.get("port", os.environ.get("API_PORT", "8000")))
559
+ setup_kwargs = {"port": port}
560
+
561
+ elif source_type == "fastapi_custom":
562
+ host = _prompt_default("Host/IP", remembered.get("host", "127.0.0.1"))
563
+ port = _prompt_default("Port", str(remembered.get("port", 8000)))
564
+ try:
565
+ port = int(port)
566
+ except ValueError:
567
+ print(" Invalid port number.")
568
+ return False
569
+ base_url = f"http://{host}:{port}"
570
+ setup_kwargs = {"base_url": base_url}
571
+ os.environ["BENCHLAB_FASTAPI_CUSTOM_URL"] = base_url
572
+ remembered = {"host": host, "port": port}
573
+
574
+ elif source_type == "mqtt":
575
+ broker = os.environ.get(
576
+ "MQTT_BROKER", remembered.get(
577
+ "broker", "localhost"))
578
+ mqtt_port = int(
579
+ os.environ.get(
580
+ "MQTT_PORT",
581
+ remembered.get(
582
+ "mqtt_port",
583
+ 1883)))
584
+ setup_kwargs = {"broker": broker, "mqtt_port": mqtt_port}
585
+
586
+ elif source_type == "mqtt_custom":
587
+ broker = _prompt_default(
588
+ "Broker host", remembered.get(
589
+ "broker", "localhost"))
590
+ mqtt_port = _prompt_default(
591
+ "Broker port", str(
592
+ remembered.get(
593
+ "mqtt_port", 1883)))
594
+ try:
595
+ mqtt_port = int(mqtt_port)
596
+ except ValueError:
597
+ print(" Invalid port number.")
598
+ return False
599
+ setup_kwargs = {"broker": broker, "mqtt_port": mqtt_port}
600
+ remembered = {"broker": broker, "mqtt_port": mqtt_port}
601
+
602
+ elif source_type == "service_http":
603
+ import urllib.parse
604
+ svc_url = os.environ.get(
605
+ "BENCHLAB_SERVICE_URL",
606
+ f"http://localhost:{SERVICE_HTTP_DEFAULT_PORT}")
607
+ parsed = urllib.parse.urlparse(svc_url)
608
+ setup_kwargs = {"host": parsed.hostname or "localhost",
609
+ "port": parsed.port or SERVICE_HTTP_DEFAULT_PORT}
610
+
611
+ ready = check_and_setup_source(source_type, **setup_kwargs)
612
+ _last_source_params[source_type] = remembered
613
+ return ready
614
+
615
+
616
+ # ──────────────────────────────────────────────────────────────
617
+ # Data Provider Mode (standalone FastAPI/MQTT, no consumer tool)
618
+ # ──────────────────────────────────────────────────────────────
619
+
620
+ def _run_data_provider(provider: str) -> None:
621
+ if provider == "fastapi":
622
+ port = _prompt_default("Port", "8000")
623
+ try:
624
+ port = int(port)
625
+ except ValueError:
626
+ print(" Invalid port number.")
627
+ return
628
+ os.environ["API_PORT"] = str(port)
629
+ if not check_and_setup_source("fastapi", port=port):
630
+ logger.error("Could not start FastAPI server.")
631
+ return
632
+ print("FastAPI server running. Press Ctrl+C to stop the provider.")
633
+ input(" (Press Enter to return to menu after verifying...) ")
634
+
635
+ elif provider == "mqtt":
636
+ host = _prompt_default("Broker host", "localhost")
637
+ port = _prompt_default("Broker port", "1883")
638
+ try:
639
+ port = int(port)
640
+ except ValueError:
641
+ print(" Invalid port number.")
642
+ return
643
+ if not check_mqtt_running(host, port):
644
+ logger.warning(f"No MQTT broker at {host}:{port}")
645
+ logger.info("Starting embedded broker...")
646
+ if not start_mqtt_broker(port):
647
+ logger.error("Could not start MQTT broker.")
648
+ return
649
+ else:
650
+ logger.info(f"MQTT broker available at {host}:{port}")
651
+ os.environ["MQTT_BROKER"] = host
652
+ os.environ["MQTT_PORT"] = str(port)
653
+ start_mqtt_source(host, port)
654
+ logger.info("Press Ctrl+C to stop the provider.")
655
+ input(" (Press Enter to return to menu after verifying...) ")
656
+
657
+
658
+ # ──────────────────────────────────────────────────────────────
659
+ # Top-level flow
660
+ # ──────────────────────────────────────────────────────────────
661
+
662
+ def _launch(tool_ids: List[str], source: str) -> None:
663
+ tool_names = [CONSUMER_TOOLS[tid]["name"] for tid in tool_ids]
664
+
665
+ _last_source_params.clear()
666
+ if not _setup_source(source):
667
+ print(f"\n Could not set up '{source}' data source.")
668
+ if source in ("named_pipe", "service_http"):
669
+ print(
670
+ " Start the BenchLab Windows service (BL_Service.exe) "
671
+ "and try again.")
672
+ return
673
+
674
+ print("\n=== Launch Summary ===")
675
+ print(f"Tools: {', '.join(tool_names)}")
676
+ print(f"Data source: {source}")
677
+ try:
678
+ if input("Launch? (Y/n): ").strip().lower() in ("n", "no"):
679
+ print("Aborted.")
680
+ return
681
+ except (EOFError, KeyboardInterrupt):
682
+ print("\nAborted.")
683
+ return
684
+
685
+ os.environ["BENCHLAB_DATA_SOURCE"] = source
686
+ record_launch(tool_ids, source, _last_source_params.get(source))
687
+
688
+ try:
689
+ if len(tool_ids) > 1:
690
+ launch_tools_concurrent(tool_ids)
691
+ else:
692
+ launch_single_tool(tool_ids[0])
693
+ except KeyboardInterrupt:
694
+ print("\n Interrupted.")
695
+ finally:
696
+ cleanup_all_services()
697
+
698
+
699
+ def interactive_loop() -> None:
700
+ """Drive the interactive menu until the user quits."""
701
+ while True:
702
+ try:
703
+ prefs = load_prefs()
704
+ default_tool_ids = [
705
+ tid for tid in (
706
+ prefs.get("last_tool_ids") or []) if tid in CONSUMER_TOOLS]
707
+ default_source = prefs.get("last_source")
708
+
709
+ picked = _run_picker_screen(default_tool_ids, default_source)
710
+ clear_screen()
711
+
712
+ if picked is None:
713
+ print("Goodbye!")
714
+ cleanup_all_services()
715
+ return
716
+
717
+ if picked[0] == "provider":
718
+ _, provider = picked
719
+ _run_data_provider(provider)
720
+ elif picked[0] == "tools":
721
+ _, tool_ids, source = picked
722
+ _launch(tool_ids, source)
723
+
724
+ input("\n Press Enter to continue... ")
725
+
726
+ except (EOFError, KeyboardInterrupt):
727
+ print("Goodbye!")
728
+ cleanup_all_services()
729
+ return