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.
- benchlab/__init__.py +14 -0
- benchlab/__main__.py +21 -0
- benchlab/config/README.md +325 -0
- benchlab/config/__init__.py +9 -0
- benchlab/config/config_client.py +631 -0
- benchlab/config/config_manager.py +495 -0
- benchlab/config/config_tool.py +358 -0
- benchlab/config/diff.py +163 -0
- benchlab/config/examples/complete_config.json +56 -0
- benchlab/config/examples/rgb_lighting.json +24 -0
- benchlab/config/examples/simple_fan.json +32 -0
- benchlab/config/schema.py +138 -0
- benchlab/core/README.md +154 -0
- benchlab/core/__init__.py +78 -0
- benchlab/core/config.py +145 -0
- benchlab/core/datasource.py +1209 -0
- benchlab/core/datasource_manager.py +418 -0
- benchlab/core/device_registry.py +206 -0
- benchlab/core/discovery.py +92 -0
- benchlab/core/infrastructure.py +309 -0
- benchlab/core/process_manager.py +356 -0
- benchlab/core/retry.py +85 -0
- benchlab/core/shared_serial.py +235 -0
- benchlab/core/statistics.py +224 -0
- benchlab/csv_log/README.md +206 -0
- benchlab/csv_log/__init__.py +5 -0
- benchlab/csv_log/csv_logger_enhanced.py +416 -0
- benchlab/csv_log/message_batcher.py +361 -0
- benchlab/csv_log/requirements.txt +3 -0
- benchlab/csv_log/smart_retry.py +466 -0
- benchlab/graph/README.md +117 -0
- benchlab/graph/__init__.py +5 -0
- benchlab/graph/app.py +353 -0
- benchlab/graph/requirements.txt +2 -0
- benchlab/graph/runner.py +42 -0
- benchlab/graph/ui.py +288 -0
- benchlab/hwinfo/README.md +135 -0
- benchlab/hwinfo/__init__.py +5 -0
- benchlab/hwinfo/hwinfo_export.py +386 -0
- benchlab/hwinfo/requirements.txt +1 -0
- benchlab/launcher.py +387 -0
- benchlab/link/.env.EXAMPLE +10 -0
- benchlab/link/README.md +172 -0
- benchlab/link/__init__.py +1 -0
- benchlab/link/link_main.py +479 -0
- benchlab/main.py +462 -0
- benchlab/menu.py +729 -0
- benchlab/menu_classic.py +410 -0
- benchlab/mqtt/README.md +201 -0
- benchlab/mqtt/__init__.py +5 -0
- benchlab/mqtt/mqtt.config_template +2 -0
- benchlab/mqtt/mqtt_publisher.py +540 -0
- benchlab/mqtt/requirements.txt +3 -0
- benchlab/prefs.py +67 -0
- benchlab/restapi/.env.example +17 -0
- benchlab/restapi/__init__.py +5 -0
- benchlab/restapi/favicon.ico +0 -0
- benchlab/restapi/readme.md +202 -0
- benchlab/restapi/requirements.txt +5 -0
- benchlab/restapi/telemetry_api.py +808 -0
- benchlab/sources.py +515 -0
- benchlab/tools.py +147 -0
- benchlab/tui/README.md +266 -0
- benchlab/tui/__init__.py +5 -0
- benchlab/tui/config.py +477 -0
- benchlab/tui/requirements.txt +2 -0
- benchlab/tui/tui_core.py +1298 -0
- benchlab/tui/tui_main.py +418 -0
- benchlab/vu/README.md +64 -0
- benchlab/vu/VU-Server/NOTICE.md +13 -0
- benchlab/vu/VU-Server/README.md +80 -0
- benchlab/vu/VU-Server/Running_from_source_on_linux.md +97 -0
- benchlab/vu/VU-Server/__init__.py +0 -0
- benchlab/vu/VU-Server/assets/vu1_hello_world.png +0 -0
- benchlab/vu/VU-Server/community_applications.md +13 -0
- benchlab/vu/VU-Server/config.yaml +9 -0
- benchlab/vu/VU-Server/database.py +256 -0
- benchlab/vu/VU-Server/dial_driver.py +534 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Gauge.py +78 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Server.py +100 -0
- benchlab/vu/VU-Server/dials/__init__.py +0 -0
- benchlab/vu/VU-Server/dials/base_logger.py +100 -0
- benchlab/vu/VU-Server/installer/inc/icon.ico +0 -0
- benchlab/vu/VU-Server/installer/install.nsi +148 -0
- benchlab/vu/VU-Server/make_version.py +40 -0
- benchlab/vu/VU-Server/pylintrc +628 -0
- benchlab/vu/VU-Server/requirements.txt +9 -0
- benchlab/vu/VU-Server/serial_driver.py +244 -0
- benchlab/vu/VU-Server/server.py +670 -0
- benchlab/vu/VU-Server/server_config.py +255 -0
- benchlab/vu/VU-Server/server_dial_handler.py +334 -0
- benchlab/vu/VU-Server/vu_notifications.py +24 -0
- benchlab/vu/VU-Server/www/android-chrome-192x192.png +0 -0
- benchlab/vu/VU-Server/www/android-chrome-512x512.png +0 -0
- benchlab/vu/VU-Server/www/apple-touch-icon.png +0 -0
- benchlab/vu/VU-Server/www/assets/css/demo.css +291 -0
- benchlab/vu/VU-Server/www/assets/css/demo.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.css +276 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.css +25813 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.css +25777 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/theme.min.css +261 -0
- benchlab/vu/VU-Server/www/assets/img/header.png +0 -0
- benchlab/vu/VU-Server/www/assets/img/logo.png +0 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.js +35 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/demo.js +132 -0
- benchlab/vu/VU-Server/www/assets/js/demo.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/jquery.min.js +2 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.js +7482 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.js +7543 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_api.js +68 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_key_settings.js +167 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_keys.js +138 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_dial.js +179 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_home.js +60 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_root.js +125 -0
- benchlab/vu/VU-Server/www/favicon-16x16.png +0 -0
- benchlab/vu/VU-Server/www/favicon-32x32.png +0 -0
- benchlab/vu/VU-Server/www/favicon.ico +0 -0
- benchlab/vu/VU-Server/www/favicon.png +0 -0
- benchlab/vu/VU-Server/www/index.html +47 -0
- benchlab/vu/VU-Server/www/views/api_keys.html +151 -0
- benchlab/vu/VU-Server/www/views/dial.html +286 -0
- benchlab/vu/VU-Server/www/views/footer.html +27 -0
- benchlab/vu/VU-Server/www/views/header.html +86 -0
- benchlab/vu/VU-Server/www/views/key_settings.html +132 -0
- benchlab/vu/VU-Server/www/views/start.html +124 -0
- benchlab/vu/__init__.py +5 -0
- benchlab/vu/assets/Barlow-Bold.ttf +0 -0
- benchlab/vu/assets/bl_dial_144x200.png +0 -0
- benchlab/vu/assets/bl_logo_144x200.png +0 -0
- benchlab/vu/devices.py +166 -0
- benchlab/vu/requirements.txt +24 -0
- benchlab/vu/sensors.py +18 -0
- benchlab/vu/vu_dial.config_template +90 -0
- benchlab/vu/vu_logo_gen.py +97 -0
- benchlab/vu/vu_server.config_template +5 -0
- benchlab/vu/vu_server_config.py +40 -0
- benchlab/vu/vu_server_manager.py +192 -0
- benchlab/vu/vu_tui.py +919 -0
- benchlab/vu/vu_updater.py +540 -0
- benchlab/wigidash/README.md +182 -0
- benchlab/wigidash/__init__.py +5 -0
- benchlab/wigidash/assets/Inter-Bold.ttf +0 -0
- benchlab/wigidash/assets/Inter.ttf +0 -0
- benchlab/wigidash/assets/benchlab.png +0 -0
- benchlab/wigidash/benchlab_fleet.py +150 -0
- benchlab/wigidash/benchlab_graph.py +517 -0
- benchlab/wigidash/benchlab_overview.py +426 -0
- benchlab/wigidash/benchlab_telemetry.py +105 -0
- benchlab/wigidash/benchlab_ui.py +190 -0
- benchlab/wigidash/benchlab_utils.py +182 -0
- benchlab/wigidash/requirements.txt +9 -0
- benchlab/wigidash/wigidash_device.py +541 -0
- benchlab/wigidash/wigidash_manager.py +342 -0
- benchlab/wigidash/wigidash_session.py +355 -0
- benchlab/wigidash/wigidash_usb.py +247 -0
- benchlab/wigidash/wigidash_widget.py +88 -0
- benchlab_pytools-3.0.0.dist-info/METADATA +239 -0
- benchlab_pytools-3.0.0.dist-info/RECORD +184 -0
- benchlab_pytools-3.0.0.dist-info/WHEEL +4 -0
- benchlab_pytools-3.0.0.dist-info/entry_points.txt +2 -0
- benchlab_pytools-3.0.0.dist-info/licenses/LICENSE +21 -0
benchlab/tui/tui_core.py
ADDED
|
@@ -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
|