With this software and firmware, you can control one or more monitors that track your CPU/GPU usage and temperatures. It works with Intel or AMD processors, and NVidia or AMD/ATI GPUs, and 11 different ESP32 devices.
Introduction
I made a previous version of this project using LVGL and a T-Display. I thought it might be worth revisiting since I've improved it quite a bit as well as changed it to work on my UIX and GFX libraries instead of LVGL. In order to work on a variety of different displays, the project has a scalable UI.
Prerequisites
You will need one or more of the following ESP32 devices:
- Lilygo T-Display S3*
- Lilygo TTGO T-Display T1 (ESP-IDF support on Github***)
- M5 Stack Fire
- M5 Stack Core2
- M5 Stack S3 Atom*
- Makerfabs ESP Display S3 Parallel w/ Touch
- Makerfabs ESP Display S3 4 inch w/ Touch
- Makerfabs ESP Display S3 4.3 inch w/ Touch
- Espressif ESP_WROVER_KIT 4.1
- Lilygo T-QT (and Pro)*
- HelTec WiFi Kit V2** (ESP-IDF support on Github***)
- Wio Terminal (Github only***)
- Lilygo T5 4.7" E-Paper Widget (Github only***)
* I've had some hangs in the PC app while communicating with these, and tracked the bug to the Arduino HAL on versions prior to 2.0.7. Anyway, the problem is lower level than my code, but it's fixed in 2.0.7 of the framework. Make sure you update your Platform IO Espressif platform to the latest.
** This screen is very small and monochrome, straining the ability of the UI to scale, and compromising readability. It may be possible to improve the display for small screens like this, but I didn't think it worth complicating the code. This device is more for fun than anything.
*** Github contains more advanced code, including ESP-IDF support, as well as support for the SAMD51 Wio Terminal. I didn't want to include it here because it's more complicated and would clutter the core explanations of the code here.
More devices can be added with some additions to lcd_config.h, lcd_init.h, and platformio.ini for ESP32 LCDs, and display.hpp/cpp for other platforms/displays.
You will need PlatformIO installed.
You will need a Windows PC.
You will need Visual Studio.
Using This Mess
Plug the device(s) into your PC. If the device has more than one USB port, plug it into the one that is connected to the serial UART USB bridge, not the native USB (ESP32-S3 only).
Next launch the PC application - you'll be asked to approve the application running under elevated privileges.
Choose the COM ports you want monitor on from the list box and then check "Started". The display(s) on your monitor(s) should change from a disconnected icon to a monitor screen and the monitoring will commence.
Understanding This Mess
The C# app uses OpenHardwareMonitor (a version patched for Raptor Lake processors is included with this download package. It queries OHWM ten times a second for usage, temperatures, and tjmax values (Intel only).
Once it has that data, it stores it as well as sends it to each selected serial port if Started is checked.
Once received over serial, the data is stored in memory in a circular buffer used for the usage and temp history graph. At that point, anything that has changed in the UI (namely the bars, graphs, and temperature readouts) is updated by UIX as it is invalidated. For the graph to work, the leftmost/oldest data is removed before new data is added if the circular buffer is full.
The temperature readout deserves some extra explanation since it is a gradient. It uses the HSV color model to transition between green, yellow and red as the temperatures get hotter. This applies to both the graph and the bar.
The UI itself is scaled around the CPU and GPU labels. For the top half (CPU), first the label is created, then the components that surround it are. For the bottom half, all the positions are duplicated and offset by half the screen height. The fonts are scaled to one tenth of the screen hieght.
Coding This Mess
Let's dive right in, shall we? First, we'll cover the firmware.
The Firmware
main.cpp
Main handles setting up the device, listening on serial, and updating the UI as necessary.
This first bit is boilerplate and just includes the necessary files. The only odd thing here is #define LCD_IMPLEMENTATION
. What this does is tell lcd_init.h to import its code into this cpp file. This must be done in exactly and only one cpp file in the project.
#include <Arduino.h>
#define LCD_IMPLEMENTATION
#include <lcd_init.h>
#include <uix.hpp>
using namespace gfx;
using namespace uix;
#include <ui.hpp>
#include <interface.hpp>
#ifdef M5STACK_CORE2
#include <m5core2_power.hpp>
#endif
The next bit our are global variables. We only have a few here, and they provide memory to hold the dynamic content in the temperature labels and a timer variable to detect when the device is no longer receiving serial data. The timer variable is just a millis()
timestamp. For the M5 Stack Core2 we declare an instance of the power library class.
static char cpu_sz[32];
static char gpu_sz[32];
static uint32_t timeout_ts = 0;
#ifdef M5STACK_CORE2
m5core2_power power;
#endif
Next are two callbacks.
The first is called by the lcd_init.h infrastructure (when not using an RGB panel interface.) It notifies UIX that the DMA transfer has completed.
The second one is called by UIX in order to send a bitmap of partial screen data to the display. It calls the lcd_init.h infrastructure in order to send bitmaps asynchronously to the LCD panel.
#ifndef LCD_PIN_NUM_VSYNC
static bool lcd_flush_ready(esp_lcd_panel_io_handle_t panel_io,
esp_lcd_panel_io_event_data_t* edata,
void* user_ctx) {
main_screen.set_flush_complete();
return true;
}
#endif
static void uix_flush(const rect16& bounds,
const void* bmp,
void* state) {
lcd_panel_draw_bitmap(bounds.x1,
bounds.y1,
bounds.x2,
bounds.y2,
(void*) bmp);
#ifdef LCD_PIN_NUM_VSYNC
main_screen.set_flush_complete();
#endif
}
Now we get to setup()
which in Arduino tradition initializes the device. We do that here. Some devices have power enable pins for running on battery. We set those as necessary.
void setup() {
Serial.begin(115200);
#ifdef T_DISPLAY_S3
pinMode(15, OUTPUT);
digitalWrite(15, HIGH);
#elif defined(S3_T_QT)
pinMode(4, OUTPUT);
digitalWrite(4, HIGH);
#endif
#ifdef M5STACK_CORE2
power.initialize();
#endif
#ifdef LCD_PIN_NUM_VSYNC
lcd_panel_init();
#else
lcd_panel_init(lcd_buffer_size,lcd_flush_ready);
#endif
main_screen_init(uix_flush);
}
On to loop()
, which we'll cover in sections.
The first bit enables us to detect when there has been no serial data for one second, in which case, it displays the disconnected "screen". It's not really a screen, but rather a label that covers up the CPU/GPU display, and then an SVG image with the disconnected icon. We could have created a separate screen for this - it would even be more efficient - but it would complicate the code, and isn't really necessary.
if(timeout_ts!=0 && millis()>timeout_ts+1000) {
timeout_ts = 0;
disconnected_label.visible(true);
disconnected_svg.visible(true);
}
Now we update the main_screen
which gives UIX a chance to update the display if necessary.
main_screen.update();
Next, we check for incoming serial data. If we find it, we reset the disconnected timeout and hide the associated controls.
int i = Serial.read();
float tmp;
if(i>-1) { timeout_ts = millis();
disconnected_label.visible(false);
disconnected_svg.visible(false);
...
Now we check for a one byte command identifier which should be 1, followed by a read_status
struct which is several 32-bit integers as defined in interface.hpp. The reason we switch on a command id is so if necessary we can add more messages in the future. It's also simply the standard way I implement binary serial communication.
switch(i) {
case read_status_t::command: {
read_status_t data;
if(sizeof(data)==Serial.readBytes((uint8_t*)&data,sizeof(data))) {
...
If the above if
evaluates to true, we process the values and update the UI's data, invalidating portions forcing redraws as necessary. Some of this won't be clear until we cover the UI portion.
if (cpu_buffers[0].full()) {
cpu_buffers[0].get(&tmp);
}
cpu_buffers[0].put(data.cpu_usage/100.0f);
cpu_values[0]=data.cpu_usage/100.0f;
if (cpu_buffers[1].full()) {
cpu_buffers[1].get(&tmp);
}
cpu_buffers[1].put(data.cpu_temp/(float)data.cpu_temp_max);
if(data.cpu_temp>cpu_max_temp) {
cpu_max_temp = data.cpu_temp;
}
cpu_values[1]=data.cpu_temp/(float)data.cpu_temp_max;
cpu_graph.invalidate();
cpu_bar.invalidate();
sprintf(cpu_sz,"%dC",data.cpu_temp);
cpu_temp_label.text(cpu_sz);
if (gpu_buffers[0].full()) {
gpu_buffers[0].get(&tmp);
}
gpu_buffers[0].put(data.gpu_usage/100.0f);
gpu_values[0] = data.gpu_usage/100.0f;
if (gpu_buffers[1].full()) {
gpu_buffers[1].get(&tmp);
}
gpu_buffers[1].put(data.gpu_temp/(float)data.gpu_temp_max);
if(data.gpu_temp>gpu_max_temp) {
gpu_max_temp = data.gpu_temp;
}
gpu_values[1] = data.gpu_temp/(float)data.gpu_temp_max;
gpu_graph.invalidate();
gpu_bar.invalidate();
sprintf(gpu_sz,"%dC",data.gpu_temp);
gpu_temp_label.text(gpu_sz);
If the data isn't what we expect, then we just read until there's nothing else available:
while(-1!=Serial.read());
ui.hpp
The UI header just declares shared type declarations and global variables pertinent to the user interface. It's fairly straightforward. screen_ex<>
is a bit involved but most of the time you can just use screen<>
which is simpler.
#pragma once
#include <lcd_config.h>
#include <uix.hpp>
#include <circular_buffer.hpp>
using screen_t = uix::screen_ex<LCD_WIDTH,LCD_HEIGHT,
LCD_FRAME_ADAPTER,LCD_X_ALIGN,LCD_Y_ALIGN>;
using label_t = uix::label<typename screen_t::control_surface_type>;
using svg_box_t = uix::svg_box<typename screen_t::control_surface_type>;
using canvas_t = uix::canvas<typename screen_t::control_surface_type>;
using color_t = gfx::color<typename screen_t::pixel_type>;
using color32_t = gfx::color<gfx::rgba_pixel<32>>;
using buffer_t = circular_buffer<float,100>;
extern buffer_t cpu_buffers[];
extern float cpu_values[];
extern int cpu_max_temp;
extern gfx::rgba_pixel<32> cpu_colors[];
extern buffer_t gpu_buffers[];
extern float gpu_values[];
extern int gpu_max_temp;
extern gfx::rgba_pixel<32> gpu_colors[];
#ifndef LCD_PIN_NUM_VSYNC
constexpr static const int lcd_buffer_size = 32 * 1024;
#else
constexpr static const int lcd_buffer_size = 64 * 1024;
#endif
extern screen_t main_screen;
extern label_t cpu_label;
extern label_t cpu_temp_label;
extern canvas_t cpu_bar;
extern canvas_t cpu_graph;
extern label_t gpu_label;
extern label_t gpu_temp_label;
extern canvas_t gpu_bar;
extern canvas_t gpu_graph;
extern label_t disconnected_label;
extern svg_box_t disconnected_svg;
extern void main_screen_init(screen_t::on_flush_callback_type flush_callback,
void* flush_callback_state = nullptr);
ui.cpp
The UI implementation contains much of the meat of our application, but despite that, thanks to UIX, it is streamlined and relatively straightforward. We won't cover it sequentially, because the file will make more sense if we explore it out of order.
We'll start with the global variable definitions which fill out the declarations in the UI header as well as introducing a handful which are private to this implementation file. This is all pretty straightforward and commented.
buffer_t cpu_buffers[2];
rgba_pixel<32> cpu_colors[] = {color32_t::blue, rgba_pixel<32>()};
float cpu_values[] = {0.0f, 0.0f};
int cpu_max_temp = 1;
buffer_t gpu_buffers[2];
rgba_pixel<32> gpu_colors[] = {color32_t::blue, rgba_pixel<32>()};
float gpu_values[] = {0.0f, 0.0f};
int gpu_max_temp = 1;
static uint8_t lcd_buffer1[lcd_buffer_size];
#ifndef LCD_PIN_NUM_VSYNC
static uint8_t lcd_buffer2[lcd_buffer_size];
screen_t main_screen(lcd_buffer_size, lcd_buffer1, lcd_buffer2);
#else
screen_t main_screen(lcd_buffer_size, lcd_buffer1, nullptr);
#endif
label_t cpu_label(main_screen);
label_t cpu_temp_label(main_screen);
canvas_t cpu_bar(main_screen);
canvas_t cpu_graph(main_screen);
static bar_info_t cpu_bar_state;
static graph_info_t cpu_graph_state;
label_t gpu_label(main_screen);
canvas_t gpu_bar(main_screen);
label_t gpu_temp_label(main_screen);
canvas_t gpu_graph(main_screen);
static bar_info_t gpu_bar_state;
static graph_info_t gpu_graph_state;
svg_box_t disconnected_svg(main_screen);
label_t disconnected_label(main_screen);
Next is main_screen_init()
which lays out and sets up all the controls. The layout scales to the size of the screen.
rgba_pixel<32> transparent(0, 0, 0, 0);
main_screen.background_color(color_t::black);
main_screen.on_flush_callback(flush_callback, flush_callback_state);
cpu_label.text("CPU");
cpu_label.text_line_height(main_screen.dimensions().height / 10);
cpu_label.bounds(text_font.measure_text(ssize16::max(),
spoint16::zero(),
cpu_label.text(),
text_font.scale(cpu_label.text_line_height()))
.bounds().offset(5, 5).inflate(8, 4));
cpu_label.text_color(color32_t::white);
cpu_label.background_color(transparent);
cpu_label.border_color(transparent);
cpu_label.text_justify(uix_justify::bottom_right);
cpu_label.text_open_font(&text_font);
main_screen.register_control(cpu_label);
cpu_temp_label.bounds(cpu_label.bounds()
.offset(0, cpu_label.text_line_height() + 1));
cpu_temp_label.text_color(color32_t::white);
cpu_temp_label.background_color(transparent);
cpu_temp_label.border_color(transparent);
cpu_temp_label.text("0C");
cpu_temp_label.text_justify(uix_justify::bottom_right);
cpu_temp_label.text_open_font(&text_font);
cpu_temp_label.text_line_height(cpu_label.text_line_height());
main_screen.register_control(cpu_temp_label);
cpu_bar.bounds({int16_t(cpu_label.bounds().x2 + 5),
cpu_label.bounds().y1,
int16_t(main_screen.dimensions().width - 5),
cpu_label.bounds().y2});
cpu_bar_state.size = 2;
cpu_bar_state.colors = cpu_colors;
cpu_bar_state.values = cpu_values;
cpu_bar.on_paint(draw_bar, &cpu_bar_state);
main_screen.register_control(cpu_bar);
cpu_graph.bounds({cpu_bar.bounds().x1,
int16_t(cpu_label.bounds().y2 + 5),
cpu_bar.bounds().x2,
int16_t(main_screen.dimensions().height /
2 - 5)});
cpu_graph_state.size = 2;
cpu_graph_state.colors = cpu_colors;
cpu_graph_state.buffers = cpu_buffers;
cpu_graph.on_paint(draw_graph, &cpu_graph_state);
main_screen.register_control(cpu_graph);
gpu_label.bounds(cpu_label.bounds().offset(0, main_screen.dimensions().height / 2));
gpu_label.text_color(color32_t::white);
gpu_label.border_color(transparent);
gpu_label.background_color(transparent);
gpu_label.text("GPU");
gpu_label.text_justify(uix_justify::bottom_right);
gpu_label.text_open_font(&text_font);
gpu_label.text_line_height(cpu_label.text_line_height());
main_screen.register_control(gpu_label);
gpu_temp_label.bounds(gpu_label.bounds().offset(0, gpu_label.text_line_height() + 1));
gpu_temp_label.text_color(color32_t::white);
gpu_temp_label.background_color(transparent);
gpu_temp_label.border_color(transparent);
gpu_temp_label.text("0C");
gpu_temp_label.text_justify(uix_justify::bottom_right);
gpu_temp_label.text_open_font(&text_font);
gpu_temp_label.text_line_height(cpu_label.text_line_height());
main_screen.register_control(gpu_temp_label);
gpu_bar.bounds({int16_t(gpu_label.bounds().x2 + 5),
gpu_label.bounds().y1,
int16_t(main_screen.dimensions().width - 5),
gpu_label.bounds().y2});
gpu_bar_state.size = 2;
gpu_bar_state.colors = gpu_colors;
gpu_bar_state.values = gpu_values;
gpu_bar.on_paint(draw_bar, &gpu_bar_state);
main_screen.register_control(gpu_bar);
gpu_graph.bounds(cpu_graph.bounds()
.offset(0, main_screen.dimensions().height / 2));
gpu_graph_state.size = 2;
gpu_graph_state.colors = gpu_colors;
gpu_graph_state.buffers = gpu_buffers;
gpu_graph.on_paint(draw_graph, &gpu_graph_state);
main_screen.register_control(gpu_graph);
disconnected_label.bounds(main_screen.bounds());
disconnected_label.background_color(color32_t::white);
disconnected_label.border_color(color32_t::white);
main_screen.register_control(disconnected_label);
float sscale;
if(main_screen.dimensions().width<128 || main_screen.dimensions().height<128) {
sscale = disconnected_icon.scale(main_screen.dimensions());
} else {
sscale = disconnected_icon.scale(size16(128,128));
}
disconnected_svg.bounds(srect16(0,
0,
disconnected_icon.dimensions().width*sscale-1,
disconnected_icon.dimensions().height*sscale-1)
.center(main_screen.bounds()));
disconnected_svg.doc(&disconnected_icon);
main_screen.register_control(disconnected_svg);
Next, we'll cover our draw state for the on_paint()
callbacks for our canvas controls. These essentially "link" the callbacks with our global data for the buffers and value.
typedef struct bar_info {
size_t size;
float* values;
rgba_pixel<32>* colors;
} bar_info_t;
typedef struct graph_info {
size_t size;
buffer_t* buffers;
rgba_pixel<32>* colors;
} graph_info_t;
On to the routine to draw the bars. This gets a little involved due to the gradient but is otherwise relatively straightforward. Basically, we divide the bar spaces vertically based on the total size of our control divided by the number of bars. We then proceed to draw a background in the specified color, but somewhat transparent in order to shadow it compared to the filled portion, which we draw afterward. With the gradients we pull a shady trick by using the HSV color model to transition between green yellow and red, but even with the shortcut it still takes some doing.
const bar_info_t& inf = *(bar_info_t*)state;
int h = destination.dimensions().height / inf.size;
int y = 0;
for (size_t i = 0; i < inf.size; ++i) {
float v = inf.values[i];
rgba_pixel<32> col = inf.colors[i];
rgba_pixel<32> bcol = col;
if (col == rgba_pixel<32>()) {
hsva_pixel<32> px = color<hsva_pixel<32>>::red;
hsva_pixel<32> px2 = color<hsva_pixel<32>>::green;
auto h1 = px.channel<channel_name::H>();
auto h2 = px2.channel<channel_name::H>();
h2 -= 32;
auto range = abs(h2 - h1) + 1;
int w = (int)ceilf(destination.dimensions().width /
(float)range) + 1;
int s = 1;
if (destination.dimensions().width < range) {
w = 1;
s = range / (float)destination.dimensions().width;
}
int x = 0;
int c = 0;
for (auto j = 0; j < range; ++j) {
px.channel<channel_name::H>(range - c - 1 + h1);
rect16 r(x, y, x + w, y + h);
if (x >= (v * destination.dimensions().width)) {
px.channel<channel_name::A>(95);
} else {
px.channel<channel_name::A>(255);
}
draw::filled_rectangle(destination,
r,
main_screen.background_color(),
&clip);
draw::filled_rectangle(destination,
r,
px,
&clip);
x += w;
c += s;
}
} else {
bcol.channel<channel_name::A>(95);
draw::filled_rectangle(destination,
srect16((destination.dimensions().width * v),
y,
destination.dimensions().width - 1,
y + h),
bcol,
&clip);
draw::filled_rectangle(destination,
srect16(0,
y,
(destination.dimensions().width * v) - 1,
y + h),
col,
&clip);
}
y += h;
}
Now onto the graph. The graph is actually a bit easier in some respects. The code is certainly shorter. What we do is simply go through the graph buffers and plot each value using anti-aliased lines of the specified color (or a gradient) from the previous point to the current point.
const graph_info_t& inf = *(graph_info_t*)state;
const uint16_t width = destination.dimensions().width;
const uint16_t height = destination.dimensions().height;
spoint16 pt;
for (size_t i = 0; i < inf.size; ++i) {
buffer_t& buf = inf.buffers[i];
rgba_pixel<32> col = inf.colors[i];
bool grad = col == rgba_pixel<32>();
float v = NAN;
if (!buf.empty()) {
v = *buf.peek(0);
pt.x = 0;
pt.y = height - (v * height) - 1;
if (pt.y < 0) pt.y = 0;
}
for (size_t i = 1; i < buf.size(); ++i) {
v = *buf.peek(i);
if (grad) {
hsva_pixel<32> px = color<hsva_pixel<32>>::red;
hsva_pixel<32> px2 = color<hsva_pixel<32>>::green;
auto h1 = px.channel<channel_name::H>();
auto h2 = px2.channel<channel_name::H>();
h2 -= 32;
auto range = abs(h2 - h1) + 1;
px.channel<channel_name::H>(h1 + (range - (v * range)));
convert(px, &col);
}
spoint16 pt2;
pt2.x = (i / 100.0f) * width;
pt2.y = height - (v * height) - 1;
if (pt2.y < 0) pt2.y = 0;
draw::line_aa(destination,
srect16(pt, pt2),
col,
col,
true,
&clip);
pt = pt2;
}
}
interface.hpp
This file declares the struct
we send over the serial buffer. There is a matching file in the C# PC companion application. They must match at a binary level in order for the PC to communicate with the ESP32(s).
#pragma once
#include <stdint.h>
typedef struct read_status {
constexpr static const int command = 1;
int cpu_usage;
int cpu_temp;
int cpu_temp_max;
int gpu_usage;
int gpu_temp;
int gpu_temp_max;
} read_status_t;
circular_buffer.hpp
This file implements a simple circular buffer template. We won't be covering it here. You can find a similar implementation in the PlatformIO
library codewitch-honey-crisis/htcw_data.
lcd_config.h
This file contains configuration information for various LCDs. You can try your hand at adding more, but you may need to create an associated driver. This is primarily used by lcd_init.h.
lcd_init.h
This file initializes and connects the LCD based on the data in lcd_config.h. It uses the ESP LCD Panel API in order to communicate with the display - asynchronously when DMA is available. Covering it is beyond the scope of this article, but you can use it in your own projects. Portions derived from LovyanGFX.
OpenSans_Regular.hpp
This file contains an array which is a True Type font file, verbatim. It is used by the firmware to render the text. It was generated using this tool. Note that you must #define FONTNAME_IMPLEMENTATION
in exactly one cpp file before including this file, where FONTNAME is the name of your font as specified in the tool UI, but all caps. The font was downloaded from fontsquirrel.com.
disconnected_icon.hpp
This file contains the disconnected icon as an SVG file verbatim, but presented in binary form even though it is XML. It is used by the firmware to render the disconnected screen. The header was generated with the same tool used to generate the font. I can't remember where I downloaded the SVG from. I found it using a Google search. Like with the font file, you must #define SVGNAME_IMPLEMENTATION
in exactly one cpp file before including this file, where SVGNAME
reflects the name specified in the tool, but in all caps.
ssd1306_surface_adapter.hpp
The SSD1306 display controller is an odd duck. It is monochrome, so it packs 8 pixels into a single byte, but those pixels in each byte are arranged vertically rather than horizontally. The bytes still line up left to right, but the pixels therein are arrange top to bottom, such that (0,3) is stored in the first byte.
lcd_init.h does not translation of the data before it is sent to the display. What this does is provide an alternate backing for a UIX control surface - typically a simple bitmap is used. That won't work here because the memory is not arranged correctly for the display. This adapter is a shim that makes all draw operations create compatible a memory footprint compatible with the display's frame buffer. How it does it is silly almost to the point of embarrassing, but beyond the scope here.
The PC Companion Application
Most of the application logic is in the main form code, so we'll cover that first.
Main.cs
The first code of note is a nested class that is used to traverse OpenHardwareMonitorLib
's published data, which is reported as a hierarchy. This effectively allows us to move through the hierarchy and update our information.
public class UpdateVisitor : IVisitor
{
public void VisitComputer(IComputer computer)
{
computer.Traverse(this);
}
public void VisitHardware(IHardware hardware)
{
hardware.Update();
foreach (IHardware subHardware in hardware.SubHardware)
subHardware.Accept(this);
}
public void VisitSensor(ISensor sensor) { }
public void VisitParameter(IParameter parameter) { }
}
The next bit of code is a structure we store in the PortBox
checked list box. It allows us to keep an associated COM port for each list item.
struct PortData
{
public SerialPort Port;
public PortData(SerialPort port)
{
Port = port;
}
public override string ToString()
{
if (Port != null)
{
return Port.PortName;
}
return "<null>";
}
public override bool Equals(object obj)
{
if (!(obj is PortData)) return false;
PortData other = (PortData)obj;
return object.Equals(Port, other.Port);
}
public override int GetHashCode()
{
if (Port == null) return 0;
return Port.GetHashCode();
}
}
The next bit code declares our member variables used to hold our system info data.
float cpuUsage;
float gpuUsage;
float cpuTemp;
float cpuTjMax;
float gpuTemp;
float gpuTjMax;
private readonly Computer _computer = new Computer
{
CPUEnabled = true,
GPUEnabled = true
};
The next bit populates our check list box full of COM ports. It remembers the ones that were already checked and keeps them checked.
void RefreshPortList()
{
var ports = new List<SerialPort>();
foreach (var item in PortBox.CheckedItems)
{
ports.Add(((PortData)item).Port);
}
PortBox.Items.Clear();
var names = SerialPort.GetPortNames();
foreach (var name in names)
{
SerialPort found = null;
foreach (var ep in ports)
{
if (ep.PortName == name)
{
found = ep;
break;
}
}
var chk = false;
if (found == null)
{
found = new SerialPort(name, 115200);
}
else
{
chk = true;
}
PortBox.Items.Add(new PortData(found));
if (chk)
{
PortBox.SetItemChecked(
PortBox.Items.IndexOf(new PortData(found)), true);
}
}
}
The timer's tick event is where we handle collecting the system information and shooting it to the COM ports.
private void UpdateTimer_Tick(object sender, EventArgs e)
{
if (StartedCheckBox.Checked)
{
CollectSystemInfo();
ReadStatus data;
data.CpuTemp = (byte)cpuTemp;
data.CpuUsage = (byte)cpuUsage;
data.GpuTemp = (byte)gpuTemp;
data.GpuUsage = (byte)gpuUsage;
data.CpuTempMax = (byte)cpuTjMax;
data.GpuTempMax = (byte)gpuTjMax;
int i = 0;
foreach (PortData pdata in PortBox.Items)
{
var port = pdata.Port;
if (PortBox.GetItemChecked(i))
{
try
{
if (!port.IsOpen)
{
port.Open();
}
if (port.WriteBufferSize - port.BytesToWrite >
1 + System.Runtime.InteropServices.Marshal.SizeOf(data))
{
var ba = new byte[] { 1 };
port.Write(ba, 0, ba.Length);
port.WriteStruct(data);
}
port.BaseStream.Flush();
}
catch { }
}
else
{
if (port.IsOpen)
{
try { port.Close(); } catch { }
}
}
++i;
}
}
}
The next routine is responsible for actually gathering the pertinent system information from OpenHardwareMonitorLib by walking through all the reported items, and stashing relevant ones in the form's member variables declared earlier.
void CollectSystemInfo()
{
var updateVisitor = new UpdateVisitor();
_computer.Accept(updateVisitor);
cpuTjMax = (int)CpuMaxUpDown.Value;
gpuTjMax = (int)GpuMaxUpDown.Value;
for (int i = 0; i < _computer.Hardware.Length; i++)
{
if (_computer.Hardware[i].HardwareType == HardwareType.CPU)
{
for (int j = 0; j < _computer.Hardware[i].Sensors.Length; j++)
{
var sensor = _computer.Hardware[i].Sensors[j];
if (sensor.SensorType == SensorType.Temperature &&
sensor.Name.Contains("CPU Package"))
{
for (int k = 0; k < sensor.Parameters.Length; ++k)
{
var p = sensor.Parameters[i];
if (p.Name.ToLowerInvariant().Contains("tjmax"))
{
cpuTjMax = (float)p.Value;
}
}
cpuTemp = sensor.Value.GetValueOrDefault();
}
else if (sensor.SensorType == SensorType.Load &&
sensor.Name.Contains("CPU Total"))
{
cpuUsage = sensor.Value.GetValueOrDefault();
}
}
}
if (_computer.Hardware[i].HardwareType == HardwareType.GpuAti ||
_computer.Hardware[i].HardwareType == HardwareType.GpuNvidia)
{
for (int j = 0; j < _computer.Hardware[i].Sensors.Length; j++)
{
var sensor = _computer.Hardware[i].Sensors[j];
if (sensor.SensorType == SensorType.Temperature &&
sensor.Name.Contains("GPU Core"))
{
gpuTemp = sensor.Value.GetValueOrDefault();
}
else if (sensor.SensorType == SensorType.Load &&
sensor.Name.Contains("GPU Core"))
{
gpuUsage = sensor.Value.GetValueOrDefault();
}
}
}
}
}
The rest of the form is trivial event handling.
Interface.cs
This file is the corollary to interface.hpp in the firmware and provides the structure necessary for shooting the binary data across the serial UART. As before, it must match the other file for them to be able to communicate. We use .NET marshalling to convert the structure to a byte array.
using System.Runtime.InteropServices;
namespace EspMon
{
[StructLayout(LayoutKind.Sequential,Pack = 4)]
internal struct ReadStatus
{
public int CpuUsage;
public int CpuTemp;
public int CpuTempMax;
public int GpuUsage;
public int GpuTemp;
public int GpuTempMax;
}
}
SerialExtensions.cs
This file provides additional functionality to the SerialPort
class. Primarily, it allows you to marshal structures over serial in binary form. We could have, and perhaps should have used binary serialization for this, since that's what it was designed for, but while this is a bit hackish, it's also the easiest way to modify and create the structures. Exploring how it works is beyond the scope of this article, but you can easily use it in your own projects.
app.manifest
This file contains the necessary information to make the executable require elevated privileges, which is necessary for OpenHardwareMonitorLib to function.
OpenHardwareMonitorLib
As mentioned before, this is not my project, but I included it because I patched it to work with Raptor Lake desktop and mobile processors.
History
- 6th June, 2023 - Initial submission
- 14th June, 2023 - Updated to support more devices, some cleanup
- 14th June, 2023 - Updated to support new UIX. Experimental support for HelTec WiFi Kit v2
- 7th July, 2023 - Updated to support Wio Terminal and ESP-IDF (Github Only)