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
@@ -0,0 +1,1298 @@
1
+ """
2
+ TUI Core - Clean UI rendering logic
3
+
4
+ Contains the main TUI class and rendering functions, separated from
5
+ datasource concerns. Consumes snapshots from DataSourceManager.
6
+ """
7
+
8
+ import curses
9
+ import logging
10
+ import time
11
+ from datetime import datetime
12
+ from typing import Dict, Any, List, Optional
13
+
14
+ from .config import Config
15
+ from benchlab.core.statistics import ChannelStats, StatsFormatter
16
+
17
+ logger = logging.getLogger("benchlab.tui.tui_core")
18
+
19
+
20
+ class TUICore:
21
+ """
22
+ Core TUI class handling rendering, navigation, and user input.
23
+ Separated from datasource management for clean architecture.
24
+ """
25
+
26
+ def __init__(self, stdscr, version: str = "1.0.0"):
27
+ """Initialize TUI core.
28
+
29
+ Args:
30
+ stdscr: Curses screen object
31
+ version: Application version string
32
+ """
33
+ self.stdscr = stdscr
34
+ self.version = version
35
+
36
+ # UI state
37
+ self.current_tab = 0
38
+ self.fleet_index = 0
39
+ self.show_help_modal = False
40
+
41
+ self._help_win = None
42
+ self._help_win_size = None
43
+ self._last_size = None
44
+
45
+ # Status message system
46
+ self.status_msg = ""
47
+ self.status_msg_expires = 0.0
48
+
49
+ # Initialize curses
50
+ curses.curs_set(0)
51
+ curses.start_color()
52
+ curses.use_default_colors()
53
+ Config.init_colors()
54
+
55
+ self.stdscr.nodelay(True)
56
+ self.stdscr.timeout(100) # 100ms poll for responsive UI
57
+
58
+ def set_status(self, msg: str, secs: float = 2.0):
59
+ """Set transient status message.
60
+
61
+ Args:
62
+ msg: Status message to display
63
+ secs: Duration in seconds to show message
64
+ """
65
+ self.status_msg = msg
66
+ self.status_msg_expires = time.monotonic() + secs
67
+
68
+ def render(self, snapshot: Dict[str, Any], stats: ChannelStats,
69
+ fleet_devices: List[Dict[str, Any]], refresh_interval: float):
70
+ """Main render method - draws entire UI.
71
+
72
+ Args:
73
+ snapshot: Current state snapshot from DataSourceManager
74
+ stats: Statistics object for min/max/avg data
75
+ fleet_devices: List of available devices for fleet tab
76
+ refresh_interval: Telemetry refresh interval
77
+ """
78
+ self.stdscr.erase()
79
+ height, width = self.stdscr.getmaxyx()
80
+
81
+ # Check minimum size
82
+ if (height < Config.MIN_TERMINAL_ROWS
83
+ or width < Config.MIN_TERMINAL_COLS):
84
+ self._render_size_warning(width, height)
85
+ return False
86
+
87
+ # Detect silent resize (windows-curses may not send KEY_RESIZE)
88
+ last = getattr(self, '_last_size', None)
89
+ if last != (height, width):
90
+ self._last_size = (height, width)
91
+ self._help_win = None
92
+ self._reset_input_settings()
93
+ self.stdscr.clearok(True)
94
+
95
+ # Render UI components
96
+ self._render_header(width)
97
+ self._render_tabs(width)
98
+ self._render_separator(width)
99
+ self._render_current_tab(
100
+ snapshot,
101
+ stats,
102
+ fleet_devices,
103
+ refresh_interval,
104
+ height,
105
+ width)
106
+ self._render_status_bar(snapshot, height, width)
107
+
108
+ # Stage stdscr first so the help window (staged after, if shown)
109
+ # composites on top of it — doupdate() paints windows in the order
110
+ # they were noutrefresh()'d, so staging order here is significant.
111
+ self.stdscr.noutrefresh()
112
+
113
+ # Show help modal if requested
114
+ if self.show_help_modal:
115
+ self._render_help_modal(height, width)
116
+
117
+ # Composite stdscr + help window (if any) into one physical update.
118
+ # Using stdscr.refresh() here (or in the caller) would immediately
119
+ # repaint the physical screen from stdscr alone, wiping out the
120
+ # help window's own refresh() and causing it to flicker/vanish on
121
+ # every render tick.
122
+ curses.doupdate()
123
+ return True
124
+
125
+ def handle_key(self, key: str) -> Dict[str, Any]:
126
+ """Handle keyboard input and return action information.
127
+
128
+ Args:
129
+ key: Key pressed
130
+
131
+ Returns:
132
+ Dict with action information for the main application to handle
133
+ """
134
+ action = {'type': 'none'}
135
+
136
+ # Global keys
137
+ if key in ('q', 'Q'):
138
+ action = {'type': 'quit'}
139
+ elif key == '?':
140
+ self.show_help_modal = not self.show_help_modal
141
+ elif self.show_help_modal:
142
+ # Any key closes help modal
143
+ self.show_help_modal = False
144
+ elif key in ('KEY_RIGHT', 'l'):
145
+ self.current_tab = (self.current_tab + 1) % len(Config.TAB_NAMES)
146
+ elif key in ('KEY_LEFT', 'h'):
147
+ self.current_tab = (self.current_tab - 1) % len(Config.TAB_NAMES)
148
+ elif key.isdigit() and int(key) < len(Config.TAB_NAMES):
149
+ self.current_tab = int(key)
150
+ elif key in ('r', 'R'):
151
+ action = {'type': 'reset_stats'}
152
+ elif key == 'f':
153
+ action = {'type': 'rescan_fleet'}
154
+ elif key == "KEY_RESIZE":
155
+ import shutil
156
+ real = shutil.get_terminal_size(fallback=(80, 24))
157
+ try:
158
+ if hasattr(curses, 'resizeterm'):
159
+ curses.resizeterm(real.lines, real.columns)
160
+ elif (hasattr(curses, '_curses')
161
+ and hasattr(curses._curses, 'resize_term')):
162
+ curses._curses.resize_term(real.lines, real.columns)
163
+ except Exception:
164
+ pass
165
+ self._reset_input_settings() # ← add this
166
+ self.stdscr.clearok(True)
167
+ self._help_win = None
168
+ self._help_win_size = None
169
+
170
+ # Tab-specific keys
171
+ elif self.current_tab == 0: # Fleet tab
172
+ fleet_action = self._handle_fleet_keys(key)
173
+ if fleet_action:
174
+ action = fleet_action
175
+
176
+ return action
177
+
178
+ def _handle_fleet_keys(self, key: str) -> Optional[Dict[str, Any]]:
179
+ """Handle fleet tab specific keys.
180
+
181
+ Args:
182
+ key: Key pressed
183
+
184
+ Returns:
185
+ Action dict or None
186
+ """
187
+ if key == 'KEY_UP':
188
+ # Let caller know fleet_index changed, they need to provide device
189
+ # count
190
+ return {'type': 'fleet_nav', 'direction': 'up'}
191
+ elif key == 'KEY_DOWN':
192
+ return {'type': 'fleet_nav', 'direction': 'down'}
193
+ elif key in ('\n', '\r', 'KEY_ENTER'):
194
+ return {'type': 'fleet_select', 'index': self.fleet_index}
195
+ return None
196
+
197
+ def update_fleet_index(self, device_count: int, direction: str = None):
198
+ """Update fleet index for navigation.
199
+
200
+ Args:
201
+ device_count: Total number of devices
202
+ direction: 'up' or 'down' for navigation
203
+ """
204
+ if direction == 'up' and device_count > 0:
205
+ self.fleet_index = (self.fleet_index - 1) % device_count
206
+ elif direction == 'down' and device_count > 0:
207
+ self.fleet_index = (self.fleet_index + 1) % device_count
208
+ elif device_count == 0:
209
+ self.fleet_index = 0
210
+
211
+ def _render_size_warning(self, width: int, height: int):
212
+ msg = (
213
+ f" Terminal too small ({width}x{height})" f" — resize to {
214
+ Config.MIN_TERMINAL_COLS}x{
215
+ Config.MIN_TERMINAL_ROWS} ")
216
+ try:
217
+ self.stdscr.addstr(
218
+ 0, 0, msg[:width], # ← truncate, don't center
219
+ curses.A_BOLD | curses.color_pair(
220
+ Config.COLOR_PAIRS['error']))
221
+ except curses.error:
222
+ pass
223
+
224
+ def _render_header(self, width: int):
225
+ """Render application header."""
226
+ header = f" BENCHLAB Telemetry v{self.version} "
227
+ try:
228
+ self.stdscr.addstr(
229
+ 0,
230
+ 0,
231
+ header.center(width),
232
+ curses.A_BOLD | curses.color_pair(
233
+ Config.COLOR_PAIRS['header']))
234
+ except curses.error:
235
+ pass
236
+
237
+ def _render_tabs(self, width: int):
238
+ """Render tab navigation bar."""
239
+ tab_x = 2
240
+ for i, name in enumerate(Config.TAB_NAMES):
241
+ label = f" {i}:{name} "
242
+ try:
243
+ if i == self.current_tab:
244
+ self.stdscr.addstr(
245
+ 2, tab_x, label, curses.color_pair(
246
+ Config.COLOR_PAIRS['header']) | curses.A_BOLD)
247
+ else:
248
+ self.stdscr.addstr(
249
+ 2, tab_x, label, curses.color_pair(
250
+ Config.COLOR_PAIRS['default']))
251
+ except curses.error:
252
+ pass
253
+ tab_x += len(label) + 1
254
+
255
+ def _render_separator(self, width: int):
256
+ """Render separator line below tabs."""
257
+ try:
258
+ self.stdscr.addstr(
259
+ 3, 0, "─" * (width - 1),
260
+ curses.color_pair(Config.COLOR_PAIRS['default']))
261
+ except curses.error:
262
+ pass
263
+
264
+ def _render_current_tab(self,
265
+ snapshot: Dict[str,
266
+ Any],
267
+ stats: ChannelStats,
268
+ fleet_devices: List[Dict[str,
269
+ Any]],
270
+ refresh_interval: float,
271
+ height: int,
272
+ width: int):
273
+ """Render the currently selected tab."""
274
+ try:
275
+ if self.current_tab == 0:
276
+ self._render_fleet_tab(snapshot, fleet_devices, height, width)
277
+ elif self.current_tab == 1:
278
+ self._render_device_tab(snapshot, refresh_interval)
279
+ elif self.current_tab == 2:
280
+ self._render_system_tab(snapshot, stats)
281
+ elif self.current_tab == 3:
282
+ self._render_mb_tab(snapshot, stats)
283
+ elif self.current_tab == 4:
284
+ self._render_hpwr_tab(snapshot, stats)
285
+ elif self.current_tab == 5:
286
+ self._render_voltage_tab(snapshot, stats)
287
+ elif self.current_tab == 6:
288
+ self._render_temperature_tab(snapshot, stats)
289
+ elif self.current_tab == 7:
290
+ self._render_fans_tab(snapshot, stats, height, width)
291
+ except curses.error:
292
+ pass
293
+
294
+ def _render_status_bar(
295
+ self, snapshot: Dict[str, Any], height: int, width: int):
296
+ """Render bottom status bar."""
297
+ try:
298
+ # Separator line
299
+ self.stdscr.addstr(
300
+ height - 2, 0, "─" * (width - 1),
301
+ curses.color_pair(Config.COLOR_PAIRS['default']))
302
+
303
+ # Left side: transient message or last error or help
304
+ if self.status_msg and time.monotonic() < self.status_msg_expires:
305
+ left_msg = self.status_msg
306
+ left_col = curses.color_pair(Config.COLOR_PAIRS['caution'])
307
+ elif snapshot.get('last_error'):
308
+ left_msg = f"! {snapshot['last_error']}"
309
+ left_col = curses.color_pair(Config.COLOR_PAIRS['error'])
310
+ else:
311
+ left_msg = "q=quit ?=help r=reset stats f=rescan"
312
+ left_col = curses.color_pair(Config.COLOR_PAIRS['default'])
313
+
314
+ # Right side: uptime | connection status | device
315
+ ct = snapshot.get('connection_time')
316
+ if snapshot.get('connected') and ct:
317
+ uptime_str = str(datetime.now() - ct).split('.')[0]
318
+ uptime_part = f"up {uptime_str} "
319
+ else:
320
+ uptime_part = ""
321
+
322
+ device_str = snapshot.get(
323
+ 'uid') or snapshot.get('port') or "no device"
324
+ con_str = "CONN" if snapshot.get('connected') else "DISC"
325
+ con_col = (
326
+ curses.color_pair(Config.COLOR_PAIRS['ok'])
327
+ if snapshot.get('connected')
328
+ else curses.color_pair(Config.COLOR_PAIRS['error']))
329
+ right_msg = f"{uptime_part}{con_str} {device_str}"
330
+ right_col = max(0, width - len(right_msg) - 2)
331
+
332
+ # Draw status bar
333
+ self.stdscr.addstr(
334
+ height - 1, 2, left_msg[:width - len(right_msg) - 4], left_col)
335
+ self.stdscr.addstr(
336
+ height - 1, right_col,
337
+ right_msg[:width - right_col - 1], con_col)
338
+
339
+ except curses.error:
340
+ pass
341
+
342
+ def _render_help_modal(self, height: int, width: int):
343
+ """Render help modal overlay."""
344
+ h = min(len(Config.HELP_TEXT) + 4, height - 2)
345
+ w = min(60, width - 4)
346
+
347
+ # Don't attempt to draw if terminal is too small for a modal
348
+ if h < 3 or w < 10:
349
+ return
350
+
351
+ sy = (height - h) // 2
352
+ sx = (width - w) // 2
353
+
354
+ # Cache window; only recreate if size changed (also prevents per-frame
355
+ # alloc)
356
+ if (not hasattr(self, '_help_win') or self._help_win is None
357
+ or getattr(self, '_help_win_size', None) != (h, w, sy, sx)):
358
+ try:
359
+ self._help_win = curses.newwin(h, w, sy, sx)
360
+ except curses.error:
361
+ return
362
+ self._help_win_size = (h, w, sy, sx)
363
+
364
+ win = self._help_win
365
+ try:
366
+ win.erase()
367
+ win.attron(
368
+ curses.color_pair(
369
+ Config.COLOR_PAIRS['header']) | curses.A_BOLD)
370
+ win.border()
371
+ win.attroff(
372
+ curses.color_pair(
373
+ Config.COLOR_PAIRS['header']) | curses.A_BOLD)
374
+ for i, line in enumerate(Config.HELP_TEXT):
375
+ if i < h - 2:
376
+ win.addstr(i + 1, 2, line[:w - 4])
377
+ # noutrefresh (not refresh) — this window is composited together
378
+ # with stdscr via a single curses.doupdate() call in render(),
379
+ # so it doesn't get immediately overwritten by stdscr's own
380
+ # refresh on the next tick.
381
+ win.noutrefresh()
382
+ except curses.error:
383
+ pass
384
+
385
+ def _reset_input_settings(self):
386
+ """Re-apply input settings — windows-curses resets these after
387
+ resize."""
388
+ self.stdscr.nodelay(True)
389
+ self.stdscr.timeout(100)
390
+ # discard any garbage keystrokes queued during resize
391
+ curses.flushinp()
392
+
393
+ # ═══════════════════════════════════════════════════════════════════════════════
394
+ # Tab Renderers
395
+ # ═══════════════════════════════════════════════════════════════════════════════
396
+
397
+ def _render_fleet_tab(self,
398
+ snapshot: Dict[str,
399
+ Any],
400
+ fleet_devices: List[Dict[str,
401
+ Any]],
402
+ height: int,
403
+ width: int):
404
+ """Render Fleet tab."""
405
+ source_type = snapshot.get('source_type', 'unknown')
406
+ if source_type == 'direct':
407
+ title = "BENCHLAB Fleet"
408
+ else:
409
+ title = f"BENCHLAB Fleet (via {source_type.upper()})"
410
+
411
+ self._draw_section(4, 2, title)
412
+
413
+ if not fleet_devices:
414
+ try:
415
+ self.stdscr.addstr(
416
+ 6, 4, "No connected devices found.", curses.color_pair(
417
+ Config.COLOR_PAIRS['error']))
418
+ except curses.error:
419
+ pass
420
+ return
421
+
422
+ # Column headers
423
+ cols = Config.Layout.FLEET_COLS
424
+ widths = Config.Layout.FLEET_WIDTHS
425
+ hdr_attr = curses.A_UNDERLINE | curses.color_pair(
426
+ Config.COLOR_PAIRS['default'])
427
+
428
+ try:
429
+ self.stdscr.addstr(6, cols['SEL'], f"{'':2}", hdr_attr)
430
+ self.stdscr.addstr(
431
+ 6, cols['MODEL'], f"{
432
+ 'Model':<{
433
+ widths['MODEL']}}", hdr_attr)
434
+ self.stdscr.addstr(
435
+ 6, cols['PORT'], f"{
436
+ 'Port':<{
437
+ widths['PORT']}}", hdr_attr)
438
+ self.stdscr.addstr(
439
+ 6, cols['FW'], f"{
440
+ 'Firmware':<{
441
+ widths['FW']}}", hdr_attr)
442
+ self.stdscr.addstr(
443
+ 6, cols['UID'], f"{
444
+ 'UID':<{
445
+ widths['UID']}}", hdr_attr)
446
+ self.stdscr.addstr(
447
+ 6, cols['STATUS'], f"{
448
+ 'Status':<{
449
+ widths['STATUS']}}", hdr_attr)
450
+ self.stdscr.addstr(6, cols['ACTIVE'], "Active", hdr_attr)
451
+ except curses.error:
452
+ pass
453
+
454
+ # Device rows
455
+ connected_uid = snapshot.get('uid')
456
+ connected = snapshot.get('connected', False)
457
+
458
+ for i, dev in enumerate(fleet_devices):
459
+ port = dev.get('port', 'Unknown')
460
+ firmware = dev.get('firmware', 0)
461
+ dev_uid = dev.get('uid', 'Unknown')
462
+ is_busy = dev_uid == "BUSY"
463
+
464
+ variant = dev.get('variant')
465
+ if variant is None and dev.get('ProductId') is not None:
466
+ variant = 'BL2' if dev.get('ProductId') == 0x11 else 'ORIGINAL'
467
+ model_str = 'BL2' if variant == 'BL2' else (
468
+ 'BL1' if variant else '?')
469
+
470
+ cursor = "->" if i == self.fleet_index else " "
471
+ is_active = connected_uid is not None and dev_uid == connected_uid
472
+ is_connected = is_active and connected
473
+
474
+ if is_busy:
475
+ status = "BUSY"
476
+ status_color = curses.color_pair(Config.COLOR_PAIRS['caution'])
477
+ elif is_connected:
478
+ status = "CONNECTED"
479
+ status_color = curses.color_pair(Config.COLOR_PAIRS['ok'])
480
+ else:
481
+ status = "DISCONNECTED"
482
+ status_color = curses.color_pair(Config.COLOR_PAIRS['error'])
483
+
484
+ row_color = (
485
+ curses.color_pair(Config.COLOR_PAIRS['header'])
486
+ | curses.A_BOLD if i == self.fleet_index
487
+ else curses.color_pair(Config.COLOR_PAIRS['default']))
488
+
489
+ row = 8 + i
490
+ try:
491
+ self.stdscr.addstr(row, cols['SEL'], cursor, row_color)
492
+ self.stdscr.addstr(
493
+ row, cols['MODEL'], f"{
494
+ model_str:<{
495
+ widths['MODEL']}}", row_color)
496
+ self.stdscr.addstr(
497
+ row, cols['PORT'], f"{
498
+ port:<{
499
+ widths['PORT']}}", row_color)
500
+
501
+ if is_busy:
502
+ self.stdscr.addstr(
503
+ row, cols['FW'], f"{'N/A':<{widths['FW']}}", row_color)
504
+ self.stdscr.addstr(
505
+ row, cols['UID'], f"{
506
+ dev_uid:<{
507
+ widths['UID']}}", curses.color_pair(
508
+ Config.COLOR_PAIRS['caution']) | curses.A_BOLD)
509
+ else:
510
+ try:
511
+ fw_int = int(firmware) if firmware is not None else 0
512
+ fw_str = f"0x{fw_int:08X}"
513
+ except (TypeError, ValueError):
514
+ fw_str = "0x????????"
515
+ self.stdscr.addstr(
516
+ row, cols['FW'], f"{
517
+ fw_str:<{
518
+ widths['FW']}}", row_color)
519
+ self.stdscr.addstr(
520
+ row, cols['UID'], f"{
521
+ dev_uid:<{
522
+ widths['UID']}}", row_color)
523
+
524
+ self.stdscr.addstr(
525
+ row, cols['STATUS'], f"{
526
+ status:<{
527
+ widths['STATUS']}}", status_color)
528
+
529
+ if is_active:
530
+ self.stdscr.addstr(
531
+ row,
532
+ cols['ACTIVE'],
533
+ "*",
534
+ curses.color_pair(
535
+ Config.COLOR_PAIRS['ok']) | curses.A_BOLD)
536
+ except curses.error:
537
+ pass
538
+
539
+ # Footer
540
+ status_text = "CONNECTED" if connected else "DISCONNECTED"
541
+ status_color = (
542
+ curses.color_pair(
543
+ Config.COLOR_PAIRS['ok']) if connected else curses.color_pair(
544
+ Config.COLOR_PAIRS['error']))
545
+ try:
546
+ self.stdscr.addstr(
547
+ height - 4,
548
+ 2,
549
+ f"Status: {status_text}",
550
+ status_color | curses.A_BOLD)
551
+ ct = snapshot.get('connection_time')
552
+ if connected and ct:
553
+ uptime = str(datetime.now() - ct).split('.')[0]
554
+ self.stdscr.addstr(
555
+ height - 2,
556
+ 2,
557
+ f"Uptime: {uptime}",
558
+ curses.color_pair(
559
+ Config.COLOR_PAIRS['caution']))
560
+ else:
561
+ self.stdscr.addstr(
562
+ height - 2,
563
+ 2,
564
+ "Uptime: —",
565
+ curses.color_pair(
566
+ Config.COLOR_PAIRS['error']))
567
+ except curses.error:
568
+ pass
569
+
570
+ def _render_device_tab(
571
+ self, snapshot: Dict[str, Any], refresh_interval: float):
572
+ """Render Device tab."""
573
+ device_info = snapshot.get('device_info') or {}
574
+ uid = snapshot.get('uid')
575
+ source_type = snapshot.get('source_type', 'Unknown')
576
+ source_desc = snapshot.get('source_desc', '')
577
+ port_str = snapshot.get('port', 'Unknown')
578
+
579
+ try:
580
+ self._draw_section(4, 2, "Connection")
581
+ self.stdscr.addstr(5, 4, f"{'Data Source':<22} {source_type}")
582
+ self.stdscr.addstr(6, 4, f"{'Connection':<22} {source_desc}")
583
+ self.stdscr.addstr(7, 4, f"{'Device Port':<22} {port_str}")
584
+
585
+ if not snapshot.get('connected'):
586
+ self.stdscr.addstr(
587
+ 10, 4, "Not connected to device.", curses.color_pair(
588
+ Config.COLOR_PAIRS['error']))
589
+ return
590
+
591
+ # Device information
592
+ def _hex_str(value):
593
+ try:
594
+ return f"0x{int(value):02X}"
595
+ except (TypeError, ValueError):
596
+ return "0x??"
597
+
598
+ vendor_str = _hex_str(device_info.get('VendorId', 0))
599
+ product_id = device_info.get('ProductId', 0)
600
+ product_str = _hex_str(product_id)
601
+ fw_str = _hex_str(device_info.get('FwVersion', 0))
602
+
603
+ variant = device_info.get('variant')
604
+ if variant is None:
605
+ variant = 'BL2' if product_id == 0x11 else 'ORIGINAL'
606
+ model_str = (
607
+ "BENCHLAB 2 (BL2)" if variant == 'BL2'
608
+ else "BENCHLAB 1 (Original)")
609
+
610
+ self._draw_section(9, 2, "Device")
611
+ self.stdscr.addstr(10, 4, f"{'Model':<22} {model_str}")
612
+ self.stdscr.addstr(11, 4, f"{'Vendor ID':<22} {vendor_str}")
613
+ self.stdscr.addstr(12, 4, f"{'Product ID':<22} {product_str}")
614
+ self.stdscr.addstr(13, 4, f"{'Device UID':<22} {uid or 'N/A'}")
615
+ self.stdscr.addstr(14, 4, f"{'Firmware Version':<22} {fw_str}")
616
+
617
+ self._draw_section(16, 2, "TUI")
618
+ self.stdscr.addstr(
619
+ 17, 4, f"{
620
+ 'Refresh Interval':<22} {refresh_interval} s")
621
+
622
+ except curses.error:
623
+ pass
624
+
625
+ def _render_system_tab(
626
+ self, snapshot: Dict[str, Any], stats: ChannelStats):
627
+ """Render System tab."""
628
+ if not snapshot.get('connected') or not snapshot.get('sensor_data'):
629
+ self._draw_disconnected("System")
630
+ return
631
+
632
+ sd = snapshot['sensor_data']
633
+ uid = snapshot.get('uid', '')
634
+ device_info = snapshot.get('device_info') or {}
635
+
636
+ # Detect device variant from device info (Product ID) or sensor data
637
+ product_id = device_info.get('ProductId')
638
+ if product_id is not None:
639
+ variant = 'BL2' if product_id == 0x11 else 'ORIGINAL'
640
+ else:
641
+ variant = Config.Channels.detect_variant(sd)
642
+ rail_channels = Config.Channels.get_rail_channels(variant)
643
+
644
+ # Summary section
645
+ row = 4
646
+ self._draw_section(row, 2, "Summary")
647
+ row += 1
648
+
649
+ sum_vals = [
650
+ sd.get(
651
+ k,
652
+ 0.0) or 0.0 for k,
653
+ _ in Config.Channels.POWER_SUMMARY]
654
+ max_sum = (Config.BarScales.POWER_MAX or
655
+ max(Config.BarScales.POWER_AUTO_FLOOR, max(sum_vals) * 1.2))
656
+
657
+ for (key, label), val in zip(Config.Channels.POWER_SUMMARY, sum_vals):
658
+ stat = stats.get(uid, key)
659
+ self._draw_bar(
660
+ row, 4, label, val, 'W', max_sum, curses.color_pair(
661
+ Config.COLOR_PAIRS['caution']), stat=stat)
662
+ row += 1
663
+
664
+ # Power telemetry
665
+ row += 1
666
+ self._draw_section(row, 2, "Power Telemetry")
667
+ row += 1
668
+
669
+ pwr_vals = [sd.get(f'{k}_Power', 0.0) or 0.0 for k, _ in rail_channels]
670
+ max_pwr = (Config.BarScales.POWER_MAX or
671
+ max(Config.BarScales.POWER_AUTO_FLOOR, max(pwr_vals) * 1.2))
672
+
673
+ for (key_pfx, label), val in zip(rail_channels, pwr_vals):
674
+ stat = stats.get(uid, f'{key_pfx}_Power')
675
+ self._draw_bar(
676
+ row, 4, label, val, 'W', max_pwr, curses.color_pair(
677
+ Config.COLOR_PAIRS['caution']), stat=stat)
678
+ row += 1
679
+
680
+ # Current telemetry
681
+ row += 1
682
+ self._draw_section(row, 2, "Current Telemetry")
683
+ row += 1
684
+
685
+ cur_vals = [
686
+ sd.get(
687
+ f'{k}_Current',
688
+ 0.0) or 0.0 for k,
689
+ _ in rail_channels]
690
+ max_cur = (
691
+ Config.BarScales.CURRENT_MAX or max(
692
+ Config.BarScales.CURRENT_AUTO_FLOOR,
693
+ max(cur_vals) * 1.2))
694
+
695
+ for (key_pfx, label), val in zip(rail_channels, cur_vals):
696
+ stat = stats.get(uid, f'{key_pfx}_Current')
697
+ self._draw_bar(
698
+ row,
699
+ 4,
700
+ label,
701
+ val,
702
+ 'A',
703
+ max_cur,
704
+ curses.color_pair(
705
+ Config.COLOR_PAIRS['info']),
706
+ stat=stat,
707
+ decimals=2)
708
+ row += 1
709
+
710
+ # Voltage telemetry
711
+ row += 1
712
+ self._draw_section(row, 2, "Voltage Telemetry")
713
+ row += 1
714
+
715
+ for (
716
+ key_pfx, label), val in zip(
717
+ rail_channels, [
718
+ sd.get(
719
+ f'{k}_Voltage', 0.0) or 0.0 for k, _ in rail_channels]):
720
+ stat = stats.get(uid, f'{key_pfx}_Voltage')
721
+ self._draw_bar(
722
+ row,
723
+ 4,
724
+ label,
725
+ val,
726
+ 'V',
727
+ Config.BarScales.RAIL_VOLTAGE_MAX,
728
+ curses.color_pair(
729
+ Config.COLOR_PAIRS['voltage']),
730
+ stat=stat,
731
+ decimals=2)
732
+ row += 1
733
+
734
+ def _render_mb_tab(self, snapshot: Dict[str, Any], stats: ChannelStats):
735
+ """Render Motherboard rails tab (ATX3V, ATX5V, ATX5VSB, ATX12V)."""
736
+ if not snapshot.get('connected') or not snapshot.get('sensor_data'):
737
+ self._draw_disconnected("Motherboard")
738
+ return
739
+
740
+ sd = snapshot['sensor_data']
741
+ uid = snapshot.get('uid', '')
742
+ mb_channels = Config.Channels.MB_RAIL_CHANNELS
743
+
744
+ row = 4
745
+ self._draw_section(row, 2, "Motherboard Power Rails")
746
+ row += 1
747
+
748
+ # Power readings
749
+ self._draw_section(row, 2, "Power")
750
+ row += 1
751
+
752
+ mb_pwr_vals = [
753
+ sd.get(
754
+ f'{k}_Power',
755
+ 0.0) or 0.0 for k,
756
+ _ in mb_channels]
757
+ max_mb_pwr = (
758
+ Config.BarScales.POWER_MAX or max(
759
+ Config.BarScales.POWER_AUTO_FLOOR,
760
+ max(mb_pwr_vals) * 1.2))
761
+
762
+ for (key_pfx, label), val in zip(mb_channels, mb_pwr_vals):
763
+ stat = stats.get(uid, f'{key_pfx}_Power')
764
+ self._draw_bar(
765
+ row, 4, label, val, 'W', max_mb_pwr, curses.color_pair(
766
+ Config.COLOR_PAIRS['caution']), stat=stat)
767
+ row += 1
768
+
769
+ # Current readings
770
+ row += 1
771
+ self._draw_section(row, 2, "Current")
772
+ row += 1
773
+
774
+ mb_cur_vals = [
775
+ sd.get(
776
+ f'{k}_Current',
777
+ 0.0) or 0.0 for k,
778
+ _ in mb_channels]
779
+ max_mb_cur = (
780
+ Config.BarScales.CURRENT_MAX or max(
781
+ Config.BarScales.CURRENT_AUTO_FLOOR,
782
+ max(mb_cur_vals) * 1.2))
783
+
784
+ for (key_pfx, label), val in zip(mb_channels, mb_cur_vals):
785
+ stat = stats.get(uid, f'{key_pfx}_Current')
786
+ self._draw_bar(
787
+ row,
788
+ 4,
789
+ label,
790
+ val,
791
+ 'A',
792
+ max_mb_cur,
793
+ curses.color_pair(
794
+ Config.COLOR_PAIRS['info']),
795
+ stat=stat,
796
+ decimals=2)
797
+ row += 1
798
+
799
+ # Voltage readings
800
+ row += 1
801
+ self._draw_section(row, 2, "Voltage")
802
+ row += 1
803
+
804
+ for (
805
+ key_pfx, label), val in zip(
806
+ mb_channels, [
807
+ sd.get(
808
+ f'{k}_Voltage', 0.0) or 0.0 for k, _ in mb_channels]):
809
+ stat = stats.get(uid, f'{key_pfx}_Voltage')
810
+ # Use appropriate voltage max based on rail type
811
+ if key_pfx == 'ATX3V':
812
+ v_max = 5.0
813
+ elif key_pfx == 'ATX5V' or key_pfx == 'ATX5VSB':
814
+ v_max = 7.0
815
+ else: # ATX12V
816
+ v_max = 15.0
817
+ self._draw_bar(row, 4, label, val, 'V', v_max, curses.color_pair(
818
+ Config.COLOR_PAIRS['voltage']), stat=stat, decimals=2)
819
+ row += 1
820
+
821
+ def _render_hpwr_tab(self, snapshot: Dict[str, Any], stats: ChannelStats):
822
+ """Render 12VHPWR tab (BL2-specific HPWR_Wx sense lines)."""
823
+ if not snapshot.get('connected') or not snapshot.get('sensor_data'):
824
+ self._draw_disconnected("12VHPWR")
825
+ return
826
+
827
+ sd = snapshot['sensor_data']
828
+ uid = snapshot.get('uid', '')
829
+ device_info = snapshot.get('device_info') or {}
830
+
831
+ # Detect device variant from device info (Product ID)
832
+ product_id = device_info.get('ProductId')
833
+ if product_id is not None:
834
+ variant = 'BL2' if product_id == 0x11 else 'ORIGINAL'
835
+ else:
836
+ variant = Config.Channels.detect_variant(sd)
837
+
838
+ # Only show HPWR_Wx sensors for BL2 (BENCHLAB 2) devices
839
+ if variant != 'BL2':
840
+ self._draw_section(4, 2, "12VHPWR Sense Lines")
841
+ try:
842
+ self.stdscr.addstr(
843
+ 6,
844
+ 4,
845
+ "HPWR_Wx sense lines are only available on BENCHLAB 2 "
846
+ "devices.",
847
+ curses.color_pair(
848
+ Config.COLOR_PAIRS['info']))
849
+ except curses.error:
850
+ pass
851
+ return
852
+
853
+ hpwr_channels = Config.Channels.HPWR_SENSE_CHANNELS
854
+
855
+ row = 4
856
+ self._draw_section(row, 2, "12VHPWR Sense Lines (BENCHLAB 2)")
857
+ row += 1
858
+
859
+ # Power readings
860
+ self._draw_section(row, 2, "Power")
861
+ row += 1
862
+
863
+ for (key_pfx, label) in hpwr_channels:
864
+ val = sd.get(f'{key_pfx}_Power', 0.0)
865
+ stat = stats.get(uid, f'{key_pfx}_Power')
866
+ self._draw_bar(
867
+ row,
868
+ 4,
869
+ label,
870
+ val,
871
+ 'W',
872
+ Config.BarScales.POWER_MAX,
873
+ curses.color_pair(
874
+ Config.COLOR_PAIRS['caution']),
875
+ stat=stat)
876
+ row += 1
877
+
878
+ # Current readings
879
+ row += 1
880
+ self._draw_section(row, 2, "Current")
881
+ row += 1
882
+
883
+ for (key_pfx, label) in hpwr_channels:
884
+ val = sd.get(f'{key_pfx}_Current', 0.0)
885
+ stat = stats.get(uid, f'{key_pfx}_Current')
886
+ self._draw_bar(
887
+ row,
888
+ 4,
889
+ label,
890
+ val,
891
+ 'A',
892
+ Config.BarScales.CURRENT_MAX,
893
+ curses.color_pair(
894
+ Config.COLOR_PAIRS['info']),
895
+ stat=stat,
896
+ decimals=2)
897
+ row += 1
898
+
899
+ # Voltage readings
900
+ row += 1
901
+ self._draw_section(row, 2, "Voltage")
902
+ row += 1
903
+
904
+ for (key_pfx, label) in hpwr_channels:
905
+ val = sd.get(f'{key_pfx}_Voltage', 0.0)
906
+ stat = stats.get(uid, f'{key_pfx}_Voltage')
907
+ self._draw_bar(
908
+ row,
909
+ 4,
910
+ label,
911
+ val,
912
+ 'V',
913
+ Config.BarScales.RAIL_VOLTAGE_MAX,
914
+ curses.color_pair(
915
+ Config.COLOR_PAIRS['voltage']),
916
+ stat=stat,
917
+ decimals=2)
918
+ row += 1
919
+
920
+ def _render_voltage_tab(
921
+ self, snapshot: Dict[str, Any], stats: ChannelStats):
922
+ """Render Voltage tab."""
923
+ if not snapshot.get('connected') or not snapshot.get('sensor_data'):
924
+ self._draw_disconnected("Voltage")
925
+ return
926
+
927
+ sd = snapshot['sensor_data']
928
+ uid = snapshot.get('uid', '')
929
+ row = 4
930
+
931
+ # Board voltages
932
+ self._draw_section(row, 2, "Board")
933
+ row += 1
934
+
935
+ for key, label, band in Config.Channels.BOARD_VOLTAGES:
936
+ val = sd.get(key, 0.0)
937
+ stat = stats.get(uid, key)
938
+ self._draw_bar(
939
+ row,
940
+ 4,
941
+ label,
942
+ val,
943
+ 'V',
944
+ Config.BarScales.BOARD_VOLTAGE_MAX,
945
+ curses.color_pair(
946
+ Config.COLOR_PAIRS['voltage']),
947
+ stat=stat,
948
+ decimals=3)
949
+ row += 1
950
+
951
+ # VIN measurements
952
+ row += 1
953
+ self._draw_section(row, 2, "Voltage Measurements")
954
+ row += 1
955
+
956
+ for vin_key in Config.Channels.VIN_CHANNELS:
957
+ val = sd.get(vin_key, 0.0)
958
+ stat = stats.get(uid, vin_key)
959
+ self._draw_bar(
960
+ row,
961
+ 4,
962
+ vin_key,
963
+ val,
964
+ 'V',
965
+ Config.BarScales.VIN_VOLTAGE_MAX,
966
+ curses.color_pair(
967
+ Config.COLOR_PAIRS['voltage']),
968
+ stat=stat,
969
+ decimals=3)
970
+ row += 1
971
+
972
+ def _render_temperature_tab(
973
+ self, snapshot: Dict[str, Any], stats: ChannelStats):
974
+ """Render Temperature tab."""
975
+ if not snapshot.get('connected') or not snapshot.get('sensor_data'):
976
+ self._draw_disconnected("Temperature")
977
+ return
978
+
979
+ sd = snapshot['sensor_data']
980
+ uid = snapshot.get('uid', '')
981
+ device_info = snapshot.get('device_info') or {}
982
+
983
+ # Detect device variant from device info (Product ID) or sensor data
984
+ product_id = device_info.get('ProductId')
985
+ if product_id is not None:
986
+ variant = 'BL2' if product_id == 0x11 else 'ORIGINAL'
987
+ else:
988
+ variant = Config.Channels.detect_variant(sd)
989
+ temp_sensors = Config.Channels.get_temperature_sensors(variant)
990
+
991
+ row = 4
992
+
993
+ # Board
994
+ self._draw_section(row, 2, "Board")
995
+ row += 1
996
+
997
+ chip_temp = sd.get('Chip_Temp', 0.0)
998
+ # Handle None values from sensor data
999
+ if chip_temp is None:
1000
+ chip_temp = 0.0
1001
+ chip_color = Config.get_temperature_color(
1002
+ chip_temp, Config.BarScales.CHIP_TEMP_WARN)
1003
+ stat = stats.get(uid, 'Chip_Temp')
1004
+ self._draw_bar(
1005
+ row,
1006
+ 4,
1007
+ 'Chip Temp',
1008
+ chip_temp,
1009
+ '°C',
1010
+ Config.BarScales.CHIP_TEMP_MAX,
1011
+ curses.color_pair(chip_color),
1012
+ stat=stat,
1013
+ decimals=1)
1014
+ row += 2
1015
+
1016
+ # System
1017
+ self._draw_section(row, 2, "System")
1018
+ row += 1
1019
+
1020
+ for key, label in [('Ambient_Temp', 'Ambient Temp'),
1021
+ ('Humidity', 'Humidity')]:
1022
+ val = sd.get(key, 0.0)
1023
+ stat = stats.get(uid, key)
1024
+ max_val = (
1025
+ Config.BarScales.AMBIENT_TEMP_MAX if key == 'Ambient_Temp'
1026
+ else Config.BarScales.HUMIDITY_MAX)
1027
+ unit = '°C' if key == 'Ambient_Temp' else '%'
1028
+ self._draw_bar(
1029
+ row,
1030
+ 4,
1031
+ label,
1032
+ val,
1033
+ unit,
1034
+ max_val,
1035
+ curses.color_pair(
1036
+ Config.COLOR_PAIRS['info']),
1037
+ stat=stat,
1038
+ decimals=1)
1039
+ row += 1
1040
+
1041
+ row += 1
1042
+
1043
+ # Sensors - dynamic based on variant (only show variant label for BL2)
1044
+ if variant == 'BL2':
1045
+ self._draw_section(row, 2, "Sensors (BENCHLAB 2)")
1046
+ else:
1047
+ self._draw_section(row, 2, "Sensors")
1048
+ row += 1
1049
+
1050
+ for i, sensor_key in enumerate(temp_sensors):
1051
+ val = sd.get(sensor_key, 0.0)
1052
+ # Handle None values from sensor data
1053
+ if val is None:
1054
+ val = 0.0
1055
+ stat = stats.get(uid, sensor_key)
1056
+ s_color = Config.get_temperature_color(
1057
+ val, Config.BarScales.SENSOR_TEMP_WARN)
1058
+ # Use actual sensor name for display
1059
+ label = sensor_key if '_' in sensor_key else f'Sensor {i + 1}'
1060
+ self._draw_bar(
1061
+ row,
1062
+ 4,
1063
+ label,
1064
+ val,
1065
+ '°C',
1066
+ Config.BarScales.SENSOR_TEMP_MAX,
1067
+ curses.color_pair(s_color),
1068
+ stat=stat,
1069
+ decimals=1)
1070
+ row += 1
1071
+
1072
+ def _render_fans_tab(self, snapshot: Dict[str, Any], stats: ChannelStats,
1073
+ height: int = None, width: int = None):
1074
+ """Render Fans tab."""
1075
+ self._draw_section(4, 2, "Fan Control & Monitoring")
1076
+
1077
+ if not snapshot.get('connected') or not snapshot.get('sensor_data'):
1078
+ self._draw_disconnected("Fan")
1079
+ return
1080
+
1081
+ sd = snapshot['sensor_data']
1082
+ uid = snapshot.get('uid', '')
1083
+
1084
+ # Column positions
1085
+ cols = Config.Layout.FAN_COLS
1086
+ rpm_max_str = str(Config.BarScales.FAN_RPM_MAX)
1087
+ hdr = (f"{'Fan':<8} {'Duty':>5}% {'RPM':>6} {'On':<3} "
1088
+ f"{'Bar (' + rpm_max_str + ' RPM)':<20} Stats")
1089
+
1090
+ try:
1091
+ self.stdscr.addstr(
1092
+ 5,
1093
+ cols['NAME'] + 2,
1094
+ hdr,
1095
+ curses.A_UNDERLINE | curses.color_pair(
1096
+ Config.COLOR_PAIRS['default']))
1097
+ except curses.error:
1098
+ pass
1099
+
1100
+ # Determine number of fans (highest fan index present, so
1101
+ # non-contiguous numbering like Fan1/Fan3 with no Fan2 still
1102
+ # renders all known fans)
1103
+ fan_indices = [int(k[3:-5]) for k in sd if k.startswith('Fan')
1104
+ and k.endswith('_Duty') and k[3:-5].isdigit()]
1105
+ num_fans = max(fan_indices, default=0)
1106
+
1107
+ # Reserve rows for the external fan row, active-count line, and the
1108
+ # status bar so fan rows never silently overlap them on a short
1109
+ # terminal. Each fan takes one row starting at row 7.
1110
+ FAN_ROWS_START = 7
1111
+ # ext fan row + active count + status bar + margin
1112
+ RESERVED_TRAILING_ROWS = 4
1113
+ if height is not None:
1114
+ max_visible_fans = max(
1115
+ 0, height - RESERVED_TRAILING_ROWS - FAN_ROWS_START)
1116
+ else:
1117
+ max_visible_fans = num_fans
1118
+ visible_fans = min(num_fans, max_visible_fans)
1119
+ hidden_fans = num_fans - visible_fans
1120
+
1121
+ # Fan rows
1122
+ for i in range(1, visible_fans + 1):
1123
+ duty = sd.get(f'Fan{i}_Duty', 0) or 0
1124
+ rpm = sd.get(f'Fan{i}_RPM', 0) or 0
1125
+ # Assume enabled since we can't get status from dict
1126
+ enabled = True
1127
+
1128
+ rpm_key = f'Fan{i}_RPM'
1129
+ duty_key = f'Fan{i}_Duty'
1130
+
1131
+ bar_len = max(
1132
+ 0, min(
1133
+ 20, int(
1134
+ (rpm / Config.BarScales.FAN_RPM_MAX) * 20)))
1135
+ fan_color = (curses.color_pair(Config.COLOR_PAIRS['ok']) if rpm > 0
1136
+ else curses.color_pair(Config.COLOR_PAIRS['error']))
1137
+ en_str = "Yes" if enabled else "No"
1138
+ bar = "█" * bar_len + "░" * (20 - bar_len)
1139
+
1140
+ mn_r, mx_r, avg_r = stats.get(uid, rpm_key)
1141
+ mn_d, mx_d, avg_d = stats.get(uid, duty_key)
1142
+
1143
+ row = 7 + i - 1
1144
+ try:
1145
+ self.stdscr.addstr(
1146
+ row,
1147
+ cols['NAME'] + 2,
1148
+ f"Fan{
1149
+ i:<5}",
1150
+ fan_color)
1151
+ self.stdscr.addstr(
1152
+ row, cols['DUTY'] + 2, f"{duty:>5}%", fan_color)
1153
+ self.stdscr.addstr(
1154
+ row, cols['RPM'] + 2, f"{rpm:>6}", fan_color)
1155
+ self.stdscr.addstr(
1156
+ row, cols['ENABLED'] + 2, f"{en_str:<3}", fan_color)
1157
+ self.stdscr.addstr(row, cols['BAR'] + 2, bar, fan_color)
1158
+
1159
+ if mn_r is not None:
1160
+ stat_str = (f" {mn_r:.0f}-{mx_r:.0f} ~{avg_r:.0f} RPM"
1161
+ f" {mn_d:.0f}-{mx_d:.0f} ~{avg_d:.0f}%")
1162
+ self.stdscr.addstr(
1163
+ row,
1164
+ cols['STATS'] + 2,
1165
+ stat_str,
1166
+ curses.color_pair(
1167
+ Config.COLOR_PAIRS['default']))
1168
+ except curses.error:
1169
+ pass
1170
+
1171
+ # Note if some fans were hidden to avoid overlapping the status bar
1172
+ if hidden_fans > 0:
1173
+ try:
1174
+ self.stdscr.addstr(
1175
+ FAN_ROWS_START + visible_fans,
1176
+ cols['NAME'] + 2,
1177
+ f"... +{hidden_fans} more fan(s) — resize terminal to "
1178
+ "see all",
1179
+ curses.color_pair(
1180
+ Config.COLOR_PAIRS['caution']))
1181
+ except curses.error:
1182
+ pass
1183
+
1184
+ # External fan
1185
+ ext_duty = sd.get('FanExtDuty', 0) or 0
1186
+ ext_bar = max(0, min(20, int(ext_duty / 5)))
1187
+ ext_row = 8 + visible_fans + 1 + (1 if hidden_fans > 0 else 0)
1188
+
1189
+ try:
1190
+ self.stdscr.addstr(
1191
+ ext_row,
1192
+ cols['NAME'] + 2,
1193
+ "Ext Fan ",
1194
+ curses.color_pair(
1195
+ Config.COLOR_PAIRS['highlight']))
1196
+ self.stdscr.addstr(
1197
+ ext_row, cols['DUTY'] + 2, f"{ext_duty:>5}%",
1198
+ curses.color_pair(Config.COLOR_PAIRS['highlight']))
1199
+ self.stdscr.addstr(
1200
+ ext_row, cols['RPM'] + 2, f"{'N/A':>6}",
1201
+ curses.color_pair(Config.COLOR_PAIRS['highlight']))
1202
+ self.stdscr.addstr(
1203
+ ext_row, cols['ENABLED'] + 2, f"{'N/A':<3}",
1204
+ curses.color_pair(Config.COLOR_PAIRS['highlight']))
1205
+ self.stdscr.addstr(
1206
+ ext_row, cols['BAR'] + 2,
1207
+ "█" * ext_bar + "░" * (20 - ext_bar),
1208
+ curses.color_pair(Config.COLOR_PAIRS['highlight']))
1209
+ except curses.error:
1210
+ pass
1211
+
1212
+ # Active count
1213
+ if num_fans > 0:
1214
+ active_count = sum(
1215
+ 1 for i in range(
1216
+ 1,
1217
+ num_fans +
1218
+ 1) if (
1219
+ sd.get(
1220
+ f'Fan{i}_RPM',
1221
+ 0) or 0) > 0)
1222
+ try:
1223
+ self.stdscr.addstr(
1224
+ ext_row + 2,
1225
+ cols['NAME'] + 2,
1226
+ f"Active: {active_count}/{num_fans} fans running",
1227
+ curses.color_pair(
1228
+ Config.COLOR_PAIRS['ok']))
1229
+ except curses.error:
1230
+ pass
1231
+
1232
+ # ═══════════════════════════════════════════════════════════════════════════════
1233
+ # Helper Methods
1234
+ # ═══════════════════════════════════════════════════════════════════════════════
1235
+
1236
+ def _draw_section(self, y: int, x: int, title: str):
1237
+ """Draw section header."""
1238
+ try:
1239
+ self.stdscr.addstr(
1240
+ y, x, f"┌─ {title} ", curses.color_pair(
1241
+ Config.COLOR_PAIRS['caution']) | curses.A_BOLD)
1242
+ except curses.error:
1243
+ pass
1244
+
1245
+ def _draw_disconnected(self, tab_name: str):
1246
+ """Draw disconnected message."""
1247
+ try:
1248
+ self.stdscr.addstr(
1249
+ 6,
1250
+ 4,
1251
+ f"{tab_name} telemetry unavailable — device disconnected.",
1252
+ curses.color_pair(
1253
+ Config.COLOR_PAIRS['error']))
1254
+ except curses.error:
1255
+ pass
1256
+
1257
+ def _draw_bar(
1258
+ self,
1259
+ y: int,
1260
+ x: int,
1261
+ label: str,
1262
+ value: float,
1263
+ unit: str,
1264
+ max_val: float,
1265
+ color: int,
1266
+ bar_width: int = None,
1267
+ decimals: int = 1,
1268
+ stat=None):
1269
+ """Draw progress bar with value and optional statistics."""
1270
+ if value is None:
1271
+ value = 0.0
1272
+
1273
+ if bar_width is None:
1274
+ bar_width = Config.Layout.BAR_WIDTH
1275
+
1276
+ filled = 0
1277
+ if max_val > 0:
1278
+ filled = max(0, min(bar_width, int((value / max_val) * bar_width)))
1279
+
1280
+ val_str = f"{value:>7.{decimals}f}"
1281
+ bar = "█" * filled + "░" * (bar_width - filled)
1282
+
1283
+ try:
1284
+ self.stdscr.addstr(
1285
+ y, x, f"{
1286
+ label:<14} {val_str} {
1287
+ unit:<3} ", color)
1288
+ self.stdscr.addstr(bar, color)
1289
+
1290
+ if stat and stat[0] is not None:
1291
+ stat_str = StatsFormatter.format_stat_string(
1292
+ stat[0], stat[1], stat[2], decimals, unit,
1293
+ Config.STAT_COLUMN_WIDTH)
1294
+ self.stdscr.addstr(
1295
+ stat_str, curses.color_pair(
1296
+ Config.COLOR_PAIRS['default']))
1297
+ except curses.error:
1298
+ pass