EspMon: A Simple PC Hardware Monitor using a T-Display S3

Updated on 2022-10-03

Monitor your CPU and GPU activity with this little project

Introduction

I play Fallout 4. It's an endlessly expandable game so I keep coming back to it even after 7 years of play. I add to the game and modify it.

Well, eventually I got a 2080ti so I could play it in 4K, and it did so easily, so I decided to throw some tough computations at it by adding heavy forest all over the landscape. Finally, I started to hear the card spin up and actually sweat a little.

I wanted more than just an audible cue, and I really don't like obscuring the game screen with overlays, so I whipped out my little T-Display S3 and made this:

It reads data about your PC from the serial port using a companion application on the PC. You can use the buttons on it to switch between usage/temperature and frequency.

Prerequisites

This project assumes you have a T-Display S3, or are willing to adapt the code to a separate device.

T-Display S3

It assumes PlatformIO, although you can use the Arduino IDE with some prep work and adaptation, mostly renaming and/or moving files around.

It assumes you are running Windows 10 or 11 and have admin access on that system.

It assumes Visual Studio 2019 or better and the .NET Framework.

Understanding this Mess

There are two projects involved here: The T-Display firmware and the PC application.

The PC application uses Open Hardware Monitor to gather information about the CPU and GPU every 10th of a second, and it uses a serial port to relay that information to a connected T-Display periodically, as the T-Display requests it.

Open Hardware Monitor

The T-Display uses LVGL to handle the graphics. It periodically requests data over the serial port and then updates the display at most once every 10th of a second.

LVGL

Coding this Mess

PC Application

The PC application is a single form with a couple of controls to choose the COM port. The application isn't currently very robust in that regard, and could be improved, but is more than adequate to prove this concept and illustrate it. As soon as the COM port is chosen, it will begin listening for the T-Display.

Meanwhile, every 10th of a second, it's updating member variables that contain various statistics about the PC's hardware.

Initially, the code listens for a '#' or '@' coming in off the serial port by hooking SerialPort.DataReceived. When it finds '#', it sends four floats containing the CPU use and temp, and the GPU use and temp. If it finds '@', it sends the CPU and GPU frequencies. If it finds something other than that, it just reads all the waiting data. The reason for that is when the T-Display starts up, it will spit unrelated spew to the serial port - a post message basically.

Here's the meat of the serial communication on the PC end:

private void _port_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
    if (_port!=null && _port.IsOpen)
    {
        var cha = new byte[1];
        if (_port.BytesToRead != 1)
        {
            var ba = new byte[_port.BytesToRead];
            _port.Read(ba, 0, ba.Length);
            if (Created && !Disposing)
            {
                Invoke(new Action(() =>
                {
                    Log.AppendText(Encoding.ASCII.GetString(ba));
                }));
            }
        }
        else
        {
            _port.Read(cha, 0, cha.Length);
            if ((char)cha[0] == '#')
            {
                var ba = BitConverter.GetBytes(cpuUsage);
                if(!BitConverter.IsLittleEndian)
                {
                    Array.Reverse(ba);
                }
                _port.Write(ba, 0, ba.Length);
                ba = BitConverter.GetBytes(cpuTemp);
                if (!BitConverter.IsLittleEndian)
                {
                    Array.Reverse(ba);
                }
                _port.Write(ba, 0, ba.Length);
                ba = BitConverter.GetBytes(gpuUsage);
                if (!BitConverter.IsLittleEndian)
                {
                    Array.Reverse(ba);
                }
                _port.Write(ba, 0, ba.Length);
                ba = BitConverter.GetBytes(gpuTemp);
                if (!BitConverter.IsLittleEndian)
                {
                    Array.Reverse(ba);
                }
                _port.Write(ba, 0, ba.Length);
                _port.BaseStream.Flush();
            } else if((char)cha[0]=='@')
            {
                var ba = BitConverter.GetBytes(cpuSpeed);

                if (!BitConverter.IsLittleEndian)
                {
                    Array.Reverse(ba);
                }
                _port.Write(ba, 0, ba.Length);
                ba = BitConverter.GetBytes(gpuSpeed);
                if (!BitConverter.IsLittleEndian)
                {
                    Array.Reverse(ba);
                }
                _port.Write(ba, 0, ba.Length);
            }
        }
    }
}

You can see aside from what we already covered, that this will also handle big endian systems. That wasn't necessary due to this being a traditional Windows .NET framework application but it's basically instinct for me at this point. Plus if this code ever gets reused somewhere else, it will handle all scenarios.

There's another aspect of the serial communication and that is getting the list of COM ports. Listing the ports is simple. We just enumerate them and add them to the combo. It gets weird choosing a port, however. What we do is we start at the last available COM port - which is probably the most recent connection - and open them in turn to see if they are available. If one is, we stop there. If it's not, we move to the previous COM port in the list:

void RefreshPortList()
{
    var p = PortCombo.Text;
    PortCombo.Items.Clear();
    var ports = SerialPort.GetPortNames();
    foreach(var port in ports)
    {
        PortCombo.Items.Add(port);
    }
    var idx = PortCombo.Items.Count-1;
    if(!string.IsNullOrWhiteSpace(p))
    {
        for(var i = 0; i < PortCombo.Items.Count; ++i)
        {
            if(p==(string)PortCombo.Items[i])
            {
                idx = i;
                break;
            }
        }
    }
    var s = new SerialPort((string)PortCombo.Items[idx]);
    if (!s.IsOpen)
    {
        try
        {
            s.Open();
            s.Close();
        }
        catch
        {
            --idx;
            if (0 > idx)
            {
                idx = PortCombo.Items.Count - 1;
            }
        }
    }
    PortCombo.SelectedIndex = idx;
}

Finally, the other important bit is gathering the hardware periodically which we do on a timer:

void CollectSystemInfo()
{
    foreach (var hardware in _computer.Hardware)
    {
        if (hardware.HardwareType == HardwareType.CPU)
        {
            hardware.Update();
            foreach (var sensor in hardware.Sensors)
            {
                if (sensor.SensorType == SensorType.Temperature &&
                    sensor.Name.Contains("CPU Package"))
                {
                    cpuTemp = sensor.Value.GetValueOrDefault();
                }
                else if (sensor.SensorType == SensorType.Load &&
                    sensor.Name.Contains("CPU Total"))
                {
                    cpuUsage = sensor.Value.GetValueOrDefault();
                }
                else if (sensor.SensorType == SensorType.Clock &&
                    sensor.Name.Contains("CPU Core #1"))
                {
                    cpuSpeed = sensor.Value.GetValueOrDefault();
                }
            }
        }
        if (hardware.HardwareType == HardwareType.GpuAti ||
            hardware.HardwareType == HardwareType.GpuNvidia)
        {
            hardware.Update();
            foreach (var sensor in hardware.Sensors)
            {
                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();
                }
                else if (sensor.SensorType == SensorType.Clock &&
                    sensor.Name.Contains("GPU Core"))
                {
                    gpuSpeed = sensor.Value.GetValueOrDefault();
                }
            }
        }
    }
}

That's all the relevant application code. The rest is just boilerplate.

T-Display S3 Firmware

Note: Before the firmware will build, you must copy /include/lv_conf.h to /.pio's libdeps subfolder for your project or it will not build.

Setup is mostly boilerplate, and would be mostly the same for virtually all applications. Basically all that happens is we're setting up the display driver, hooking it in to LVGL, initializing the display, and activating the USB Serial bridge:

void setup() {
    pinMode(PIN_POWER_ON, OUTPUT);
    digitalWrite(PIN_POWER_ON, HIGH);
    Serial.begin(115200);

    pinMode(PIN_LCD_RD, OUTPUT);
    digitalWrite(PIN_LCD_RD, HIGH);
    esp_lcd_i80_bus_handle_t i80_bus = NULL;
    esp_lcd_i80_bus_config_t bus_config = {
        .dc_gpio_num = PIN_LCD_DC,
        .wr_gpio_num = PIN_LCD_WR,
        .clk_src = LCD_CLK_SRC_PLL160M,
        .data_gpio_nums =
            {
                PIN_LCD_D0,
                PIN_LCD_D1,
                PIN_LCD_D2,
                PIN_LCD_D3,
                PIN_LCD_D4,
                PIN_LCD_D5,
                PIN_LCD_D6,
                PIN_LCD_D7,
            },
        .bus_width = 8,
        .max_transfer_bytes = LVGL_LCD_BUF_SIZE * sizeof(uint16_t),
    };
    esp_lcd_new_i80_bus(&bus_config, &i80_bus);

    esp_lcd_panel_io_i80_config_t io_config = {
        .cs_gpio_num = PIN_LCD_CS,
        .pclk_hz = EXAMPLE_LCD_PIXEL_CLOCK_HZ,
        .trans_queue_depth = 20,
        .on_color_trans_done = notify_lvgl_flush_ready,
        .user_ctx = &disp_drv,
        .lcd_cmd_bits = 8,
        .lcd_param_bits = 8,
        .dc_levels =
            {
                .dc_idle_level = 0,
                .dc_cmd_level = 0,
                .dc_dummy_level = 0,
                .dc_data_level = 1,
            },
    };
    ESP_ERROR_CHECK(esp_lcd_new_panel_io_i80(i80_bus, &io_config, &io_handle));
    esp_lcd_panel_handle_t panel_handle = NULL;
    esp_lcd_panel_dev_config_t panel_config = {
        .reset_gpio_num = PIN_LCD_RES,
        .color_space = ESP_LCD_COLOR_SPACE_RGB,
        .bits_per_pixel = 16,
    };
    esp_lcd_new_panel_st7789(io_handle, &panel_config, &panel_handle);
    esp_lcd_panel_reset(panel_handle);
    esp_lcd_panel_init(panel_handle);
    esp_lcd_panel_invert_color(panel_handle, true);

    esp_lcd_panel_swap_xy(panel_handle, true);
    esp_lcd_panel_mirror(panel_handle, false, true);
    // the gap is LCD panel specific, even panels with the same driver IC, can
    // have different gap value
    esp_lcd_panel_set_gap(panel_handle, 0, 35);

    /* Lighten the screen with gradient */
    ledcSetup(0, 10000, 8);
    ledcAttachPin(PIN_LCD_BL, 0);
    for (uint8_t i = 0; i < 0xFF; i++) {
        ledcWrite(0, i);
        delay(2);
    }

    lv_init();
    lv_disp_buf = (lv_color_t *)heap_caps_malloc
    (LVGL_LCD_BUF_SIZE * sizeof(lv_color_t), MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
    lv_disp_buf2 = (lv_color_t *)heap_caps_malloc
    (LVGL_LCD_BUF_SIZE * sizeof(lv_color_t), MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
    lv_disp_draw_buf_init(&disp_buf, lv_disp_buf, lv_disp_buf2, LVGL_LCD_BUF_SIZE);
    /*Initialize the display*/
    lv_disp_drv_init(&disp_drv);
    /*Change the following line to your display resolution*/
    disp_drv.hor_res = LCD_H_RES;
    disp_drv.ver_res = LCD_V_RES;
    disp_drv.flush_cb = lvgl_flush_cb;
    disp_drv.draw_buf = &disp_buf;
    disp_drv.user_data = panel_handle;
    lv_disp_drv_register(&disp_drv);

    is_initialized_lvgl = true;

    ui_init();
    ui_patch();
    lv_canvas_set_buffer(ui_CpuGraph, cpu_graph_buf, 135, 37, LV_IMG_CF_TRUE_COLOR);
    lv_canvas_set_buffer(ui_GpuGraph, gpu_graph_buf, 135, 37, LV_IMG_CF_TRUE_COLOR);
    lv_canvas_set_buffer(ui_CpuGhzGraph, cpu_graph_buf, 135, 37, LV_IMG_CF_TRUE_COLOR);
    lv_canvas_set_buffer(ui_GpuGhzGraph, gpu_graph_buf, 135, 37, LV_IMG_CF_TRUE_COLOR);

    button_prev.callback(button_prev_cb);
    button_next.callback(button_next_cb);

    USBSerial.begin(115200);
}

One odd thing about the above is we're using the same buffers for the canvases on either screen. This is because we only need one at a time, so we can just reuse the memory.

We really fire things off in loop():

static int ticker = 0;
void loop() {
    button_prev.update();
    button_next.update();
    if (ticker++ >= 33) {
        ticker = 0;
        switch (screen) {
            case 0:
                update_screen_0();
            break;
            case 1:
                update_screen_1();
            break;
        }
    }

    lv_timer_handler();
    delay(3);
}

First, we give the buttons a chance to get fired. Then approximately once every 10th of a second, we update the current screen. Finally, we let LVGL do its thing.

Finally, the screen updates. Basically, for the first screen, we just read the usage and temperature values if there's serial data waiting. With the data, we update the CPU and GPU bars, adding the data to the cpu_graph and gpu_graph buffers. If either of the buffers is full, we remove the oldest item. If we need to redraw either one, we construct a path of lines for LVGL to consume using scaled values, and then draw it.

With the second screen, things get a little more complicated, despite the fact that we're only reading CPU and GPU frequency. The reason being we do not have an effective range of values we can use, since we don't know the upper and lower limits of your CPU and GPU speeds.

Due to the above, the code keeps track of the minumum and maximum values seen and uses that as our effective range. The code also makes sure that the values are tared to the minimum value. Both routines are very similar, so we'll just look at the second screen's code:

static float screen_1_cpu_min=NAN,screen_1_cpu_max=NAN;
static float screen_1_gpu_min=NAN,screen_1_gpu_max=NAN;
static void update_screen_1() {
    float tmp;
    float v;
    bool redraw_cpu, redraw_gpu;
    float cpu_scale, gpu_scale;
    char sz[64];
    union {
        float f;
        uint8_t b[4];
    } fbu;
    redraw_cpu = false;
    redraw_gpu = false;
    if (USBSerial.available()) {
        int i = USBSerial.readBytes(fbu.b, sizeof(fbu.b));
        if (i == 0) {
            USBSerial.write('@');
        } else {
            if (cpu_graph.full()) {
                cpu_graph.get(&tmp);
            }
            v = (fbu.f);
            cpu_graph.put(v);
            if(screen_1_cpu_min!=screen_1_cpu_min||v<screen_1_cpu_min) {
                screen_1_cpu_min = v;
            }
            if(screen_1_cpu_max!=screen_1_cpu_max||v>screen_1_cpu_max) {
                screen_1_cpu_max = v;
            }
            cpu_scale = screen_1_cpu_max-screen_1_cpu_min+1;
            float offs = - (screen_1_cpu_min/cpu_scale);
            redraw_cpu = true;
            lv_bar_set_value(ui_CpuGhzBar, ((v/cpu_scale)+offs)*100, LV_ANIM_ON);
            snprintf(sz, sizeof(sz), "%0.1fGHz", fbu.f/1000.0);
            lv_label_set_text(ui_CpuGhzLabel, sz);
            if (USBSerial.available()) {
                i = USBSerial.readBytes(fbu.b, sizeof(fbu.b));
                if (i != 0) {
                    if (gpu_graph.full()) {
                        gpu_graph.get(&tmp);
                    }
                    v = (fbu.f);
                    gpu_graph.put(v);
                    if(screen_1_gpu_min!=screen_1_gpu_min||v<screen_1_gpu_min) {
                        screen_1_gpu_min = v;
                    }
                    if(screen_1_gpu_max!=screen_1_gpu_max||v>screen_1_gpu_max) {
                        screen_1_gpu_max = v;
                    }
                    gpu_scale = screen_1_gpu_max-screen_1_gpu_min+1;
                    offs = - (screen_1_gpu_min/gpu_scale);
                    redraw_gpu = true;
                    lv_bar_set_value(ui_GpuGhzBar, ((v/gpu_scale)+offs)*100, LV_ANIM_ON);
                    snprintf(sz, sizeof(sz), "%0.1fGHz", fbu.f/1000.0);
                    lv_label_set_text(ui_GpuGhzLabel, sz);
                } else {
                    USBSerial.write('@');
                }
            } else {
                USBSerial.write('@');
            }
        }
    } else {
        USBSerial.write('@');
    }
    if (redraw_cpu) {
        float offs = - (screen_1_cpu_min/cpu_scale);
        lv_point_t pts[sizeof(cpu_graph)];
        lv_draw_line_dsc_t dsc;
        lv_draw_line_dsc_init(&dsc);
        dsc.width = 1;
        dsc.color = lv_color_hex(0x0000FF);
        dsc.opa = LV_OPA_100;
        lv_canvas_fill_bg(ui_CpuGhzGraph, lv_color_white(), LV_OPA_100);
        v = *cpu_graph.peek(0);
        pts[0].x = 0;
        pts[0].y = 36 - ((v/cpu_scale)+offs) * 36;
        for (size_t i = 1; i < cpu_graph.size(); ++i) {
            v = *cpu_graph.peek(i);
            pts[i].x = i;
            pts[i].y = 36 - ((v/cpu_scale)+offs) * 36;
        }
        lv_canvas_draw_line(ui_CpuGhzGraph, pts, cpu_graph.size(), &dsc);
    }
    if (redraw_gpu) {
        float offs = - (screen_1_gpu_min/gpu_scale);
        lv_point_t pts[sizeof(gpu_graph)];
        lv_draw_line_dsc_t dsc;
        lv_draw_line_dsc_init(&dsc);
        dsc.width = 1;
        dsc.color = lv_color_hex(0xFF0000);
        dsc.opa = LV_OPA_100;
        lv_canvas_fill_bg(ui_GpuGhzGraph, lv_color_white(), LV_OPA_100);
        v = *gpu_graph.peek(0);
        pts[0].x = 0;
        pts[0].y = 36 - ((v/gpu_scale)+offs) * 36;
        for (size_t i = 1; i < gpu_graph.size(); ++i) {
            v = *gpu_graph.peek(i);
            pts[i].x = i;
            pts[i].y = 36 - ((v/gpu_scale)+offs) * 36;
        }
        lv_canvas_draw_line(ui_GpuGhzGraph, pts, gpu_graph.size(), &dsc);
    }
}

One thing we haven't covered is the UI itself in ui.h. The reason is that this code wasn't written by me, but rather it was generated by Squareline Studio which is a visual designer that creates LVGL code, a bit like the Windows Forms designer creates C# code.

Squareline Studio

Conclusion

This is a useful little utility by itself, but can easily be extended to meet your own needs. Happy coding!

History

  • 1st October, 2022 - Initial submission
  • 2nd October, 2022 - Added sparklines
  • 3rd October, 2022 - Added frequency screen