Pull to refresh
2751.78
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

EspMon: мониторинг CPU и GPU с помощью T-Display S3

Reading time10 min
Views4.7K
Original author: Honey the codewitch

Я любительница Fallout 4. Эту игру можно расширять бесконечно, поэтому мне до сих пор, даже спустя семь лет, интересно к ней возвращаться. Я постоянно что-то или в неё добавляю, или изменяю.

Когда у меня, наконец, появилась 2080ti, я смогла запустить её в 4К. Игра пошла настолько легко, что я решила нагрузить её вычислениями, добавив по всему ландшафту густой лес. В конце концов, я заметила, что карточка зашумела и начала потеть.

Но мне хотелось мониторить нагрузку не только по шуму системы охлаждения, а забивать экран всякими наложениями я не люблю. Поэтому я достала свой миниатюрный T-Display S3 и решила реализовать всё это на нем.

Это устройство считывает данные о состоянии ПК с последовательного порта, используя сопутствующее приложение, установленное на компьютере. С помощью кнопок можно переключаться между показателями загруженности/температуры оборудования и его текущей частоты.

Скачать



Что потребуется


  • T-Display S3, либо придётся адаптировать код под иное устройство.
  • PlatformIO. Хотя можно использовать и Arduino IDE, но тогда нужно будет внести кое-какие изменения, в основном переименовать и переместить некоторые файлы.
  • Windows 10/11 с правами администратора.
  • Visual Studio 2019 или новее, а также .NET Framework.

Пояснения


Весь проект состоит из двух частей: прошивки для T-Display и приложения для ПК.

Приложение для ПК использует Open Hardware Monitor для сбора информации о CPU и GPU каждую десятую долю секунды, периодически передавая собранную информацию через последовательный порт на подключённый T-Display.

За обработку графического отображения отвечает LVGL. Эта библиотека периодически запрашивает данные через последовательный порт, после чего обновляет дисплей, но не чаще одного раза в 0.1 сек.

Код


▍ Приложение для ПК


Это приложение выполнено в виде одиночного окна с возможностью выбора COM-порта. Пока что оно в этом плане продумано не лучшим образом, но для демонстрации работоспособности концепции вполне сойдёт. После выбора COM-порта код начинает прослушивать его в ожидании подключения дисплея.

При этом каждую десятую долю секунды приложение будет обновлять переменные членов, содержащие собранные показатели состояния оборудования.

Изначально код прослушивает последовательный порт в ожидании ‘#’ или ‘@’, которые перехватывает из SerialPort.DataReceived. При обнаружении ‘#’ он отправляет четыре значения с плавающей запятой, отражающие показатели загрузки и температуры CPU и GPU. Если же на порт поступает ‘@’, код отправляет частоты CPU и GPU. В случае обнаружения чего-то другого он просто считывает все ожидающие данные. Дело в том, что при запуске T-Display отправляет на порт не относящуюся к делу информацию – по сути, post message.

Вот основной код для коммуникации через порт со стороны ПК:

Считывание данных
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);
            }
        }
    }
}


Помимо уже описанного, мы видим, что он также обрабатывает системы адресации с обратным порядком байтов. Эта функция в данном случае необязательна, так как мы работаем с традиционным приложением Windows NET. Framework, но для меня это уже инстинктивное решение. К тому же если в дальнейшем код потребуется использовать где-то ещё, оно будет готов обработать все сценарии.

У моей реализации коммуникации есть ещё один аспект, связанный с получением списка COM-портов. Сама эта процедура довольно проста. Достаточно просто их пронумеровать и добавить в общий список. Но вот выбор порта выполняется несколько странным путём. Для этого мы (начиная с последнего доступного), открываем их все по очереди, проверяя каждый на доступность и останавливаясь при обнаружении такового.

Обнаружение порта
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;
}


Наконец, последней важной частью является происходящий по таймеру сбор информации о состоянии оборудования:

Сбор информации
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();
                }
            }
        }
    }
}


Это всё, о чём стоило сказать в отношении кода приложения. В остальном он вполне рутинный.

Прошивка T-Display S3


Примечание: перед компиляцией прошивки вам нужно будет сначала скопировать libdeps/include/lv_conf.h в подкаталог /.pio/libdeps, иначе она не скомпилируется.

Настройка по большому счёту — стандартная и окажется однообразной для практически всех приложений. По сути, здесь мы устанавливаем драйвер дисплея, подключаем его к LVGL, инициализируем сам дисплей и активируем мост USB Serial:

Настройка
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);
    // gap будет свой для каждой ЖК-панели; его значение может отличаться даже среди панелей, имеющих одинаковую микросхему драйвера
    esp_lcd_panel_set_gap(panel_handle, 0, 35);

    /* Осветление экрана с помощью градиента */
    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);
    /*Инициализация дисплея*/
    lv_disp_drv_init(&disp_drv);
    /*Установите ниже разрешение вашего экрана*/
    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);
}


В приведённом коде есть один странный нюанс – использование одних и тех же буферов для холстов на обоих экранах. Дело в том, что одновременно он нам нужен только один, поэтому содержимое памяти можно переиспользовать.

Выполняется же всё в 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);
}

Сначала мы даём возможность сработать кнопкам. Затем каждую десятую долю секунды начинает выполняться обновление текущего экрана, после чего действие предоставляется LVGL.

Наконец, само обновление экрана. Для первого мы просто считываем показания загруженности и температуры оборудования, если на порту эти данные представлены. Считанными значениями мы обновляем индикаторы CPU и GPU, добавляя их в буферы cpu_graph и gpu_graph. Если какой-либо из этих буферов оказывается заполнен, мы удаляем из него самый старый элемент. Если требуется повторить отрисовку содержимого какого-то буфера, мы выстраиваем для LVGL путь прохождения строк, используя масштабированные значения, после чего его отрисовываем.

В случае второго экрана всё немного сложнее, даже несмотря на то, что здесь мы считываем только частоту CPU и GPU. Причина в отсутствии рабочего диапазона значений, то есть нам неизвестны верхние и нижние пределы скорости ваших процессоров и графических ускорителей.

В связи с этим коду приходится отслеживать наблюдаемые максимальные и минимальные значения, используя их в качестве рабочей области. Код также обеспечивает, чтобы эти значения тарировались относительно минимума. Обе подпрограммы очень похожи, поэтому я приведу код только для второго экрана:

Обновление экрана
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);
    }
}


Единственное, что мы не разобрали – это код самого UI в ui.h. Просто его я не писала, а сгенерировала в визуальном редакторе Squareline Studio, который создаёт код для LVGL, в некотором смысле подобно редактору Windows Forms, создающему код C#.

Заключение


У нас получилась небольшая утилита, которая уже полезна сама по себе, но также может быть доработана под ваши собственные задачи.

Успехов!
Telegram-канал с полезностями и уютный чат
Tags:
Hubs:
Total votes 33: ↑32 and ↓1+31
Comments6

Articles

Information

Website
ruvds.com
Registered
Founded
Employees
11–30 employees
Location
Россия
Representative
ruvds