Skip to content

Commit

Permalink
Merge pull request #461 from mathoudebine/hicwic/feature/plot-graph
Browse files Browse the repository at this point in the history
Add line graphs!
  • Loading branch information
mathoudebine authored Feb 19, 2024
2 parents ff2a4b0 + ea66169 commit 9c60f3e
Show file tree
Hide file tree
Showing 26 changed files with 1,817 additions and 270 deletions.
Binary file modified external/LibreHardwareMonitor/LibreHardwareMonitorLib.dll
Binary file not shown.
101 changes: 100 additions & 1 deletion library/lcd/lcd_comm.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
import time
from abc import ABC, abstractmethod
from enum import IntEnum
from typing import Tuple
from typing import Tuple, List

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

self.DisplayPILImage(bar_image, x, y)

def DisplayLineGraph(self, x: int, y: int, width: int, height: int,
values: List[float],
min_value: int = 0,
max_value: int = 100,
autoscale: bool = False,
line_color: Tuple[int, int, int] = (0, 0, 0),
graph_axis: bool = True,
axis_color: Tuple[int, int, int] = (0, 0, 0),
background_color: Tuple[int, int, int] = (255, 255, 255),
background_image: str = None):
# Generate a plot graph and display it
# Provide the background image path to display plot graph with transparent background

if isinstance(line_color, str):
line_color = tuple(map(int, line_color.split(', ')))

if isinstance(axis_color, str):
axis_color = tuple(map(int, axis_color.split(', ')))

if isinstance(background_color, str):
background_color = tuple(map(int, background_color.split(', ')))

assert x <= self.get_width(), 'Progress bar X coordinate must be <= display width'
assert y <= self.get_height(), 'Progress bar Y coordinate must be <= display height'
assert x + width <= self.get_width(), 'Progress bar width exceeds display width'
assert y + height <= self.get_height(), 'Progress bar height exceeds display height'

if background_image is None:
# A bitmap is created with solid background
graph_image = Image.new('RGB', (width, height), background_color)
else:
# A bitmap is created from provided background image
graph_image = self.open_image(background_image)

# Crop bitmap to keep only the plot graph background
graph_image = graph_image.crop(box=(x, y, x + width, y + height))

# if autoscale is enabled, define new min/max value to "zoom" the graph
if autoscale:
trueMin = max_value
trueMax = min_value
for value in values:
if not math.isnan(value):
if trueMin > value:
trueMin = value
if trueMax < value:
trueMax = value

if trueMin != max_value and trueMax != min_value:
min_value = max(trueMin - 5, min_value)
max_value = min(trueMax + 5, max_value)

step = width / len(values)
# pre compute yScale multiplier value
yScale = height / (max_value - min_value)

plotsX = []
plotsY = []
count = 0
for value in values:
if not math.isnan(value):
# Don't let the set value exceed our min or max value, this is bad :)
if value < min_value:
value = min_value
elif max_value < value:
value = max_value

assert min_value <= value <= max_value, 'Plot point value shall be between min and max'

plotsX.append(count * step)
plotsY.append(height - (value - min_value) * yScale)

count += 1

# Draw plot graph
draw = ImageDraw.Draw(graph_image)
draw.line(list(zip(plotsX, plotsY)), fill=line_color, width=2)

if graph_axis:
# Draw axis
draw.line([0, height - 1, width - 1, height - 1], fill=axis_color)
draw.line([0, 0, 0, height - 1], fill=axis_color)

# Draw Legend
draw.line([0, 0, 1, 0], fill=axis_color)
text = f"{int(max_value)}"
font = ImageFont.truetype("./res/fonts/" + "roboto/Roboto-Black.ttf", 10)
left, top, right, bottom = font.getbbox(text)
draw.text((2, 0 - top), text,
font=font, fill=axis_color)

text = f"{int(min_value)}"
font = ImageFont.truetype("./res/fonts/" + "roboto/Roboto-Black.ttf", 10)
left, top, right, bottom = font.getbbox(text)
draw.text((width - 1 - right, height - 2 - bottom), text,
font=font, fill=axis_color)

self.DisplayPILImage(graph_image, x, y)

def DisplayRadialProgressBar(self, xc: int, yc: int, radius: int, bar_width: int,
min_value: int = 0,
max_value: int = 100,
Expand Down
8 changes: 8 additions & 0 deletions library/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ def CPUTemperature():
stats.CPU.temperature()


@async_job("CPU_FanSpeed")
@schedule(timedelta(seconds=config.THEME_DATA['STATS']['CPU']['FAN_SPEED'].get("INTERVAL", None)).total_seconds())
def CPUFanSpeed():
""" Refresh the CPU Fan Speed """
# logger.debug("Refresh CPU Fan Speed")
stats.CPU.fan_speed()


@async_job("GPU_Stats")
@schedule(timedelta(seconds=config.THEME_DATA['STATS']['GPU'].get("INTERVAL", None)).total_seconds())
def GpuStats():
Expand Down
9 changes: 7 additions & 2 deletions library/sensors/sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ def load() -> Tuple[float, float, float]: # 1 / 5 / 15min avg (%)

@staticmethod
@abstractmethod
def is_temperature_available() -> bool:
def temperature() -> float:
pass

@staticmethod
@abstractmethod
def temperature() -> float:
def fan_percent() -> float:
pass


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

@staticmethod
@abstractmethod
def fan_percent() -> float:
pass

@staticmethod
@abstractmethod
def is_available() -> bool:
Expand Down
35 changes: 30 additions & 5 deletions library/sensors/sensors_custom.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# turing-smart-screen-python - a Python system monitor and library for USB-C displays like Turing Smart Screen or XuanFang
# https://github.com/mathoudebine/turing-smart-screen-python/

# Copyright (C) 2021-2023 Matthieu Houdebine (mathoudebine)
#
# This program is free software: you can redistribute it and/or modify
Expand All @@ -20,8 +19,10 @@
# There is no limitation on how much custom data source classes can be added to this file
# See CustomDataExample theme for the theme implementation part

import math
import platform
from abc import ABC, abstractmethod
from typing import List


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

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


# Example for a custom data class that has numeric and text values
class ExampleCustomNumericData(CustomDataSource):
# This list is used to store the last 10 values to display a line graph
last_val = [math.nan] * 10 # By default, it is filed with math.nan values to indicate there is no data stored

def as_numeric(self) -> float:
# Numeric value will be used for graph and radial progress bars
# Here a Python function from another module can be called to get data
# Example: return my_module.get_rgb_led_brightness() / return audio.system_volume() ...
return 75.845
# Example: self.value = my_module.get_rgb_led_brightness() / audio.system_volume() ...
self.value = 75.845

# Store the value to the history list that will be used for line graph
self.last_val.append(self.value)
# Also remove the oldest value from history list
self.last_val.pop(0)

return self.value

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

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


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

def as_string(self) -> str:
# If a custom data class only has text values, it won't be possible to display graph or radial bars
return "Python version: " + platform.python_version()
return "Python: " + platform.python_version()

def last_values(self) -> List[float]:
# If a custom data class only has text values, it won't be possible to display line graph
pass
61 changes: 41 additions & 20 deletions library/sensors/sensors_librehardwaremonitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,11 @@
handle.IsCpuEnabled = True
handle.IsGpuEnabled = True
handle.IsMemoryEnabled = True
handle.IsMotherboardEnabled = False
handle.IsControllerEnabled = False
handle.IsMotherboardEnabled = True # For CPU Fan Speed
handle.IsControllerEnabled = True # For CPU Fan Speed
handle.IsNetworkEnabled = True
handle.IsStorageEnabled = True
handle.IsPsuEnabled = False
handle.Open()
for hardware in handle.Hardware:
if hardware.HardwareType == Hardware.HardwareType.Cpu:
Expand All @@ -90,7 +91,7 @@
def get_hw_and_update(hwtype: Hardware.HardwareType, name: str = None) -> Hardware.Hardware:
for hardware in handle.Hardware:
if hardware.HardwareType == hwtype:
if (name and hardware.Name == name) or not name:
if (name and hardware.Name == name) or name is None:
hardware.Update()
return hardware
return None
Expand Down Expand Up @@ -132,7 +133,7 @@ def get_gpu_name() -> str:

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

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

@staticmethod
def is_temperature_available() -> bool:
cpu = get_hw_and_update(Hardware.HardwareType.Cpu)
for sensor in cpu.Sensors:
if sensor.SensorType == Hardware.SensorType.Temperature:
if str(sensor.Name).startswith("Core") or str(sensor.Name).startswith("CPU Package"):
return True

return False

@staticmethod
def temperature() -> float:
cpu = get_hw_and_update(Hardware.HardwareType.Cpu)
Expand All @@ -236,6 +227,19 @@ def temperature() -> float:

return math.nan

@staticmethod
def fan_percent() -> float:
mb = get_hw_and_update(Hardware.HardwareType.Motherboard)
for sh in mb.SubHardware:
sh.Update()
for sensor in sh.Sensors:
if sensor.SensorType == Hardware.SensorType.Control and "#2" in str(
sensor.Name): # Is Motherboard #2 Fan always the CPU Fan ?
return float(sensor.Value)

# No Fan Speed sensor for this CPU model
return math.nan


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

# Get GPU to use for sensors, and update it
@classmethod
def stats(cls) -> Tuple[float, float, float, float]: # load (%) / used mem (%) / used mem (Mb) / temp (°C)
def get_gpu_to_use(cls):
gpu_to_use = get_hw_and_update(Hardware.HardwareType.GpuAmd, cls.gpu_name)
if gpu_to_use is None:
gpu_to_use = get_hw_and_update(Hardware.HardwareType.GpuNvidia, cls.gpu_name)
if gpu_to_use is None:
gpu_to_use = get_hw_and_update(Hardware.HardwareType.GpuIntel, cls.gpu_name)

return gpu_to_use

@classmethod
def stats(cls) -> Tuple[float, float, float, float]: # load (%) / used mem (%) / used mem (Mb) / temp (°C)
gpu_to_use = cls.get_gpu_to_use()
if gpu_to_use is None:
# GPU not supported
return math.nan, math.nan, math.nan, math.nan
Expand Down Expand Up @@ -279,11 +290,7 @@ def stats(cls) -> Tuple[float, float, float, float]: # load (%) / used mem (%)

@classmethod
def fps(cls) -> int:
gpu_to_use = get_hw_and_update(Hardware.HardwareType.GpuAmd, cls.gpu_name)
if gpu_to_use is None:
gpu_to_use = get_hw_and_update(Hardware.HardwareType.GpuNvidia, cls.gpu_name)
if gpu_to_use is None:
gpu_to_use = get_hw_and_update(Hardware.HardwareType.GpuIntel, cls.gpu_name)
gpu_to_use = cls.get_gpu_to_use()
if gpu_to_use is None:
# GPU not supported
return -1
Expand All @@ -298,6 +305,20 @@ def fps(cls) -> int:
# No FPS sensor for this GPU model
return -1

@classmethod
def fan_percent(cls) -> float:
gpu_to_use = cls.get_gpu_to_use()
if gpu_to_use is None:
# GPU not supported
return math.nan

for sensor in gpu_to_use.Sensors:
if sensor.SensorType == Hardware.SensorType.Control:
return float(sensor.Value)

# No Fan Speed sensor for this GPU model
return math.nan

@classmethod
def is_available(cls) -> bool:
cls.gpu_name = get_gpu_name()
Expand Down
Loading

0 comments on commit 9c60f3e

Please sign in to comment.