diff --git a/.gitignore b/.gitignore index 31aac1a..9da5653 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ !/WeatherPredictor/ !/pump_controller_8_2_OLED_DONE/ +!/CPU_temp/ # --- Правила ниже действуют внутри разрешённых проектов --- # Идут после белого списка намеренно: более поздний шаблон побеждает, поэтому diff --git a/CPU_temp/CPU_temp.ino b/CPU_temp/CPU_temp.ino new file mode 100644 index 0000000..c04cff6 --- /dev/null +++ b/CPU_temp/CPU_temp.ino @@ -0,0 +1,67 @@ +// Индикатор температуры CPU: плата принимает строки "T=67.0;L=34" по USB-COM +// от агента на ПК и рисует их на встроенном OLED 72x40. Сама ничего не меряет. +// +// Все настройки — в config.h. + +#include "config.h" +#include "link.h" +#include "ui.h" + +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; +static bool alarmActive = false; + +void setup() { + pinMode(PIN_LED, OUTPUT); + digitalWrite(PIN_LED, !LED_ACTIVE_LEVEL); + + linkBegin(); + uiBegin(); +} + +// Гистерезис: включаемся на верхнем пороге, отпускаем только на нижнем. +static void updateAlarm(float tempC) { + if (!alarmActive && tempC >= ALARM_ON_C) alarmActive = true; + else if (alarmActive && tempC <= ALARM_OFF_C) alarmActive = false; +} + +void loop() { + CpuStats fresh; + if (linkPoll(fresh)) { + stats = fresh; + lastData = millis(); + everGot = true; + } + + const bool isFresh = everGot && (millis() - lastData < STALE_MS); + + if (isFresh) { + updateAlarm(stats.tempC); + } else { + alarmActive = false; // без данных о перегреве судить не по чему + } + + // Общая фаза для экрана и светодиода, чтобы они моргали синхронно. + const bool blinkOn = (millis() / ALARM_BLINK_MS) % 2 == 0; + + // Тревога — мигает, нет данных — горит ровно, норма — погашен. + bool ledOn; + if (!isFresh) ledOn = true; + else if (alarmActive) ledOn = blinkOn; + else ledOn = false; + digitalWrite(PIN_LED, ledOn ? LED_ACTIVE_LEVEL : !LED_ACTIVE_LEVEL); + + // Историю дописываем только при живой связи: пауза не должна рисоваться провалом. + if (isFresh && millis() - lastSample >= SAMPLE_MS) { + lastSample = millis(); + uiPushSample(stats.tempC); + } + + if (millis() - lastDraw >= DRAW_MS) { + lastDraw = millis(); + uiRender(isFresh, stats.tempC, stats.loadPct, alarmActive && blinkOn); + } +} diff --git a/CPU_temp/README.md b/CPU_temp/README.md new file mode 100644 index 0000000..804b9fa --- /dev/null +++ b/CPU_temp/README.md @@ -0,0 +1,344 @@ +# CPU Temp — индикатор температуры процессора на ESP32-C3 + +Небольшая плата ESP32-C3 со встроенным OLED-экраном 0.42″ стоит на столе и +показывает температуру процессора компьютера: крупная цифра, загрузка CPU рядом +и график за последнюю минуту. При перегреве экран мигает негативом. + +``` +LibreHardwareMonitorLib.dll ──> PowerShell-агент ──COM (USB CDC)──> ESP32-C3 ──I2C──> OLED 72×40 + (через драйвер PawnIO) раз в секунду "T=67.0;L=34" U8g2 +``` + +**Плата ничего не измеряет сама.** Она принимает готовые строки по USB и рисует +их. Всё чтение датчиков происходит на компьютере — иначе никак: температура CPU +доступна только через драйвер уровня ядра. + +Подключение — по USB-кабелю, которым плата и так питается. Wi-Fi не +используется: настраивать нечего, задержки нет. + +--- + +## Что понадобится + +| | Что | Примечание | +|---|---|---| +| Железо | Плата ESP32-C3 со встроенным OLED 0.42″ (72×40) | «фиолетовая» плата с разъёмом Type-C | +| | Кабель USB Type-C | он же питание, он же канал данных | +| ПО на ПК | Windows | агент написан под Windows PowerShell 5.1 | +| | Arduino IDE 2.x | сборка и заливка прошивки | +| | LibreHardwareMonitor | чтение датчиков | +| | PawnIO | драйвер, без него температуры не будет | + +Всё программное обеспечение бесплатное. + +--- + +## Часть 1. Прошивка платы + +### 1.1 Arduino IDE + +Скачать с и установить. Проект собран на +версии 2.3.10. + +### 1.2 Ядро ESP32 + +**Инструменты → Плата → Менеджер плат**, найти **esp32** (Espressif Systems), +установить. Проект собран на версии 3.3.8. + +Добавлять URL в настройках IDE не нужно: пакет есть в штатном индексе Arduino. + +### 1.3 Библиотека U8g2 + +**Инструменты → Управлять библиотеками**, найти **U8g2** (автор oliver), +установить. Проект собран на версии 2.36.19. + +Библиотека поддерживает экран 72×40 готовым драйвером. Это важнее, чем кажется: +у такого экрана ненулевое смещение по обеим осям, и вручную его подбирать +пришлось бы вслепую. + +### 1.4 Настройки платы + +Подключить плату кабелем. В меню **Инструменты** выставить: + +| Пункт меню | Значение | +|---|---| +| Плата | **ESP32C3 Dev Module** | +| **USB CDC On Boot** | **Enabled** ← обязательно | +| Flash Size | **4MB (32Mb)** | +| Partition Scheme | Default 4MB with spiffs | +| Upload Speed | 921600 | +| Порт | тот, что появился после подключения платы | + +Два пункта критичны: + +- **USB CDC On Boot = Enabled.** По умолчанию стоит `Disabled`, и тогда `Serial` + уходит в аппаратный UART вместо USB. Прошивка соберётся и зальётся, но данные + до платы доходить не будут, а на экране навсегда останется `NO DATA`. +- **Flash Size должен совпадать с физическим объёмом флеша.** У этой платы 4 МБ. + Посмотреть фактический объём можно через **Инструменты → Get Board Info** или + в выводе утилиты прошивки. При несовпадении разделы уезжают за границу + микросхемы. + +Если номер COM-порта неочевиден: **Диспетчер устройств → Порты (COM и LPT)**, +плата видна как устройство с идентификатором `VID_303A&PID_1001` (Espressif). + +### 1.5 Определить пины дисплея + +На **этой** плате экран подключён к `SDA = GPIO5`, `SCL = GPIO6`, адрес `0x3C` — +уже прописано в `config.h`, шаг можно пропустить. + +Если плата похожая, но другая, не доверяйте надписям на текстолите: подписи +`GPIO8/GPIO9` рядом с пометкой I2C относятся к стандартным пинам чипа, а не к +разводке встроенного экрана. Залейте `tools/i2c_scan/i2c_scan.ino` и откройте +монитор порта на скорости 115200. Сканер перебирает пары пинов и печатает +найденное: + +``` +--- I2C scan --- +SDA=5 SCL=6: device 0x3C +SDA=6 SCL=5: nothing +SDA=8 SCL=9: nothing +SDA=9 SCL=8: nothing +``` + +Найденную пару вписать в `config.h` (`PIN_SDA`, `PIN_SCL`). + +### 1.6 Залить прошивку + +Открыть `CPU_temp.ino` в Arduino IDE и нажать **Загрузить**. + +Сразу после заливки на экране появится `NO DATA` — так и должно быть: агента на +компьютере ещё нет. + +То же самое из командной строки: + +``` +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 . +``` + +Вместо `COM4` подставить свой порт. + +--- + +## Часть 2. Агент на компьютере + +Агент — скрипт `agent/cpu_temp_agent.ps1`. Он читает датчики и раз в секунду +шлёт плате строку. + +### 2.1 LibreHardwareMonitor + +Скачать со страницы релизов + архив +**`LibreHardwareMonitor.zip`** и распаковать в `D:\Soft\LibreHardwareMonitor`. + +- Путь можно выбрать другой, но тогда поправьте переменную `$LhmPath` в начале + скрипта. +- **Не берите `LibreHardwareMonitor.NET.10.zip`** — это сборка под .NET 10, а + Windows PowerShell 5.1 работает на .NET Framework и такую сборку не загрузит. +- Запускать саму программу не нужно. Используются только библиотека + `LibreHardwareMonitorLib.dll` и лежащий рядом `HidSharp.dll`. + +### 2.2 Драйвер PawnIO + +Скачать со страницы файл +`PawnIO_setup.exe` и запустить **без аргументов**, двойным щелчком, затем пройти +мастер. Потребуются права администратора. + +Установщик не принимает ключей командной строки: на `/S` или `/quiet` он +отвечает кодом 87 и молча ничего не делает. + +**Зачем это нужно.** Начиная с версии 0.9.5 LibreHardwareMonitor читает регистры +процессора через PawnIO, а не через прежний WinRing0, и в архив с программой +драйвер не входит. Без него **загрузка CPU читается, а все температуры пустые** — +даже из консоли администратора. Симптом сбивает с толку: выглядит как нехватка +прав, хотя права ни при чём. + +### 2.3 Проверить вручную + +Открыть PowerShell **от имени администратора** (правый щелчок по значку → +«Запуск от имени администратора») и выполнить: + +``` +powershell -ExecutionPolicy Bypass -File "D:\Soft\Arduino\CPU_temp\agent\cpu_temp_agent.ps1" -Echo +``` + +Ожидается поток строк раз в секунду: + +``` +connected: COM4 +T=47.0;L=24 +T=48.0;L=14 +``` + +Одновременно на экране платы появится температура. Остановить — Ctrl+C. + +Права администратора нужны всегда: без них драйвер не открывается. Агент +проверяет это при старте и называет причину, а не молчит. + +### 2.4 Автозапуск при входе в систему + +В консоли администратора: + +``` +$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:USERDOMAIN\$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` нужен, иначе Планировщик убьёт агента через трое + суток. + +Проверить, не перезагружаясь: + +``` +Start-ScheduledTask -TaskName 'CPU Temp Agent' +(Get-ScheduledTask -TaskName 'CPU Temp Agent').State +``` + +Ожидается `Running`. + +Удалить задание: + +``` +Unregister-ScheduledTask -TaskName 'CPU Temp Agent' -Confirm:$false +``` + +--- + +## Повторная заливка прошивки + +Плата прошивается через тот же COM-порт, по которому идут данные. Пока агент +держит порт, заливка падает с `PermissionError`. Порядок такой: + +``` +Stop-ScheduledTask -TaskName 'CPU Temp Agent' +# ... залить прошивку ... +Start-ScheduledTask -TaskName 'CPU Temp Agent' +``` + +Порт может держать и монитор порта Arduino IDE — его тоже надо закрыть. + +--- + +## Настройка + +Всё, что имеет смысл менять, собрано в `config.h`: + +| Константа | По умолчанию | Смысл | +|---|---|---| +| `PIN_SDA`, `PIN_SCL` | 5, 6 | пины экрана | +| `DISPLAY_ROTATION` | `U8G2_R2` | ориентация; `U8G2_R0` — повернуть обратно | +| `ALARM_ON_C` | 85.0 | температура включения тревоги | +| `ALARM_OFF_C` | 82.0 | температура отпускания тревоги | +| `ALARM_BLINK_MS` | 500 | полупериод мигания | +| `PIN_LED` | 8 | светодиод | +| `LED_ACTIVE_LEVEL` | `LOW` | уровень, при котором светодиод горит | +| `SCALE_MIN`, `SCALE_MAX` | 30.0, 90.0 | шкала графика, °C | +| `SERIAL_BAUD` | 115200 | скорость обмена; менять вместе с агентом | +| `STALE_MS` | 5000 | молчание, после которого показывается `NO DATA` | +| `SAMPLE_MS` | 1000 | шаг истории графика | +| `DRAW_MS` | 200 | период перерисовки экрана | + +### Почему порогов тревоги два, а не один + +С одним порогом тревога дёргалась бы включениями и выключениями каждую секунду, +пока температура топчется на границе. Поэтому включение и отпускание разведены: +загорается на 85, гаснет только при спуске до 82. + +### Почему шкала графика фиксированная + +Автомасштаб отвергнут сознательно: в простое колебания ±1 °C рисовались бы во +всю высоту экрана и выглядели тревожно, хотя ничего не происходит. + +--- + +## Что показывает экран + +| Состояние | Экран | Светодиод GPIO8 | +|---|---|---| +| Норма | температура, загрузка, график | погашен | +| Перегрев | то же, мигает негативом | мигает синхронно с экраном | +| Нет данных дольше 5 с | `NO DATA` | горит ровно | + +В режиме `NO DATA` история графика не пополняется — иначе обрыв связи выглядел бы +как резкое падение температуры. + +--- + +## Протокол обмена + +Агент шлёт плате по одной строке в секунду, в конце — перевод строки: + +``` +T=67.0;L=34 +``` + +| Ключ | Смысл | +|---|---| +| `T` | температура CPU Package, °C | +| `L` | общая загрузка CPU, % | + +Неизвестные ключи прошивка молча игнорирует, поэтому протокол расширяем: можно +добавить, например, `G=` для видеокарты, не трогая уже прошитые платы. + +**Разделитель дробной части — только точка.** Агент форматирует числа через +`InvariantCulture`: в русской локали иначе вышло бы `T=67,0`, и разбор на плате +прочитал бы `67`, молча потеряв дробную часть. + +Плату можно проверить без агента, отправив строки вручную. Порт при этом должен +быть свободен, то есть агента надо остановить: + +``` +$sp = New-Object System.IO.Ports.SerialPort 'COM4',115200,'None',8,'One' +$sp.DtrEnable = $true; $sp.RtsEnable = $false +$sp.Open() +40..90 | ForEach-Object { $sp.WriteLine("T=$_.0;L=50"); Start-Sleep -Milliseconds 400 } +$sp.Close() +``` + +Так же удобно проверять тревогу, не меняя пороги в `config.h`: подать значение +выше 85, затем 83 (тревога должна сохраниться) и 81 (должна отпустить). + +--- + +## Если не работает + +| Симптом | Причина | Что делать | +|---|---|---| +| На экране всегда `NO DATA`, хотя агент шлёт строки | `USB CDC On Boot` выключен, `Serial` ушёл в аппаратный UART | включить в меню платы и перезалить | +| Экран не светится вовсе | неверные пины I2C | залить `tools/i2c_scan`, найти пару, вписать в `config.h` | +| Агент пишет, что температура недоступна | не установлен PawnIO | установить драйвер, см. 2.2 | +| Агент отказывается запускаться из-за прав | консоль не от администратора | открыть «от имени администратора» | +| Заливка падает с `PermissionError` | порт держит агент или монитор порта IDE | `Stop-ScheduledTask`, закрыть монитор | +| Температура без дробной части | число пришло с запятой вместо точки | агент должен форматировать через `InvariantCulture` | +| Скрипт `.ps1` падает с ошибками про скобки | файл сохранён в UTF-8 **без BOM** | пересохранить в UTF-8 **с BOM**: PowerShell 5.1 иначе читает файл как ANSI и портит кириллицу | +| После перетыкания в другой разъём данные не идут | сменился номер порта | ничего не делать: агент ищет плату по `VID_303A&PID_1001` | + +--- + +## Как устроено + +| Путь | Что это | +|---|---| +| `config.h` | все настройки | +| `CPU_temp.ino` | свежесть данных, гистерезис тревоги, светодиод | +| `link.h` / `link.cpp` | неблокирующее чтение строки из Serial и её разбор | +| `ui.h` / `ui.cpp` | отрисовка через U8g2, кольцевой буфер истории, негатив | +| `tools/i2c_scan/` | одноразовый скетч поиска пинов дисплея | +| `agent/cpu_temp_agent.ps1` | агент для ПК | +| `agent/README.md` | подробности по агенту | +| `docs/` | спецификация и план реализации | + +Разделение простое: `link` ничего не знает об экране, `ui` не знает, откуда +взялись данные, а логика состояний живёт в скетче. + +## Расход ресурсов + +Агент занимает около 70 МБ оперативной памяти и со временем не растёт. Почти всё +это — обвязка Windows PowerShell и .NET Framework; сам цикл держит в памяти +несколько чисел. + +Прошивка занимает 26% флеша платы и 4% её оперативной памяти. diff --git a/CPU_temp/agent/README.md b/CPU_temp/agent/README.md new file mode 100644 index 0000000..af545a2 --- /dev/null +++ b/CPU_temp/agent/README.md @@ -0,0 +1,86 @@ +# Агент CPU Temp + +Читает температуру и загрузку процессора и раз в секунду отдаёт их плате +ESP32-C3 по USB-COM строкой вида `T=67.0;L=34`. + +## Что нужно установить + +### 1. LibreHardwareMonitor + +Скачать со +архив **`LibreHardwareMonitor.zip`** и распаковать в `D:\Soft\LibreHardwareMonitor`. + +Не брать `LibreHardwareMonitor.NET.10.zip`: это сборка под .NET 10, а Windows +PowerShell 5.1 работает на .NET Framework и такую сборку не загрузит. + +Само приложение запускать не нужно — используются только +`LibreHardwareMonitorLib.dll` и лежащий рядом `HidSharp.dll`. + +### 2. Драйвер PawnIO + +Скачать со файл +`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`, потеряв дробную часть. diff --git a/CPU_temp/agent/cpu_temp_agent.ps1 b/CPU_temp/agent/cpu_temp_agent.ps1 new file mode 100644 index 0000000..e073bde --- /dev/null +++ b/CPU_temp/agent/cpu_temp_agent.ps1 @@ -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 + } +} diff --git a/CPU_temp/config.h b/CPU_temp/config.h new file mode 100644 index 0000000..236329d --- /dev/null +++ b/CPU_temp/config.h @@ -0,0 +1,52 @@ +// Все настройки проекта в одном месте: пины, пороги, шкалы, периоды. +// Менять поведение прошивки здесь, а не по коду модулей. + +#pragma once + +#include + +// --- Дисплей --------------------------------------------------------------- + +// Пины I2C встроенного OLED. Подтверждены сканером tools/i2c_scan: +// подписи GPIO8/GPIO9 на шелкографии - это дефолтные Wire-пины чипа, +// а не разводка экрана. +static const uint8_t PIN_SDA = 5; +static const uint8_t PIN_SCL = 6; + +// Ориентация: U8G2_R0 - как разведено на плате, U8G2_R2 - поворот на 180. +#define DISPLAY_ROTATION U8G2_R2 + +// --- Тревога по перегреву -------------------------------------------------- + +// Два порога, а не один: с одним тревога дёргалась бы каждую секунду, +// пока температура топчется на границе. +static const float ALARM_ON_C = 85.0f; // включить тревогу при этой температуре +static const float ALARM_OFF_C = 82.0f; // выключить, только опустившись до этой + +// Период полуфазы мигания: экран и светодиод моргают синхронно. +static const uint32_t ALARM_BLINK_MS = 500; + +// --- Светодиод ------------------------------------------------------------- + +// Тревога - мигает, нет данных - горит ровно, норма - погашен. +static const uint8_t PIN_LED = 8; + +// Уровень, при котором светодиод горит. Определено опытом: светодиод на GPIO8 +// зажигается НИЗКИМ уровнем. Побочно это удобно - в обычном режиме пин стоит +// в высоком уровне, а GPIO8 является пином начальной загрузки, которому +// высокий уровень и нужен. +static const uint8_t LED_ACTIVE_LEVEL = LOW; + +// --- График ---------------------------------------------------------------- + +// Шкала фиксированная: при автомасштабе шум +-1 градус в простое +// рисовался бы горами и создавал ложную тревогу. +static const float SCALE_MIN = 30.0f; +static const float SCALE_MAX = 90.0f; + +// --- Связь и тайминги ------------------------------------------------------ + +static const uint32_t SERIAL_BAUD = 115200; +static const uint32_t STALE_MS = 5000; // после этого молчания показываем NO DATA +static const uint32_t SAMPLE_MS = 1000; // шаг истории графика +static const uint32_t DRAW_MS = 200; // период перерисовки экрана diff --git a/CPU_temp/docs/2026-09-14-cpu-temp-design.md b/CPU_temp/docs/2026-09-14-cpu-temp-design.md new file mode 100644 index 0000000..373eb47 --- /dev/null +++ b/CPU_temp/docs/2026-09-14-cpu-temp-design.md @@ -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, а не к разводке +встроенного экрана. diff --git a/CPU_temp/docs/2026-09-14-cpu-temp-plan.md b/CPU_temp/docs/2026-09-14-cpu-temp-plan.md new file mode 100644 index 0000000..9513e6d --- /dev/null +++ b/CPU_temp/docs/2026-09-14-cpu-temp-plan.md @@ -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 + +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 + +// Инициализирует дисплей и показывает заставку ожидания. +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 + +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 ` ради `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 + +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 = ; Load = }` + +- [ ] **Step 1: Скачать и распаковать LibreHardwareMonitor** + +Нужна сборка **net472**: в системе только Windows PowerShell 5.1 (.NET Framework), сборку net8.0 он не загрузит. Взять последний релиз со страницы , файл вида `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 ` — адрес `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 можно делать параллельно с прошивкой — она ни от чего не зависит. diff --git a/CPU_temp/link.cpp b/CPU_temp/link.cpp new file mode 100644 index 0000000..c234bf3 --- /dev/null +++ b/CPU_temp/link.cpp @@ -0,0 +1,62 @@ +#include "link.h" + +#include + +#include "config.h" + +static const size_t BUF_SIZE = 64; +static char buf[BUF_SIZE]; +static size_t len = 0; + +void linkBegin() { + Serial.begin(SERIAL_BAUD); + 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; +} diff --git a/CPU_temp/link.h b/CPU_temp/link.h new file mode 100644 index 0000000..67f0810 --- /dev/null +++ b/CPU_temp/link.h @@ -0,0 +1,12 @@ +#pragma once + +struct CpuStats { + float tempC; + int loadPct; +}; + +void linkBegin(); + +// Неблокирующее чтение. Возвращает true, когда собрана и разобрана +// целая строка вида "T=67.0;L=34". Неизвестные ключи игнорируются. +bool linkPoll(CpuStats &out); diff --git a/CPU_temp/tools/i2c_scan/i2c_scan.ino b/CPU_temp/tools/i2c_scan/i2c_scan.ino new file mode 100644 index 0000000..930316b --- /dev/null +++ b/CPU_temp/tools/i2c_scan/i2c_scan.ino @@ -0,0 +1,42 @@ +// Одноразовый скетч: ищет, на какой паре пинов висит встроенный OLED. +// Шелкография подписывает GPIO8/GPIO9 (дефолтные Wire-пины ESP32-C3), но у плат +// этого семейства экран обычно разведён на GPIO5/GPIO6. Проверяем перебором. + +#include + +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); +} diff --git a/CPU_temp/ui.cpp b/CPU_temp/ui.cpp new file mode 100644 index 0000000..b2a861d --- /dev/null +++ b/CPU_temp/ui.cpp @@ -0,0 +1,87 @@ +#include "ui.h" + +#include +#include + +#include "config.h" + +// Порядок параметров: (rotation, reset, clock, data) — SCL идёт раньше SDA. +static U8G2_SSD1306_72X40_ER_F_HW_I2C display(DISPLAY_ROTATION, 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; + +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); + } +} + +// Негатив всего кадра. Через буфер, а не командой контроллера: так поведение +// не зависит от конкретной микросхемы дисплея. +static void invertBuffer() { + uint8_t *b = display.getBufferPtr(); + const size_t n = (size_t)display.getBufferTileWidth() * display.getBufferTileHeight() * 8; + for (size_t i = 0; i < n; i++) b[i] = (uint8_t)~b[i]; +} + +void uiRender(bool fresh, float tempC, int loadPct, bool invert) { + display.clearBuffer(); + + if (!fresh) { + display.setFont(u8g2_font_6x10_tf); + display.drawStr(0, 14, "NO"); + display.drawStr(0, 26, "DATA"); + if (invert) invertBuffer(); + 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, 8, "\xb0"); + + // Загрузка — шрифтом вдвое выше прежнего. + display.setFont(u8g2_font_7x14_tf); + snprintf(buf, sizeof(buf), "%d%%", loadPct); + display.drawStr(numWidth + 2, 24, buf); + + drawGraph(); + + if (invert) invertBuffer(); + + display.sendBuffer(); +} diff --git a/CPU_temp/ui.h b/CPU_temp/ui.h new file mode 100644 index 0000000..f124d92 --- /dev/null +++ b/CPU_temp/ui.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +// Инициализирует дисплей и показывает заставку ожидания. +void uiBegin(); + +// Добавляет значение в кольцевой буфер истории. Вызывать раз в секунду +// и только при свежих данных, чтобы пауза связи не рисовалась как провал. +void uiPushSample(float tempC); + +// Перерисовывает экран целиком. +// fresh == false - данные устарели, вместо цифр выводится NO DATA. +// invert == true - кадр показывается в негативе (фаза мигания тревоги). +void uiRender(bool fresh, float tempC, int loadPct, bool invert); diff --git a/README.md b/README.md index ad21e50..9f9746c 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,9 @@ A collection of Arduino / ESP microcontroller projects. panel, and posts events to an HTTPS webhook. Settings live in LittleFS. See its [README](pump_controller_8_2_OLED_DONE/README.md) and [MANUAL](pump_controller_8_2_OLED_DONE/MANUAL.md). + +- **[CPU_temp](CPU_temp/)** — desktop CPU temperature monitor on an ESP32-C3 with + a built-in 0.42-inch OLED: a Windows agent reads the sensors and feeds the + board over USB serial, which shows the temperature, CPU load and a one-minute + history graph, and blinks an overheat alarm. See its + [README](CPU_temp/README.md).