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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

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

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

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

Скачать


  • приложение — 137.1 КБ (GitHub);
  • прошивка — 20.6 КБ (GitHub).

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


  • 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-канал с полезностями и уютный чат
Источник: https://habr.com/ru/company/ruvds/blog/691474/


Интересные статьи

Интересные статьи

Kotlin Symbol Processing (KSP), наш новый инструмент для создания легких плагинов компилятора на языке Kotlin, теперь стабилен! KSP обладает функциональностью, схожей с Kotlin Annotation Processing To...
Приветствую жителей Хабра! Задался тут вопросом, как можно обойтись без статического IP для экспериментов в домашних условиях. Наткнулся на вот эту статью. Если вы хотите развернуть...
Всем привет. В продолжение прошлой статьи, связанной с костылями и SSH для мониторинга места и метрик производительности доступных нам томов на NetApp, хочу поделиться и ...
Сотрудники Сеульского университета опубликовали исследование о симуляции движения двуногих персонажей на основе работы суставов и мышечных сокращений, использующей нейросеть с Deep Reinforcement ...
Хочу поделиться нашим годичным опытом при поиске решения для организации централизованного и упорядоченного доступа к ключам электронной защиты в нашей организации (ключи для доступа к площадкам ...