msiltop 0.2.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.
- msiltop/__init__.py +0 -0
- msiltop/msiltop.py +932 -0
- msiltop/parsers.py +62 -0
- msiltop/soc_info.json +18 -0
- msiltop/utils.py +161 -0
- msiltop-0.2.0.dist-info/METADATA +276 -0
- msiltop-0.2.0.dist-info/RECORD +10 -0
- msiltop-0.2.0.dist-info/WHEEL +4 -0
- msiltop-0.2.0.dist-info/entry_points.txt +2 -0
- msiltop-0.2.0.dist-info/licenses/LICENSE +22 -0
msiltop/msiltop.py
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import click
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections import deque
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from textual.app import App, ComposeResult
|
|
8
|
+
from textual.containers import Horizontal, Vertical
|
|
9
|
+
from textual.widgets import ProgressBar, Static, Label
|
|
10
|
+
from textual_plotext import PlotextPlot
|
|
11
|
+
import plotext as plt
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from .utils import run_powermetrics_process, parse_powermetrics, get_soc_info, get_ram_metrics_dict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MetricGauge(Static):
|
|
17
|
+
"""Custom gauge widget to display metrics with progress bar and text"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, title: str = "", max_value: int = 100, **kwargs):
|
|
20
|
+
super().__init__(**kwargs)
|
|
21
|
+
self.title = title
|
|
22
|
+
self.max_value = max_value
|
|
23
|
+
self._value = 0
|
|
24
|
+
|
|
25
|
+
def compose(self) -> ComposeResult:
|
|
26
|
+
yield Label(self.title, id="gauge-title")
|
|
27
|
+
yield ProgressBar(total=self.max_value, show_percentage=True, id="gauge-progress")
|
|
28
|
+
|
|
29
|
+
def update_value(self, value: int, title: Optional[str] = None):
|
|
30
|
+
self._value = value
|
|
31
|
+
if title:
|
|
32
|
+
self.title = title
|
|
33
|
+
self.query_one("#gauge-title", Label).update(self.title)
|
|
34
|
+
self.query_one("#gauge-progress", ProgressBar).update(progress=value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PowerChart(PlotextPlot):
|
|
38
|
+
"""Custom chart widget for power consumption data"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, title: str = "", interval: int = 1, color: str = "cyan", max_points: int = 1200, **kwargs):
|
|
41
|
+
super().__init__(**kwargs)
|
|
42
|
+
self.title = title
|
|
43
|
+
self.interval = interval
|
|
44
|
+
self.plot_color = color
|
|
45
|
+
# Store up to max_points data points
|
|
46
|
+
self.data_points = deque(maxlen=max_points)
|
|
47
|
+
self.timestamps = deque(maxlen=max_points)
|
|
48
|
+
self.start_time = time.time()
|
|
49
|
+
# Track min/max values seen across all time
|
|
50
|
+
self.min_value_seen = None
|
|
51
|
+
self.max_value_seen = None
|
|
52
|
+
self._last_value = None
|
|
53
|
+
self._last_range = None
|
|
54
|
+
self._last_ticks = None
|
|
55
|
+
self._last_labels = None
|
|
56
|
+
|
|
57
|
+
def on_mount(self):
|
|
58
|
+
self.plt.title(self.title)
|
|
59
|
+
self.plt.xlabel("Time (minutes ago)")
|
|
60
|
+
self.plt.ylabel("Power (%)")
|
|
61
|
+
# Apply custom colors before setting auto_theme
|
|
62
|
+
# Set auto_theme to False to prevent overriding custom colors
|
|
63
|
+
self.auto_theme = False
|
|
64
|
+
self.plt.plotsize(None, None) # Auto-size
|
|
65
|
+
# Set Y-axis decimal precision
|
|
66
|
+
self.plt.yfrequency(0) # This will auto-determine the frequency
|
|
67
|
+
|
|
68
|
+
def add_data(self, value: float, render: bool = True):
|
|
69
|
+
current_time = time.time()
|
|
70
|
+
self.data_points.append(value)
|
|
71
|
+
self.timestamps.append(current_time)
|
|
72
|
+
if not render:
|
|
73
|
+
return
|
|
74
|
+
if self._last_value is not None and abs(value - self._last_value) < 0.01:
|
|
75
|
+
return
|
|
76
|
+
self._last_value = value
|
|
77
|
+
|
|
78
|
+
# Update min/max values seen
|
|
79
|
+
if self.min_value_seen is None or value < self.min_value_seen:
|
|
80
|
+
self.min_value_seen = value
|
|
81
|
+
if self.max_value_seen is None or value > self.max_value_seen:
|
|
82
|
+
self.max_value_seen = value
|
|
83
|
+
|
|
84
|
+
self.plt.clear_data()
|
|
85
|
+
|
|
86
|
+
# Always set up axes, even with no data
|
|
87
|
+
# Use tracked min/max values for scaling
|
|
88
|
+
if self.min_value_seen is not None and self.max_value_seen is not None:
|
|
89
|
+
# Ensure a minimum range to avoid flat lines
|
|
90
|
+
range_size = self.max_value_seen - self.min_value_seen
|
|
91
|
+
if range_size < 0.5: # Minimum 0.5% range
|
|
92
|
+
# Center the range around the midpoint
|
|
93
|
+
midpoint = (self.min_value_seen + self.max_value_seen) / 2
|
|
94
|
+
y_min = max(0, midpoint - 0.25)
|
|
95
|
+
y_max = midpoint + 0.25
|
|
96
|
+
else:
|
|
97
|
+
# Add 10% padding on both sides
|
|
98
|
+
padding = range_size * 0.1
|
|
99
|
+
y_min = max(0, self.min_value_seen - padding) # Don't go below 0
|
|
100
|
+
y_max = self.max_value_seen + padding
|
|
101
|
+
else:
|
|
102
|
+
# Default when no data
|
|
103
|
+
y_min = 0
|
|
104
|
+
y_max = 1.0
|
|
105
|
+
|
|
106
|
+
# Create 5 evenly spaced y-ticks only when range changes
|
|
107
|
+
current_range = (round(y_min, 2), round(y_max, 2))
|
|
108
|
+
if current_range != self._last_range:
|
|
109
|
+
y_ticks = []
|
|
110
|
+
y_labels = []
|
|
111
|
+
for i in range(5):
|
|
112
|
+
val = y_min + (y_max - y_min) * i / 4
|
|
113
|
+
y_ticks.append(val)
|
|
114
|
+
y_labels.append(f"{val:.1f}")
|
|
115
|
+
self._last_ticks = y_ticks
|
|
116
|
+
self._last_labels = y_labels
|
|
117
|
+
self._last_range = current_range
|
|
118
|
+
self.plt.yticks(self._last_ticks, self._last_labels)
|
|
119
|
+
# Set y-axis limits
|
|
120
|
+
self.plt.ylim(y_min, y_max)
|
|
121
|
+
|
|
122
|
+
# Set x-axis to show 0.0 to 0.6 minutes ago by default
|
|
123
|
+
default_x_ticks = [0.0, -0.1, -0.2, -0.3, -0.4, -0.5, -0.6]
|
|
124
|
+
default_x_labels = ["0.0", "0.1", "0.2", "0.3", "0.4", "0.5", "0.6"]
|
|
125
|
+
|
|
126
|
+
if len(self.data_points) > 1:
|
|
127
|
+
# Calculate time differences from now in minutes
|
|
128
|
+
time_diffs = [(current_time - t) / 60 for t in self.timestamps]
|
|
129
|
+
# Reverse so most recent is on the right
|
|
130
|
+
time_diffs = [-td for td in time_diffs]
|
|
131
|
+
|
|
132
|
+
# Use RGB color for plotting
|
|
133
|
+
self.plt.plot(time_diffs, list(self.data_points), marker="braille", color=self.plot_color)
|
|
134
|
+
|
|
135
|
+
# Set x-axis labels - show actual time values
|
|
136
|
+
if len(time_diffs) >= 5:
|
|
137
|
+
# Show 5 evenly spaced labels
|
|
138
|
+
indices = [0, len(time_diffs)//4, len(time_diffs)//2, 3*len(time_diffs)//4, len(time_diffs)-1]
|
|
139
|
+
ticks = [time_diffs[i] for i in indices]
|
|
140
|
+
labels = [f"{abs(t):.1f}" for t in ticks]
|
|
141
|
+
self.plt.xticks(ticks, labels)
|
|
142
|
+
else:
|
|
143
|
+
# For fewer points, show all
|
|
144
|
+
labels = [f"{abs(t):.1f}" for t in time_diffs]
|
|
145
|
+
self.plt.xticks(time_diffs, labels)
|
|
146
|
+
else:
|
|
147
|
+
# No data yet, show default x-axis
|
|
148
|
+
self.plt.xticks(default_x_ticks, default_x_labels)
|
|
149
|
+
self.plt.xlim(-0.6, 0)
|
|
150
|
+
|
|
151
|
+
self.refresh()
|
|
152
|
+
|
|
153
|
+
def update_title(self, title: str):
|
|
154
|
+
self.title = title
|
|
155
|
+
self.plt.title(title)
|
|
156
|
+
self.refresh()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class UsageChart(PlotextPlot):
|
|
160
|
+
"""Custom chart widget for usage percentage data"""
|
|
161
|
+
|
|
162
|
+
def __init__(self, title: str = "", ylabel: str = "Usage (%)", interval: int = 1, color: str = "cyan", max_points: int = 1200, **kwargs):
|
|
163
|
+
super().__init__(**kwargs)
|
|
164
|
+
self.title = title
|
|
165
|
+
self.ylabel = ylabel
|
|
166
|
+
self.interval = interval
|
|
167
|
+
self.plot_color = color
|
|
168
|
+
# Store up to max_points data points
|
|
169
|
+
self.data_points = deque(maxlen=max_points)
|
|
170
|
+
self.timestamps = deque(maxlen=max_points)
|
|
171
|
+
self.start_time = time.time()
|
|
172
|
+
# Track min/max values seen across all time
|
|
173
|
+
self.min_value_seen = None
|
|
174
|
+
self.max_value_seen = None
|
|
175
|
+
self._last_value = None
|
|
176
|
+
self._last_range = None
|
|
177
|
+
self._last_ticks = None
|
|
178
|
+
self._last_labels = None
|
|
179
|
+
|
|
180
|
+
def on_mount(self):
|
|
181
|
+
self.plt.title(self.title)
|
|
182
|
+
self.plt.xlabel("Time (minutes ago)")
|
|
183
|
+
self.plt.ylabel(self.ylabel)
|
|
184
|
+
self.plt.ylim(0, 100)
|
|
185
|
+
# Apply custom colors before setting auto_theme
|
|
186
|
+
# Set auto_theme to False to prevent overriding custom colors
|
|
187
|
+
self.auto_theme = False
|
|
188
|
+
self.plt.plotsize(None, None) # Auto-size
|
|
189
|
+
# Set Y-axis decimal precision
|
|
190
|
+
self.plt.yfrequency(0) # This will auto-determine the frequency
|
|
191
|
+
|
|
192
|
+
def add_data(self, value: float, render: bool = True):
|
|
193
|
+
current_time = time.time()
|
|
194
|
+
self.data_points.append(value)
|
|
195
|
+
self.timestamps.append(current_time)
|
|
196
|
+
if not render:
|
|
197
|
+
return
|
|
198
|
+
if self._last_value is not None and abs(value - self._last_value) < 0.1:
|
|
199
|
+
return
|
|
200
|
+
self._last_value = value
|
|
201
|
+
|
|
202
|
+
# Update min/max values seen
|
|
203
|
+
if self.min_value_seen is None or value < self.min_value_seen:
|
|
204
|
+
self.min_value_seen = value
|
|
205
|
+
if self.max_value_seen is None or value > self.max_value_seen:
|
|
206
|
+
self.max_value_seen = value
|
|
207
|
+
|
|
208
|
+
self.plt.clear_data()
|
|
209
|
+
|
|
210
|
+
# Always set up axes, even with no data
|
|
211
|
+
# For usage charts, we'll use dynamic scaling but ensure we can see the full 0-100 range if needed
|
|
212
|
+
if self.min_value_seen is not None and self.max_value_seen is not None:
|
|
213
|
+
# If values are spread across a wide range, show full 0-100
|
|
214
|
+
if self.max_value_seen > 80 or (self.max_value_seen - self.min_value_seen) > 50:
|
|
215
|
+
# Use traditional 0-100 scale
|
|
216
|
+
y_min = 0
|
|
217
|
+
y_max = 100
|
|
218
|
+
y_ticks = [0, 25, 50, 75, 100]
|
|
219
|
+
else:
|
|
220
|
+
# Use dynamic scaling for better visibility of small variations
|
|
221
|
+
range_size = self.max_value_seen - self.min_value_seen
|
|
222
|
+
if range_size < 5: # Minimum 5% range
|
|
223
|
+
# Center the range around the midpoint
|
|
224
|
+
midpoint = (self.min_value_seen + self.max_value_seen) / 2
|
|
225
|
+
y_min = max(0, midpoint - 2.5)
|
|
226
|
+
y_max = min(100, midpoint + 2.5)
|
|
227
|
+
else:
|
|
228
|
+
# Add 10% padding on both sides
|
|
229
|
+
padding = range_size * 0.1
|
|
230
|
+
y_min = max(0, self.min_value_seen - padding)
|
|
231
|
+
y_max = min(100, self.max_value_seen + padding)
|
|
232
|
+
|
|
233
|
+
# Create 5 evenly spaced y-ticks
|
|
234
|
+
y_ticks = []
|
|
235
|
+
for i in range(5):
|
|
236
|
+
val = y_min + (y_max - y_min) * i / 4
|
|
237
|
+
y_ticks.append(val)
|
|
238
|
+
else:
|
|
239
|
+
# Default when no data
|
|
240
|
+
y_min = 0
|
|
241
|
+
y_max = 100
|
|
242
|
+
y_ticks = [0, 25, 50, 75, 100]
|
|
243
|
+
|
|
244
|
+
current_range = (round(y_min, 2), round(y_max, 2))
|
|
245
|
+
if current_range != self._last_range:
|
|
246
|
+
y_labels = [f"{val:.1f}" for val in y_ticks]
|
|
247
|
+
self._last_ticks = y_ticks
|
|
248
|
+
self._last_labels = y_labels
|
|
249
|
+
self._last_range = current_range
|
|
250
|
+
self.plt.yticks(self._last_ticks, self._last_labels)
|
|
251
|
+
self.plt.ylim(y_min, y_max)
|
|
252
|
+
|
|
253
|
+
# Set x-axis to show 0.0 to 0.6 minutes ago by default
|
|
254
|
+
default_x_ticks = [0.0, -0.1, -0.2, -0.3, -0.4, -0.5, -0.6]
|
|
255
|
+
default_x_labels = ["0.0", "0.1", "0.2", "0.3", "0.4", "0.5", "0.6"]
|
|
256
|
+
|
|
257
|
+
if len(self.data_points) > 1:
|
|
258
|
+
# Calculate time differences from now in minutes
|
|
259
|
+
time_diffs = [(current_time - t) / 60 for t in self.timestamps]
|
|
260
|
+
# Reverse so most recent is on the right
|
|
261
|
+
time_diffs = [-td for td in time_diffs]
|
|
262
|
+
|
|
263
|
+
# Use RGB color for plotting
|
|
264
|
+
self.plt.plot(time_diffs, list(self.data_points), marker="braille", color=self.plot_color)
|
|
265
|
+
|
|
266
|
+
# Set x-axis labels - show actual time values
|
|
267
|
+
if len(time_diffs) >= 5:
|
|
268
|
+
# Show 5 evenly spaced labels
|
|
269
|
+
indices = [0, len(time_diffs)//4, len(time_diffs)//2, 3*len(time_diffs)//4, len(time_diffs)-1]
|
|
270
|
+
ticks = [time_diffs[i] for i in indices]
|
|
271
|
+
labels = [f"{abs(t):.1f}" for t in ticks]
|
|
272
|
+
self.plt.xticks(ticks, labels)
|
|
273
|
+
else:
|
|
274
|
+
# For fewer points, show all
|
|
275
|
+
labels = [f"{abs(t):.1f}" for t in time_diffs]
|
|
276
|
+
self.plt.xticks(time_diffs, labels)
|
|
277
|
+
else:
|
|
278
|
+
# No data yet, show default x-axis
|
|
279
|
+
self.plt.xticks(default_x_ticks, default_x_labels)
|
|
280
|
+
self.plt.xlim(-0.6, 0)
|
|
281
|
+
|
|
282
|
+
self.refresh()
|
|
283
|
+
|
|
284
|
+
def update_title(self, title: str):
|
|
285
|
+
self.title = title
|
|
286
|
+
self.plt.title(title)
|
|
287
|
+
self.refresh()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class MultiLineChart(PlotextPlot):
|
|
291
|
+
"""Custom chart widget for displaying multiple data series on the same chart"""
|
|
292
|
+
|
|
293
|
+
def __init__(self, title: str = "", ylabel: str = "Value", interval: int = 1, color: str = "cyan", max_points: int = 1200, **kwargs):
|
|
294
|
+
super().__init__(**kwargs)
|
|
295
|
+
self.title = title
|
|
296
|
+
self.ylabel = ylabel
|
|
297
|
+
self.interval = interval
|
|
298
|
+
self.plot_color = color
|
|
299
|
+
self.max_points = max_points
|
|
300
|
+
# Store up to max_points data points for each series
|
|
301
|
+
self.data_series = {} # Will store {'series_name': {'data': deque, 'timestamps': deque}}
|
|
302
|
+
self.start_time = time.time()
|
|
303
|
+
self._last_values = {}
|
|
304
|
+
|
|
305
|
+
def on_mount(self):
|
|
306
|
+
self.plt.title(self.title)
|
|
307
|
+
self.plt.xlabel("Time (minutes ago)")
|
|
308
|
+
self.plt.ylabel(self.ylabel)
|
|
309
|
+
# Apply custom colors before setting auto_theme
|
|
310
|
+
# Set auto_theme to False to prevent overriding custom colors
|
|
311
|
+
self.auto_theme = False
|
|
312
|
+
self.plt.plotsize(None, None) # Auto-size
|
|
313
|
+
# Set Y-axis decimal precision
|
|
314
|
+
self.plt.yfrequency(0) # This will auto-determine the frequency
|
|
315
|
+
|
|
316
|
+
def add_data(self, series_name: str, value: float, y_axis: str = "left", color: str | None = None, render: bool = True):
|
|
317
|
+
"""Add data point to a specific series"""
|
|
318
|
+
current_time = time.time()
|
|
319
|
+
|
|
320
|
+
# Initialize series if it doesn't exist
|
|
321
|
+
if series_name not in self.data_series:
|
|
322
|
+
# Use provided color or default to the chart's color
|
|
323
|
+
series_color = color if color else self.plot_color
|
|
324
|
+
self.data_series[series_name] = {
|
|
325
|
+
'data': deque(maxlen=self.max_points),
|
|
326
|
+
'timestamps': deque(maxlen=self.max_points),
|
|
327
|
+
'y_axis': y_axis,
|
|
328
|
+
'color': series_color
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
self.data_series[series_name]['data'].append(value)
|
|
332
|
+
self.data_series[series_name]['timestamps'].append(current_time)
|
|
333
|
+
if not render:
|
|
334
|
+
return
|
|
335
|
+
last_value = self._last_values.get(series_name)
|
|
336
|
+
if last_value is not None and abs(value - last_value) < 0.1:
|
|
337
|
+
return
|
|
338
|
+
self._last_values[series_name] = value
|
|
339
|
+
|
|
340
|
+
self.plt.clear_data()
|
|
341
|
+
|
|
342
|
+
# Determine y-axis range based on all data
|
|
343
|
+
all_data_left = []
|
|
344
|
+
all_data_right = []
|
|
345
|
+
|
|
346
|
+
for name, series in self.data_series.items():
|
|
347
|
+
if series['y_axis'] == 'left':
|
|
348
|
+
all_data_left.extend(list(series['data']))
|
|
349
|
+
else:
|
|
350
|
+
all_data_right.extend(list(series['data']))
|
|
351
|
+
|
|
352
|
+
# Set up left y-axis (usage percentage)
|
|
353
|
+
if all_data_left:
|
|
354
|
+
y_min_left = 0
|
|
355
|
+
y_max_left = max(max(all_data_left), 100) # At least 100 for percentage
|
|
356
|
+
y_ticks_left = [0, 25, 50, 75, 100] if y_max_left <= 100 else [0, y_max_left/4, y_max_left/2, 3*y_max_left/4, y_max_left]
|
|
357
|
+
y_labels_left = [f"{val:.1f}" for val in y_ticks_left]
|
|
358
|
+
self.plt.yticks(y_ticks_left, y_labels_left)
|
|
359
|
+
self.plt.ylim(0, y_max_left * 1.1)
|
|
360
|
+
|
|
361
|
+
# Set x-axis to show 0.0 to 0.6 minutes ago by default
|
|
362
|
+
default_x_ticks = [0.0, -0.1, -0.2, -0.3, -0.4, -0.5, -0.6]
|
|
363
|
+
default_x_labels = ["0.0", "0.1", "0.2", "0.3", "0.4", "0.5", "0.6"]
|
|
364
|
+
|
|
365
|
+
# Plot all series
|
|
366
|
+
has_data = False
|
|
367
|
+
for name, series in self.data_series.items():
|
|
368
|
+
if len(series['data']) > 1:
|
|
369
|
+
has_data = True
|
|
370
|
+
# Calculate time differences from now in minutes
|
|
371
|
+
time_diffs = [(current_time - t) / 60 for t in series['timestamps']]
|
|
372
|
+
# Reverse so most recent is on the right
|
|
373
|
+
time_diffs = [-td for td in time_diffs]
|
|
374
|
+
|
|
375
|
+
# Use series-specific color
|
|
376
|
+
self.plt.plot(time_diffs, list(series['data']), marker="braille", color=series['color'], label=name)
|
|
377
|
+
|
|
378
|
+
if has_data:
|
|
379
|
+
# Use the most recent series' timestamps for x-axis
|
|
380
|
+
most_recent_series = max(self.data_series.values(), key=lambda s: len(s['data']))
|
|
381
|
+
time_diffs = [(current_time - t) / 60 for t in most_recent_series['timestamps']]
|
|
382
|
+
time_diffs = [-td for td in time_diffs]
|
|
383
|
+
|
|
384
|
+
# Set x-axis labels - show actual time values
|
|
385
|
+
if len(time_diffs) >= 5:
|
|
386
|
+
# Show 5 evenly spaced labels
|
|
387
|
+
indices = [0, len(time_diffs)//4, len(time_diffs)//2, 3*len(time_diffs)//4, len(time_diffs)-1]
|
|
388
|
+
ticks = [time_diffs[i] for i in indices]
|
|
389
|
+
labels = [f"{abs(t):.1f}" for t in ticks]
|
|
390
|
+
self.plt.xticks(ticks, labels)
|
|
391
|
+
else:
|
|
392
|
+
# For fewer points, show all
|
|
393
|
+
labels = [f"{abs(t):.1f}" for t in time_diffs]
|
|
394
|
+
self.plt.xticks(time_diffs, labels)
|
|
395
|
+
else:
|
|
396
|
+
# No data yet, show default x-axis
|
|
397
|
+
self.plt.xticks(default_x_ticks, default_x_labels)
|
|
398
|
+
self.plt.xlim(-0.6, 0)
|
|
399
|
+
|
|
400
|
+
self.refresh()
|
|
401
|
+
|
|
402
|
+
def update_title(self, title: str):
|
|
403
|
+
self.title = title
|
|
404
|
+
self.plt.title(title)
|
|
405
|
+
self.refresh()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class FluidTopApp(App):
|
|
409
|
+
"""Main FluidTop application using Textual"""
|
|
410
|
+
|
|
411
|
+
# CSS is set dynamically in _apply_theme method
|
|
412
|
+
|
|
413
|
+
def __init__(self, interval: int, theme: str, avg: int, max_count: int, show_cores: bool = False):
|
|
414
|
+
self.interval = interval
|
|
415
|
+
# Store theme temporarily, don't assign to self.theme yet
|
|
416
|
+
theme_value = theme
|
|
417
|
+
self.theme_colors = self._get_theme_colors(theme_value)
|
|
418
|
+
# Apply theme BEFORE calling super().__init__()
|
|
419
|
+
self._apply_theme(theme_value)
|
|
420
|
+
|
|
421
|
+
super().__init__()
|
|
422
|
+
|
|
423
|
+
# Store theme value in a regular instance variable (not reactive)
|
|
424
|
+
self._theme_name = theme_value
|
|
425
|
+
self.avg = avg
|
|
426
|
+
self.max_count = max_count
|
|
427
|
+
self.show_cores = show_cores
|
|
428
|
+
|
|
429
|
+
# Initialize metrics storage
|
|
430
|
+
# No longer tracking averages or peaks
|
|
431
|
+
|
|
432
|
+
# Total energy consumption tracking (in watt-seconds) for each component
|
|
433
|
+
self.total_energy_consumed = 0
|
|
434
|
+
self.cpu_energy_consumed = 0
|
|
435
|
+
self.gpu_energy_consumed = 0
|
|
436
|
+
self.ane_energy_consumed = 0
|
|
437
|
+
|
|
438
|
+
# Powermetrics process
|
|
439
|
+
self.powermetrics_process = None
|
|
440
|
+
self.timecode = None
|
|
441
|
+
self.last_timestamp = 0
|
|
442
|
+
self.count = 0
|
|
443
|
+
|
|
444
|
+
# SoC info
|
|
445
|
+
self.soc_info_dict = get_soc_info()
|
|
446
|
+
|
|
447
|
+
# Smoothing buffers for CPU usage
|
|
448
|
+
self.cpu_usage_buffer_size = 5 # Average over last 5 samples
|
|
449
|
+
self.e_cpu_usage_buffer = []
|
|
450
|
+
self.p_cpu_usage_buffer = []
|
|
451
|
+
self._render_every_n = 2
|
|
452
|
+
self._render_tick = 0
|
|
453
|
+
self._core_usage_buffer = {}
|
|
454
|
+
|
|
455
|
+
def _get_theme_colors(self, theme: str) -> str:
|
|
456
|
+
"""Get the color mapping for the theme using plotext-compatible color names"""
|
|
457
|
+
# Using plotext-compatible color names instead of hex colors
|
|
458
|
+
theme_chart_colors = {
|
|
459
|
+
'default': 'gray',
|
|
460
|
+
'dark': 'white',
|
|
461
|
+
'blue': 'blue',
|
|
462
|
+
'green': 'green',
|
|
463
|
+
'red': 'red',
|
|
464
|
+
'purple': 'magenta',
|
|
465
|
+
'orange': 'yellow',
|
|
466
|
+
'cyan': 'cyan',
|
|
467
|
+
'magenta': 'magenta'
|
|
468
|
+
}
|
|
469
|
+
return theme_chart_colors.get(theme, 'cyan')
|
|
470
|
+
|
|
471
|
+
def _apply_theme(self, theme: str):
|
|
472
|
+
"""Apply color theme to the application"""
|
|
473
|
+
# Using shadcn-inspired hex colors for better design consistency
|
|
474
|
+
themes = {
|
|
475
|
+
'default': {'primary': '#18181b', 'accent': '#71717a'}, # zinc-900, zinc-500
|
|
476
|
+
'dark': {'primary': '#fafafa', 'accent': '#a1a1aa'}, # zinc-50, zinc-400
|
|
477
|
+
'blue': {'primary': '#1e40af', 'accent': '#3b82f6'}, # blue-800, blue-500
|
|
478
|
+
'green': {'primary': '#166534', 'accent': '#22c55e'}, # green-800, green-500
|
|
479
|
+
'red': {'primary': '#dc2626', 'accent': '#ef4444'}, # red-600, red-500
|
|
480
|
+
'purple': {'primary': '#7c3aed', 'accent': '#a855f7'}, # violet-600, purple-500
|
|
481
|
+
'orange': {'primary': '#FD8161', 'accent': '#f97316'}, # orange-600, orange-500
|
|
482
|
+
'cyan': {'primary': '#5DAF8D', 'accent': '#06b6d4'}, # cyan-600, cyan-500
|
|
483
|
+
'magenta': {'primary': '#db2777', 'accent': '#ec4899'} # pink-600, pink-500
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if theme in themes:
|
|
487
|
+
colors = themes[theme]
|
|
488
|
+
# Update CSS with theme colors and reduced padding
|
|
489
|
+
self.CSS = f"""
|
|
490
|
+
Screen {{
|
|
491
|
+
layers: base;
|
|
492
|
+
overflow: hidden hidden;
|
|
493
|
+
}}
|
|
494
|
+
|
|
495
|
+
Screen > Container {{
|
|
496
|
+
width: 100%;
|
|
497
|
+
height: 100%;
|
|
498
|
+
overflow: hidden hidden;
|
|
499
|
+
}}
|
|
500
|
+
|
|
501
|
+
MetricGauge {{
|
|
502
|
+
height: 3;
|
|
503
|
+
margin: 0;
|
|
504
|
+
border: solid {colors['primary']};
|
|
505
|
+
}}
|
|
506
|
+
|
|
507
|
+
PowerChart {{
|
|
508
|
+
height: 1fr;
|
|
509
|
+
margin: 0;
|
|
510
|
+
border: none;
|
|
511
|
+
background: $surface;
|
|
512
|
+
}}
|
|
513
|
+
|
|
514
|
+
PowerChart PlotextPlot {{
|
|
515
|
+
background: $surface;
|
|
516
|
+
}}
|
|
517
|
+
|
|
518
|
+
UsageChart {{
|
|
519
|
+
height: 1fr;
|
|
520
|
+
margin: 0;
|
|
521
|
+
border: none;
|
|
522
|
+
background: $surface;
|
|
523
|
+
}}
|
|
524
|
+
|
|
525
|
+
UsageChart PlotextPlot {{
|
|
526
|
+
background: $surface;
|
|
527
|
+
}}
|
|
528
|
+
|
|
529
|
+
MultiLineChart {{
|
|
530
|
+
height: 1fr;
|
|
531
|
+
margin: 0;
|
|
532
|
+
border: none;
|
|
533
|
+
background: $surface;
|
|
534
|
+
}}
|
|
535
|
+
|
|
536
|
+
MultiLineChart PlotextPlot {{
|
|
537
|
+
background: $surface;
|
|
538
|
+
}}
|
|
539
|
+
|
|
540
|
+
#usage-section {{
|
|
541
|
+
border: solid {colors['primary']};
|
|
542
|
+
padding: 0;
|
|
543
|
+
height: 1fr;
|
|
544
|
+
background: $surface;
|
|
545
|
+
}}
|
|
546
|
+
|
|
547
|
+
#power-section {{
|
|
548
|
+
border: solid {colors['primary']};
|
|
549
|
+
padding: 0;
|
|
550
|
+
height: 1fr;
|
|
551
|
+
background: $surface;
|
|
552
|
+
}}
|
|
553
|
+
|
|
554
|
+
#power-section Horizontal {{
|
|
555
|
+
margin-bottom: 1;
|
|
556
|
+
}}
|
|
557
|
+
|
|
558
|
+
#power-section Horizontal:last-child {{
|
|
559
|
+
margin-bottom: 0;
|
|
560
|
+
}}
|
|
561
|
+
|
|
562
|
+
#controls-section {{
|
|
563
|
+
border: solid {colors['accent']};
|
|
564
|
+
padding: 0 1;
|
|
565
|
+
height: 3;
|
|
566
|
+
background: $surface;
|
|
567
|
+
}}
|
|
568
|
+
|
|
569
|
+
#controls-content {{
|
|
570
|
+
layout: horizontal;
|
|
571
|
+
align: center middle;
|
|
572
|
+
width: 100%;
|
|
573
|
+
height: 100%;
|
|
574
|
+
}}
|
|
575
|
+
|
|
576
|
+
#system-info-label {{
|
|
577
|
+
width: 2fr;
|
|
578
|
+
text-align: left;
|
|
579
|
+
color: $text;
|
|
580
|
+
padding: 0 1;
|
|
581
|
+
}}
|
|
582
|
+
|
|
583
|
+
#controls-buttons {{
|
|
584
|
+
width: 1fr;
|
|
585
|
+
layout: horizontal;
|
|
586
|
+
align: right middle;
|
|
587
|
+
height: 100%;
|
|
588
|
+
}}
|
|
589
|
+
|
|
590
|
+
#timestamp-label {{
|
|
591
|
+
width: auto;
|
|
592
|
+
text-align: right;
|
|
593
|
+
color: $text;
|
|
594
|
+
padding: 0 1;
|
|
595
|
+
}}
|
|
596
|
+
|
|
597
|
+
Label {{
|
|
598
|
+
color: $text;
|
|
599
|
+
margin: 0;
|
|
600
|
+
padding: 0;
|
|
601
|
+
}}
|
|
602
|
+
|
|
603
|
+
#controls-title {{
|
|
604
|
+
text-style: bold;
|
|
605
|
+
margin: 0;
|
|
606
|
+
padding: 0;
|
|
607
|
+
}}
|
|
608
|
+
"""
|
|
609
|
+
|
|
610
|
+
def compose(self) -> ComposeResult:
|
|
611
|
+
"""Compose the UI layout"""
|
|
612
|
+
|
|
613
|
+
# Controls section with power and system info at the top
|
|
614
|
+
with Vertical(id="controls-section"):
|
|
615
|
+
with Horizontal(id="controls-content"):
|
|
616
|
+
# System info on the left
|
|
617
|
+
yield Label("Initializing...", id="system-info-label")
|
|
618
|
+
# Timestamp on the right
|
|
619
|
+
with Horizontal(id="controls-buttons"):
|
|
620
|
+
yield Label("", id="timestamp-label")
|
|
621
|
+
|
|
622
|
+
# Usage Charts section
|
|
623
|
+
with Vertical(id="usage-section"):
|
|
624
|
+
with Horizontal():
|
|
625
|
+
yield MultiLineChart("CPU Usage (E-CPU & P-CPU)", ylabel="Usage (%)", interval=self.interval, color=self.theme_colors, id="cpu-combined-chart")
|
|
626
|
+
yield UsageChart("GPU", interval=self.interval, color=self.theme_colors, id="gpu-usage-chart")
|
|
627
|
+
yield UsageChart("RAM Usage", ylabel="RAM (%)", interval=self.interval, color=self.theme_colors, id="ram-usage-chart")
|
|
628
|
+
if self.show_cores:
|
|
629
|
+
with Vertical(id="core-usage-section"):
|
|
630
|
+
with Horizontal():
|
|
631
|
+
yield MultiLineChart("E-Core Usage", ylabel="Usage (%)", interval=self.interval, color="blue", id="e-core-usage-chart")
|
|
632
|
+
yield MultiLineChart("P-Core Usage", ylabel="Usage (%)", interval=self.interval, color="red", id="p-core-usage-chart")
|
|
633
|
+
|
|
634
|
+
# Power section
|
|
635
|
+
with Vertical(id="power-section"):
|
|
636
|
+
with Horizontal():
|
|
637
|
+
yield PowerChart("CPU Power", interval=self.interval, color=self.theme_colors, id="cpu-power-chart")
|
|
638
|
+
yield PowerChart("GPU Power", interval=self.interval, color=self.theme_colors, id="gpu-power-chart")
|
|
639
|
+
yield PowerChart("ANE Power", interval=self.interval, color=self.theme_colors, id="ane-power-chart")
|
|
640
|
+
|
|
641
|
+
async def on_mount(self):
|
|
642
|
+
"""Initialize the application on mount"""
|
|
643
|
+
# Start powermetrics process
|
|
644
|
+
self.timecode = str(int(time.time()))
|
|
645
|
+
self.powermetrics_process = run_powermetrics_process(
|
|
646
|
+
self.timecode, interval=int(self.interval * 1000)
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
# Wait for first reading
|
|
650
|
+
await self.wait_for_first_reading()
|
|
651
|
+
|
|
652
|
+
# Start update timer
|
|
653
|
+
self.set_interval(self.interval, self.update_metrics)
|
|
654
|
+
|
|
655
|
+
# Update system info label
|
|
656
|
+
system_info = f"{self.soc_info_dict['name']} ({self.soc_info_dict['e_core_count']}E+{self.soc_info_dict['p_core_count']}P+{self.soc_info_dict['gpu_core_count']}GPU)"
|
|
657
|
+
self.query_one("#system-info-label", Label).update(system_info)
|
|
658
|
+
|
|
659
|
+
# Initialize timestamp
|
|
660
|
+
await self.update_timestamp()
|
|
661
|
+
|
|
662
|
+
async def wait_for_first_reading(self):
|
|
663
|
+
"""Wait for the first powermetrics reading"""
|
|
664
|
+
while True:
|
|
665
|
+
ready = parse_powermetrics(timecode=self.timecode)
|
|
666
|
+
if ready:
|
|
667
|
+
self.last_timestamp = ready[-1]
|
|
668
|
+
break
|
|
669
|
+
await asyncio.sleep(0.1)
|
|
670
|
+
|
|
671
|
+
async def update_metrics(self):
|
|
672
|
+
"""Update all metrics - called by timer"""
|
|
673
|
+
try:
|
|
674
|
+
# Handle max_count restart
|
|
675
|
+
if self.max_count > 0 and self.count >= self.max_count:
|
|
676
|
+
self.count = 0
|
|
677
|
+
self.powermetrics_process.terminate()
|
|
678
|
+
self.timecode = str(int(time.time()))
|
|
679
|
+
self.powermetrics_process = run_powermetrics_process(
|
|
680
|
+
self.timecode, interval=int(self.interval * 1000)
|
|
681
|
+
)
|
|
682
|
+
self.count += 1
|
|
683
|
+
|
|
684
|
+
# Parse powermetrics data
|
|
685
|
+
ready = parse_powermetrics(timecode=self.timecode)
|
|
686
|
+
if not ready:
|
|
687
|
+
return
|
|
688
|
+
|
|
689
|
+
cpu_metrics_dict, gpu_metrics_dict, thermal_pressure, _, timestamp = ready
|
|
690
|
+
|
|
691
|
+
if timestamp <= self.last_timestamp:
|
|
692
|
+
return
|
|
693
|
+
|
|
694
|
+
self.last_timestamp = timestamp
|
|
695
|
+
|
|
696
|
+
# CPU, GPU, and ANE gauge widgets have been removed
|
|
697
|
+
|
|
698
|
+
self._render_tick += 1
|
|
699
|
+
should_render = (self._render_tick % self._render_every_n) == 0
|
|
700
|
+
# Update usage charts
|
|
701
|
+
await self.update_usage_charts(cpu_metrics_dict, gpu_metrics_dict, should_render)
|
|
702
|
+
|
|
703
|
+
# Update power charts
|
|
704
|
+
await self.update_power_charts(cpu_metrics_dict, thermal_pressure, should_render)
|
|
705
|
+
|
|
706
|
+
# Update timestamp
|
|
707
|
+
await self.update_timestamp()
|
|
708
|
+
|
|
709
|
+
except Exception as e:
|
|
710
|
+
# Handle errors gracefully
|
|
711
|
+
pass
|
|
712
|
+
|
|
713
|
+
async def update_usage_charts(self, cpu_metrics_dict, gpu_metrics_dict, should_render: bool):
|
|
714
|
+
"""Update usage chart metrics"""
|
|
715
|
+
# Update combined CPU chart (E-CPU and P-CPU)
|
|
716
|
+
cpu_combined_chart = self.query_one("#cpu-combined-chart", MultiLineChart)
|
|
717
|
+
|
|
718
|
+
# Get E-CPU and P-CPU usage data
|
|
719
|
+
e_cpu_usage_raw = cpu_metrics_dict['E-Cluster_active']
|
|
720
|
+
p_cpu_usage_raw = cpu_metrics_dict['P-Cluster_active']
|
|
721
|
+
|
|
722
|
+
# Add to smoothing buffers
|
|
723
|
+
self.e_cpu_usage_buffer.append(e_cpu_usage_raw)
|
|
724
|
+
self.p_cpu_usage_buffer.append(p_cpu_usage_raw)
|
|
725
|
+
|
|
726
|
+
# Keep buffer size limited
|
|
727
|
+
if len(self.e_cpu_usage_buffer) > self.cpu_usage_buffer_size:
|
|
728
|
+
self.e_cpu_usage_buffer.pop(0)
|
|
729
|
+
if len(self.p_cpu_usage_buffer) > self.cpu_usage_buffer_size:
|
|
730
|
+
self.p_cpu_usage_buffer.pop(0)
|
|
731
|
+
|
|
732
|
+
# Calculate smoothed values (average of buffer)
|
|
733
|
+
e_cpu_usage = int(sum(self.e_cpu_usage_buffer) / len(self.e_cpu_usage_buffer))
|
|
734
|
+
p_cpu_usage = int(sum(self.p_cpu_usage_buffer) / len(self.p_cpu_usage_buffer))
|
|
735
|
+
|
|
736
|
+
# Add both CPU types to the same chart with different colors
|
|
737
|
+
cpu_combined_chart.add_data(
|
|
738
|
+
f"E-CPU ({self.soc_info_dict['e_core_count']} cores)",
|
|
739
|
+
e_cpu_usage,
|
|
740
|
+
y_axis="left",
|
|
741
|
+
color="blue",
|
|
742
|
+
render=should_render,
|
|
743
|
+
)
|
|
744
|
+
cpu_combined_chart.add_data(
|
|
745
|
+
f"P-CPU ({self.soc_info_dict['p_core_count']} cores)",
|
|
746
|
+
p_cpu_usage,
|
|
747
|
+
y_axis="left",
|
|
748
|
+
color="red",
|
|
749
|
+
render=should_render,
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
# Update title to show both CPU types (smoothed values)
|
|
753
|
+
combined_title = f"E-CPU: {e_cpu_usage}% | P-CPU: {p_cpu_usage}%"
|
|
754
|
+
cpu_combined_chart.update_title(combined_title)
|
|
755
|
+
|
|
756
|
+
# Update GPU usage chart
|
|
757
|
+
gpu_chart = self.query_one("#gpu-usage-chart", UsageChart)
|
|
758
|
+
gpu_usage = gpu_metrics_dict['active']
|
|
759
|
+
gpu_title = f"GPU ({self.soc_info_dict['gpu_core_count']} cores): {gpu_usage}%"
|
|
760
|
+
gpu_chart.update_title(gpu_title)
|
|
761
|
+
gpu_chart.add_data(gpu_usage, render=should_render)
|
|
762
|
+
|
|
763
|
+
# Update RAM usage chart with swap information
|
|
764
|
+
ram_metrics_dict = get_ram_metrics_dict()
|
|
765
|
+
ram_chart = self.query_one("#ram-usage-chart", UsageChart)
|
|
766
|
+
ram_usage_percent = 100 - ram_metrics_dict["free_percent"] # Convert from free to used percentage
|
|
767
|
+
|
|
768
|
+
# Include swap information in the title
|
|
769
|
+
if ram_metrics_dict["swap_total_GB"] < 0.1:
|
|
770
|
+
ram_title = f"RAM: {ram_usage_percent:.1f}% ({ram_metrics_dict['used_GB']:.1f}/{ram_metrics_dict['total_GB']:.1f}GB) - swap inactive"
|
|
771
|
+
else:
|
|
772
|
+
ram_title = f"RAM: {ram_usage_percent:.1f}% ({ram_metrics_dict['used_GB']:.1f}/{ram_metrics_dict['total_GB']:.1f}GB) - swap: {ram_metrics_dict['swap_used_GB']:.1f}/{ram_metrics_dict['swap_total_GB']:.1f}GB"
|
|
773
|
+
|
|
774
|
+
ram_chart.update_title(ram_title)
|
|
775
|
+
ram_chart.add_data(ram_usage_percent, render=should_render)
|
|
776
|
+
|
|
777
|
+
if self.show_cores:
|
|
778
|
+
await self.update_core_usage_charts(cpu_metrics_dict, should_render)
|
|
779
|
+
|
|
780
|
+
async def update_core_usage_charts(self, cpu_metrics_dict, should_render: bool):
|
|
781
|
+
"""Update per-core usage charts (optional)"""
|
|
782
|
+
e_core_chart = self.query_one("#e-core-usage-chart", MultiLineChart)
|
|
783
|
+
p_core_chart = self.query_one("#p-core-usage-chart", MultiLineChart)
|
|
784
|
+
|
|
785
|
+
for core_id in cpu_metrics_dict.get("e_core", []):
|
|
786
|
+
key = f"E{core_id}"
|
|
787
|
+
metric_key = f"E-Cluster{core_id}_active"
|
|
788
|
+
core_value = cpu_metrics_dict.get(metric_key)
|
|
789
|
+
if core_value is None:
|
|
790
|
+
continue
|
|
791
|
+
self._core_usage_buffer.setdefault(key, []).append(core_value)
|
|
792
|
+
if len(self._core_usage_buffer[key]) > self.cpu_usage_buffer_size:
|
|
793
|
+
self._core_usage_buffer[key].pop(0)
|
|
794
|
+
smoothed = int(sum(self._core_usage_buffer[key]) / len(self._core_usage_buffer[key]))
|
|
795
|
+
e_core_chart.add_data(key, smoothed, y_axis="left", color="blue", render=should_render)
|
|
796
|
+
|
|
797
|
+
for core_id in cpu_metrics_dict.get("p_core", []):
|
|
798
|
+
key = f"P{core_id}"
|
|
799
|
+
metric_key = f"P-Cluster{core_id}_active"
|
|
800
|
+
core_value = cpu_metrics_dict.get(metric_key)
|
|
801
|
+
if core_value is None:
|
|
802
|
+
continue
|
|
803
|
+
self._core_usage_buffer.setdefault(key, []).append(core_value)
|
|
804
|
+
if len(self._core_usage_buffer[key]) > self.cpu_usage_buffer_size:
|
|
805
|
+
self._core_usage_buffer[key].pop(0)
|
|
806
|
+
smoothed = int(sum(self._core_usage_buffer[key]) / len(self._core_usage_buffer[key]))
|
|
807
|
+
p_core_chart.add_data(key, smoothed, y_axis="left", color="red", render=should_render)
|
|
808
|
+
|
|
809
|
+
async def update_power_charts(self, cpu_metrics_dict, thermal_pressure, should_render: bool):
|
|
810
|
+
"""Update power chart metrics"""
|
|
811
|
+
cpu_max_power = self.soc_info_dict["cpu_max_power"]
|
|
812
|
+
gpu_max_power = self.soc_info_dict["gpu_max_power"]
|
|
813
|
+
ane_max_power = 8.0
|
|
814
|
+
|
|
815
|
+
# Calculate power values (already in watts from powermetrics)
|
|
816
|
+
package_power_W = cpu_metrics_dict["package_W"]
|
|
817
|
+
cpu_power_W = cpu_metrics_dict["cpu_W"]
|
|
818
|
+
gpu_power_W = cpu_metrics_dict["gpu_W"]
|
|
819
|
+
ane_power_W = cpu_metrics_dict["ane_W"]
|
|
820
|
+
|
|
821
|
+
# Update energy consumption for each component (watts * seconds = watt-seconds)
|
|
822
|
+
self.total_energy_consumed += package_power_W * self.interval
|
|
823
|
+
self.cpu_energy_consumed += cpu_power_W * self.interval
|
|
824
|
+
self.gpu_energy_consumed += gpu_power_W * self.interval
|
|
825
|
+
self.ane_energy_consumed += ane_power_W * self.interval
|
|
826
|
+
|
|
827
|
+
# Helper function to format energy display
|
|
828
|
+
def format_energy(energy_ws):
|
|
829
|
+
energy_wh = energy_ws / 3600 # Convert watt-seconds to watt-hours
|
|
830
|
+
energy_mwh = energy_wh * 1000 # Convert to milliwatt-hours
|
|
831
|
+
|
|
832
|
+
if energy_mwh < 0.01:
|
|
833
|
+
# For very small values, show in scientific notation or as <0.01mWh
|
|
834
|
+
return "<0.01mWh" if energy_mwh > 0 else "0.00mWh"
|
|
835
|
+
elif energy_mwh < 10:
|
|
836
|
+
# For small values, use 2 decimal points
|
|
837
|
+
return f"{energy_mwh:.2f}mWh"
|
|
838
|
+
elif energy_wh < 1.0:
|
|
839
|
+
# For values under 1Wh, use 1 decimal point
|
|
840
|
+
return f"{energy_mwh:.1f}mWh"
|
|
841
|
+
elif energy_wh < 1000:
|
|
842
|
+
return f"{energy_wh:.2f}Wh"
|
|
843
|
+
else:
|
|
844
|
+
return f"{energy_wh / 1000:.3f}kWh"
|
|
845
|
+
|
|
846
|
+
# Update charts
|
|
847
|
+
cpu_power_chart = self.query_one("#cpu-power-chart", PowerChart)
|
|
848
|
+
cpu_power_percent = cpu_power_W / cpu_max_power * 100 # Keep as float
|
|
849
|
+
cpu_energy_display = format_energy(self.cpu_energy_consumed)
|
|
850
|
+
cpu_title = f"CPU: {cpu_power_W:.2f}W (total: {cpu_energy_display})"
|
|
851
|
+
cpu_power_chart.update_title(cpu_title)
|
|
852
|
+
cpu_power_chart.add_data(cpu_power_percent, render=should_render)
|
|
853
|
+
|
|
854
|
+
gpu_power_chart = self.query_one("#gpu-power-chart", PowerChart)
|
|
855
|
+
gpu_power_percent = gpu_power_W / gpu_max_power * 100 # Keep as float
|
|
856
|
+
gpu_energy_display = format_energy(self.gpu_energy_consumed)
|
|
857
|
+
gpu_title = f"GPU: {gpu_power_W:.2f}W (total: {gpu_energy_display})"
|
|
858
|
+
gpu_power_chart.update_title(gpu_title)
|
|
859
|
+
gpu_power_chart.add_data(gpu_power_percent, render=should_render)
|
|
860
|
+
|
|
861
|
+
ane_power_chart = self.query_one("#ane-power-chart", PowerChart)
|
|
862
|
+
ane_power_percent = ane_power_W / ane_max_power * 100 # Keep as float
|
|
863
|
+
ane_energy_display = format_energy(self.ane_energy_consumed)
|
|
864
|
+
ane_title = f"ANE: {ane_power_W:.2f}W (total: {ane_energy_display})"
|
|
865
|
+
ane_power_chart.update_title(ane_title)
|
|
866
|
+
ane_power_chart.add_data(ane_power_percent, render=should_render)
|
|
867
|
+
|
|
868
|
+
# Update system info label with total power and thermal info
|
|
869
|
+
thermal_throttle = "no" if thermal_pressure == "Nominal" else "yes"
|
|
870
|
+
total_energy_display = format_energy(self.total_energy_consumed)
|
|
871
|
+
system_info = f"{self.soc_info_dict['name']} ({self.soc_info_dict['e_core_count']}E+{self.soc_info_dict['p_core_count']}P+{self.soc_info_dict['gpu_core_count']}GPU) | Total: {package_power_W:.1f}W ({total_energy_display}) | Throttle: {thermal_throttle}"
|
|
872
|
+
self.query_one("#system-info-label", Label).update(system_info)
|
|
873
|
+
|
|
874
|
+
async def update_timestamp(self):
|
|
875
|
+
"""Update the timestamp display"""
|
|
876
|
+
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
877
|
+
timestamp_label = self.query_one("#timestamp-label", Label)
|
|
878
|
+
timestamp_label.update(f"📅 {current_time}")
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def on_unmount(self):
|
|
882
|
+
"""Clean up when app is closed"""
|
|
883
|
+
if self.powermetrics_process:
|
|
884
|
+
try:
|
|
885
|
+
self.powermetrics_process.terminate()
|
|
886
|
+
except:
|
|
887
|
+
pass
|
|
888
|
+
|
|
889
|
+
@click.command()
|
|
890
|
+
@click.option('--interval', type=float, default=1.0,
|
|
891
|
+
help='Display interval and sampling interval for powermetrics (seconds)')
|
|
892
|
+
@click.option('--theme', type=click.Choice(['default', 'dark', 'blue', 'green', 'red', 'purple', 'orange', 'cyan', 'magenta']), default='cyan',
|
|
893
|
+
help='Choose color theme')
|
|
894
|
+
@click.option('--avg', type=int, default=30,
|
|
895
|
+
help='Interval for averaged values (seconds)')
|
|
896
|
+
@click.option('--max_count', type=int, default=0,
|
|
897
|
+
help='Max show count to restart powermetrics')
|
|
898
|
+
@click.option('--show_cores', is_flag=True, default=False,
|
|
899
|
+
help='Show per-core CPU usage charts')
|
|
900
|
+
def main(interval, theme, avg, max_count, show_cores):
|
|
901
|
+
"""msiltop: Performance monitoring CLI tool for Apple Silicon"""
|
|
902
|
+
return _main_logic(interval, theme, avg, max_count, show_cores=show_cores)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _main_logic(interval, theme, avg, max_count, show_cores=False):
|
|
906
|
+
"""Main logic using Textual app"""
|
|
907
|
+
print("\nMSILTOP - Performance monitoring CLI tool for Apple Silicon")
|
|
908
|
+
print("Get help at `https://github.com/pratikdevnani/msiltop`")
|
|
909
|
+
print("P.S. You are recommended to run MSILTOP with `sudo msiltop`\n")
|
|
910
|
+
|
|
911
|
+
# Create and run the Textual app
|
|
912
|
+
app = FluidTopApp(interval, theme, avg, max_count, show_cores=show_cores)
|
|
913
|
+
try:
|
|
914
|
+
app.run()
|
|
915
|
+
except KeyboardInterrupt:
|
|
916
|
+
print("Stopping...")
|
|
917
|
+
finally:
|
|
918
|
+
# Cleanup is handled in app.on_unmount()
|
|
919
|
+
pass
|
|
920
|
+
|
|
921
|
+
return app.powermetrics_process
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
if __name__ == "__main__":
|
|
925
|
+
powermetrics_process = main()
|
|
926
|
+
try:
|
|
927
|
+
powermetrics_process.terminate()
|
|
928
|
+
print("Successfully terminated powermetrics process")
|
|
929
|
+
except Exception as e:
|
|
930
|
+
print(e)
|
|
931
|
+
powermetrics_process.terminate()
|
|
932
|
+
print("Successfully terminated powermetrics process")
|