feat(cpu_temp): индикатор температуры CPU на ESP32-C3 с OLED
Плата принимает по USB-CDC строки "T=67.0;L=34" от агента на ПК и рисует крупную цифру, загрузку и график за последнюю минуту на экране 72x40. Пины дисплея (SDA=5, SCL=6) определены сканером, а не по шелкографии: подписи GPIO8/GPIO9 на плате - это дефолтные Wire-пины чипа. Агент читает сенсоры через LibreHardwareMonitorLib. С версии 0.9.5 она требует отдельно установленный драйвер PawnIO: без него загрузка CPU читается, а температуры пусты даже под администратором. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ed4dad0a52
commit
59848698ff
12 files changed
+1473
No files matched your search
@@ -13,6 +13,7 @@
|
||||
|
||||
!/WeatherPredictor/
|
||||
!/pump_controller_8_2_OLED_DONE/
|
||||
!/CPU_temp/
|
||||
|
||||
# --- Правила ниже действуют внутри разрешённых проектов ---
|
||||
# Идут после белого списка намеренно: более поздний шаблон побеждает, поэтому
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Индикатор температуры CPU: плата принимает строки "T=67.0;L=34" по USB-COM
|
||||
// от агента на ПК и рисует их на встроенном OLED 72x40. Сама ничего не меряет.
|
||||
|
||||
#include "link.h"
|
||||
#include "ui.h"
|
||||
|
||||
static const uint32_t STALE_MS = 5000; // после этого молчания показываем NO DATA
|
||||
static const uint32_t SAMPLE_MS = 1000; // шаг истории графика
|
||||
static const uint32_t DRAW_MS = 200; // период перерисовки
|
||||
|
||||
static CpuStats stats = {0.0f, 0};
|
||||
static uint32_t lastData = 0;
|
||||
static uint32_t lastSample = 0;
|
||||
static uint32_t lastDraw = 0;
|
||||
static bool everGot = false;
|
||||
|
||||
void setup() {
|
||||
linkBegin();
|
||||
uiBegin();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
CpuStats fresh;
|
||||
if (linkPoll(fresh)) {
|
||||
stats = fresh;
|
||||
lastData = millis();
|
||||
everGot = true;
|
||||
}
|
||||
|
||||
const bool isFresh = everGot && (millis() - lastData < STALE_MS);
|
||||
|
||||
// Историю дописываем только при живой связи: пауза не должна рисоваться провалом.
|
||||
if (isFresh && millis() - lastSample >= SAMPLE_MS) {
|
||||
lastSample = millis();
|
||||
uiPushSample(stats.tempC);
|
||||
}
|
||||
|
||||
if (millis() - lastDraw >= DRAW_MS) {
|
||||
lastDraw = millis();
|
||||
uiRender(isFresh, stats.tempC, stats.loadPct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# CPU Temp — индикатор температуры процессора на ESP32-C3
|
||||
|
||||
Плата ESP32-C3 со встроенным OLED 0.42″ (72×40) стоит на столе и показывает
|
||||
температуру процессора компьютера: крупная цифра, загрузка CPU рядом и график
|
||||
за последнюю минуту.
|
||||
|
||||
```
|
||||
LibreHardwareMonitorLib.dll ──> PowerShell-агент ──COM (USB CDC)──> ESP32-C3 ──I2C──> OLED 72×40
|
||||
(через драйвер PawnIO) раз в секунду "T=67.0;L=34" U8g2
|
||||
```
|
||||
|
||||
Плата ничего не измеряет сама: она принимает готовые строки и рисует их. Всё
|
||||
чтение железа происходит на ПК.
|
||||
|
||||
## Состав
|
||||
|
||||
| Путь | Что это |
|
||||
|---|---|
|
||||
| `CPU_temp.ino` | основной скетч: тайм-аут свежести данных, частота перерисовки |
|
||||
| `link.h` / `link.cpp` | неблокирующее чтение строки из Serial и её разбор |
|
||||
| `ui.h` / `ui.cpp` | отрисовка через U8g2 и кольцевой буфер истории |
|
||||
| `tools/i2c_scan/` | одноразовый скетч: поиск пинов встроенного дисплея |
|
||||
| `agent/` | агент для ПК, см. [agent/README.md](agent/README.md) |
|
||||
| `docs/` | спецификация и план реализации |
|
||||
|
||||
## Железо
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Плата | ESP32-C3 (QFN32), ревизия v0.4, встроенный OLED 0.42″ |
|
||||
| Флеш | 4 МБ |
|
||||
| Дисплей | SSD1306 72×40, I2C, адрес `0x3C`, **SDA = GPIO5, SCL = GPIO6** |
|
||||
| Подключение к ПК | нативный USB, `VID_303A&PID_1001` |
|
||||
|
||||
Пины дисплея определены сканером `tools/i2c_scan`, а не по шелкографии: подписи
|
||||
`GPIO8/GPIO9` на плате относятся к дефолтным Wire-пинам чипа, а не к разводке
|
||||
экрана.
|
||||
|
||||
## Сборка и заливка
|
||||
|
||||
```
|
||||
arduino-cli compile -b esp32:esp32:esp32c3:CDCOnBoot=cdc,FlashSize=4M,PartitionScheme=default .
|
||||
arduino-cli upload -p COM4 -b esp32:esp32:esp32c3:CDCOnBoot=cdc,FlashSize=4M,PartitionScheme=default .
|
||||
```
|
||||
|
||||
`CDCOnBoot=cdc` обязателен: по умолчанию в ядре стоит `Disabled`, и тогда
|
||||
`Serial` уходит в аппаратный UART вместо USB, а данные до платы не доходят.
|
||||
|
||||
**Перед заливкой остановить агента** — он держит тот же COM-порт:
|
||||
|
||||
```
|
||||
Stop-ScheduledTask -TaskName 'CPU Temp Agent'
|
||||
```
|
||||
|
||||
## Протокол
|
||||
|
||||
Одна строка в секунду, `\n` в конце:
|
||||
|
||||
```
|
||||
T=67.0;L=34
|
||||
```
|
||||
|
||||
`T` — температура CPU Package в °C, `L` — общая загрузка CPU в процентах.
|
||||
Неизвестные ключи прошивка игнорирует, поэтому протокол можно расширять
|
||||
(например, `G=` для GPU), не трогая старую прошивку.
|
||||
|
||||
Если валидная строка не приходила дольше 5 секунд, на экране появляется
|
||||
`NO DATA`, и история перестаёт пополняться — чтобы пауза связи не выглядела
|
||||
как падение температуры.
|
||||
|
||||
## Экран
|
||||
|
||||
Шкала графика фиксированная, 30–90 °C. Автомасштаб сознательно не используется:
|
||||
при нём шум ±1 °C в простое рисовался бы горами и создавал ложную тревогу.
|
||||
|
||||
## Требования к ПК
|
||||
|
||||
Windows, LibreHardwareMonitor и драйвер PawnIO — порядок установки и разбор
|
||||
подводных камней в [agent/README.md](agent/README.md).
|
||||
@@ -0,0 +1,86 @@
|
||||
# Агент CPU Temp
|
||||
|
||||
Читает температуру и загрузку процессора и раз в секунду отдаёт их плате
|
||||
ESP32-C3 по USB-COM строкой вида `T=67.0;L=34`.
|
||||
|
||||
## Что нужно установить
|
||||
|
||||
### 1. LibreHardwareMonitor
|
||||
|
||||
Скачать со <https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases>
|
||||
архив **`LibreHardwareMonitor.zip`** и распаковать в `D:\Soft\LibreHardwareMonitor`.
|
||||
|
||||
Не брать `LibreHardwareMonitor.NET.10.zip`: это сборка под .NET 10, а Windows
|
||||
PowerShell 5.1 работает на .NET Framework и такую сборку не загрузит.
|
||||
|
||||
Само приложение запускать не нужно — используются только
|
||||
`LibreHardwareMonitorLib.dll` и лежащий рядом `HidSharp.dll`.
|
||||
|
||||
### 2. Драйвер PawnIO
|
||||
|
||||
Скачать со <https://github.com/namazso/PawnIO.Setup/releases> файл
|
||||
`PawnIO_setup.exe` и запустить **без аргументов**: установщик не принимает
|
||||
никаких ключей, и на `/S` или `/quiet` отвечает кодом 87.
|
||||
|
||||
Зачем: с версии 0.9.5 LibreHardwareMonitor читает MSR-регистры процессора через
|
||||
PawnIO, а не через WinRing0, и в архив с программой этот драйвер не входит.
|
||||
|
||||
**Без PawnIO загрузка CPU читается, а все температуры пустые** — даже из консоли
|
||||
администратора. Выглядит как нехватка прав, но причина другая.
|
||||
|
||||
## Запуск вручную
|
||||
|
||||
Нужны права администратора: без них драйвер не открывается.
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File "D:\Soft\Arduino\CPU_temp\agent\cpu_temp_agent.ps1" -Echo
|
||||
```
|
||||
|
||||
Ключ `-Echo` печатает каждую отправленную строку — только для проверки.
|
||||
|
||||
При старте агент сам проверяет, доступна ли температура, и при отказе называет
|
||||
причину, а не крутит молча пустой цикл.
|
||||
|
||||
## Автозапуск
|
||||
|
||||
Задание Планировщика `CPU Temp Agent`: триггер «при входе в систему», наивысшие
|
||||
права, скрытое окно.
|
||||
|
||||
Регистрация (из консоли администратора):
|
||||
|
||||
```powershell
|
||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "D:\Soft\Arduino\CPU_temp\agent\cpu_temp_agent.ps1"'
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
|
||||
$set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||
Register-ScheduledTask -TaskName 'CPU Temp Agent' -Action $action -Trigger $trigger -Settings $set -RunLevel Highest -Force
|
||||
```
|
||||
|
||||
`-RunLevel Highest` обязателен. Нулевой `ExecutionTimeLimit` нужен, иначе
|
||||
Планировщик убьёт агента через трое суток.
|
||||
|
||||
Удаление:
|
||||
|
||||
```powershell
|
||||
Unregister-ScheduledTask -TaskName 'CPU Temp Agent' -Confirm:$false
|
||||
```
|
||||
|
||||
## Перед заливкой прошивки — остановить агента
|
||||
|
||||
Плата прошивается через тот же COM-порт, по которому идут данные. Пока агент
|
||||
держит порт, заливка падает с `PermissionError`.
|
||||
|
||||
```powershell
|
||||
Stop-ScheduledTask -TaskName 'CPU Temp Agent'
|
||||
```
|
||||
|
||||
После заливки задание можно запустить снова: `Start-ScheduledTask -TaskName 'CPU Temp Agent'`.
|
||||
|
||||
## Замечания по коду
|
||||
|
||||
- Файл `cpu_temp_agent.ps1` сохранён в **UTF-8 с BOM**. Без BOM PowerShell 5.1
|
||||
читает его как ANSI, кириллица в комментариях превращается в мусор, и скрипт
|
||||
перестаёт разбираться с невнятными ошибками про скобки.
|
||||
- Номер COM-порта не задан жёстко: плата ищется по `VID_303A&PID_1001`, потому
|
||||
что номер меняется при перетыкании в другой разъём.
|
||||
- Числа форматируются через `InvariantCulture`: в русской локали иначе вышло бы
|
||||
`T=67,0`, и `atof` на плате прочитал бы `67`, потеряв дробную часть.
|
||||
@@ -0,0 +1,132 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Читает температуру и загрузку CPU и раз в секунду отдаёт их плате ESP32-C3
|
||||
по USB-COM в виде строки "T=67.0;L=34".
|
||||
|
||||
.NOTES
|
||||
Требует прав администратора: без них драйвер LibreHardwareMonitor не
|
||||
загружается, и температуры приходят пустыми (загрузка при этом читается —
|
||||
отсюда ложное ощущение, что всё работает).
|
||||
#>
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Печатать каждую отправленную строку в консоль (для отладки).
|
||||
[switch]$Echo
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$LhmPath = 'D:\Soft\LibreHardwareMonitor\LibreHardwareMonitorLib.dll'
|
||||
|
||||
if (-not (Test-Path $LhmPath)) {
|
||||
throw "Не найден $LhmPath. Скачайте LibreHardwareMonitor (архив LibreHardwareMonitor.zip, не .NET.10) и распакуйте в D:\Soft\LibreHardwareMonitor"
|
||||
}
|
||||
|
||||
Add-Type -Path $LhmPath
|
||||
|
||||
$computer = New-Object LibreHardwareMonitor.Hardware.Computer
|
||||
$computer.IsCpuEnabled = $true
|
||||
$computer.Open()
|
||||
|
||||
function Get-CpuStats {
|
||||
foreach ($hw in $computer.Hardware) { $hw.Update() }
|
||||
|
||||
$temp = $null
|
||||
$load = $null
|
||||
foreach ($hw in $computer.Hardware) {
|
||||
foreach ($s in $hw.Sensors) {
|
||||
if ($s.SensorType -eq 'Temperature' -and $s.Name -eq 'CPU Package') { $temp = $s.Value }
|
||||
if ($s.SensorType -eq 'Load' -and $s.Name -eq 'CPU Total') { $load = $s.Value }
|
||||
}
|
||||
}
|
||||
|
||||
# Возвращаем обе величины даже неполными: вызывающий код должен уметь
|
||||
# сказать, какая именно отсутствует, а не валить всё в одно "нет данных".
|
||||
return @{
|
||||
Temp = $temp
|
||||
Load = $load
|
||||
}
|
||||
}
|
||||
|
||||
# Температура читается только через драйвер ядра. У LibreHardwareMonitor 0.9.5+
|
||||
# это PawnIO, который ставится отдельным установщиком и в архив с программой не
|
||||
# входит. Без него загрузка читается (она из счётчиков Windows), а температура —
|
||||
# нет. Права администратора этого не заменяют.
|
||||
function Assert-SensorsAvailable {
|
||||
$probe = Get-CpuStats
|
||||
if ($null -eq $probe.Temp) {
|
||||
$hasPawnIo = (Get-CimInstance Win32_SystemDriver | Where-Object { $_.Name -match 'PawnIO' }) -ne $null
|
||||
Write-Host 'Температура CPU недоступна.' -ForegroundColor Red
|
||||
if (-not $hasPawnIo) {
|
||||
Write-Host 'Причина: не установлен драйвер PawnIO (https://pawnio.eu).' -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host 'Драйвер PawnIO найден, но значение пустое — запущено без прав администратора?' -ForegroundColor Red
|
||||
}
|
||||
throw 'Нет источника температуры'
|
||||
}
|
||||
if ($null -eq $probe.Load) { throw 'Недоступна загрузка CPU (сенсор "CPU Total" не найден)' }
|
||||
}
|
||||
|
||||
# Форматирование только через InvariantCulture: в русской локали вышло бы
|
||||
# "T=67,0", и atof на плате прочитал бы 67, молча потеряв дробную часть.
|
||||
function Format-StatsLine($stats) {
|
||||
return [string]::Format([cultureinfo]::InvariantCulture, 'T={0:F1};L={1}', [double]$stats.Temp, [int][math]::Round([double]$stats.Load))
|
||||
}
|
||||
|
||||
# Номер порта не хардкодим: он меняется при перетыкании в другой разъём.
|
||||
function Find-BoardPort {
|
||||
Get-CimInstance Win32_PnPEntity |
|
||||
Where-Object { $_.DeviceID -like 'USB\VID_303A&PID_1001*' -and $_.Name -match '\(COM\d+\)' } |
|
||||
ForEach-Object { if ($_.Name -match '\((COM\d+)\)') { $Matches[1] } } |
|
||||
Select-Object -First 1
|
||||
}
|
||||
|
||||
function Open-BoardPort {
|
||||
$name = Find-BoardPort
|
||||
if (-not $name) { return $null }
|
||||
|
||||
$sp = New-Object System.IO.Ports.SerialPort $name, 115200, 'None', 8, 'One'
|
||||
# RTS явно выключен: одновременное дёрганье DTR и RTS переводит плату в загрузчик.
|
||||
$sp.DtrEnable = $true
|
||||
$sp.RtsEnable = $false
|
||||
$sp.WriteTimeout = 1000
|
||||
$sp.NewLine = "`n"
|
||||
$sp.Open()
|
||||
Write-Host "connected: $name"
|
||||
return $sp
|
||||
}
|
||||
|
||||
# Падаем сразу и с внятной причиной, а не крутим бесконечный цикл молча.
|
||||
Assert-SensorsAvailable
|
||||
|
||||
$port = $null
|
||||
while ($true) {
|
||||
try {
|
||||
if ($null -eq $port -or -not $port.IsOpen) {
|
||||
if ($null -ne $port) { $port.Dispose(); $port = $null }
|
||||
$port = Open-BoardPort
|
||||
if ($null -eq $port) { Start-Sleep -Seconds 2; continue }
|
||||
}
|
||||
|
||||
$stats = Get-CpuStats
|
||||
if ($null -ne $stats.Temp -and $null -ne $stats.Load) {
|
||||
$line = Format-StatsLine $stats
|
||||
$port.WriteLine($line)
|
||||
if ($Echo) { Write-Host $line }
|
||||
}
|
||||
elseif ($Echo) {
|
||||
Write-Host "пропуск: Temp=$($stats.Temp) Load=$($stats.Load)"
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
catch {
|
||||
# Порт исчез: перезагрузка платы, перезаливка прошивки, выдёргивание кабеля.
|
||||
Write-Host "port lost: $($_.Exception.Message)"
|
||||
if ($null -ne $port) { try { $port.Dispose() } catch {} ; $port = $null }
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
# CPU Temp — настольный индикатор температуры процессора
|
||||
|
||||
Дата: 2026-09-14
|
||||
Статус: дизайн согласован, реализация не начата
|
||||
|
||||
## Задача
|
||||
|
||||
Показывать температуру CPU домашнего ПК на отдельной плате ESP32-C3 с
|
||||
OLED-экраном 72×40, стоящей на столе. Кроме текущего значения — история за
|
||||
последнюю минуту, чтобы было видно, греется машина под нагрузкой или нет.
|
||||
|
||||
## Оборудование
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Плата | ESP32-C3 (QFN32), ревизия v0.4, встроенный OLED 0.42″ |
|
||||
| Флеш | 4 МБ (XMC), кристалл 40 МГц |
|
||||
| Подключение | нативный USB Espressif, `VID_303A&PID_1001`, на текущем ПК — COM4 |
|
||||
| FQBN | `esp32:esp32:esp32c3` (ядро esp32 3.3.8) |
|
||||
| ПК | Windows 10 Pro, Intel Core i5-11400H |
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
LibreHardwareMonitorLib.dll ──> PowerShell-агент ──COM4 (USB CDC)──> ESP32-C3 ──I2C──> OLED 72×40
|
||||
(драйвер, MSR) раз в секунду текстовая строка U8g2
|
||||
```
|
||||
|
||||
Плата ничего не измеряет сама и не хранит настроек: принимает строки и рисует.
|
||||
Вся работа с железом ПК — на стороне агента.
|
||||
|
||||
### Почему так
|
||||
|
||||
- **Данные с ПК, а не встроенный датчик ESP32.** Встроенный сенсор меряет
|
||||
собственный кристалл платы — величина малоинформативная.
|
||||
- **Температуру CPU читает LibreHardwareMonitor, а не наш код.** Прямое чтение
|
||||
требует MSR-регистров через подписанный драйвер ядра и отдельных формул под
|
||||
каждое поколение CPU. LHM даёт готовую `LibreHardwareMonitorLib.dll`.
|
||||
- **Транспорт — COM-порт, не Wi-Fi.** Плата и так питается по USB от этого же
|
||||
ПК: провод уже есть. Нет настройки сети, паролей, IP и mDNS.
|
||||
- **Агент на PowerShell, не на C#.** Загрузка .NET-сборки доступна из
|
||||
PowerShell напрямую; компилятор и IDE не нужны.
|
||||
|
||||
## Протокол
|
||||
|
||||
Одна строка в секунду, `\n` в конце:
|
||||
|
||||
```
|
||||
T=67.0;L=34
|
||||
```
|
||||
|
||||
| Ключ | Смысл | Формат |
|
||||
|---|---|---|
|
||||
| `T` | CPU Package, °C | дробное |
|
||||
| `L` | общая загрузка CPU, % | целое |
|
||||
|
||||
Формат «ключ=значение» выбран ради расширяемости: добавление `G=` (GPU) или
|
||||
`M=` (RAM) не ломает старую прошивку — неизвестные ключи прошивка молча
|
||||
игнорирует. Скорость 115200.
|
||||
|
||||
**Отсутствие данных.** Если валидная строка не приходила дольше 5 секунд,
|
||||
прошивка показывает `NO DATA` и перестаёт дописывать историю (чтобы пауза не
|
||||
рисовалась как провал температуры).
|
||||
|
||||
## Экран
|
||||
|
||||
Разрешение 72×40, монохром, драйвер SSD1306 с ненулевым смещением по обеим осям.
|
||||
Используется `U8G2_SSD1306_72X40_ER_F_HW_I2C` из U8g2 2.36.19 (уже установлена).
|
||||
|
||||
```
|
||||
┌────────────────────────┐
|
||||
│ 67° 34% │ крупные цифры (~24 px) + мелкая загрузка
|
||||
│ │
|
||||
│ ▁▂▃▃▄▆█▆▅▄▃▂▂▁▁▂▃▄▅▆ │ спарклайн: 72 точки = 72 секунды
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
**Шкала графика фиксированная: 30–90 °C.** Автомасштаб отвергнут: в простое
|
||||
шум ±1 °C рисовался бы горами и создавал ложную тревогу. Значения вне диапазона
|
||||
обрезаются по краю.
|
||||
|
||||
История — кольцевой буфер на 72 значения (по пикселю на точку, одно значение в
|
||||
секунду).
|
||||
|
||||
## Состав проекта
|
||||
|
||||
```
|
||||
CPU_temp/
|
||||
CPU_temp.ino setup/loop, тайм-аут «нет данных»
|
||||
link.h / link.cpp чтение строки из Serial, разбор в структуру CpuStats
|
||||
ui.h / ui.cpp отрисовка U8g2, кольцевой буфер истории
|
||||
docs/ этот документ
|
||||
agent/
|
||||
cpu_temp_agent.ps1 агент для ПК
|
||||
README.md установка и автозапуск
|
||||
```
|
||||
|
||||
Границы модулей: `link` ничего не знает об экране и отдаёт только структуру с
|
||||
данными и признаком свежести; `ui` не знает, откуда данные взялись, и принимает
|
||||
ту же структуру. Оба тестируются и меняются независимо.
|
||||
|
||||
## Агент
|
||||
|
||||
Файл `agent/cpu_temp_agent.ps1`:
|
||||
|
||||
1. загружает `LibreHardwareMonitorLib.dll` из `D:\Soft\LibreHardwareMonitor`;
|
||||
2. включает мониторинг CPU, ищет сенсоры `Temperature/CPU Package` и
|
||||
`Load/CPU Total`;
|
||||
3. находит COM-порт по `VID_303A&PID_1001` (не по фиксированному номеру: номер
|
||||
меняется при перетыкании в другой разъём);
|
||||
4. раз в секунду пишет строку протокола;
|
||||
5. при исчезновении порта (перезагрузка платы, перезаливка прошивки) —
|
||||
переподключается в цикле, не падая.
|
||||
|
||||
**Требует прав администратора**: без них драйвер LHM не загружается и
|
||||
температура приходит пустой.
|
||||
|
||||
**Автозапуск** — задание в Планировщике заданий: триггер «при входе в систему»,
|
||||
галка «Выполнить с наивысшими правами», окно скрыто.
|
||||
|
||||
## Установка LibreHardwareMonitor
|
||||
|
||||
Каталог `D:\Soft\LibreHardwareMonitor` — вне репозитория Arduino. Само
|
||||
приложение не запускается, нужны только `LibreHardwareMonitorLib.dll` и её
|
||||
драйвер.
|
||||
|
||||
## Порядок работ и риски
|
||||
|
||||
1. **Определить пины I2C дисплея эмпирически.** Шелкография подписывает
|
||||
`GPIO8/GPIO9` (дефолтные Wire-пины ESP32-C3), но встроенный OLED у плат этого
|
||||
семейства обычно на `GPIO5/GPIO6`. Первый шаг — залить I2C-сканер и найти
|
||||
адрес экрана перебором обеих пар. Пины не угадывать.
|
||||
2. Прошивка: вывод фиксированной заглушки на экран — убедиться в геометрии и
|
||||
смещении 72×40.
|
||||
3. Приём и разбор строк, `NO DATA` по тайм-ауту.
|
||||
4. График.
|
||||
5. Агент, затем автозапуск.
|
||||
|
||||
Прочие риски:
|
||||
|
||||
- **COM4 — тот же порт, через который прошивается плата.** Перед каждой
|
||||
заливкой агента надо останавливать, иначе `PermissionError`. То же ограничение
|
||||
уже описано в `CLAUDE.md` для монитора порта Arduino IDE.
|
||||
- **`USB CDC On Boot` должен быть включён** в настройках платы, иначе `Serial`
|
||||
уйдёт в аппаратный UART, а не в USB.
|
||||
|
||||
## Решения, принятые явно
|
||||
|
||||
- Git: проект пока **не** добавляется в белый список `.gitignore`. Заливаем,
|
||||
когда появятся первые рабочие результаты, чтобы не засорять публичную историю.
|
||||
- Автозапуск агента: через Планировщик заданий (вариант B), а не ручной ярлык.
|
||||
- Каталог LHM: `D:\Soft\LibreHardwareMonitor`.
|
||||
|
||||
## Вне рамок (YAGNI)
|
||||
|
||||
- GPU, RAM, частоты, вентиляторы — протокол расширяем, но сейчас не делаем.
|
||||
- Переключение экранов кнопкой BOOT.
|
||||
- Wi-Fi, веб-панель, сохранение настроек в LittleFS — прошивке нечего хранить.
|
||||
|
||||
## Подтверждённые пины (Задача 1, 2026-09-14)
|
||||
|
||||
Сканер `tools/i2c_scan` перебрал пары (5,6), (6,5), (8,9), (9,8). Устройство
|
||||
отвечает только на одной:
|
||||
|
||||
```
|
||||
SDA=5 SCL=6: device 0x3C
|
||||
```
|
||||
|
||||
Итого: **SDA = GPIO5, SCL = GPIO6, адрес 0x3C**. Подпись `GPIO8/GPIO9` на
|
||||
шелкографии относится к дефолтным Wire-пинам ESP32-C3, а не к разводке
|
||||
встроенного экрана.
|
||||
@@ -0,0 +1,755 @@
|
||||
# CPU Temp Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Плата ESP32-C3 с OLED 72×40 показывает температуру CPU домашнего ПК крупными цифрами и график за последнюю минуту, получая данные по USB-COM от агента на ПК.
|
||||
|
||||
**Architecture:** PowerShell-агент на ПК читает сенсоры через `LibreHardwareMonitorLib.dll` и раз в секунду пишет в COM-порт строку `T=67.0;L=34`. Прошивка разбирает строку и рисует экран через U8g2. Плата ничего не измеряет и ничего не хранит.
|
||||
|
||||
**Tech Stack:** Arduino IDE + arduino-cli (внутри IDE), ядро `esp32:esp32 3.3.8`, библиотека U8g2 2.36.19, Windows PowerShell 5.1, LibreHardwareMonitor (сборка net472).
|
||||
|
||||
Спека: [2026-09-14-cpu-temp-design.md](2026-09-14-cpu-temp-design.md)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **FQBN со всеми опциями:** `esp32:esp32:esp32c3:CDCOnBoot=cdc,FlashSize=4M,PartitionScheme=default`.
|
||||
`CDCOnBoot=cdc` обязателен — по умолчанию в ядре стоит `Disabled`, и тогда `Serial` уйдёт в аппаратный UART, а не в USB, и данные до платы не дойдут.
|
||||
- **`FlashSize=4M` соответствует физическому чипу** (4 МБ XMC, подтверждено сводкой платы). Не менять.
|
||||
- **arduino-cli только из состава IDE:** `C:/Program Files/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe`. Проверка сборки — только через `arduino-cli compile`, не через `g++ -fsyntax-only`.
|
||||
- **Порт платы:** `COM4` на текущем ПК (`USB\VID_303A&PID_1001`). В коде агента номер не хардкодить — искать по VID/PID.
|
||||
- **Скорость Serial: 115200** и в прошивке, и в агенте.
|
||||
- **Один и тот же COM4 используется и для заливки, и для данных.** Перед каждой заливкой останавливать агента и закрывать монитор порта, иначе `PermissionError`.
|
||||
- **Числа в протоколе — только с точкой как разделителем** (`67.0`). Локаль системы русская, поэтому агент обязан форматировать через `InvariantCulture`, иначе уйдёт `67,0` и `atof` на плате прочитает `67`.
|
||||
- **Git: коммитов в этих задачах нет.** Проект намеренно вне белого списка `.gitignore` до первых рабочих результатов; добавление в репозиторий — отдельная Задача 8 по явному слову пользователя.
|
||||
- Каталог LibreHardwareMonitor: `D:\Soft\LibreHardwareMonitor` (вне репозитория Arduino).
|
||||
|
||||
## Структура файлов
|
||||
|
||||
```
|
||||
CPU_temp/
|
||||
CPU_temp.ino setup/loop, тайм-аут свежести данных
|
||||
link.h / link.cpp неблокирующее чтение строки из Serial и разбор в CpuStats
|
||||
ui.h / ui.cpp U8g2, кольцевой буфер истории, вся отрисовка
|
||||
tools/i2c_scan/
|
||||
i2c_scan.ino одноразовый скетч для поиска пинов дисплея
|
||||
agent/
|
||||
cpu_temp_agent.ps1 агент для ПК
|
||||
README.md установка и автозапуск
|
||||
docs/ спека и этот план
|
||||
```
|
||||
|
||||
Границы: `link` не знает об экране, `ui` не знает об источнике данных. Обмен — через структуру `CpuStats`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Определить пины I2C дисплея
|
||||
|
||||
Шелкография подписывает `GPIO8/GPIO9` — это дефолтные Wire-пины ESP32-C3, но встроенный OLED у плат этого семейства обычно разведён на `GPIO5/GPIO6`. Пины не угадываем, а находим перебором.
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/i2c_scan/i2c_scan.ino`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: ничего
|
||||
- Produces: подтверждённая пара `(SDA, SCL)` и адрес дисплея — записываются в `ui.cpp` в Задаче 2
|
||||
|
||||
- [ ] **Step 1: Написать скетч-сканер**
|
||||
|
||||
Сканирование повторяется в `loop` каждые 5 секунд: при нативном USB порт переоткрывается после заливки, и однократный вывод из `setup` можно не успеть поймать.
|
||||
|
||||
```cpp
|
||||
#include <Wire.h>
|
||||
|
||||
struct PinPair { uint8_t sda; uint8_t scl; };
|
||||
|
||||
static const PinPair CANDIDATES[] = { {5, 6}, {6, 5}, {8, 9}, {9, 8} };
|
||||
|
||||
static void scanPair(uint8_t sda, uint8_t scl) {
|
||||
Wire.end();
|
||||
if (!Wire.begin(sda, scl)) {
|
||||
Serial.printf("SDA=%u SCL=%u: init failed\n", sda, scl);
|
||||
return;
|
||||
}
|
||||
uint8_t found = 0;
|
||||
for (uint8_t addr = 1; addr < 127; addr++) {
|
||||
Wire.beginTransmission(addr);
|
||||
if (Wire.endTransmission() == 0) {
|
||||
Serial.printf("SDA=%u SCL=%u: device 0x%02X\n", sda, scl, addr);
|
||||
found++;
|
||||
}
|
||||
}
|
||||
if (found == 0) {
|
||||
Serial.printf("SDA=%u SCL=%u: nothing\n", sda, scl);
|
||||
}
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
Serial.println("--- I2C scan ---");
|
||||
for (const PinPair &p : CANDIDATES) {
|
||||
scanPair(p.sda, p.scl);
|
||||
}
|
||||
delay(5000);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Собрать**
|
||||
|
||||
```bash
|
||||
"C:/Program Files/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe" compile -b esp32:esp32:esp32c3:CDCOnBoot=cdc,FlashSize=4M,PartitionScheme=default "D:/Soft/Arduino/CPU_temp/tools/i2c_scan"
|
||||
```
|
||||
|
||||
Ожидается: `Sketch uses ... bytes`, ошибок нет.
|
||||
|
||||
- [ ] **Step 3: Залить**
|
||||
|
||||
```bash
|
||||
"C:/Program Files/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe" upload -p COM4 -b esp32:esp32:esp32c3:CDCOnBoot=cdc,FlashSize=4M,PartitionScheme=default "D:/Soft/Arduino/CPU_temp/tools/i2c_scan"
|
||||
```
|
||||
|
||||
Если падает с `PermissionError` — порт держит монитор Arduino IDE (процесс `serial-monitor`) или прошлый сеанс чтения порта; закрыть и повторить.
|
||||
|
||||
- [ ] **Step 4: Прочитать вывод**
|
||||
|
||||
```powershell
|
||||
$sp = New-Object System.IO.Ports.SerialPort 'COM4',115200,'None',8,'One'
|
||||
$sp.DtrEnable = $true; $sp.ReadTimeout = 2000
|
||||
$sp.Open()
|
||||
$deadline = (Get-Date).AddSeconds(15)
|
||||
while ((Get-Date) -lt $deadline) { try { $sp.ReadLine() } catch {} }
|
||||
$sp.Close()
|
||||
```
|
||||
|
||||
Ожидается строка вида `SDA=5 SCL=6: device 0x3C` ровно для одной пары (0x3C — типовой адрес SSD1306; 0x3D тоже допустим).
|
||||
|
||||
- [ ] **Step 5: Зафиксировать результат**
|
||||
|
||||
Дописать найденную пару и адрес в конец файла `docs/2026-09-14-cpu-temp-design.md` разделом «Подтверждённые пины». Если ни одна пара не дала устройства — остановиться и доложить пользователю, дальше идти нельзя.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Экран с заглушкой
|
||||
|
||||
**Files:**
|
||||
- Create: `ui.h`, `ui.cpp`, `CPU_temp.ino`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: пара пинов из Задачи 1
|
||||
- Produces:
|
||||
- `void uiBegin()`
|
||||
- `void uiRender(bool fresh, float tempC, int loadPct)`
|
||||
- (`uiPushSample` добавляется в Задаче 3)
|
||||
|
||||
- [ ] **Step 1: Написать `ui.h`**
|
||||
|
||||
```cpp
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// Инициализирует дисплей и показывает заставку ожидания.
|
||||
void uiBegin();
|
||||
|
||||
// Перерисовывает экран целиком.
|
||||
// fresh == false означает, что данные устарели: вместо цифр выводится NO DATA.
|
||||
void uiRender(bool fresh, float tempC, int loadPct);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Написать `ui.cpp`**
|
||||
|
||||
Пины `PIN_SDA`/`PIN_SCL` подставить из Задачи 1. Конструктор `U8G2_SSD1306_72X40_ER_F_HW_I2C` принимает параметры в порядке `(rotation, reset, clock, data)` — то есть SCL идёт раньше SDA, это частый источник ошибок.
|
||||
|
||||
```cpp
|
||||
#include "ui.h"
|
||||
|
||||
#include <U8g2lib.h>
|
||||
|
||||
static const uint8_t PIN_SDA = 5; // подтверждено Задачей 1
|
||||
static const uint8_t PIN_SCL = 6; // подтверждено Задачей 1
|
||||
|
||||
static U8G2_SSD1306_72X40_ER_F_HW_I2C display(U8G2_R0, U8X8_PIN_NONE, PIN_SCL, PIN_SDA);
|
||||
|
||||
void uiBegin() {
|
||||
display.begin();
|
||||
display.clearBuffer();
|
||||
display.setFont(u8g2_font_6x10_tf);
|
||||
display.drawStr(0, 24, "WAIT");
|
||||
display.sendBuffer();
|
||||
}
|
||||
|
||||
void uiRender(bool fresh, float tempC, int loadPct) {
|
||||
display.clearBuffer();
|
||||
|
||||
if (!fresh) {
|
||||
display.setFont(u8g2_font_6x10_tf);
|
||||
display.drawStr(0, 14, "NO");
|
||||
display.drawStr(0, 26, "DATA");
|
||||
display.sendBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
char buf[8];
|
||||
|
||||
// Крупные цифры температуры.
|
||||
snprintf(buf, sizeof(buf), "%d", (int)(tempC + 0.5f));
|
||||
display.setFont(u8g2_font_logisoso24_tn);
|
||||
display.drawStr(0, 24, buf);
|
||||
const int numWidth = display.getStrWidth(buf);
|
||||
|
||||
// Градус и загрузка — мелким шрифтом справа.
|
||||
display.setFont(u8g2_font_5x7_tf);
|
||||
display.drawStr(numWidth + 2, 10, "\xb0");
|
||||
snprintf(buf, sizeof(buf), "%d%%", loadPct);
|
||||
display.drawStr(numWidth + 2, 24, buf);
|
||||
|
||||
display.sendBuffer();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Написать `CPU_temp.ino` с фиксированной заглушкой**
|
||||
|
||||
```cpp
|
||||
#include "ui.h"
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
uiBegin();
|
||||
delay(1000);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
uiRender(true, 67.0f, 34);
|
||||
delay(1000);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Собрать и залить**
|
||||
|
||||
```bash
|
||||
"C:/Program Files/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe" compile -b esp32:esp32:esp32c3:CDCOnBoot=cdc,FlashSize=4M,PartitionScheme=default "D:/Soft/Arduino/CPU_temp"
|
||||
```
|
||||
|
||||
Ожидается успешная сборка, затем `upload -p COM4` с тем же FQBN.
|
||||
|
||||
- [ ] **Step 5: Проверить глазами и поправить вёрстку**
|
||||
|
||||
На экране должно быть `67°` крупно и `34%` мелко, ничего не обрезано по правому краю и не налезает друг на друга. Ширина всего 72 px, поэтому при обрезании — уменьшить крупный шрифт до `u8g2_font_logisoso22_tn` или перенести `34%` на строку графика. Спросить у пользователя, как выглядит, и не считать задачу выполненной без его подтверждения.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: График истории
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui.h`, `ui.cpp`
|
||||
- Modify: `CPU_temp.ino`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `uiRender` из Задачи 2
|
||||
- Produces: `void uiPushSample(float tempC)` — вызывается ровно раз в секунду
|
||||
|
||||
- [ ] **Step 1: Добавить объявление в `ui.h`**
|
||||
|
||||
Дописать под `uiBegin()`:
|
||||
|
||||
```cpp
|
||||
// Добавляет значение в кольцевой буфер истории. Вызывать раз в секунду
|
||||
// и только при свежих данных, чтобы пауза связи не рисовалась как провал.
|
||||
void uiPushSample(float tempC);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Реализовать буфер и отрисовку графика в `ui.cpp`**
|
||||
|
||||
Шкала фиксированная 30–90 °C — автомасштаб отвергнут в спеке: в простое шум ±1 °C рисовался бы горами.
|
||||
|
||||
Добавить после объявления `display`:
|
||||
|
||||
```cpp
|
||||
static const uint8_t GRAPH_W = 72; // по пикселю на отсчёт
|
||||
static const uint8_t GRAPH_TOP = 28;
|
||||
static const uint8_t GRAPH_H = 12;
|
||||
static const float SCALE_MIN = 30.0f;
|
||||
static const float SCALE_MAX = 90.0f;
|
||||
|
||||
static uint8_t history[GRAPH_W]; // 0 = нет данных, иначе высота столбика 1..GRAPH_H
|
||||
static uint8_t historyCount = 0;
|
||||
|
||||
void uiPushSample(float tempC) {
|
||||
if (tempC < SCALE_MIN) tempC = SCALE_MIN;
|
||||
if (tempC > SCALE_MAX) tempC = SCALE_MAX;
|
||||
const float norm = (tempC - SCALE_MIN) / (SCALE_MAX - SCALE_MIN);
|
||||
uint8_t h = (uint8_t)(norm * GRAPH_H + 0.5f);
|
||||
if (h < 1) h = 1;
|
||||
|
||||
memmove(history, history + 1, GRAPH_W - 1);
|
||||
history[GRAPH_W - 1] = h;
|
||||
if (historyCount < GRAPH_W) historyCount++;
|
||||
}
|
||||
|
||||
static void drawGraph() {
|
||||
for (uint8_t x = 0; x < GRAPH_W; x++) {
|
||||
const uint8_t h = history[x];
|
||||
if (h == 0) continue;
|
||||
display.drawVLine(x, GRAPH_TOP + GRAPH_H - h, h);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Вызвать `drawGraph()` в `uiRender` перед `display.sendBuffer()` в ветке свежих данных. Добавить `#include <string.h>` ради `memmove`.
|
||||
|
||||
- [ ] **Step 3: Подать в заглушку меняющееся значение**
|
||||
|
||||
Заменить `loop` в `CPU_temp.ino`, чтобы график было видно в движении:
|
||||
|
||||
```cpp
|
||||
void loop() {
|
||||
static float t = 40.0f;
|
||||
static float step = 2.0f;
|
||||
t += step;
|
||||
if (t > 85.0f || t < 35.0f) step = -step;
|
||||
|
||||
uiPushSample(t);
|
||||
uiRender(true, t, 34);
|
||||
delay(1000);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Собрать, залить, посмотреть**
|
||||
|
||||
Ожидается: пила, ползущая справа налево, столбики не вылезают за нижний край и не перекрывают цифры. Подтвердить у пользователя.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Приём данных по COM
|
||||
|
||||
**Files:**
|
||||
- Create: `link.h`, `link.cpp`
|
||||
- Modify: `CPU_temp.ino`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `uiPushSample`, `uiRender`
|
||||
- Produces:
|
||||
- `struct CpuStats { float tempC; int loadPct; }`
|
||||
- `void linkBegin()`
|
||||
- `bool linkPoll(CpuStats &out)` — `true` ровно в тот момент, когда разобрана целая валидная строка
|
||||
|
||||
- [ ] **Step 1: Написать `link.h`**
|
||||
|
||||
```cpp
|
||||
#pragma once
|
||||
|
||||
struct CpuStats {
|
||||
float tempC;
|
||||
int loadPct;
|
||||
};
|
||||
|
||||
void linkBegin();
|
||||
|
||||
// Неблокирующее чтение. Возвращает true, когда собрана и разобрана
|
||||
// целая строка вида "T=67.0;L=34". Неизвестные ключи игнорируются.
|
||||
bool linkPoll(CpuStats &out);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Написать `link.cpp`**
|
||||
|
||||
```cpp
|
||||
#include "link.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
static const size_t BUF_SIZE = 64;
|
||||
static char buf[BUF_SIZE];
|
||||
static size_t len = 0;
|
||||
|
||||
void linkBegin() {
|
||||
Serial.begin(115200);
|
||||
len = 0;
|
||||
}
|
||||
|
||||
static bool parseLine(char *line, CpuStats &out) {
|
||||
bool haveTemp = false;
|
||||
bool haveLoad = false;
|
||||
CpuStats parsed = {0.0f, 0};
|
||||
|
||||
char *save = nullptr;
|
||||
for (char *tok = strtok_r(line, ";", &save); tok; tok = strtok_r(nullptr, ";", &save)) {
|
||||
char *eq = strchr(tok, '=');
|
||||
if (!eq) continue;
|
||||
*eq = '\0';
|
||||
const char *key = tok;
|
||||
const char *val = eq + 1;
|
||||
|
||||
if (strcmp(key, "T") == 0) {
|
||||
parsed.tempC = atof(val);
|
||||
haveTemp = true;
|
||||
} else if (strcmp(key, "L") == 0) {
|
||||
parsed.loadPct = atoi(val);
|
||||
haveLoad = true;
|
||||
}
|
||||
// прочие ключи игнорируются намеренно: протокол расширяемый
|
||||
}
|
||||
|
||||
if (!haveTemp || !haveLoad) return false;
|
||||
out = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool linkPoll(CpuStats &out) {
|
||||
bool got = false;
|
||||
while (Serial.available()) {
|
||||
const char c = (char)Serial.read();
|
||||
if (c == '\r') continue;
|
||||
if (c == '\n') {
|
||||
buf[len] = '\0';
|
||||
if (len > 0 && parseLine(buf, out)) got = true;
|
||||
len = 0;
|
||||
continue;
|
||||
}
|
||||
if (len < BUF_SIZE - 1) {
|
||||
buf[len++] = c;
|
||||
} else {
|
||||
len = 0; // строка длиннее буфера — мусор, начинаем заново
|
||||
}
|
||||
}
|
||||
return got;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Собрать всё вместе в `CPU_temp.ino`**
|
||||
|
||||
Отсчёт для графика добавляется раз в секунду и только при свежих данных.
|
||||
|
||||
```cpp
|
||||
#include "link.h"
|
||||
#include "ui.h"
|
||||
|
||||
static const uint32_t STALE_MS = 5000; // тайм-аут свежести из спеки
|
||||
static const uint32_t SAMPLE_MS = 1000; // шаг истории
|
||||
|
||||
static CpuStats stats = {0.0f, 0};
|
||||
static uint32_t lastData = 0;
|
||||
static uint32_t lastSample = 0;
|
||||
static bool everGot = false;
|
||||
|
||||
void setup() {
|
||||
linkBegin();
|
||||
uiBegin();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
CpuStats fresh;
|
||||
if (linkPoll(fresh)) {
|
||||
stats = fresh;
|
||||
lastData = millis();
|
||||
everGot = true;
|
||||
}
|
||||
|
||||
const bool isFresh = everGot && (millis() - lastData < STALE_MS);
|
||||
|
||||
if (isFresh && millis() - lastSample >= SAMPLE_MS) {
|
||||
lastSample = millis();
|
||||
uiPushSample(stats.tempC);
|
||||
}
|
||||
|
||||
static uint32_t lastDraw = 0;
|
||||
if (millis() - lastDraw >= 200) {
|
||||
lastDraw = millis();
|
||||
uiRender(isFresh, stats.tempC, stats.loadPct);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Собрать и залить**
|
||||
|
||||
```bash
|
||||
"C:/Program Files/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe" compile -b esp32:esp32:esp32c3:CDCOnBoot=cdc,FlashSize=4M,PartitionScheme=default "D:/Soft/Arduino/CPU_temp"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Проверить приём вручную**
|
||||
|
||||
Сразу после заливки на экране должно быть `NO DATA`. Затем отправить строки руками:
|
||||
|
||||
```powershell
|
||||
$sp = New-Object System.IO.Ports.SerialPort 'COM4',115200,'None',8,'One'
|
||||
$sp.DtrEnable = $true; $sp.RtsEnable = $false
|
||||
$sp.Open()
|
||||
40..80 | ForEach-Object { $sp.WriteLine("T=$_.0;L=50"); Start-Sleep -Milliseconds 500 }
|
||||
$sp.Close()
|
||||
```
|
||||
|
||||
Ожидается: цифра растёт с 40 до 80, график ползёт вверх, через 5 секунд после закрытия порта экран показывает `NO DATA`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Установить LibreHardwareMonitor и прочитать сенсоры
|
||||
|
||||
**Files:**
|
||||
- Create: `agent/cpu_temp_agent.ps1` (первая версия — только печать в консоль)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: ничего
|
||||
- Produces: функция `Get-CpuStats`, возвращающая хеш-таблицу `@{ Temp = <double>; Load = <int> }`
|
||||
|
||||
- [ ] **Step 1: Скачать и распаковать LibreHardwareMonitor**
|
||||
|
||||
Нужна сборка **net472**: в системе только Windows PowerShell 5.1 (.NET Framework), сборку net8.0 он не загрузит. Взять последний релиз со страницы <https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases>, файл вида `LibreHardwareMonitor-net472.zip`, распаковать в `D:\Soft\LibreHardwareMonitor`.
|
||||
|
||||
Проверка:
|
||||
|
||||
```powershell
|
||||
Test-Path 'D:\Soft\LibreHardwareMonitor\LibreHardwareMonitorLib.dll'
|
||||
Test-Path 'D:\Soft\LibreHardwareMonitor\HidSharp.dll'
|
||||
```
|
||||
|
||||
Ожидается `True` для обоих — `HidSharp.dll` обязателен, это зависимость библиотеки, без неё `Add-Type` пройдёт, а открытие компьютера упадёт.
|
||||
|
||||
- [ ] **Step 2: Проверить чтение сенсоров одной строкой**
|
||||
|
||||
Запустить **в консоли, открытой от имени администратора** — без прав драйвер не загрузится и температура будет пустой:
|
||||
|
||||
```powershell
|
||||
Add-Type -Path 'D:\Soft\LibreHardwareMonitor\LibreHardwareMonitorLib.dll'
|
||||
$c = New-Object LibreHardwareMonitor.Hardware.Computer
|
||||
$c.IsCpuEnabled = $true
|
||||
$c.Open()
|
||||
$c.Hardware[0].Update()
|
||||
$c.Hardware[0].Sensors | Select-Object SensorType, Name, Value | Format-Table
|
||||
$c.Close()
|
||||
```
|
||||
|
||||
Ожидается таблица, в которой есть строка `Temperature CPU Package <число около 40–60>`. Если значение пустое — консоль запущена не от администратора. Если имя сенсора отличается (у некоторых CPU это `Core (Tctl/Tdie)`), записать фактическое имя и использовать его дальше.
|
||||
|
||||
- [ ] **Step 3: Написать `agent/cpu_temp_agent.ps1` с выводом в консоль**
|
||||
|
||||
```powershell
|
||||
#Requires -RunAsAdministrator
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$LhmPath = 'D:\Soft\LibreHardwareMonitor\LibreHardwareMonitorLib.dll'
|
||||
|
||||
Add-Type -Path $LhmPath
|
||||
$computer = New-Object LibreHardwareMonitor.Hardware.Computer
|
||||
$computer.IsCpuEnabled = $true
|
||||
$computer.Open()
|
||||
|
||||
function Get-CpuStats {
|
||||
foreach ($hw in $computer.Hardware) { $hw.Update() }
|
||||
|
||||
$temp = $null
|
||||
$load = $null
|
||||
foreach ($hw in $computer.Hardware) {
|
||||
foreach ($s in $hw.Sensors) {
|
||||
if ($s.SensorType -eq 'Temperature' -and $s.Name -eq 'CPU Package') { $temp = $s.Value }
|
||||
if ($s.SensorType -eq 'Load' -and $s.Name -eq 'CPU Total') { $load = $s.Value }
|
||||
}
|
||||
}
|
||||
if ($null -eq $temp -or $null -eq $load) { return $null }
|
||||
return @{ Temp = [double]$temp; Load = [int][math]::Round($load) }
|
||||
}
|
||||
|
||||
# Форматирование только через InvariantCulture: в русской локали получилось бы
|
||||
# "67,0", и atof на плате прочитал бы 67 вместо 67.0.
|
||||
function Format-StatsLine($stats) {
|
||||
return [string]::Format([cultureinfo]::InvariantCulture, 'T={0:F1};L={1}', $stats.Temp, $stats.Load)
|
||||
}
|
||||
|
||||
while ($true) {
|
||||
$s = Get-CpuStats
|
||||
if ($null -eq $s) { Write-Host 'no sensors'; } else { Write-Host (Format-StatsLine $s) }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить и проверить формат**
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File 'D:\Soft\Arduino\CPU_temp\agent\cpu_temp_agent.ps1'
|
||||
```
|
||||
|
||||
Ожидается поток строк вида `T=47.0;L=12`, раз в секунду. **Обязательно убедиться, что разделитель — точка, а не запятая.** Прервать Ctrl+C.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Передача в COM-порт с переподключением
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent/cpu_temp_agent.ps1`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `Get-CpuStats`, `Format-StatsLine` из Задачи 5
|
||||
- Produces: рабочая связка ПК → плата
|
||||
|
||||
- [ ] **Step 1: Добавить поиск порта по VID/PID**
|
||||
|
||||
Номер порта меняется при перетыкании в другой разъём, поэтому ищем устройство, а не COM4. Вставить после `Add-Type`:
|
||||
|
||||
```powershell
|
||||
function Find-BoardPort {
|
||||
Get-CimInstance Win32_PnPEntity |
|
||||
Where-Object { $_.DeviceID -like 'USB\VID_303A&PID_1001*' -and $_.Name -match '\(COM\d+\)' } |
|
||||
ForEach-Object { if ($_.Name -match '\((COM\d+)\)') { $Matches[1] } } |
|
||||
Select-Object -First 1
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Проверить поиск порта отдельно**
|
||||
|
||||
Скрипт не подключать (`.`-загрузка запустит бесконечный цикл) — выполнить тело функции в консоли напрямую:
|
||||
|
||||
```powershell
|
||||
Get-CimInstance Win32_PnPEntity |
|
||||
Where-Object { $_.DeviceID -like 'USB\VID_303A&PID_1001*' -and $_.Name -match '\(COM\d+\)' } |
|
||||
ForEach-Object { if ($_.Name -match '\((COM\d+)\)') { $Matches[1] } }
|
||||
```
|
||||
|
||||
Ожидается `COM4`. Если пусто — плата не воткнута либо перечислилась под другим VID/PID.
|
||||
|
||||
- [ ] **Step 3: Заменить цикл на отправку в порт**
|
||||
|
||||
`RtsEnable = $false` задан явно: одновременное дёрганье DTR и RTS на плате Espressif переводит её в загрузчик.
|
||||
|
||||
```powershell
|
||||
function Open-BoardPort {
|
||||
$name = Find-BoardPort
|
||||
if (-not $name) { return $null }
|
||||
$sp = New-Object System.IO.Ports.SerialPort $name, 115200, 'None', 8, 'One'
|
||||
$sp.DtrEnable = $true
|
||||
$sp.RtsEnable = $false
|
||||
$sp.WriteTimeout = 1000
|
||||
$sp.NewLine = "`n"
|
||||
$sp.Open()
|
||||
Write-Host "connected: $name"
|
||||
return $sp
|
||||
}
|
||||
|
||||
$port = $null
|
||||
while ($true) {
|
||||
try {
|
||||
if ($null -eq $port -or -not $port.IsOpen) {
|
||||
if ($null -ne $port) { $port.Dispose(); $port = $null }
|
||||
$port = Open-BoardPort
|
||||
if ($null -eq $port) { Start-Sleep -Seconds 2; continue }
|
||||
}
|
||||
|
||||
$s = Get-CpuStats
|
||||
if ($null -ne $s) { $port.WriteLine((Format-StatsLine $s)) }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
catch {
|
||||
# Порт исчез: перезагрузка платы, перезаливка прошивки, выдёргивание кабеля.
|
||||
Write-Host "port lost: $($_.Exception.Message)"
|
||||
if ($null -ne $port) { try { $port.Dispose() } catch {} ; $port = $null }
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Убрать из цикла `Write-Host` с каждой строкой — иначе в фоновом режиме это лишняя работа.
|
||||
|
||||
- [ ] **Step 4: Проверить связку целиком**
|
||||
|
||||
Запустить скрипт от администратора. Ожидается: на экране платы появляются реальные цифры температуры, график заполняется за ~72 секунды. Нагрузить CPU (например, архиватором) — цифра и график должны поехать вверх.
|
||||
|
||||
- [ ] **Step 5: Проверить переподключение**
|
||||
|
||||
Не останавливая агента, нажать на плате кнопку **RST**. Ожидается: в консоли `port lost: ...`, затем `connected: COM4` в течение нескольких секунд, и данные снова идут. Это же поведение обязано отработать после перезаливки прошивки.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Автозапуск агента
|
||||
|
||||
**Files:**
|
||||
- Create: `agent/README.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: рабочий `cpu_temp_agent.ps1`
|
||||
- Produces: задание Планировщика `CPU Temp Agent`
|
||||
|
||||
- [ ] **Step 1: Создать задание в Планировщике**
|
||||
|
||||
Выполнить в консоли администратора:
|
||||
|
||||
```powershell
|
||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
|
||||
-Argument '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "D:\Soft\Arduino\CPU_temp\agent\cpu_temp_agent.ps1"'
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
|
||||
$set = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||
Register-ScheduledTask -TaskName 'CPU Temp Agent' -Action $action -Trigger $trigger -Settings $set -RunLevel Highest -Force
|
||||
```
|
||||
|
||||
`-RunLevel Highest` — это и есть «выполнить с наивысшими правами», без него драйвер LHM не загрузится. `ExecutionTimeLimit` нулевой, иначе Планировщик убьёт агента через 3 дня.
|
||||
|
||||
- [ ] **Step 2: Проверить задание, не перезагружаясь**
|
||||
|
||||
```powershell
|
||||
Start-ScheduledTask -TaskName 'CPU Temp Agent'
|
||||
Start-Sleep -Seconds 5
|
||||
(Get-ScheduledTask -TaskName 'CPU Temp Agent').State
|
||||
```
|
||||
|
||||
Ожидается `Running`, и на экране платы — живые цифры.
|
||||
|
||||
- [ ] **Step 3: Проверить после перезагрузки**
|
||||
|
||||
Перезагрузить ПК, войти в систему, убедиться, что экран оживает сам в течение ~30 секунд.
|
||||
|
||||
- [ ] **Step 4: Написать `agent/README.md`**
|
||||
|
||||
Должен содержать: требование сборки net472 и почему; необходимость прав администратора и что будет без них; команды регистрации и удаления задания (`Unregister-ScheduledTask -TaskName 'CPU Temp Agent' -Confirm:$false`); **обязательный шаг «остановить задание перед заливкой прошивки»**, иначе `PermissionError` на COM4:
|
||||
|
||||
```powershell
|
||||
Stop-ScheduledTask -TaskName 'CPU Temp Agent'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Добавить проект в git
|
||||
|
||||
Выполнять **только по явному слову пользователя** — по его решению проект держится локально до первых рабочих результатов.
|
||||
|
||||
**Files:**
|
||||
- Modify: `D:/Soft/Arduino/.gitignore`
|
||||
- Create: `README.md` в корне проекта
|
||||
|
||||
- [ ] **Step 1: Добавить в белый список**
|
||||
|
||||
Внести `CPU_temp` в белый список `.gitignore` тем же шаблоном, что применён к соседним отслеживаемым проектам — посмотреть на их записи и повторить форму, не изобретая свою.
|
||||
|
||||
- [ ] **Step 2: Проверить, что попадает в индекс**
|
||||
|
||||
```bash
|
||||
git -C "D:/Soft/Arduino" status --short
|
||||
```
|
||||
|
||||
Ожидается: файлы `CPU_temp/`, и **ничего** из соседних проектов. В списке не должно быть `D:\Soft\LibreHardwareMonitor` (он вне репозитория) и временных файлов сборки.
|
||||
|
||||
- [ ] **Step 3: Написать `README.md` проекта**
|
||||
|
||||
Схема связки, требования к ПК, порядок установки, ссылка на `agent/README.md` и на спеку.
|
||||
|
||||
- [ ] **Step 4: Коммит**
|
||||
|
||||
Автор задан локально в репозитории как `Vitali <oskarvitalii@gmail.com>` — адрес `vitali@mlmsoft.com` для этого публичного репозитория чужой и попасть в историю не должен.
|
||||
|
||||
```bash
|
||||
git -C "D:/Soft/Arduino" add CPU_temp .gitignore
|
||||
git -C "D:/Soft/Arduino" commit -m "feat(cpu_temp): индикатор температуры CPU на ESP32-C3 с OLED"
|
||||
```
|
||||
|
||||
Перед пушем: чистое дерево, показать список уходящих коммитов, поискать секреты в диффе. Пуш — в `dev`, в фоне, повтор при `Failed to authenticate user` — это норма.
|
||||
|
||||
---
|
||||
|
||||
## Порядок и зависимости
|
||||
|
||||
```
|
||||
Task 1 (пины) ──> Task 2 (экран) ──> Task 3 (график) ──┐
|
||||
├──> Task 6 (связка) ──> Task 7 (автозапуск) ──> Task 8 (git)
|
||||
Task 4 (приём) ────────────────────────────────────────┤
|
||||
Task 5 (агент, консоль) ───────────────────────────────┘
|
||||
```
|
||||
|
||||
Задача 4 требует Задачи 3 (вызывает `uiPushSample`). Задачу 5 можно делать параллельно с прошивкой — она ни от чего не зависит.
|
||||
@@ -0,0 +1,60 @@
|
||||
#include "link.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
static const size_t BUF_SIZE = 64;
|
||||
static char buf[BUF_SIZE];
|
||||
static size_t len = 0;
|
||||
|
||||
void linkBegin() {
|
||||
Serial.begin(115200);
|
||||
len = 0;
|
||||
}
|
||||
|
||||
static bool parseLine(char *line, CpuStats &out) {
|
||||
bool haveTemp = false;
|
||||
bool haveLoad = false;
|
||||
CpuStats parsed = {0.0f, 0};
|
||||
|
||||
char *save = nullptr;
|
||||
for (char *tok = strtok_r(line, ";", &save); tok; tok = strtok_r(nullptr, ";", &save)) {
|
||||
char *eq = strchr(tok, '=');
|
||||
if (!eq) continue;
|
||||
*eq = '\0';
|
||||
const char *key = tok;
|
||||
const char *val = eq + 1;
|
||||
|
||||
if (strcmp(key, "T") == 0) {
|
||||
parsed.tempC = atof(val);
|
||||
haveTemp = true;
|
||||
} else if (strcmp(key, "L") == 0) {
|
||||
parsed.loadPct = atoi(val);
|
||||
haveLoad = true;
|
||||
}
|
||||
// прочие ключи игнорируются намеренно: протокол расширяемый
|
||||
}
|
||||
|
||||
if (!haveTemp || !haveLoad) return false;
|
||||
out = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool linkPoll(CpuStats &out) {
|
||||
bool got = false;
|
||||
while (Serial.available()) {
|
||||
const char c = (char)Serial.read();
|
||||
if (c == '\r') continue;
|
||||
if (c == '\n') {
|
||||
buf[len] = '\0';
|
||||
if (len > 0 && parseLine(buf, out)) got = true;
|
||||
len = 0;
|
||||
continue;
|
||||
}
|
||||
if (len < BUF_SIZE - 1) {
|
||||
buf[len++] = c;
|
||||
} else {
|
||||
len = 0; // строка длиннее буфера — мусор, начинаем заново
|
||||
}
|
||||
}
|
||||
return got;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
struct CpuStats {
|
||||
float tempC;
|
||||
int loadPct;
|
||||
};
|
||||
|
||||
void linkBegin();
|
||||
|
||||
// Неблокирующее чтение. Возвращает true, когда собрана и разобрана
|
||||
// целая строка вида "T=67.0;L=34". Неизвестные ключи игнорируются.
|
||||
bool linkPoll(CpuStats &out);
|
||||
@@ -0,0 +1,42 @@
|
||||
// Одноразовый скетч: ищет, на какой паре пинов висит встроенный OLED.
|
||||
// Шелкография подписывает GPIO8/GPIO9 (дефолтные Wire-пины ESP32-C3), но у плат
|
||||
// этого семейства экран обычно разведён на GPIO5/GPIO6. Проверяем перебором.
|
||||
|
||||
#include <Wire.h>
|
||||
|
||||
struct PinPair { uint8_t sda; uint8_t scl; };
|
||||
|
||||
static const PinPair CANDIDATES[] = { {5, 6}, {6, 5}, {8, 9}, {9, 8} };
|
||||
|
||||
static void scanPair(uint8_t sda, uint8_t scl) {
|
||||
Wire.end();
|
||||
if (!Wire.begin(sda, scl)) {
|
||||
Serial.printf("SDA=%u SCL=%u: init failed\n", sda, scl);
|
||||
return;
|
||||
}
|
||||
uint8_t found = 0;
|
||||
for (uint8_t addr = 1; addr < 127; addr++) {
|
||||
Wire.beginTransmission(addr);
|
||||
if (Wire.endTransmission() == 0) {
|
||||
Serial.printf("SDA=%u SCL=%u: device 0x%02X\n", sda, scl, addr);
|
||||
found++;
|
||||
}
|
||||
}
|
||||
if (found == 0) {
|
||||
Serial.printf("SDA=%u SCL=%u: nothing\n", sda, scl);
|
||||
}
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Повторяем в цикле: при нативном USB порт переоткрывается после заливки,
|
||||
// и однократный вывод из setup можно не успеть поймать.
|
||||
Serial.println("--- I2C scan ---");
|
||||
for (const PinPair &p : CANDIDATES) {
|
||||
scanPair(p.sda, p.scl);
|
||||
}
|
||||
delay(5000);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#include "ui.h"
|
||||
|
||||
#include <U8g2lib.h>
|
||||
#include <string.h>
|
||||
|
||||
// Подтверждено сканером из tools/i2c_scan: экран отвечает только на этой паре.
|
||||
static const uint8_t PIN_SDA = 5;
|
||||
static const uint8_t PIN_SCL = 6;
|
||||
|
||||
// Порядок параметров: (rotation, reset, clock, data) — SCL идёт раньше SDA.
|
||||
static U8G2_SSD1306_72X40_ER_F_HW_I2C display(U8G2_R0, U8X8_PIN_NONE, PIN_SCL, PIN_SDA);
|
||||
|
||||
static const uint8_t GRAPH_W = 72; // по пикселю на отсчёт: 72 точки = 72 секунды
|
||||
static const uint8_t GRAPH_TOP = 28;
|
||||
static const uint8_t GRAPH_H = 12;
|
||||
|
||||
// Шкала фиксированная: при автомасштабе шум +-1 градус в простое рисовался бы горами.
|
||||
static const float SCALE_MIN = 30.0f;
|
||||
static const float SCALE_MAX = 90.0f;
|
||||
|
||||
static uint8_t history[GRAPH_W]; // 0 = нет данных, иначе высота столбика 1..GRAPH_H
|
||||
|
||||
void uiBegin() {
|
||||
display.begin();
|
||||
display.clearBuffer();
|
||||
display.setFont(u8g2_font_6x10_tf);
|
||||
display.drawStr(0, 24, "WAIT");
|
||||
display.sendBuffer();
|
||||
}
|
||||
|
||||
void uiPushSample(float tempC) {
|
||||
if (tempC < SCALE_MIN) tempC = SCALE_MIN;
|
||||
if (tempC > SCALE_MAX) tempC = SCALE_MAX;
|
||||
|
||||
const float norm = (tempC - SCALE_MIN) / (SCALE_MAX - SCALE_MIN);
|
||||
uint8_t h = (uint8_t)(norm * GRAPH_H + 0.5f);
|
||||
if (h < 1) h = 1;
|
||||
|
||||
memmove(history, history + 1, GRAPH_W - 1);
|
||||
history[GRAPH_W - 1] = h;
|
||||
}
|
||||
|
||||
static void drawGraph() {
|
||||
for (uint8_t x = 0; x < GRAPH_W; x++) {
|
||||
const uint8_t h = history[x];
|
||||
if (h == 0) continue;
|
||||
display.drawVLine(x, GRAPH_TOP + GRAPH_H - h, h);
|
||||
}
|
||||
}
|
||||
|
||||
void uiRender(bool fresh, float tempC, int loadPct) {
|
||||
display.clearBuffer();
|
||||
|
||||
if (!fresh) {
|
||||
display.setFont(u8g2_font_6x10_tf);
|
||||
display.drawStr(0, 14, "NO");
|
||||
display.drawStr(0, 26, "DATA");
|
||||
display.sendBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
char buf[8];
|
||||
|
||||
// Крупные цифры температуры.
|
||||
snprintf(buf, sizeof(buf), "%d", (int)(tempC + 0.5f));
|
||||
display.setFont(u8g2_font_logisoso24_tn);
|
||||
display.drawStr(0, 24, buf);
|
||||
const int numWidth = display.getStrWidth(buf);
|
||||
|
||||
// Градус и загрузка — мелким шрифтом справа.
|
||||
display.setFont(u8g2_font_5x7_tf);
|
||||
display.drawStr(numWidth + 2, 10, "\xb0");
|
||||
snprintf(buf, sizeof(buf), "%d%%", loadPct);
|
||||
display.drawStr(numWidth + 2, 24, buf);
|
||||
|
||||
drawGraph();
|
||||
|
||||
display.sendBuffer();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// Инициализирует дисплей и показывает заставку ожидания.
|
||||
void uiBegin();
|
||||
|
||||
// Добавляет значение в кольцевой буфер истории. Вызывать раз в секунду
|
||||
// и только при свежих данных, чтобы пауза связи не рисовалась как провал.
|
||||
void uiPushSample(float tempC);
|
||||
|
||||
// Перерисовывает экран целиком.
|
||||
// fresh == false означает, что данные устарели: вместо цифр выводится NO DATA.
|
||||
void uiRender(bool fresh, float tempC, int loadPct);
|
||||
Reference in new issue
Block a user