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/vu/vu_tui.py ADDED
@@ -0,0 +1,919 @@
1
+ # benchlab/vu/vu_tui.py
2
+ import curses
3
+ import json
4
+ import logging
5
+ import requests
6
+ import time
7
+ import signal
8
+ import sys
9
+ import threading
10
+ from pathlib import Path
11
+
12
+ from benchlab.vu import devices
13
+ from benchlab.vu.vu_server_manager import (
14
+ start_vu_server, check_vu_server, terminate_vu_server, forward_logs)
15
+
16
+ # --- Logger setup ---
17
+ logger = logging.getLogger(__name__)
18
+ logger.setLevel(logging.DEBUG)
19
+
20
+ # Make sure logs go to console
21
+ ch = logging.StreamHandler(sys.stdout)
22
+ ch.setLevel(logging.INFO)
23
+ formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
24
+ ch.setFormatter(formatter)
25
+ logger.addHandler(ch)
26
+
27
+ VU_SERVER_CONFIG = Path(__file__).parent / "vu_server.config"
28
+ VU_DIAL_CONFIG = Path(__file__).parent / "vu_dial.config"
29
+
30
+
31
+ def load_json(path, default=None):
32
+ if path.exists():
33
+ with open(path, "r", encoding="utf-8") as f:
34
+ return json.load(f)
35
+ return default if default is not None else {}
36
+
37
+
38
+ def save_json(path, data):
39
+ with open(path, "w", encoding="utf-8") as f:
40
+ json.dump(data, f, indent=2)
41
+
42
+
43
+ def _parse_float_or(value, fallback):
44
+ """Parse *value* as a float, returning *fallback* on empty/invalid input
45
+ instead of raising — matches the existing guarded-input pattern used
46
+ elsewhere in this TUI (e.g. tab2's int() cast, sensor selection)."""
47
+ if not value:
48
+ return fallback
49
+ try:
50
+ return float(value)
51
+ except ValueError:
52
+ return fallback
53
+
54
+
55
+ def get_available_sensors(datasource=None) -> list:
56
+ """Return sensor keys from a live datasource snapshot.
57
+ Falls back to an empty list if no data is available yet.
58
+ """
59
+ if datasource is None:
60
+ return []
61
+ try:
62
+ raw = datasource.list_devices()
63
+ if isinstance(raw, dict):
64
+ uids = list(raw.keys())
65
+ else:
66
+ uids = [d.get("uid") for d in raw if d.get("uid")]
67
+ if not uids:
68
+ return []
69
+ datasource.select_device(uids[0])
70
+ snap = datasource.snapshot()
71
+ data = snap.get("sensor_data") or snap.get(
72
+ "all_telemetry", {}).get(uids[0], {})
73
+ return [k for k in data if k.lower() != "timestamp"]
74
+ except Exception as e:
75
+ logger.warning(f"Could not get sensor list from datasource: {e}")
76
+ return []
77
+
78
+
79
+ class VUTUI:
80
+ def __init__(self, stdscr, server_proc=None, datasource=None):
81
+ self.stdscr = stdscr
82
+ self.running = True
83
+ self.server_proc = server_proc
84
+ self.datasource = datasource
85
+
86
+ try:
87
+ curses.curs_set(0)
88
+ except BaseException:
89
+ pass
90
+ self.stdscr.nodelay(False)
91
+
92
+ # Tabs
93
+ self.tab_index = 0
94
+ self.tab2_cursor = 0
95
+ self.tab2_start_line = 0
96
+ self.tab3_cursor = 0
97
+
98
+ # Colors
99
+ curses.start_color()
100
+ curses.use_default_colors()
101
+ curses.init_pair(
102
+ 1,
103
+ curses.COLOR_WHITE,
104
+ curses.COLOR_YELLOW) # Active tab
105
+ curses.init_pair(2, curses.COLOR_GREEN, -
106
+ 1) # Status OK
107
+ curses.init_pair(3, curses.COLOR_RED, -
108
+ 1) # Warnings/errors
109
+ curses.init_pair(4, curses.COLOR_MAGENTA, -
110
+ 1) # Temperature
111
+ # Inactive tab
112
+ curses.init_pair(6, curses.COLOR_WHITE, -1)
113
+ curses.init_pair(
114
+ 7,
115
+ curses.COLOR_BLACK,
116
+ curses.COLOR_CYAN) # Title bar
117
+
118
+ # Load configs & devices
119
+ self.reload_configs()
120
+ self.SENSORS = get_available_sensors(self.datasource)
121
+
122
+ # -------------------- CLEANUP --------------------
123
+ def cleanup(self):
124
+ """Close benchlab serial ports. The auto-started VU server process
125
+ (if any) is terminated separately by the caller (run_vu_tui), which
126
+ owns server_proc — not tracked on self.
127
+ """
128
+ logger.info("Starting cleanup...")
129
+
130
+ # Close benchlab ports
131
+ for b in getattr(self, "benchlabs", []):
132
+ try:
133
+ if hasattr(b, "close") and callable(b.close):
134
+ b.close()
135
+ logger.info(
136
+ f"Closed serial port for benchlab {
137
+ b.get(
138
+ 'port', '?')}")
139
+ except Exception as e:
140
+ logger.warning(
141
+ f"Failed to close port {
142
+ b.get(
143
+ 'port',
144
+ '?')}: {e}")
145
+
146
+ logger.info("Cleanup complete.")
147
+
148
+ # -------------------- CONFIG / DIALS --------------------
149
+ def reload_configs(self):
150
+ template_server = VU_SERVER_CONFIG.parent / "vu_server.config_template"
151
+ template_dial = VU_DIAL_CONFIG.parent / "vu_dial.config_template"
152
+
153
+ if not VU_SERVER_CONFIG.exists():
154
+ src = template_server if template_server.exists() else None
155
+ cfg = load_json(
156
+ src,
157
+ {
158
+ "vu_server_url": "http://localhost:5340",
159
+ "api_key": "",
160
+ "logo_file": ""}) if src else {
161
+ "vu_server_url": "http://localhost:5340",
162
+ "api_key": "",
163
+ "logo_file": ""}
164
+ save_json(VU_SERVER_CONFIG, cfg)
165
+ logger.info(
166
+ "Created VU server config from template"
167
+ if src else "Created default VU server config")
168
+
169
+ if not VU_DIAL_CONFIG.exists():
170
+ if template_dial.exists():
171
+ entries = load_json(template_dial, [])
172
+ for e in entries:
173
+ e["benchlab_port"] = ""
174
+ e["benchlab_uid"] = ""
175
+ e["sensor"] = ""
176
+ save_json(VU_DIAL_CONFIG, entries)
177
+ logger.info("Created VU dial config from template")
178
+ else:
179
+ save_json(VU_DIAL_CONFIG, [])
180
+ logger.info("Created empty VU dial config")
181
+
182
+ self.server_cfg = load_json(VU_SERVER_CONFIG, {})
183
+ self.dial_cfg = load_json(VU_DIAL_CONFIG, [])
184
+ self.benchlabs = devices.get_benchlab_devices(self.datasource)
185
+
186
+ self.vu_server_running = devices.vu_server_check(
187
+ self.server_cfg.get("vu_server_url", "http://localhost:5340"),
188
+ self.server_cfg.get("api_key", "")
189
+ )
190
+
191
+ if self.vu_server_running:
192
+ self.vu_dials = devices.get_vu_dials(
193
+ self.server_cfg.get("vu_server_url", "http://localhost:5340"),
194
+ self.server_cfg.get("api_key", "")
195
+ )
196
+ else:
197
+ self.vu_dials = []
198
+
199
+ # Default config for unconfigured dials
200
+ for uid, name in self.vu_dials:
201
+ if not any(d.get("dial_uid") == uid for d in self.dial_cfg):
202
+ self.dial_cfg.append({
203
+ "dial_uid": uid,
204
+ "dial_name": name,
205
+ "benchlab_port": "Unknown",
206
+ "benchlab_uid": "",
207
+ "sensor": "",
208
+ "min": 0,
209
+ "max": 100,
210
+ "backlight": [100, 100, 100],
211
+ "easing_dial": [50, 5],
212
+ "easing_backlight": [50, 5]
213
+ })
214
+
215
+ def update_dial_name_on_server(self, dial_uid, new_name):
216
+ url = f"{
217
+ self.server_cfg.get(
218
+ 'vu_server_url',
219
+ 'http://localhost:5340')}/api/v0/dial/{dial_uid}/name"
220
+ params = {
221
+ "key": self.server_cfg.get("api_key", ""),
222
+ "name": new_name
223
+ }
224
+ try:
225
+ resp = requests.get(url, params=params, timeout=3)
226
+ if resp.status_code in (200, 201):
227
+ return True
228
+ else:
229
+ logger.warning(
230
+ f"Failed to update dial {dial_uid} name on server: {
231
+ resp.status_code} {
232
+ resp.text}")
233
+ except Exception as e:
234
+ logger.warning(
235
+ f"Exception updating dial {dial_uid} name on server: {e}")
236
+ return False
237
+
238
+ # -------------------- HELPERS --------------------
239
+ def get_or_create_cfg_entry(self, dial):
240
+ cfg_entry = next(
241
+ (d for d in self.dial_cfg if d['dial_uid'] == dial['uid']), None)
242
+ if cfg_entry:
243
+ return cfg_entry
244
+ cfg_entry = {
245
+ "dial_uid": dial['uid'],
246
+ "dial_name": dial['name'],
247
+ "benchlab_port": dial.get("benchlab_port", "Unknown"),
248
+ "benchlab_uid": dial.get("benchlab_uid", ""),
249
+ "sensor": dial.get("sensor", "Not configured"),
250
+ "min": 0,
251
+ "max": 100,
252
+ "backlight": [100, 100, 100],
253
+ "easing_dial": [50, 5],
254
+ "easing_backlight": [50, 5]
255
+ }
256
+ self.dial_cfg.append(cfg_entry)
257
+ return cfg_entry
258
+
259
+ # -------------------- TABS --------------------
260
+ def draw_tabs(self):
261
+ tabs = ["Overview", "Server Config", "Dial Mapping"]
262
+ x = 0
263
+ y = 2 # always draw tabs on line 2
264
+ self.h, self.w = self.stdscr.getmaxyx()
265
+
266
+ for idx, name in enumerate(tabs):
267
+ if idx == self.tab_index:
268
+ self.stdscr.attron(curses.color_pair(1))
269
+ self.stdscr.addstr(y, x, f" {name} ")
270
+ self.stdscr.attroff(curses.color_pair(1))
271
+ else:
272
+ self.stdscr.attron(curses.color_pair(6))
273
+ self.stdscr.addstr(y, x, f" {name} ")
274
+ self.stdscr.attroff(curses.color_pair(6))
275
+ x += len(name) + 3 # spacing
276
+
277
+ # -------------------- TAB 1 --------------------
278
+ def draw_tab1(self):
279
+ self.stdscr.clear()
280
+ self.h, self.w = self.stdscr.getmaxyx()
281
+ y_start = 4
282
+ y = y_start
283
+
284
+ # --- Header ---
285
+ self.stdscr.attron(curses.color_pair(7))
286
+ self.stdscr.addstr(0, 0, " BENCHLAB VU CONFIGURATION ".center(self.w))
287
+ self.stdscr.attroff(curses.color_pair(7))
288
+
289
+ self.draw_tabs()
290
+
291
+ # --- VU Server Config ---
292
+ self.stdscr.addstr(y, 2, "VU Server Config:", curses.A_UNDERLINE)
293
+ y += 1
294
+ keys = list(self.server_cfg.keys())
295
+ key_col_width = max((len(k) for k in keys), default=0) + 2
296
+ for k, v in self.server_cfg.items():
297
+ try:
298
+ self.stdscr.addstr(y, 4, f"{k}: ")
299
+ self.stdscr.addstr(y, 4 + key_col_width, f"{v}")
300
+ except curses.error:
301
+ break
302
+ y += 1
303
+ y += 1
304
+
305
+ # --- Benchlab devices ---
306
+ self.stdscr.addstr(
307
+ y,
308
+ 2,
309
+ "Available Benchlab Devices:",
310
+ curses.A_UNDERLINE)
311
+ y += 1
312
+ for b in self.benchlabs:
313
+ try:
314
+ self.stdscr.addstr(y, 4, f"{b['uid']} - {b['port']}")
315
+ except curses.error:
316
+ break
317
+ y += 1
318
+ y += 1
319
+
320
+ # --- VU Dials ---
321
+ self.stdscr.addstr(y, 2, "Available VU Dials:", curses.A_UNDERLINE)
322
+ y += 1
323
+ if self.vu_server_running:
324
+ for uid, name in self.vu_dials:
325
+ try:
326
+ self.stdscr.addstr(y, 4, f"{uid} - {name}")
327
+ except curses.error:
328
+ break
329
+ y += 1
330
+ else:
331
+ try:
332
+ self.stdscr.addstr(
333
+ y,
334
+ 4,
335
+ "N/A: VU server is not running!",
336
+ curses.color_pair(3))
337
+ except curses.error:
338
+ pass
339
+ y += 1
340
+ y += 1
341
+
342
+ # --- VU Dial Config Table ---
343
+ self.stdscr.addstr(y, 2, "VU Dial Config:", curses.A_UNDERLINE)
344
+ y += 1
345
+
346
+ if not self.dial_cfg:
347
+ try:
348
+ self.stdscr.addstr(
349
+ y, 4, "No VU dials configured", curses.color_pair(3))
350
+ except curses.error:
351
+ pass
352
+ y += 1
353
+ else:
354
+ # Table headers
355
+ headers = [
356
+ "UID",
357
+ "Name",
358
+ "Sensor",
359
+ "Min/Max",
360
+ "Benchlab",
361
+ "Backlight",
362
+ "Easing"]
363
+ # widths for alignment only
364
+ col_widths = [24, 10, 10, 11, 12, 15, 10]
365
+ x = 4
366
+ for i, h in enumerate(headers):
367
+ try:
368
+ self.stdscr.addstr(y, x, h, curses.A_UNDERLINE)
369
+ except curses.error:
370
+ pass
371
+ x += col_widths[i] + 1
372
+ y += 1
373
+
374
+ # Table rows
375
+ for dial in self.dial_cfg:
376
+ x = 4
377
+ row = [
378
+ str(dial.get("dial_uid", "")),
379
+ str(dial.get("dial_name", "")),
380
+ str(dial.get("sensor", "")),
381
+ f"{dial.get('min', '')}/{dial.get('max', '')}",
382
+ str(dial.get("benchlab_port", "")),
383
+ str(dial.get("backlight", "")),
384
+ f"{dial.get('easing_dial', '')}",
385
+ ]
386
+ for i, cell in enumerate(row):
387
+ try:
388
+ self.stdscr.addstr(y, x, cell.ljust(col_widths[i]))
389
+ except curses.error:
390
+ pass
391
+ x += col_widths[i] + 1
392
+ y += 1
393
+
394
+ # --- Footer with Provisioning ---
395
+ self.stdscr.addstr(
396
+ self.h - 1,
397
+ 0,
398
+ "TAB: Switch tabs | r: Reload | p: Provision new devices | "
399
+ "q: Quit")
400
+
401
+ def handle_tab1_input(self, key):
402
+ if key in (ord('r'), ord('R')):
403
+ self.reload_configs()
404
+ elif key in (ord('p'), ord('P')):
405
+ self.stdscr.addstr(
406
+ self.h - 3,
407
+ 0,
408
+ "Scanning and provisioning new devices...".ljust(
409
+ self.w))
410
+ self.stdscr.refresh()
411
+ success = devices.provision_vu_dials(
412
+ self.server_cfg.get("vu_server_url", "http://localhost:5340"),
413
+ self.server_cfg.get("api_key", "")
414
+ )
415
+ self.vu_dials = devices.get_vu_dials(
416
+ self.server_cfg.get("vu_server_url", "http://localhost:5340"),
417
+ self.server_cfg.get("api_key", "")
418
+ )
419
+ msg = (
420
+ "Provisioning successful." if success
421
+ else "Provisioning failed or no new devices found.")
422
+ self.stdscr.addstr(self.h - 3, 0, msg.ljust(self.w))
423
+ self.stdscr.refresh()
424
+ curses.napms(1000)
425
+
426
+ # -------------------- TAB 2 --------------------
427
+ def draw_tab2(self):
428
+ self.stdscr.erase() # erase instead of clear; safer
429
+ h, w = self.stdscr.getmaxyx()
430
+
431
+ # --- Header ---
432
+ self.stdscr.attron(curses.color_pair(7))
433
+ self.stdscr.addstr(0, 0, " BENCHLAB VU CONFIGURATION ".center(w))
434
+ self.stdscr.attroff(curses.color_pair(7))
435
+
436
+ # --- Tabs ---
437
+ self.draw_tabs()
438
+
439
+ y = 4
440
+ keys = list(self.server_cfg.keys())
441
+ key_col_width = max((len(k) for k in keys), default=0) + 2
442
+ visible_lines = self.h - y - 4
443
+
444
+ if self.tab2_cursor < self.tab2_start_line:
445
+ self.tab2_start_line = self.tab2_cursor
446
+ elif self.tab2_cursor >= self.tab2_start_line + visible_lines:
447
+ self.tab2_start_line = self.tab2_cursor - visible_lines + 1
448
+
449
+ for idx, key in enumerate(
450
+ keys[self.tab2_start_line:
451
+ self.tab2_start_line + visible_lines]):
452
+ val = str(self.server_cfg[key])
453
+ line_y = y + idx
454
+ key_text = f"{key}:".ljust(key_col_width)
455
+ self.stdscr.addstr(line_y, 4, key_text)
456
+ if idx + self.tab2_start_line == self.tab2_cursor:
457
+ self.stdscr.addstr(line_y, 4 +
458
+ key_col_width, val, curses.color_pair(1))
459
+ else:
460
+ self.stdscr.addstr(line_y, 4 + key_col_width, val)
461
+
462
+ self.stdscr.addstr(
463
+ self.h - 1,
464
+ 0,
465
+ "↑/↓: Navigate | Enter: Edit | s: Save | TAB: Switch tabs")
466
+
467
+ def handle_tab2_input(self, key):
468
+ keys = list(self.server_cfg.keys())
469
+ max_idx = len(keys) - 1
470
+
471
+ if key == curses.KEY_UP:
472
+ self.tab2_cursor = max(0, self.tab2_cursor - 1)
473
+ elif key == curses.KEY_DOWN:
474
+ self.tab2_cursor = min(max_idx, self.tab2_cursor + 1)
475
+ elif key in (curses.KEY_ENTER, ord('\n')):
476
+ key_name = keys[self.tab2_cursor]
477
+ old_val = str(self.server_cfg[key_name])
478
+ curses.echo()
479
+ prompt = f"Enter new value for {key_name}: "
480
+ self.stdscr.addstr(self.h - 2, 0, " " * (self.w - 1))
481
+ self.stdscr.addstr(self.h - 2, 0, prompt)
482
+ new_val = self.stdscr.getstr(self.h - 2, len(prompt), 60).decode()
483
+ curses.noecho()
484
+ if new_val:
485
+ if old_val.isdigit():
486
+ try:
487
+ new_val = int(new_val)
488
+ except ValueError:
489
+ new_val = old_val
490
+ self.server_cfg[key_name] = new_val
491
+ elif key in (ord('s'), ord('S')):
492
+ save_json(VU_SERVER_CONFIG, self.server_cfg)
493
+ self.stdscr.addstr(
494
+ self.h - 2,
495
+ 0,
496
+ "Config saved!".ljust(
497
+ self.w),
498
+ curses.color_pair(2))
499
+ self.stdscr.refresh()
500
+ time.sleep(1)
501
+
502
+ # -------------------- TAB 3 --------------------
503
+ def draw_tab3(self):
504
+ self.stdscr.erase() # erase instead of clear; safer
505
+ h, w = self.stdscr.getmaxyx()
506
+
507
+ # --- Header ---
508
+ self.stdscr.attron(curses.color_pair(7))
509
+ self.stdscr.addstr(0, 0, " BENCHLAB VU CONFIGURATION ".center(w))
510
+ self.stdscr.attroff(curses.color_pair(7))
511
+
512
+ # --- Tabs ---
513
+ self.draw_tabs()
514
+
515
+ y = 4
516
+ # Benchlab devices
517
+ self.stdscr.addstr(
518
+ y,
519
+ 2,
520
+ "Available Benchlab Devices:",
521
+ curses.A_UNDERLINE)
522
+ y += 1
523
+ for b in self.benchlabs:
524
+ self.stdscr.addstr(y, 2, f"{b['port']} - {b['uid']}")
525
+ y += 1
526
+ y += 1
527
+
528
+ # Build combined dial list
529
+ self.all_dials = []
530
+ for uid, name in self.vu_dials:
531
+ cfg_entry = next(
532
+ (d for d in self.dial_cfg if d.get("dial_uid") == uid), None)
533
+ benchlab_port = cfg_entry.get(
534
+ "benchlab_port", "Unknown") if cfg_entry else "Unknown"
535
+ sensor = cfg_entry.get(
536
+ "sensor", "Not configured") if cfg_entry else "Not configured"
537
+ min_val = cfg_entry.get("min", 0) if cfg_entry else 0
538
+ max_val = cfg_entry.get("max", 100) if cfg_entry else 100
539
+ self.all_dials.append({
540
+ "uid": uid,
541
+ "name": name,
542
+ "benchlab_port": benchlab_port,
543
+ "sensor": sensor,
544
+ "min": min_val,
545
+ "max": max_val
546
+ })
547
+
548
+ # Column widths
549
+ col_uid_width = max((len(d["uid"])
550
+ for d in self.all_dials), default=0) + 2
551
+ col_name_width = max((len(d["name"])
552
+ for d in self.all_dials), default=0) + 2
553
+ col_port_width = max((len(d["benchlab_port"])
554
+ for d in self.all_dials), default=0) + 2
555
+ col_min_width = 5
556
+ col_max_width = 5
557
+
558
+ self.stdscr.addstr(
559
+ y, 2, f"{
560
+ 'Dial UID':<{col_uid_width}} {
561
+ 'Dial Name':<{col_name_width}} " f"{
562
+ 'Benchlab':<{col_port_width}} {
563
+ 'Min':<{col_min_width}} " f"{
564
+ 'Max':<{col_max_width}} Sensor", curses.A_UNDERLINE)
565
+ y += 1
566
+
567
+ for idx, dial in enumerate(self.all_dials):
568
+ line = f"{
569
+ dial['uid']:<{col_uid_width}} {
570
+ dial['name']:<{col_name_width}} " f"{
571
+ dial['benchlab_port']:<{col_port_width}} {
572
+ dial['min']:<{col_min_width}} " f"{
573
+ dial['max']:<{col_max_width}} {
574
+ dial['sensor']}"
575
+ self.stdscr.addstr(
576
+ y + idx,
577
+ 2,
578
+ line[:self.w - 3],
579
+ curses.color_pair(1) if idx == self.tab3_cursor else 0)
580
+
581
+ footer_text = (
582
+ "Enter: Configure Dial | ↑/↓: Navigate | TAB: Switch tabs | "
583
+ "r: Reload | q: Quit")
584
+ self.stdscr.addstr(
585
+ self.h - 1,
586
+ 0,
587
+ footer_text[:self.w - 1])
588
+
589
+ def handle_tab3_input(self, key):
590
+ if not self.all_dials:
591
+ return
592
+ if key == curses.KEY_UP:
593
+ self.tab3_cursor = max(0, self.tab3_cursor - 1)
594
+ elif key == curses.KEY_DOWN:
595
+ self.tab3_cursor = min(
596
+ len(self.all_dials) - 1, self.tab3_cursor + 1)
597
+ elif key in (ord('\n'), curses.KEY_ENTER):
598
+ self.dial_mapping()
599
+ elif key == ord('r'):
600
+ self.reload_configs()
601
+
602
+ # -------------------- DIAL MAPPING --------------------
603
+ def dial_mapping(self):
604
+ if not self.all_dials:
605
+ h, w = self.stdscr.getmaxyx()
606
+ self.stdscr.addstr(
607
+ h - 2,
608
+ 0,
609
+ "No VU dials available!",
610
+ curses.color_pair(3))
611
+ self.stdscr.refresh()
612
+ curses.napms(1000)
613
+ return
614
+
615
+ h, w = self.stdscr.getmaxyx()
616
+ dial = self.all_dials[self.tab3_cursor]
617
+
618
+ # --- Step 1: Dial Name ---
619
+ current_name = dial.get("name", "")
620
+ prompt = f"Set Dial Name [{current_name}]: "
621
+ self.stdscr.move(h - 3, 0)
622
+ self.stdscr.clrtoeol()
623
+ self.stdscr.addstr(h - 3, 0, prompt[:w - 1])
624
+ curses.echo()
625
+ inp = self.stdscr.getstr(h - 3, len(prompt), 60).decode().strip()
626
+ curses.noecho()
627
+ if inp:
628
+ dial["name"] = inp
629
+
630
+ # Ensure cfg_entry exists or update
631
+ cfg_entry = next(
632
+ (d for d in self.dial_cfg if d['dial_uid'] == dial['uid']), None)
633
+ if not cfg_entry:
634
+ cfg_entry = {
635
+ "dial_uid": dial['uid'],
636
+ "dial_name": dial.get("name", ""),
637
+ "benchlab_port": dial.get("benchlab_port", "Unknown"),
638
+ "benchlab_uid": dial.get("benchlab_uid", ""),
639
+ "sensor": dial.get("sensor", "Not configured"),
640
+ "min": 0,
641
+ "max": 100,
642
+ "backlight": [100, 100, 100],
643
+ "easing_dial": [50, 5],
644
+ "easing_backlight": [50, 5]
645
+ }
646
+ self.dial_cfg.append(cfg_entry)
647
+ else:
648
+ if inp:
649
+ cfg_entry["dial_name"] = inp
650
+
651
+ success = self.update_dial_name_on_server(dial['uid'], dial["name"])
652
+ if success:
653
+ self.stdscr.addstr(
654
+ h - 2,
655
+ 0,
656
+ "Dial name updated on server!".ljust(w),
657
+ curses.color_pair(2))
658
+ else:
659
+ self.stdscr.addstr(
660
+ h - 2,
661
+ 0,
662
+ "Failed to update dial name on server!".ljust(w),
663
+ curses.color_pair(3))
664
+ self.stdscr.refresh()
665
+ curses.napms(1000)
666
+
667
+ # --- Step 2: Benchlab Port ---
668
+ current_port = dial.get("benchlab_port", "")
669
+ ports = [b["port"] for b in self.benchlabs]
670
+ prompt = f"Select Benchlab Port [{current_port}]: "
671
+ self.stdscr.move(h - 3, 0)
672
+ self.stdscr.clrtoeol()
673
+ self.stdscr.addstr(h - 3, 0, prompt[:w - 1])
674
+ curses.echo()
675
+ choice = self.stdscr.getstr(h - 3, len(prompt), 20).decode().strip()
676
+ curses.noecho()
677
+ if choice and choice in ports:
678
+ dial["benchlab_port"] = choice
679
+ dial["benchlab_uid"] = next(
680
+ (b["uid"] for b in self.benchlabs if b["port"] == choice), "")
681
+ cfg_entry["benchlab_port"] = choice
682
+ cfg_entry["benchlab_uid"] = dial["benchlab_uid"]
683
+
684
+ # --- Step 3: Sensor Selection ---
685
+ # Refresh sensor list in case datasource wasn't ready at startup
686
+ if not self.SENSORS:
687
+ self.SENSORS = get_available_sensors(self.datasource)
688
+
689
+ if not self.SENSORS:
690
+ self.stdscr.addstr(
691
+ h - 2,
692
+ 0,
693
+ "No sensors available yet — ensure device is connected."
694
+ .ljust(w),
695
+ curses.color_pair(3))
696
+ self.stdscr.refresh()
697
+ curses.napms(2000)
698
+ return
699
+
700
+ num_sensors = len(self.SENSORS)
701
+ col_width = max(len(s) for s in self.SENSORS) + 4
702
+ num_cols = max(1, w // col_width)
703
+ num_rows = (num_sensors + num_cols - 1) // num_cols
704
+
705
+ while True:
706
+ # Clear sensor display area
707
+ for row in range(num_rows):
708
+ self.stdscr.move(h - num_rows - 3 + row, 0)
709
+ self.stdscr.clrtoeol()
710
+
711
+ # Display sensors
712
+ for idx, sensor in enumerate(self.SENSORS):
713
+ row = idx % num_rows
714
+ col = idx // num_rows
715
+ x = col * col_width
716
+ s_str = f"{idx + 1}. {sensor}"
717
+ self.stdscr.addstr(h - num_rows - 3 + row,
718
+ x, s_str[:col_width - 1])
719
+
720
+ prompt = f"Select sensor number for {
721
+ dial['name']} or 'q' to skip: "
722
+ self.stdscr.move(h - 4, 0)
723
+ self.stdscr.clrtoeol()
724
+ self.stdscr.addstr(h - 3, 0, prompt[:w - 1])
725
+ curses.echo()
726
+ inp = self.stdscr.getstr(h - 3, len(prompt), 5).decode().strip()
727
+ curses.noecho()
728
+
729
+ if inp.lower() == 'q':
730
+ break
731
+
732
+ try:
733
+ sel = int(inp) - 1
734
+ if 0 <= sel < len(self.SENSORS):
735
+ dial['sensor'] = self.SENSORS[sel]
736
+ cfg_entry['sensor'] = self.SENSORS[sel]
737
+ break
738
+ except ValueError:
739
+ continue
740
+
741
+ # --- Step 4: Min / Max ---
742
+ current_min = cfg_entry.get("min", 0)
743
+ current_max = cfg_entry.get("max", 100)
744
+
745
+ # MIN
746
+ prompt = f"Set MIN [{current_min}]: "
747
+ self.stdscr.move(h - 3, 0)
748
+ self.stdscr.clrtoeol()
749
+ self.stdscr.addstr(h - 3, 0, prompt[:w - 1])
750
+ curses.echo()
751
+ inp = self.stdscr.getstr(h - 3, len(prompt), 10).decode().strip()
752
+ curses.noecho()
753
+ val_min = _parse_float_or(inp, current_min)
754
+
755
+ # MAX
756
+ prompt = f"Set MAX [{current_max}]: "
757
+ self.stdscr.move(h - 3, 0)
758
+ self.stdscr.clrtoeol()
759
+ self.stdscr.addstr(h - 3, 0, prompt[:w - 1])
760
+ curses.echo()
761
+ inp = self.stdscr.getstr(h - 3, len(prompt), 10).decode().strip()
762
+ curses.noecho()
763
+ val_max = _parse_float_or(inp, current_max)
764
+
765
+ if val_max <= val_min:
766
+ self.stdscr.addstr(
767
+ h - 2,
768
+ 0,
769
+ "MAX must be greater than MIN!",
770
+ curses.color_pair(3))
771
+ self.stdscr.refresh()
772
+ curses.napms(1500)
773
+ else:
774
+ cfg_entry["min"] = val_min
775
+ cfg_entry["max"] = val_max
776
+ save_json(VU_DIAL_CONFIG, self.dial_cfg)
777
+ self.stdscr.addstr(
778
+ h - 2,
779
+ 0,
780
+ "Dial mapping saved!".ljust(w),
781
+ curses.color_pair(2))
782
+ self.stdscr.refresh()
783
+ curses.napms(1000)
784
+
785
+ # -------------------- MAIN LOOP --------------------
786
+ def run(self):
787
+ while self.running:
788
+ if self.tab_index == 0:
789
+ self.draw_tab1()
790
+ elif self.tab_index == 1:
791
+ self.draw_tab2()
792
+ elif self.tab_index == 2:
793
+ self.draw_tab3()
794
+
795
+ self.stdscr.refresh()
796
+ key = self.stdscr.getch()
797
+
798
+ if key == ord('\t'):
799
+ self.tab_index = (self.tab_index + 1) % 3
800
+ elif key in (ord('q'), ord('Q')):
801
+ break
802
+ else:
803
+ if self.tab_index == 0:
804
+ self.handle_tab1_input(key)
805
+ elif self.tab_index == 1:
806
+ self.handle_tab2_input(key)
807
+ elif self.tab_index == 2:
808
+ self.handle_tab3_input(key)
809
+
810
+ # -------------------- RUN TUI --------------------
811
+
812
+
813
+ def run_vu_tui(stdscr, server_proc=None, datasource=None):
814
+ tui = VUTUI(stdscr, server_proc=server_proc, datasource=datasource)
815
+
816
+ def sigint_handler(sig, frame):
817
+ tui.running = False
818
+ logger.info("Ctrl+C detected. Exiting TUI...")
819
+
820
+ signal.signal(signal.SIGINT, sigint_handler)
821
+
822
+ try:
823
+ tui.run()
824
+ finally:
825
+ logger.info("Cleaning up TUI and server...")
826
+ if tui:
827
+ tui.cleanup()
828
+ if server_proc:
829
+ terminate_vu_server(server_proc)
830
+ logger.info("VU server terminated.")
831
+
832
+ # -------------------- LAUNCH FUNCTION --------------------
833
+
834
+
835
+ def launch_vu_config(args=None):
836
+ """Launch the VU configuration TUI.
837
+
838
+ Parameters
839
+ ----------
840
+ args:
841
+ Standard benchlab args namespace. If None, reads from env vars.
842
+ """
843
+ import os
844
+ import types as _types
845
+ from benchlab.core.datasource_manager import DataSourceManager
846
+
847
+ if args is None:
848
+ args = _types.SimpleNamespace(
849
+ source=os.environ.get(
850
+ "BENCHLAB_DATA_SOURCE",
851
+ "direct"),
852
+ api_url=os.environ.get(
853
+ "BENCHLAB_API_URL",
854
+ "http://127.0.0.1:8000"),
855
+ mqtt_broker=os.environ.get(
856
+ "MQTT_BROKER",
857
+ "localhost"),
858
+ mqtt_port=int(
859
+ os.environ.get(
860
+ "MQTT_PORT",
861
+ "1883")),
862
+ interval=1.0,
863
+ )
864
+
865
+ logger.info("Launching the BENCHLAB VU Server & Dials Configuration")
866
+ time.sleep(1)
867
+ logger.info("Launch updater with -vu")
868
+ time.sleep(1)
869
+ logger.info("Checking for VU server...")
870
+ time.sleep(1)
871
+
872
+ server_cfg = load_json(VU_SERVER_CONFIG, {})
873
+ server_url = server_cfg.get("vu_server_url", "http://localhost:5340")
874
+ api_key = server_cfg.get("api_key", "")
875
+
876
+ server_proc = None
877
+ if not check_vu_server(server_url, api_key):
878
+ logger.info("No server detected — starting local VU server...")
879
+ server_proc = start_vu_server()
880
+ if server_proc:
881
+ logger.info(f"Local VU server started at {server_url}")
882
+ threading.Thread(
883
+ target=forward_logs, args=(
884
+ server_proc,), daemon=True).start()
885
+ else:
886
+ logger.warning("Failed to start local VU server.")
887
+ else:
888
+ logger.info(f"VU server already running at {server_url}")
889
+
890
+ time.sleep(1)
891
+
892
+ ds_kwargs = {}
893
+ if args.source in ("fastapi", "fastapi_custom"):
894
+ ds_kwargs["base_url"] = args.api_url
895
+ elif args.source == "mqtt":
896
+ ds_kwargs["broker"] = args.mqtt_broker
897
+ ds_kwargs["port"] = args.mqtt_port
898
+
899
+ try:
900
+ datasource = DataSourceManager(source_type=args.source, **ds_kwargs)
901
+ if not datasource.connect():
902
+ logger.warning(
903
+ f"Could not connect to {
904
+ args.source} datasource — sensor list may be empty")
905
+ except Exception:
906
+ # run_vu_tui's own cleanup (which terminates server_proc) never runs
907
+ # if we fail before reaching curses.wrapper below, so terminate it
908
+ # here instead of leaking the auto-started server process.
909
+ terminate_vu_server(server_proc)
910
+ raise
911
+
912
+ try:
913
+ curses.wrapper(run_vu_tui, server_proc, datasource)
914
+ finally:
915
+ datasource.disconnect()
916
+
917
+
918
+ if __name__ == "__main__":
919
+ launch_vu_config()