Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / IoT / Arduino

EspMon Reboot

4.62/5 (6 votes)
11 Jul 2023MIT12 min read 14.2K   86  
Monitor your PC's CPU and GPU with an ESP32
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.

 

ESPMon UIX

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.

C++
#include <Arduino.h>
// required to import the actual definitions
// for lcd_init.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. 

C++
// label string data
static char cpu_sz[32];
static char gpu_sz[32];

// signal timer for disconnection detection
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.

C++
// only needed if not RGB interface screen
#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);
    // if RGB, no DMA, so we are done once the above completes
#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.

C++
void setup() {
    Serial.begin(115200);
    // enable the power pins, as necessary
#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

    // RGB interface LCD init is slightly different
#ifdef LCD_PIN_NUM_VSYNC
    lcd_panel_init();
#else
    lcd_panel_init(lcd_buffer_size,lcd_flush_ready);
#endif
    // initialize the main screen (ui.cpp)
    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.

C++
// timeout for disconnection detection (1 second)
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.

C++
// update the UI
main_screen.update();

Next, we check for incoming serial data. If we find it, we reset the disconnected timeout and hide the associated controls.

C++
// listen for incoming serial
int i = Serial.read();
float tmp;
if(i>-1) { // if data received...
    // reset the disconnect timeout
    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.

C++
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.

C++
// update the CPU graph buffer (usage)
if (cpu_buffers[0].full()) {
    cpu_buffers[0].get(&tmp);
}
cpu_buffers[0].put(data.cpu_usage/100.0f);
// update the bar and label values (usage)
cpu_values[0]=data.cpu_usage/100.0f;
// update the CPU graph buffer (temperature)
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;
}
// update the bar and label values (temperature)
cpu_values[1]=data.cpu_temp/(float)data.cpu_temp_max;
// force a redraw of the CPU bar and graph
cpu_graph.invalidate();
cpu_bar.invalidate();
// update CPU the label (temperature)
sprintf(cpu_sz,"%dC",data.cpu_temp);
cpu_temp_label.text(cpu_sz);
// update the GPU graph buffer (usage)
if (gpu_buffers[0].full()) {
    gpu_buffers[0].get(&tmp);
}
gpu_buffers[0].put(data.gpu_usage/100.0f);
// update the bar and label values (usage)
gpu_values[0] = data.gpu_usage/100.0f;
// update the GPU graph buffer (temperature)
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;
}
// update the bar and label values (temperature)
gpu_values[1] = data.gpu_temp/(float)data.gpu_temp_max;
// force a redraw of the GPU bar and graph
gpu_graph.invalidate();
gpu_bar.invalidate();
// update GPU the label (temperature)
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:

C++
// eat bad data
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.

C++
#pragma once
#include <lcd_config.h>
#include <uix.hpp>
#include <circular_buffer.hpp>
// declare the types for our controls and other things
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>;
// X11 colors (used for screen)
using color_t = gfx::color<typename screen_t::pixel_type>;
// RGBA8888 X11 colors (used for controls)
using color32_t = gfx::color<gfx::rgba_pixel<32>>;
// circular buffer for graphs
using buffer_t = circular_buffer<float,100>;

// the buffers hold the graph data for the CPU
extern buffer_t cpu_buffers[];
// the array holds the bar/label data for the CPU
extern float cpu_values[];
// the max temperature received for the CPU
extern int cpu_max_temp;
// the colors for the CPU bar and graph
extern gfx::rgba_pixel<32> cpu_colors[];
// the buffers hold the graph data for the GPU
extern buffer_t gpu_buffers[];
// the array holds the bar/label data for the GPU
extern float gpu_values[];
// the max temperature received for the GPU
extern int gpu_max_temp;
// the colors for the GPU bar and graph
extern gfx::rgba_pixel<32> gpu_colors[];

// for most screens, we declare two 32kB buffers which
// we swap out for DMA. For RGB screens, DMA is not
// used so we put 64kB in one buffer
#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

// the screen that holds the controls
extern screen_t main_screen;

// the controls for the CPU
extern label_t cpu_label;
extern label_t cpu_temp_label;
extern canvas_t cpu_bar;
extern canvas_t cpu_graph;

// the controls for the GPU
extern label_t gpu_label;
extern label_t gpu_temp_label;
extern canvas_t gpu_bar;
extern canvas_t gpu_graph;

// the controls for the disconnected "screen"
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.

C++
// define the declarations from the header
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;

// define our transfer buffer(s) and initialize
// the main screen with it/them.
// for RGB interface screens we only use one
// because there is no DMA
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

// define our CPU controls and state
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;

// define our GPU controls and 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;

// define our disconnected controls
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.

C++
// declare a transparent pixel/color
rgba_pixel<32> transparent(0, 0, 0, 0);
// screen is black
main_screen.background_color(color_t::black);
// set the flush callback
main_screen.on_flush_callback(flush_callback, flush_callback_state);

// declare the first label. Everything else is based on this.
// to do so, we measure the size of the text (@ 1/10th of 
// height of the screen) and bound the label based on that
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));
// set the design properties
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);
// register the control with the screen
main_screen.register_control(cpu_label);

// the temp label is right below the first 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);

// the bars are to the right of the 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);

// the graph is below the above items.
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);

// the GPU label is offset from the CPU
// label by half the height of the screen
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);

// lay out the rest of the controls the 
// same as was done with the CPU
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);
// here we center and scale the SVG control based on
// the size of the screen, clamped to a max of 128x128
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.

C++
// used for the draw routines
// the state data for the bars
typedef struct bar_info {
    size_t size;
    float* values;
    rgba_pixel<32>* colors;
} bar_info_t;
// the state data for the graphs
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.

C++
// reconstitute our state info
const bar_info_t& inf = *(bar_info_t*)state;
// get the height of each bar
int h = destination.dimensions().height / inf.size;
int y = 0;
for (size_t i = 0; i < inf.size; ++i) {
    // the current value to graph
    float v = inf.values[i];
    rgba_pixel<32> col = inf.colors[i];
    rgba_pixel<32> bcol = col;
    // if the color is the default (black), then we create a gradient
    // using the HSV color model
    if (col == rgba_pixel<32>()) {
        // two reference points for the ends of the graph
        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>();
        // adjust so we don't overshoot
        h2 -= 32;
        // the actual range we're drawing
        auto range = abs(h2 - h1) + 1;
        // the width of each gradient segment
        int w = (int)ceilf(destination.dimensions().width / 
                            (float)range) + 1;
        // the step of each segment - default 1
        int s = 1;
        // if the gradient is larger than the control
        if (destination.dimensions().width < range) {
            // change the segment to width 1
            w = 1;
            // and make its step larger
            s = range / (float)destination.dimensions().width;
        }
        int x = 0;
        // c is the current color offset
        // it increases by s (step)
        int c = 0;
        // for each color in the range
        for (auto j = 0; j < range; ++j) {
            // adjust the H value (inverted and offset)
            px.channel<channel_name::H>(range - c - 1 + h1);
            // create the rect for our segment
            rect16 r(x, y, x + w, y + h);
            // if we're drawing the filled part
            // it's fully opaque
            // otherwise it's semi-transparent
            if (x >= (v * destination.dimensions().width)) {
                px.channel<channel_name::A>(95);
            } else {
                px.channel<channel_name::A>(255);
            }
            // black out the area underneath so alpha blending
            // works correctly
            draw::filled_rectangle(destination, 
                                r, 
                                main_screen.background_color(), 
                                &clip);
            // draw the segment
            draw::filled_rectangle(destination, 
                                r, 
                                px, 
                                &clip);
            // increment
            x += w;
            c += s;
        }
    } else {
        // draw the solid color bars
        // first draw the background
        bcol.channel<channel_name::A>(95);
        draw::filled_rectangle(destination, 
                            srect16((destination.dimensions().width * v), 
                                    y, 
                                    destination.dimensions().width - 1, 
                                    y + h), 
                            bcol, 
                            &clip);
        // now the filled part
        draw::filled_rectangle(destination, 
                            srect16(0, 
                                    y, 
                                    (destination.dimensions().width * v) - 1, 
                                    y + h),
                            col, 
                            &clip);
    }
    // increment to the next bar
    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.

C++
// reconstitute the state
const graph_info_t& inf = *(graph_info_t*)state;
// store the dimensions
const uint16_t width = destination.dimensions().width;
const uint16_t height = destination.dimensions().height;
spoint16 pt;
// for each graph
for (size_t i = 0; i < inf.size; ++i) {
    // easy access to the current buffer
    buffer_t& buf = inf.buffers[i];
    // the current color
    rgba_pixel<32> col = inf.colors[i];
    // is the graph a gradient?
    bool grad = col == rgba_pixel<32>();
    // the point value
    float v = NAN;
    // if we have data
    if (!buf.empty()) {
        // get and store the first value
        // (translating it to the graph)
        v = *buf.peek(0);
        pt.x = 0;
        pt.y = height - (v * height) - 1;
        if (pt.y < 0) pt.y = 0;
    }
    // for each subsequent value
    for (size_t i = 1; i < buf.size(); ++i) {
        // retrieve the value
        v = *buf.peek(i);
        // if it's a gradient
        if (grad) {
            // get our anchors for the ends
            hsva_pixel<32> px = color<hsva_pixel<32>>::red;
            hsva_pixel<32> px2 = color<hsva_pixel<32>>::green;
            // get our H values
            auto h1 = px.channel<channel_name::H>();
            auto h2 = px2.channel<channel_name::H>();
            // offset the second one to avoid overshoot
            h2 -= 32;
            // get the H range
            auto range = abs(h2 - h1) + 1;
            // set the H value based on v (inverted and offet)
            px.channel<channel_name::H>(h1 + (range - (v * range)));
            // convert to RGBA8888
            convert(px, &col);
        }
        // compute the current data point
        spoint16 pt2;
        pt2.x = (i / 100.0f) * width;
        pt2.y = height - (v * height) - 1;
        if (pt2.y < 0) pt2.y = 0;
        // draw an anti-aliased line
        // from the old point to the 
        // new point.
        draw::line_aa(destination, 
                        srect16(pt, pt2), 
                        col, 
                        col, 
                        true, 
                        &clip);
        // store the current point as 
        // the next old point
        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).

C++
#pragma once
#include <stdint.h>
// the packet to receive
typedef struct read_status {
    // the command id
    constexpr static const int command = 1;
    // cpu usage from 0-100
    int cpu_usage;
    // cpu temp (C)
    int cpu_temp;
    // cpu tjmax
    int cpu_temp_max;
    // gpu usage from 0-100
    int gpu_usage;
    // gpu temp (C)
    int gpu_temp;
    // gpu tjmax
    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.

C#
// traverses OHWM data
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.

C#
// list item that holds a com port
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.

C#
// local members for system info
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.

C#
// Populates the PortBox control with COM ports
void RefreshPortList()
{
    // get the active ports
    var ports = new List<SerialPort>();
    foreach (var item in PortBox.CheckedItems)
    {
        ports.Add(((PortData)item).Port);
    }
    // reset the portbox
    PortBox.Items.Clear();
    var names = SerialPort.GetPortNames();
    foreach (var name in names)
    {
        // check to see if the port is
        // one of the checked ports
        SerialPort found = null;
        foreach (var ep in ports)
        {
            if (ep.PortName == name)
            {
                found = ep;
                break;
            }
        }
        var chk = false;
        if (found == null)
        {
            // create a new port
            found = new SerialPort(name, 115200);
        }
        else
        {
            chk = true;
        }
        PortBox.Items.Add(new PortData(found));
        if (chk)
        {
            // if it's one of our previously
            // checked ports, check it
            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.

C#
private void UpdateTimer_Tick(object sender, EventArgs e)
{
    // only process if we're started
    if (StartedCheckBox.Checked)
    {
        // gather the system info
        CollectSystemInfo();
        // put it in the struct for sending
        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;
        // go through all the ports
        int i = 0;
        foreach (PortData pdata in PortBox.Items)
        {
            var port = pdata.Port;
            // if it's checked
            if (PortBox.GetItemChecked(i))
            {
                try
                {
                    // open if necessary
                    if (!port.IsOpen)
                    {
                        port.Open();
                    }
                    // if there's enough write buffer left
                    if (port.WriteBufferSize - port.BytesToWrite > 
                        1 + System.Runtime.InteropServices.Marshal.SizeOf(data))
                    {
                        // write the command id
                        var ba = new byte[] { 1 };
                        port.Write(ba, 0, ba.Length);
                        // write the data
                        port.WriteStruct(data);
                    }
                    port.BaseStream.Flush();
                }
                catch { }
            }
            else
            {
                // make sure unchecked ports are closed
                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.

C#
void CollectSystemInfo()
{
    // use OpenHardwareMonitorLib to collect the system info
    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"))
                {
                    // store
                    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"))
                {
                    // store
                    gpuTemp = sensor.Value.GetValueOrDefault();
                }
                else if (sensor.SensorType == SensorType.Load && 
                    sensor.Name.Contains("GPU Core"))
                {
                    // store
                    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.

C#
using System.Runtime.InteropServices;
namespace EspMon
{
    // the data to send over serial
    // pack for 32-bit systems
    [StructLayout(LayoutKind.Sequential,Pack = 4)]
    internal struct ReadStatus
    {
        // command = 1
        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)

License

This article, along with any associated source code and files, is licensed under The MIT License