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,517 @@
1
+ # benchlab_graph.py
2
+
3
+ from benchlab.wigidash.benchlab_utils import display_image, get_logger
4
+ from benchlab.wigidash.benchlab_ui import (
5
+ load_fonts, draw_header, draw_footer, bind_button, load_logo, UIButton,
6
+ UITheme, BUTTON_DEFS
7
+ )
8
+ import time
9
+ import matplotlib.dates as mdates
10
+ import matplotlib.pyplot as plt
11
+ from datetime import datetime
12
+ from PIL import Image, ImageDraw
13
+
14
+ import io
15
+ import matplotlib
16
+ matplotlib.use("Agg")
17
+
18
+
19
+ logger = get_logger("BenchlabGraph")
20
+
21
+
22
+ class BenchlabGraph:
23
+ SCREEN_WIDTH = UITheme.SCREEN_WIDTH
24
+ SCREEN_HEIGHT = UITheme.SCREEN_HEIGHT
25
+ HEADER_HEIGHT = UITheme.HEADER_HEIGHT
26
+ FOOTER_HEIGHT = UITheme.FOOTER_HEIGHT
27
+ PADDING = UITheme.PADDING
28
+ COLOR_SECTION = UITheme.COLOR_SECTION
29
+ COLOR_TEXT = UITheme.COLOR_TEXT
30
+
31
+ NON_PLOT_METRICS = {
32
+ "Fan1_Status", "Fan2_Status", "Fan3_Status", "Fan4_Status",
33
+ "Fan5_Status", "Fan6_Status", "Fan7_Status", "Fan8_Status",
34
+ "Fan9_Status", "FanExtDuty", "Fans"
35
+ }
36
+
37
+ def __init__(
38
+ self,
39
+ wigidash,
40
+ wigi,
41
+ metrics,
42
+ telemetry_history=None,
43
+ telemetry_context=None,
44
+ manager=None):
45
+ self.wigidash = wigidash
46
+ self.wigi = wigi
47
+ self.metrics = metrics
48
+ self.history = telemetry_history or wigi.history
49
+ self.telemetry_context = telemetry_context
50
+ self.manager = manager
51
+
52
+ self.last_touch_time = 0
53
+ self.all_metrics = list(wigi.sensor_data.keys())
54
+ self.selected_metrics = metrics.copy()
55
+ self.running = False
56
+
57
+ self.ui_fonts = load_fonts()
58
+ self.ui_logo = load_logo()
59
+
60
+ self.footer_btn_config = []
61
+ self.metric_btn_config = []
62
+
63
+ # -------------------------------
64
+ # Page Lifecycle
65
+ # -------------------------------
66
+
67
+ def start(self):
68
+ self.running = True
69
+ logger.info(
70
+ f"Graph page started with metrics: {
71
+ self.selected_metrics}")
72
+
73
+ def return_to_overview(self):
74
+ logger.info("Returning to Overview page")
75
+ self.running = False
76
+ if self.wigi.overview_page is None:
77
+ self.wigi.overview_page = self.wigi.create_overview_page()
78
+ self.wigi.next_page = "overview"
79
+
80
+ # -------------------------------
81
+ # Touch Handling
82
+ # -------------------------------
83
+
84
+ def check_touch(self, touch):
85
+ if not getattr(self, "running", False):
86
+ return
87
+
88
+ now = int(time.monotonic() * 1000)
89
+ if now - getattr(self, "last_touch_time", 0) < 150:
90
+ return
91
+
92
+ if touch is None or getattr(touch, "Type", 0) == 0:
93
+ return
94
+
95
+ x, y = getattr(touch, "X", 0), getattr(touch, "Y", 0)
96
+
97
+ # Footer buttons
98
+ for btn in self.footer_btn_config:
99
+ if btn["x0"] <= x <= btn["x1"] and btn["y0"] <= y <= btn["y1"]:
100
+ logger.info(f"Footer button pressed: {btn['text']}")
101
+ if btn.get("callback"):
102
+ btn["callback"]()
103
+ self.last_touch_time = now
104
+ return
105
+
106
+ # Metric buttons (toggle for plotting)
107
+ for btn in self.metric_btn_config:
108
+ if btn["x0"] <= x <= btn["x1"] and btn["y0"] <= y <= btn["y1"]:
109
+ # Metric toggle button
110
+ if "metric" in btn:
111
+ metric = btn["metric"]
112
+ if not hasattr(self, "plot_metrics"):
113
+ self.plot_metrics = self.selected_metrics.copy()
114
+ if metric in self.plot_metrics:
115
+ self.plot_metrics.remove(metric)
116
+ else:
117
+ self.plot_metrics.append(metric)
118
+ # Callback button (e.g., All Duty / All RPM)
119
+ elif "callback" in btn and btn["callback"]:
120
+ btn["callback"]()
121
+ self.last_touch_time = now
122
+ return
123
+
124
+ # -------------------------------
125
+ # Helpers
126
+ # -------------------------------
127
+
128
+ def toggle_all_fan_metrics(self, suffix):
129
+ """Selects/deselects all metrics ending with the given suffix
130
+ (_Duty or _RPM)."""
131
+ # Check if all metrics of this type are already selected
132
+ all_selected = all(
133
+ m in self.plot_metrics
134
+ for m in self.selected_metrics
135
+ if m.endswith(suffix)
136
+ )
137
+
138
+ # Toggle: if all selected, deselect all; otherwise, select all
139
+ for m in self.selected_metrics:
140
+ if m.endswith(suffix):
141
+ if all_selected:
142
+ if m in self.plot_metrics:
143
+ self.plot_metrics.remove(m)
144
+ else:
145
+ if m not in self.plot_metrics:
146
+ self.plot_metrics.append(m)
147
+
148
+ def select_rail_section_metrics(self, key_suffix):
149
+ """Select all metrics for a given section (Power, Current, Voltage)."""
150
+ all_rails = [
151
+ 'EPS1',
152
+ 'EPS2',
153
+ '12V',
154
+ '5V',
155
+ '5VSB',
156
+ '3.3V',
157
+ 'PCIE8_1',
158
+ 'PCIE8_2',
159
+ 'PCIE8_3',
160
+ 'HPWR1',
161
+ 'HPWR2']
162
+ metrics = [f"{r}_{key_suffix}" for r in all_rails
163
+ if f"{r}_{key_suffix}" in self.wigi.sensor_data]
164
+ self.plot_metrics = metrics
165
+ logger.info(
166
+ f"Graph metrics updated for section '{key_suffix}': {
167
+ self.plot_metrics}")
168
+
169
+ # -------------------------------
170
+ # Rendering
171
+ # -------------------------------
172
+
173
+ def render_and_display(self):
174
+ """Wrapper for launcher compatibility"""
175
+ self.render_graph()
176
+
177
+ def render_graph(self):
178
+ img = Image.new(
179
+ 'RGB', (self.SCREEN_WIDTH, self.SCREEN_HEIGHT), color=(
180
+ 0, 0, 0))
181
+ draw = ImageDraw.Draw(img)
182
+
183
+ # Use class attributes from UITheme
184
+ padding = self.PADDING
185
+ header_height = self.HEADER_HEIGHT
186
+ footer_height = self.FOOTER_HEIGHT
187
+
188
+ self.padding = padding
189
+ self.header_height = header_height
190
+
191
+ total_top_width = self.SCREEN_WIDTH - 4 * padding
192
+ self.col1_width = self.col2_width = int(total_top_width * 0.25) - 3
193
+ self.col3_width = (
194
+ total_top_width - self.col1_width - self.col2_width - 1)
195
+
196
+ self.x1 = padding
197
+ self.x2 = self.x1 + self.col1_width + padding
198
+ self.x3 = self.x2 + self.col2_width + padding
199
+
200
+ # ---- Helper: draw rounded card ----
201
+ def rounded_card(x, y, w, h, title):
202
+ draw.rounded_rectangle(
203
+ [x, y, x + w, y + h], radius=12,
204
+ outline=(200, 200, 200), width=1, fill=(15, 15, 15))
205
+ draw.text((x + 8, y + 8),
206
+ title,
207
+ fill=self.COLOR_SECTION,
208
+ font=self.ui_fonts["title"])
209
+
210
+ # ---- Initialize plot_metrics if missing ----
211
+ if not hasattr(self, "plot_metrics"):
212
+ self.plot_metrics = self.selected_metrics.copy()
213
+
214
+ # ---- Header ----
215
+ draw_header(
216
+ draw,
217
+ img,
218
+ self.ui_fonts,
219
+ "BENCHLAB TELEMETRY HISTORY",
220
+ self.ui_logo)
221
+
222
+ # ---- Footer ----
223
+ btns = [
224
+ bind_button(UIButton.SHUTDOWN, self.manager.graceful_shutdown),
225
+ bind_button(UIButton.OVERVIEW, self.return_to_overview),
226
+ ]
227
+ # Safely determine context for port/UID
228
+ ctx = getattr(self, "telemetry_context", None)
229
+
230
+ if ctx:
231
+ port = getattr(ctx, "port", "N/A")
232
+ uid = getattr(ctx, "uid", "N/A")
233
+ device_info = getattr(ctx, "device_info", {})
234
+ vid = device_info.get("VendorId", 0)
235
+ pid = device_info.get("ProductId", 0)
236
+ fw = device_info.get("FwVersion", 0)
237
+
238
+ info_parts = [
239
+ f"Port: {port}",
240
+ f"Vendor ID: 0x{vid:02X}",
241
+ f"Product ID: 0x{pid:02X}",
242
+ f"FW: 0x{fw:02X}",
243
+ f"UID: {uid or 'N/A'}"
244
+ ]
245
+ info_line = " | ".join(info_parts)
246
+ else:
247
+ info_line = "Port: N/A | UID: N/A"
248
+
249
+ # Draw footer using correct fonts
250
+ self.footer_btn_config = draw_footer(
251
+ draw, self.ui_fonts, info_line, btns)
252
+
253
+ # ---- Left panel: metrics ----
254
+ panel_width = 200
255
+ btn_h = 25
256
+ panel_x = padding
257
+ panel_y = header_height + padding
258
+ self.metric_btn_config = []
259
+
260
+ draw.text((panel_x + 5, panel_y),
261
+ "Metrics",
262
+ fill=self.COLOR_SECTION,
263
+ font=self.ui_fonts["title"])
264
+ panel_y += self.ui_fonts["title"].getbbox("Metrics")[3] + 8
265
+
266
+ # Split numeric metrics into fan pairs and others
267
+ fan_pairs = []
268
+ other_metrics = []
269
+
270
+ for m in self.selected_metrics:
271
+ if m in BenchlabGraph.NON_PLOT_METRICS:
272
+ continue
273
+ if "_Duty" in m:
274
+ fan_num = m.split("_")[0] # e.g., "Fan1"
275
+ rpm_metric = fan_num + "_RPM"
276
+ if rpm_metric in self.selected_metrics:
277
+ fan_pairs.append((m, rpm_metric))
278
+ elif "_RPM" not in m:
279
+ other_metrics.append(m) # non-fan numeric metrics
280
+
281
+ # ---- Draw fan pairs first ----
282
+ half_width = panel_width // 2 - 4 # 4px spacing between Duty and RPM
283
+ spacing = 4
284
+ for duty_m, rpm_m in fan_pairs:
285
+ y0 = panel_y
286
+ y1 = y0 + btn_h
287
+
288
+ # Left half: Duty
289
+ color = (
290
+ 252,
291
+ 228,
292
+ 119) if duty_m in self.plot_metrics else (
293
+ 50,
294
+ 50,
295
+ 50)
296
+ text_color = BUTTON_DEFS.get(UIButton.GRAPH_METRIC, {}).get(
297
+ "color", (0, 0, 0)
298
+ ) if duty_m in self.plot_metrics else self.COLOR_TEXT
299
+ draw.rectangle([panel_x, y0, panel_x + half_width, y1],
300
+ fill=color, outline=(200, 200, 200))
301
+ draw.text((panel_x + 5, y0 + 6),
302
+ duty_m,
303
+ fill=text_color,
304
+ font=self.ui_fonts["text"])
305
+ self.metric_btn_config.append({
306
+ "x0": panel_x, "y0": y0, "x1": panel_x + half_width, "y1": y1,
307
+ "metric": duty_m
308
+ })
309
+
310
+ # Right half: RPM
311
+ x_start = panel_x + half_width + spacing
312
+ color = (
313
+ 252,
314
+ 228,
315
+ 119) if rpm_m in self.plot_metrics else (
316
+ 50,
317
+ 50,
318
+ 50)
319
+ text_color = BUTTON_DEFS.get(UIButton.GRAPH_METRIC, {}).get(
320
+ "color", (0, 0, 0)
321
+ ) if rpm_m in self.plot_metrics else self.COLOR_TEXT
322
+ draw.rectangle([x_start, y0, panel_x + panel_width,
323
+ y1], fill=color, outline=(200, 200, 200))
324
+ draw.text((x_start + 5, y0 + 6),
325
+ rpm_m,
326
+ fill=text_color,
327
+ font=self.ui_fonts["text"])
328
+ self.metric_btn_config.append({
329
+ "x0": x_start, "y0": y0, "x1": panel_x + panel_width, "y1": y1,
330
+ "metric": rpm_m
331
+ })
332
+
333
+ panel_y += btn_h + 4 # next row
334
+
335
+ # ---- Draw other numeric metrics ----
336
+ for metric in other_metrics:
337
+ y0 = panel_y
338
+ y1 = y0 + btn_h
339
+ color = (
340
+ 252,
341
+ 228,
342
+ 119) if metric in self.plot_metrics else (
343
+ 50,
344
+ 50,
345
+ 50)
346
+ text_color = BUTTON_DEFS.get(UIButton.GRAPH_METRIC, {}).get(
347
+ "color", (0, 0, 0)
348
+ ) if metric in self.plot_metrics else self.COLOR_TEXT
349
+ draw.rectangle([panel_x, y0, panel_x + panel_width,
350
+ y1], fill=color, outline=(200, 200, 200))
351
+ draw.text((panel_x + 5, y0 + 6),
352
+ metric,
353
+ fill=text_color,
354
+ font=self.ui_fonts["text"])
355
+ self.metric_btn_config.append({
356
+ "x0": panel_x, "y0": y0, "x1": panel_x + panel_width, "y1": y1,
357
+ "metric": metric
358
+ })
359
+ panel_y += btn_h + 4
360
+
361
+ # ---- Add "All Duty" and "All RPM" buttons ----
362
+ fan_metrics_present = any(
363
+ m for m in self.selected_metrics if "_Duty" in m or "_RPM" in m
364
+ )
365
+
366
+ # ---- Add "All Duty" and "All RPM" buttons only on Fans page ----
367
+ if fan_metrics_present:
368
+ all_btn_h = 30
369
+ all_btn_y = panel_y + 8
370
+
371
+ all_btns = [
372
+ {"text": "All Duty", "type": "_Duty"},
373
+ {"text": "All RPM", "type": "_RPM"}
374
+ ]
375
+
376
+ for i, btn in enumerate(all_btns):
377
+ y0 = all_btn_y + i * (all_btn_h + 4)
378
+ y1 = y0 + all_btn_h
379
+ draw.rectangle([panel_x, y0, panel_x + panel_width, y1],
380
+ fill=(100, 100, 100), outline=(200, 200, 200))
381
+ draw.text((panel_x + 5, y0 + 6),
382
+ btn["text"],
383
+ font=self.ui_fonts["text"],
384
+ fill=(255, 255, 255))
385
+
386
+ def make_callback(suffix):
387
+ return lambda s=suffix: self.toggle_all_fan_metrics(s)
388
+
389
+ self.metric_btn_config.append({
390
+ "x0": panel_x, "y0": y0,
391
+ "x1": panel_x + panel_width, "y1": y1,
392
+ "callback": make_callback(btn["type"]),
393
+ "text": btn["text"]
394
+ })
395
+
396
+ # ---- Graph area ----
397
+ graph_x = panel_width + 2 * padding
398
+ graph_y = header_height + padding
399
+ graph_w = self.SCREEN_WIDTH - graph_x - padding
400
+ graph_h = self.SCREEN_HEIGHT - graph_y - footer_height - padding
401
+
402
+ if self.plot_metrics:
403
+ fig, ax = plt.subplots(
404
+ figsize=(graph_w / 100, graph_h / 100), dpi=100,
405
+ facecolor=(0, 0, 0))
406
+ ax.set_facecolor((0, 0, 0))
407
+
408
+ y_min, y_max = float('inf'), float('-inf')
409
+
410
+ # Get timestamps once
411
+ timestamps = self.history.get_history('timestamp') or []
412
+
413
+ for m in self.plot_metrics:
414
+ if m in BenchlabGraph.NON_PLOT_METRICS:
415
+ continue
416
+
417
+ values = self.history.get_history(m) or []
418
+ if not values:
419
+ continue
420
+
421
+ # Align timestamps and values
422
+ min_len = min(len(timestamps), len(values))
423
+ ts_aligned = timestamps[-min_len:]
424
+ vals_aligned = values[-min_len:]
425
+
426
+ # Filter numeric data only
427
+ clean = [
428
+ (t, v) for t, v in zip(
429
+ ts_aligned, vals_aligned) if isinstance(
430
+ v, (int, float))]
431
+ if not clean:
432
+ continue
433
+
434
+ clean_ts, clean_vals = zip(*clean)
435
+ # Handle Unix timestamps (direct serial), milliseconds (MQTT),
436
+ # and ISO strings (DataSource)
437
+ x_vals = []
438
+ for t in clean_ts:
439
+ if isinstance(t, str):
440
+ # ISO format string from DataSource
441
+ x_vals.append(datetime.fromisoformat(t))
442
+ # Likely milliseconds (Unix timestamp * 1000)
443
+ elif t > 1e12:
444
+ # Convert milliseconds to seconds
445
+ x_vals.append(datetime.fromtimestamp(t / 1000))
446
+ else:
447
+ # Unix timestamp in seconds from direct serial
448
+ x_vals.append(datetime.fromtimestamp(t))
449
+
450
+ # Plot
451
+ short_label = m.replace(
452
+ "_Power",
453
+ "").replace(
454
+ "_Current",
455
+ "").replace(
456
+ "_Voltage",
457
+ "")
458
+ ax.plot(x_vals, clean_vals, label=short_label)
459
+
460
+ y_min = min(y_min, min(clean_vals))
461
+ y_max = max(y_max, max(clean_vals))
462
+
463
+ # Configure x-axis as time
464
+ if timestamps:
465
+ ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M:%S'))
466
+ ax.xaxis.set_major_locator(mdates.AutoDateLocator())
467
+
468
+ # Y-axis limits with padding
469
+ if y_min == float('inf') or y_max == float('-inf'):
470
+ y_bottom, y_top = 0, 1
471
+ elif y_min == y_max:
472
+ y_bottom = 0 if y_min >= 0 else y_min * 0.9
473
+ y_top = y_max + 0.1 * abs(y_max) if y_max != 0 else 1
474
+ else:
475
+ y_bottom = 0 if y_min >= 0 else y_min * 1.1
476
+ y_top = y_max * 1.1 if y_max != 0 else 1
477
+
478
+ ax.set_ylim(y_bottom, y_top)
479
+
480
+ ax.tick_params(colors='white', labelcolor='white')
481
+ plt.xticks(rotation=45, ha='right')
482
+ ax.grid(True, color='gray', linestyle='--', linewidth=0.5)
483
+
484
+ # Labels
485
+ ax.set_xlabel("Time", color='white')
486
+ units = list({
487
+ self.wigi.sensor_units.get(m)
488
+ for m in self.plot_metrics
489
+ if self.wigi.sensor_units.get(m)
490
+ })
491
+ ax.set_ylabel(units[0] if len(units) == 1 else ", ".join(
492
+ units) if units else "Value", color='white')
493
+
494
+ # Legend
495
+ legend = ax.legend(
496
+ loc='lower center',
497
+ bbox_to_anchor=(0.5, 1.1),
498
+ ncol=5,
499
+ fontsize=10,
500
+ facecolor='black',
501
+ edgecolor='black'
502
+ )
503
+ for text in legend.get_texts():
504
+ text.set_color("white")
505
+
506
+ # Convert figure to PIL image
507
+ buf = io.BytesIO()
508
+ fig.tight_layout()
509
+ fig.savefig(buf, format="png", facecolor=fig.get_facecolor())
510
+ plt.close(fig)
511
+ buf.seek(0)
512
+ graph_img = Image.open(buf).convert("RGB")
513
+ graph_img = graph_img.resize((graph_w, graph_h))
514
+ img.paste(graph_img, (graph_x, graph_y))
515
+
516
+ # ---- Display ----
517
+ display_image(self.wigidash, img)