Skip to content

Commit 9c60f3e

Browse files
authored
Merge pull request #461 from mathoudebine/hicwic/feature/plot-graph
Add line graphs!
2 parents ff2a4b0 + ea66169 commit 9c60f3e

26 files changed

+1817
-270
lines changed
16.5 KB
Binary file not shown.

Diff for: library/lcd/lcd_comm.py

+100-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import time
2626
from abc import ABC, abstractmethod
2727
from enum import IntEnum
28-
from typing import Tuple
28+
from typing import Tuple, List
2929

3030
import serial
3131
from PIL import Image, ImageDraw, ImageFont
@@ -321,6 +321,105 @@ def DisplayProgressBar(self, x: int, y: int, width: int, height: int, min_value:
321321

322322
self.DisplayPILImage(bar_image, x, y)
323323

324+
def DisplayLineGraph(self, x: int, y: int, width: int, height: int,
325+
values: List[float],
326+
min_value: int = 0,
327+
max_value: int = 100,
328+
autoscale: bool = False,
329+
line_color: Tuple[int, int, int] = (0, 0, 0),
330+
graph_axis: bool = True,
331+
axis_color: Tuple[int, int, int] = (0, 0, 0),
332+
background_color: Tuple[int, int, int] = (255, 255, 255),
333+
background_image: str = None):
334+
# Generate a plot graph and display it
335+
# Provide the background image path to display plot graph with transparent background
336+
337+
if isinstance(line_color, str):
338+
line_color = tuple(map(int, line_color.split(', ')))
339+
340+
if isinstance(axis_color, str):
341+
axis_color = tuple(map(int, axis_color.split(', ')))
342+
343+
if isinstance(background_color, str):
344+
background_color = tuple(map(int, background_color.split(', ')))
345+
346+
assert x <= self.get_width(), 'Progress bar X coordinate must be <= display width'
347+
assert y <= self.get_height(), 'Progress bar Y coordinate must be <= display height'
348+
assert x + width <= self.get_width(), 'Progress bar width exceeds display width'
349+
assert y + height <= self.get_height(), 'Progress bar height exceeds display height'
350+
351+
if background_image is None:
352+
# A bitmap is created with solid background
353+
graph_image = Image.new('RGB', (width, height), background_color)
354+
else:
355+
# A bitmap is created from provided background image
356+
graph_image = self.open_image(background_image)
357+
358+
# Crop bitmap to keep only the plot graph background
359+
graph_image = graph_image.crop(box=(x, y, x + width, y + height))
360+
361+
# if autoscale is enabled, define new min/max value to "zoom" the graph
362+
if autoscale:
363+
trueMin = max_value
364+
trueMax = min_value
365+
for value in values:
366+
if not math.isnan(value):
367+
if trueMin > value:
368+
trueMin = value
369+
if trueMax < value:
370+
trueMax = value
371+
372+
if trueMin != max_value and trueMax != min_value:
373+
min_value = max(trueMin - 5, min_value)
374+
max_value = min(trueMax + 5, max_value)
375+
376+
step = width / len(values)
377+
# pre compute yScale multiplier value
378+
yScale = height / (max_value - min_value)
379+
380+
plotsX = []
381+
plotsY = []
382+
count = 0
383+
for value in values:
384+
if not math.isnan(value):
385+
# Don't let the set value exceed our min or max value, this is bad :)
386+
if value < min_value:
387+
value = min_value
388+
elif max_value < value:
389+
value = max_value
390+
391+
assert min_value <= value <= max_value, 'Plot point value shall be between min and max'
392+
393+
plotsX.append(count * step)
394+
plotsY.append(height - (value - min_value) * yScale)
395+
396+
count += 1
397+
398+
# Draw plot graph
399+
draw = ImageDraw.Draw(graph_image)
400+
draw.line(list(zip(plotsX, plotsY)), fill=line_color, width=2)
401+
402+
if graph_axis:
403+
# Draw axis
404+
draw.line([0, height - 1, width - 1, height - 1], fill=axis_color)
405+
draw.line([0, 0, 0, height - 1], fill=axis_color)
406+
407+
# Draw Legend
408+
draw.line([0, 0, 1, 0], fill=axis_color)
409+
text = f"{int(max_value)}"
410+
font = ImageFont.truetype("./res/fonts/" + "roboto/Roboto-Black.ttf", 10)
411+
left, top, right, bottom = font.getbbox(text)
412+
draw.text((2, 0 - top), text,
413+
font=font, fill=axis_color)
414+
415+
text = f"{int(min_value)}"
416+
font = ImageFont.truetype("./res/fonts/" + "roboto/Roboto-Black.ttf", 10)
417+
left, top, right, bottom = font.getbbox(text)
418+
draw.text((width - 1 - right, height - 2 - bottom), text,
419+
font=font, fill=axis_color)
420+
421+
self.DisplayPILImage(graph_image, x, y)
422+
324423
def DisplayRadialProgressBar(self, xc: int, yc: int, radius: int, bar_width: int,
325424
min_value: int = 0,
326425
max_value: int = 100,

Diff for: library/scheduler.py

+8
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,14 @@ def CPUTemperature():
110110
stats.CPU.temperature()
111111

112112

113+
@async_job("CPU_FanSpeed")
114+
@schedule(timedelta(seconds=config.THEME_DATA['STATS']['CPU']['FAN_SPEED'].get("INTERVAL", None)).total_seconds())
115+
def CPUFanSpeed():
116+
""" Refresh the CPU Fan Speed """
117+
# logger.debug("Refresh CPU Fan Speed")
118+
stats.CPU.fan_speed()
119+
120+
113121
@async_job("GPU_Stats")
114122
@schedule(timedelta(seconds=config.THEME_DATA['STATS']['GPU'].get("INTERVAL", None)).total_seconds())
115123
def GpuStats():

Diff for: library/sensors/sensors.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@ def load() -> Tuple[float, float, float]: # 1 / 5 / 15min avg (%)
4141

4242
@staticmethod
4343
@abstractmethod
44-
def is_temperature_available() -> bool:
44+
def temperature() -> float:
4545
pass
4646

4747
@staticmethod
4848
@abstractmethod
49-
def temperature() -> float:
49+
def fan_percent() -> float:
5050
pass
5151

5252

@@ -61,6 +61,11 @@ def stats() -> Tuple[float, float, float, float]: # load (%) / used mem (%) / u
6161
def fps() -> int:
6262
pass
6363

64+
@staticmethod
65+
@abstractmethod
66+
def fan_percent() -> float:
67+
pass
68+
6469
@staticmethod
6570
@abstractmethod
6671
def is_available() -> bool:

Diff for: library/sensors/sensors_custom.py

+30-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
# turing-smart-screen-python - a Python system monitor and library for USB-C displays like Turing Smart Screen or XuanFang
22
# https://github.com/mathoudebine/turing-smart-screen-python/
3-
43
# Copyright (C) 2021-2023 Matthieu Houdebine (mathoudebine)
54
#
65
# This program is free software: you can redistribute it and/or modify
@@ -20,8 +19,10 @@
2019
# There is no limitation on how much custom data source classes can be added to this file
2120
# See CustomDataExample theme for the theme implementation part
2221

22+
import math
2323
import platform
2424
from abc import ABC, abstractmethod
25+
from typing import List
2526

2627

2728
# Custom data classes must be implemented in this file, inherit the CustomDataSource and implement its 2 methods
@@ -40,27 +41,47 @@ def as_string(self) -> str:
4041
# If this function is empty, the numeric value will be used as string without formatting
4142
pass
4243

44+
@abstractmethod
45+
def last_values(self) -> List[float]:
46+
# List of last numeric values will be used for plot graph
47+
# If you do not want to draw a line graph or if your custom data has no numeric values, keep this function empty
48+
pass
49+
4350

4451
# Example for a custom data class that has numeric and text values
4552
class ExampleCustomNumericData(CustomDataSource):
53+
# This list is used to store the last 10 values to display a line graph
54+
last_val = [math.nan] * 10 # By default, it is filed with math.nan values to indicate there is no data stored
55+
4656
def as_numeric(self) -> float:
4757
# Numeric value will be used for graph and radial progress bars
4858
# Here a Python function from another module can be called to get data
49-
# Example: return my_module.get_rgb_led_brightness() / return audio.system_volume() ...
50-
return 75.845
59+
# Example: self.value = my_module.get_rgb_led_brightness() / audio.system_volume() ...
60+
self.value = 75.845
61+
62+
# Store the value to the history list that will be used for line graph
63+
self.last_val.append(self.value)
64+
# Also remove the oldest value from history list
65+
self.last_val.pop(0)
66+
67+
return self.value
5168

5269
def as_string(self) -> str:
5370
# Text value will be used for text display and radial progress bar inner text.
5471
# Numeric value can be formatted here to be displayed as expected
5572
# It is also possible to return a text unrelated to the numeric value
5673
# If this function is empty, the numeric value will be used as string without formatting
5774
# Example here: format numeric value: add unit as a suffix, and keep 1 digit decimal precision
58-
return f'{self.as_numeric(): .1f}%'
75+
return f'{self.value:>5.1f}%'
5976
# Important note! If your numeric value can vary in size, be sure to display it with a default size.
6077
# E.g. if your value can range from 0 to 9999, you need to display it with at least 4 characters every time.
6178
# --> return f'{self.as_numeric():>4}%'
6279
# Otherwise, part of the previous value can stay displayed ("ghosting") after a refresh
6380

81+
def last_values(self) -> List[float]:
82+
# List of last numeric values will be used for plot graph
83+
return self.last_val
84+
6485

6586
# Example for a custom data class that only has text values
6687
class ExampleCustomTextOnlyData(CustomDataSource):
@@ -70,4 +91,8 @@ def as_numeric(self) -> float:
7091

7192
def as_string(self) -> str:
7293
# If a custom data class only has text values, it won't be possible to display graph or radial bars
73-
return "Python version: " + platform.python_version()
94+
return "Python: " + platform.python_version()
95+
96+
def last_values(self) -> List[float]:
97+
# If a custom data class only has text values, it won't be possible to display line graph
98+
pass

Diff for: library/sensors/sensors_librehardwaremonitor.py

+41-20
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,11 @@
6565
handle.IsCpuEnabled = True
6666
handle.IsGpuEnabled = True
6767
handle.IsMemoryEnabled = True
68-
handle.IsMotherboardEnabled = False
69-
handle.IsControllerEnabled = False
68+
handle.IsMotherboardEnabled = True # For CPU Fan Speed
69+
handle.IsControllerEnabled = True # For CPU Fan Speed
7070
handle.IsNetworkEnabled = True
7171
handle.IsStorageEnabled = True
72+
handle.IsPsuEnabled = False
7273
handle.Open()
7374
for hardware in handle.Hardware:
7475
if hardware.HardwareType == Hardware.HardwareType.Cpu:
@@ -90,7 +91,7 @@
9091
def get_hw_and_update(hwtype: Hardware.HardwareType, name: str = None) -> Hardware.Hardware:
9192
for hardware in handle.Hardware:
9293
if hardware.HardwareType == hwtype:
93-
if (name and hardware.Name == name) or not name:
94+
if (name and hardware.Name == name) or name is None:
9495
hardware.Update()
9596
return hardware
9697
return None
@@ -132,7 +133,7 @@ def get_gpu_name() -> str:
132133

133134
logger.warning(
134135
"Found %d GPUs on your system (%d AMD / %d Nvidia / %d Intel). Auto identify which GPU to use." % (
135-
len(hw_gpus), amd_gpus, nvidia_gpus, intel_gpus))
136+
len(hw_gpus), amd_gpus, nvidia_gpus, intel_gpus))
136137

137138
if nvidia_gpus >= 1:
138139
# One (or more) Nvidia GPU: use first available for stats
@@ -204,16 +205,6 @@ def load() -> Tuple[float, float, float]: # 1 / 5 / 15min avg (%):
204205
# Get this data from psutil because it is not available from LibreHardwareMonitor
205206
return psutil.getloadavg()
206207

207-
@staticmethod
208-
def is_temperature_available() -> bool:
209-
cpu = get_hw_and_update(Hardware.HardwareType.Cpu)
210-
for sensor in cpu.Sensors:
211-
if sensor.SensorType == Hardware.SensorType.Temperature:
212-
if str(sensor.Name).startswith("Core") or str(sensor.Name).startswith("CPU Package"):
213-
return True
214-
215-
return False
216-
217208
@staticmethod
218209
def temperature() -> float:
219210
cpu = get_hw_and_update(Hardware.HardwareType.Cpu)
@@ -236,6 +227,19 @@ def temperature() -> float:
236227

237228
return math.nan
238229

230+
@staticmethod
231+
def fan_percent() -> float:
232+
mb = get_hw_and_update(Hardware.HardwareType.Motherboard)
233+
for sh in mb.SubHardware:
234+
sh.Update()
235+
for sensor in sh.Sensors:
236+
if sensor.SensorType == Hardware.SensorType.Control and "#2" in str(
237+
sensor.Name): # Is Motherboard #2 Fan always the CPU Fan ?
238+
return float(sensor.Value)
239+
240+
# No Fan Speed sensor for this CPU model
241+
return math.nan
242+
239243

240244
class Gpu(sensors.Gpu):
241245
# GPU to use is detected once, and its name is saved for future sensors readings
@@ -244,13 +248,20 @@ class Gpu(sensors.Gpu):
244248
# Latest FPS value is backed up in case next reading returns no value
245249
prev_fps = 0
246250

251+
# Get GPU to use for sensors, and update it
247252
@classmethod
248-
def stats(cls) -> Tuple[float, float, float, float]: # load (%) / used mem (%) / used mem (Mb) / temp (°C)
253+
def get_gpu_to_use(cls):
249254
gpu_to_use = get_hw_and_update(Hardware.HardwareType.GpuAmd, cls.gpu_name)
250255
if gpu_to_use is None:
251256
gpu_to_use = get_hw_and_update(Hardware.HardwareType.GpuNvidia, cls.gpu_name)
252257
if gpu_to_use is None:
253258
gpu_to_use = get_hw_and_update(Hardware.HardwareType.GpuIntel, cls.gpu_name)
259+
260+
return gpu_to_use
261+
262+
@classmethod
263+
def stats(cls) -> Tuple[float, float, float, float]: # load (%) / used mem (%) / used mem (Mb) / temp (°C)
264+
gpu_to_use = cls.get_gpu_to_use()
254265
if gpu_to_use is None:
255266
# GPU not supported
256267
return math.nan, math.nan, math.nan, math.nan
@@ -279,11 +290,7 @@ def stats(cls) -> Tuple[float, float, float, float]: # load (%) / used mem (%)
279290

280291
@classmethod
281292
def fps(cls) -> int:
282-
gpu_to_use = get_hw_and_update(Hardware.HardwareType.GpuAmd, cls.gpu_name)
283-
if gpu_to_use is None:
284-
gpu_to_use = get_hw_and_update(Hardware.HardwareType.GpuNvidia, cls.gpu_name)
285-
if gpu_to_use is None:
286-
gpu_to_use = get_hw_and_update(Hardware.HardwareType.GpuIntel, cls.gpu_name)
293+
gpu_to_use = cls.get_gpu_to_use()
287294
if gpu_to_use is None:
288295
# GPU not supported
289296
return -1
@@ -298,6 +305,20 @@ def fps(cls) -> int:
298305
# No FPS sensor for this GPU model
299306
return -1
300307

308+
@classmethod
309+
def fan_percent(cls) -> float:
310+
gpu_to_use = cls.get_gpu_to_use()
311+
if gpu_to_use is None:
312+
# GPU not supported
313+
return math.nan
314+
315+
for sensor in gpu_to_use.Sensors:
316+
if sensor.SensorType == Hardware.SensorType.Control:
317+
return float(sensor.Value)
318+
319+
# No Fan Speed sensor for this GPU model
320+
return math.nan
321+
301322
@classmethod
302323
def is_available(cls) -> bool:
303324
cls.gpu_name = get_gpu_name()

0 commit comments

Comments
 (0)