Compare commits

..
29 Commits
Author SHA1 Message Date
oskarvitaliiandClaude Opus 5 fec4c9be1a Merge dev into master: документация приведена в соответствие с кодом
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:10:49 +07:00
oskarvitaliiandClaude Opus 5 f25f24b6c5 docs: привести README в соответствие с реализацией
Сверка документации с кодом выявила четыре расхождения, все появились
после вчерашней правки протокола:

- схема в шапке и пример вывода агента показывали строку без ключа M,
  хотя агент его шлёт: человек сверил бы свой вывод с образцом и решил,
  что у него что-то не так;
- в таблице состава не были упомянуты кнопка и выбор экрана;
- формулировка "короткое нажатие" подразумевала различие с длинным,
  которого в коде нет.

Все 16 констант из таблицы настроек сверены с config.h, поведенческие
утверждения - с кодом, расход прошивки подтверждён сборкой: 26% и 4%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:08:49 +07:00
oskarvitaliiandClaude Opus 5 2fd0a44555 Merge dev into master: экран памяти, кнопка BOOT и планы по видеокарте
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:58:01 +07:00
oskarvitaliiandClaude Opus 5 f18090b5b7 feat(ui): экран оперативной памяти и переключение кнопкой BOOT
Второй экран показывает загрузку ОЗУ крупной цифрой и её график со
шкалой 0-100 - у памяти она естественная, подбирать нечего. В углу свой
значок, планка памяти вместо микросхемы: он же и говорит, какой экран
открыт.

Тревога в приоритете: при перегреве плата принудительно возвращается на
экран процессора и кнопка не работает, пока температура не упадёт. Смысл
тревоги в том, что её нельзя прощёлкать.

Экран памяти выпадает из листания, если агент не шлёт ключ M - со старым
агентом кнопка просто ничего не делает. То же решение подойдёт машинам
без дискретной видеокарты.

Выбранный экран не запоминается при перезагрузке: хранить негде.
Тревоги по памяти нет намеренно - загрузка ОЗУ под 100% здесь обычное
рабочее состояние.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:56:33 +07:00
oskarvitaliiandClaude Opus 5 4344fa8873 feat(agent): слать загрузку оперативной памяти ключом M
Прошивка её пока не показывает - на 72x40 нет места под третий
показатель. Это намеренно: неизвестный ключ прошивка игнорирует, поэтому
агента можно обновлять независимо от плат.

Память берётся у железа с именем Total Memory - это физическая память.
У соседнего Virtual Memory датчик называется точно так же, но считает
вместе с файлом подкачки и показывает заниженную цифру: 87% против
фактических 97%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 19:04:11 +07:00
oskarvitaliiandClaude Opus 5 b15421305c docs: записать в бэклог план по видеокарте
Выбран приоритетный экран, а не листалка: устройство смотрится боковым
зрением и должно само показывать то, что ближе к своему порогу.
Совмещённый экран отвергнут по измерениям - вторая температура влезает
только шрифтом 5x7, и смысл крупной цифры теряется.

Отдельным пунктом записан опыт, который надо поставить до реализации:
проверить, будит ли опрос дискретную видеокарту и не начнёт ли мониторинг
сам расходовать батарею. Измеритель есть - датчик мощности разряда
батареи, прав администратора он не требует.

Записана и главная ловушка подхода: при близких к порогам показателях
экран начнёт скакать между ними, нужна задержка переключения.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:08:11 +07:00
oskarvitaliiandClaude Opus 5 ed726f7fb6 docs: внести в бэклог перечень доступных датчиков
Проверено перебором через LibreHardwareMonitorLib после установки PawnIO.
Ключевой вывод: температура видеокарты NVIDIA читается без прав
администратора, потому что идёт через её драйвер, а не через MSR. Админ
нужен только процессору.

Дисков и датчиков материнской платы с вентиляторами на этой машине нет.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:50:15 +07:00
oskarvitaliiandClaude Opus 5 96df0465d5 Merge dev into master: сглаживание температуры и бэклог
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:42:59 +07:00
oskarvitaliiandClaude Opus 5 fcc96f5090 feat(ui): сглаживать температуру скользящим средним за 5 замеров
Мгновенное значение плавает на +-1 градус, и цифра мельтешила каждую
секунду при полном покое. На экран теперь идёт среднее за
TEMP_AVG_SAMPLES последних замеров; агент шлёт раз в секунду, так что
это ровно 5 секунд.

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

Тревога переведена на усреднённое значение: иначе экран показывал бы 84,
когда мигание уже идёт. Загрузка и график намеренно остались сырыми.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:41:20 +07:00
oskarvitaliiandClaude Opus 5 1047ae1452 docs: завести бэклог проекта
Первый пункт - другие показатели (GPU, память, диски) и листалка экранов
кнопкой BOOT. Протокол к этому уже готов: незнакомые ключи прошивка
игнорирует, так что агент может начать слать новые поля, не ломая уже
прошитые платы.

Там же записаны открытые хвосты: обрезание строки при 100 градусах и
100% загрузки, непроверенный перезагрузкой автозапуск, ручной цикл
заливки и вес обзорного изображения.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:33:02 +07:00
oskarvitaliiandClaude Opus 5 a006c45d98 Merge dev into master: значок процессора и обзорное изображение
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:29:48 +07:00
oskarvitaliiandClaude Opus 5 135278ba18 docs: добавить обзорное изображение проекта
К изображению дана сноска с двумя поправками: экран на плате 0.42 дюйма,
а не 0.96, и график строится по температуре, а не по загрузке.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:29:20 +07:00
oskarvitaliiandClaude Opus 5 c76c2ef7e5 feat(ui): значок процессора в правом верхнем углу
Полоса y=0..10 справа выбрана по измерениям на самой плате, а не на глаз:
строка загрузки лежит ниже (y=11..24), а цифры температуры не дотягиваются
туда даже трёхзначными (49 px + градус = 56 из 72). Значок 11x11 не
перекрывает ничего ни при каких значениях и не исчезает под нагрузкой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:29:19 +07:00
oskarvitaliiandClaude Opus 5 6c4cdb4605 Merge dev into master: индикатор температуры CPU на ESP32-C3
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:04:34 +07:00
oskarvitaliiandClaude Opus 5 e366f844a0 docs: add CPU_temp to the projects index
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:47:26 +07:00
oskarvitaliiandClaude Opus 5 03db58473c docs: переписать README как пошаговую инструкцию по повторению
Прежний README описывал устройство проекта, но по нему нельзя было
собрать всё с нуля. Теперь это инструкция: что установить, какие пункты
меню выставить, как определить пины дисплея, как поставить агента и
автозапуск, как проверить без агента.

Отдельно вынесены грабли, на которые мы наступили: USB CDC On Boot по
умолчанию выключен, LibreHardwareMonitor требует PawnIO, разделитель
дробной части должен быть точкой, .ps1 нужен UTF-8 с BOM, порт надо
освобождать перед заливкой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:40:25 +07:00
oskarvitaliiandClaude Opus 5 bd0a87ea49 feat(ui): тревога по перегреву и config.h со всеми настройками
Настройки собраны в config.h: пины, ориентация, пороги тревоги, шкала
графика, тайминги и скорость Serial. Раньше они были раскиданы по
модулям, и подстройка требовала лазить по коду.

Тревога включается на ALARM_ON_C и отпускает только на ALARM_OFF_C
(85 и 82 градуса). Один порог заставлял бы её дёргаться каждую секунду,
пока температура топчется на границе.

В тревоге экран мигает негативом (инверсия буфера, а не команда
контроллера - не зависит от микросхемы дисплея), светодиод GPIO8 мигает
с ним в общей фазе. Без данных светодиод горит ровно, в норме погашен.
Зажигается низким уровнем: проверено опытом, заодно в простое пин стоит
высоким, что безопасно для GPIO8 как пина начальной загрузки.

Переменная alarm переименована в alarmActive: имя alarm занято функцией
из unistd.h, которая попадает в сборку ESP32.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:32:31 +07:00
oskarvitaliiandClaude Opus 5 db86597433 feat(ui): повернуть экран на 180 градусов и укрупнить загрузку
Ориентация вынесена в константу DISPLAY_ROTATION: менять её теперь
правкой одной строки, а не параметра в конструкторе.

Знак градуса поднят с базовой линии 10 на 8 - встал надстрочным знаком
вровень с верхом цифр. Загрузка CPU выводится шрифтом 7x14 вместо 5x7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:08:34 +07:00
oskarvitaliiandClaude Opus 5 59848698ff 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>
2026-09-14 21:42:24 +07:00
oskarvitalii ac8a72b05d Merge dev into master: снимок веб-панели в документации 2026-08-08 09:32:58 +07:00
oskarvitalii 7a7075f6de Merge dev into master: исправлено описание поведения реле при загрузке
Документация утверждала, что реле при загрузке молчат. На собранном
приборе оба реле кратковременно моргают при перезагрузке; поведение
признано некритичным, прошивка не менялась, исправлен текст.
2026-08-08 01:55:23 +07:00
oskarvitalii f77ff376d4 Merge dev into master: переработанный интерфейс и текст события в webhook
Веб-панель переделана в световое табло: главный ответ читается с
расстояния, цвета по конвенции сигнальных ламп, настройки свёрнуты.
Исправлено: лампа выхода «Авария» горела зелёным при срабатывании,
обновление стирало ввод в форме, шрифты тянулись с Google Fonts и на
объекте не загружались.

Страница переехала во флеш (PROGMEM) и в отдельный файл web_page.h -
последнее вынужденно: генератор прототипов Arduino не понимает сырые
строковые литералы и ломался на JavaScript внутри.

OLED: верхняя строка отдана статусу кеглем 2, добавлено состояние реле.

В теле webhook поле status теперь содержит человеческую фразу
(«Насосы стоят, подачу держит резерв»), машиночитаемое значение
переехало в новое поле state.

Прошито и проверено на приборе: COM7, LOLIN D1 ESP-WROOM-02, 2MB FS:64KB.
2026-08-08 01:40:10 +07:00
oskarvitalii d53035a5ef Merge dev into master: pump_controller_8_2_OLED_DONE
Добавлен проект контроллера насосной станции на Wemos D1 mini вместе с
его историей: мониторинг двух основных и резервного насоса, два реле
аварий на D3/D4 (модули active-LOW), OLED, веб-панель с настройками и
состоянием реле, уведомления на HTTPS-webhook, настройки в LittleFS.

Также: .gitignore переведён на белый список, чтобы рабочий каталог
D:\Soft\Arduino мог служить рабочим деревом репозитория, не втягивая
в него старые версии скетчей, архивы и драйверы.
2026-08-07 23:20:29 +07:00
oskarvitaliiandClaude Opus 4.8 bda2ecb7a3 Merge dev into master: carousel dot fix
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:56:38 +07:00
oskarvitaliiandClaude Opus 4.8 208669d19f Merge dev into master: boot icon carousel
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:51:03 +07:00
oskarvitaliiandClaude Opus 4.8 68bdfdc879 Merge dev into master: RU/EN web interface + scrollable forecast log
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:27:09 +07:00
oskarvitaliiandClaude Opus 4.8 2bf49c14ac revert: restore pre-4b59b89 web server (drop chunked API + low-memory auto-reboot)
The chunked API handlers and the maxblock<6000 auto-restart from 4b59b89
broke the web UI on-device; restore the working String-based handlers and
plain loop while keeping the scrollable forecast log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:48:25 +07:00
oskarvitaliiandClaude Opus 4.8 f8294d4aed feat(web): scrollable Forecast log with fixed height
Cap the log panel at ~216px with a thin themed scrollbar so a long
forecast history does not stretch the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 07:08:21 +07:00
oskarvitaliiandClaude Opus 4.8 4b59b8936b fix: stream large API responses to stop heap-exhaustion truncation
Serving /api/history (and /api/forecasts) built a ~12 KB String +
JsonDocument every 15 s; over days this fragmented the ESP8266 heap until
the TCP stack could no longer send a full response, causing the web page
to fail with ERR_CONTENT_LENGTH_MISMATCH. Stream both array endpoints via
chunked transfer (small stack buffer, no big allocations). Add free-heap
to /api/current and Serial, plus a low-memory safety restart (history is
persisted first).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 06:57:09 +07:00
16 changed files with 2293 additions and 0 deletions

No files matched your search

+1
View File
@@ -13,6 +13,7 @@
!/WeatherPredictor/ !/WeatherPredictor/
!/pump_controller_8_2_OLED_DONE/ !/pump_controller_8_2_OLED_DONE/
!/CPU_temp/
# --- Правила ниже действуют внутри разрешённых проектов --- # --- Правила ниже действуют внутри разрешённых проектов ---
# Идут после белого списка намеренно: более поздний шаблон побеждает, поэтому # Идут после белого списка намеренно: более поздний шаблон побеждает, поэтому
+141
View File
@@ -0,0 +1,141 @@
// Индикатор состояния ПК: плата принимает строки "T=67.0;L=34;M=97" по USB-COM
// от агента на ПК и рисует их на встроенном OLED 72x40. Сама ничего не меряет.
//
// Два экрана: процессор и оперативная память, переключение кнопкой BOOT.
// Все настройки — в config.h.
#include "config.h"
#include "link.h"
#include "ui.h"
static CpuStats stats = {0.0f, 0, -1};
static uint32_t lastData = 0;
static uint32_t lastSample = 0;
static uint32_t lastDraw = 0;
static bool everGot = false;
static bool alarmActive = false;
static bool wasFresh = false;
static float tempShown = 0.0f; // усреднённое значение, именно оно на экране
static UiScreen screen = UI_SCREEN_CPU;
// Скользящее среднее температуры. Сумма пересчитывается заново, а не копится:
// на пяти числах это бесплатно, зато нет накопления ошибки округления за сутки
// непрерывной работы.
static float tempWindow[TEMP_AVG_SAMPLES];
static uint8_t tempCount = 0;
static uint8_t tempIndex = 0;
static void tempAvgReset() {
tempCount = 0;
tempIndex = 0;
}
static float tempAvgPush(float v) {
tempWindow[tempIndex] = v;
tempIndex = (uint8_t)((tempIndex + 1) % TEMP_AVG_SAMPLES);
if (tempCount < TEMP_AVG_SAMPLES) tempCount++;
float sum = 0.0f;
for (uint8_t i = 0; i < tempCount; i++) sum += tempWindow[i];
return sum / tempCount;
}
void setup() {
pinMode(PIN_LED, OUTPUT);
digitalWrite(PIN_LED, !LED_ACTIVE_LEVEL);
pinMode(PIN_BUTTON, INPUT_PULLUP);
linkBegin();
uiBegin();
}
// Гистерезис: включаемся на верхнем пороге, отпускаем только на нижнем.
static void updateAlarm(float tempC) {
if (!alarmActive && tempC >= ALARM_ON_C) alarmActive = true;
else if (alarmActive && tempC <= ALARM_OFF_C) alarmActive = false;
}
// Экран памяти показываем, только если данные по ней действительно приходят:
// со старым агентом ключа M в строке нет вовсе.
static bool ramAvailable() {
return stats.memPct >= 0;
}
static void onButtonPress() {
// Тревога в приоритете: уйти с экрана процессора нельзя, пока он горячий.
if (alarmActive) return;
if (!ramAvailable()) return;
screen = (screen == UI_SCREEN_CPU) ? UI_SCREEN_RAM : UI_SCREEN_CPU;
}
// Кнопка BOOT замыкает пин на землю, поэтому нажатию соответствует LOW.
static void pollButton() {
static bool stable = HIGH;
static bool lastRead = HIGH;
static uint32_t changedAt = 0;
const bool now = digitalRead(PIN_BUTTON);
if (now != lastRead) {
lastRead = now;
changedAt = millis();
}
if (millis() - changedAt >= BUTTON_DEBOUNCE_MS && now != stable) {
stable = now;
if (stable == LOW) onButtonPress();
}
}
void loop() {
CpuStats fresh;
if (linkPoll(fresh)) {
stats = fresh;
tempShown = tempAvgPush(fresh.tempC);
lastData = millis();
everGot = true;
}
const bool isFresh = everGot && (millis() - lastData < STALE_MS);
// После паузы связи среднее нельзя считать по значениям до обрыва.
if (wasFresh && !isFresh) tempAvgReset();
wasFresh = isFresh;
if (isFresh) {
// Тревога по усреднённому значению: иначе экран показывал бы 84, а мигание
// уже шло бы. Побочно это защищает от срабатывания на одиночный выброс.
updateAlarm(tempShown);
} else {
alarmActive = false; // без данных о перегреве судить не по чему
}
pollButton();
// Тревога перебивает выбор пользователя и возвращает на экран процессора.
if (alarmActive) screen = UI_SCREEN_CPU;
if (screen == UI_SCREEN_RAM && !ramAvailable()) screen = UI_SCREEN_CPU;
// Общая фаза для экрана и светодиода, чтобы они моргали синхронно.
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();
uiPushCpuSample(stats.tempC); // график по сырым: 5 градусов на пиксель, шум там не виден
if (ramAvailable()) uiPushRamSample(stats.memPct);
}
if (millis() - lastDraw >= DRAW_MS) {
lastDraw = millis();
uiRender(screen, isFresh, tempShown, stats.loadPct, stats.memPct, alarmActive && blinkOn);
}
}
+402
View File
@@ -0,0 +1,402 @@
# CPU Temp — индикатор температуры процессора на ESP32-C3
Небольшая плата ESP32-C3 со встроенным OLED-экраном 0.42″ стоит на столе и
показывает состояние компьютера: крупная цифра, мелкий показатель рядом и график
за последнюю минуту. Экранов два — процессор и оперативная память, переключаются
кнопкой BOOT. При перегреве процессора экран мигает негативом.
```
LibreHardwareMonitorLib.dll ──> PowerShell-агент ──COM (USB CDC)──> ESP32-C3 ──I2C──> OLED 72×40
(через драйвер PawnIO) раз в секунду "T=67.0;L=34;M=97" U8g2
```
**Плата ничего не измеряет сама.** Она принимает готовые строки по USB и рисует
их. Всё чтение датчиков происходит на компьютере — иначе никак: температура CPU
доступна только через драйвер уровня ядра.
![Обзор проекта](docs/promo.png)
*Две поправки к изображению: экран на этой плате 0.42″ (72×40), а не 0.96″;
график строится по температуре, а не по загрузке — загрузка выводится цифрой.*
Подключение — по 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
Скачать с <https://www.arduino.cc/en/software> и установить. Проект собран на
версии 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
Скачать со страницы релизов
<https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases> архив
**`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
Скачать со страницы <https://github.com/namazso/PawnIO.Setup/releases> файл
`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;M=97
T=48.0;L=14;M=97
```
Одновременно на экране платы появится температура. Остановить — 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 | светодиод |
| `PIN_BUTTON` | 9 | кнопка BOOT, переключает экраны |
| `BUTTON_DEBOUNCE_MS` | 50 | защита от дребезга контактов |
| `LED_ACTIVE_LEVEL` | `LOW` | уровень, при котором светодиод горит |
| `TEMP_AVG_SAMPLES` | 5 | окно сглаживания температуры, замеров |
| `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 вокруг стабильного, и цифра мельтешит
каждую секунду, хотя ничего не происходит — это отвлекает. На экран выводится
среднее за `TEMP_AVG_SAMPLES` последних замеров, то есть за 5 секунд: агент
шлёт раз в секунду.
Плата за это — запаздывание реакции на реальный нагрев примерно на 2–3 секунды.
Для температуры процессора незаметно: она физически инертна.
Что осталось несглаженным и почему:
- **загрузка CPU** — её скачки настоящие, ради них на неё и смотрят;
- **график** — 12 пикселей на диапазон 60 °C дают 5 °C на пиксель, шум там и так
не виден, зато история остаётся сырой.
Тревога считается по усреднённому значению, а не по сырому: иначе экран
показывал бы 84, когда мигание уже началось. Побочно это защищает от
срабатывания на одиночный выброс — перегрев должен продержаться пару секунд.
### Почему шкала графика фиксированная
Автомасштаб отвергнут сознательно: в простое колебания ±1 °C рисовались бы во
всю высоту экрана и выглядели тревожно, хотя ничего не происходит.
---
## Что показывает экран
Экранов два, переключаются нажатием кнопки **BOOT** (длительность не важна):
| Экран | Крупно | Мелко | График |
|---|---|---|---|
| Процессор | температура, °C | загрузка, % | температура, шкала 30–90 °C |
| Память | загрузка, % | знак процента | загрузка, шкала 0–100 % |
Значок в правом верхнем углу говорит, какой экран открыт: микросхема — процессор,
планка памяти — ОЗУ.
| Состояние | Экран | Светодиод GPIO8 |
|---|---|---|
| Норма | выбранный экран | погашен |
| Перегрев | **принудительно** экран процессора, мигает негативом | мигает синхронно с экраном |
| Нет данных дольше 5 с | `NO DATA` | горит ровно |
**Тревога в приоритете.** При перегреве плата сама возвращается на экран
процессора, и кнопка перестаёт работать до тех пор, пока температура не упадёт.
Смысл тревоги в том, что её нельзя прощёлкать.
Выбранный экран **не запоминается** при перезагрузке платы: энергонезависимого
хранилища в прошивке нет вовсе, после сброса всегда показывается процессор.
Тревога считается только по температуре процессора. По памяти её нет намеренно:
загрузка ОЗУ под 100 % — обычное рабочее состояние, мигать на него бессмысленно.
В режиме `NO DATA` история графиков не пополняется — иначе обрыв связи выглядел
бы как резкое падение значений.
---
## Протокол обмена
Агент шлёт плате по одной строке в секунду, в конце — перевод строки:
```
T=67.0;L=34;M=97
```
| Ключ | Смысл |
|---|---|
| `T` | температура CPU Package, °C |
| `L` | общая загрузка CPU, % |
| `M` | загрузка оперативной памяти, % |
Ключ `M` показывается на отдельном экране памяти — третьему показателю на экране
72×40 места нет. Если агент старый и ключа `M` не шлёт, экран памяти из листания
выпадает сам, и кнопка ничего не делает.
Берётся он у железа с именем `Total Memory` — это физическая память. У соседнего
`Virtual Memory` датчик называется точно так же, но считает вместе с файлом
подкачки и показывает заниженную цифру.
Неизвестные ключи прошивка молча игнорирует, поэтому протокол расширяем: можно
добавить, например, `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/` | спецификация, план реализации и [бэклог](docs/BACKLOG.md) |
Разделение простое: `link` ничего не знает об экране, `ui` не знает, откуда
взялись данные, а логика состояний живёт в скетче.
## Расход ресурсов
Агент занимает около 70 МБ оперативной памяти и со временем не растёт. Почти всё
это — обвязка Windows PowerShell и .NET Framework; сам цикл держит в памяти
несколько чисел.
Прошивка занимает 26% флеша платы и 4% её оперативной памяти.
+86
View File
@@ -0,0 +1,86 @@
# Агент CPU Temp
Читает температуру и загрузку процессора и раз в секунду отдаёт их плате
ESP32-C3 по USB-COM строкой вида `T=67.0;L=34;M=97`.
## Что нужно установить
### 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`, потеряв дробную часть.
+145
View File
@@ -0,0 +1,145 @@
<#
.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.IsMemoryEnabled = $true
$computer.Open()
function Get-CpuStats {
foreach ($hw in $computer.Hardware) { $hw.Update() }
$temp = $null
$load = $null
$mem = $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 }
# Имя железа проверяем обязательно: у "Virtual Memory" датчик зовётся
# точно так же, но считает физическую память вместе с файлом подкачки
# и показывает заниженную цифру.
if ($hw.Name -eq 'Total Memory' -and $s.SensorType -eq 'Load' -and $s.Name -eq 'Memory') { $mem = $s.Value }
}
}
# Возвращаем обе величины даже неполными: вызывающий код должен уметь
# сказать, какая именно отсутствует, а не валить всё в одно "нет данных".
return @{
Temp = $temp
Load = $load
Mem = $mem
}
}
# Температура читается только через драйвер ядра. У 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) {
$line = [string]::Format([cultureinfo]::InvariantCulture, 'T={0:F1};L={1}', [double]$stats.Temp, [int][math]::Round([double]$stats.Load))
# Память необязательна: если датчика нет, строка просто идёт без ключа M,
# и прошивка этого не заметит.
if ($null -ne $stats.Mem) {
$line += [string]::Format([cultureinfo]::InvariantCulture, ';M={0}', [int][math]::Round([double]$stats.Mem))
}
return $line
}
# Номер порта не хардкодим: он меняется при перетыкании в другой разъём.
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
}
}
+71
View File
@@ -0,0 +1,71 @@
// Все настройки проекта в одном месте: пины, пороги, шкалы, периоды.
// Менять поведение прошивки здесь, а не по коду модулей.
#pragma once
#include <Arduino.h>
// --- Дисплей ---------------------------------------------------------------
// Пины 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;
// --- Кнопка переключения экранов -------------------------------------------
// Кнопка BOOT. Это пин начальной загрузки: если держать его нажатым в момент
// сброса, плата уйдёт в режим прошивки - штатное поведение кнопки, а не наша
// беда. В работе читается как обычный вход с подтяжкой.
static const uint8_t PIN_BUTTON = 9;
// Порог защиты от дребезга контактов.
static const uint32_t BUTTON_DEBOUNCE_MS = 50;
// --- Сглаживание показаний -------------------------------------------------
// Скользящее среднее температуры: цифра не должна мельтешить на +-1 градус,
// пока ничего не происходит. Единица - один принятый замер, а агент шлёт раз
// в секунду, поэтому 5 означает "среднее за последние 5 секунд".
// Загрузка сознательно не сглаживается: её скачки настоящие, ради них на неё
// и смотрят.
static const uint8_t TEMP_AVG_SAMPLES = 5;
// --- График ----------------------------------------------------------------
// Шкала фиксированная: при автомасштабе шум +-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; // период перерисовки экрана
+171
View File
@@ -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, а не к разводке
встроенного экрана.
+755
View File
@@ -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 можно делать параллельно с прошивкой — она ни от чего не зависит.
+218
View File
@@ -0,0 +1,218 @@
# Бэклог
Идеи и незакрытые хвосты по проекту. Не план работ: сюда складывается то, что
решено не делать сейчас, чтобы не потерялось.
---
## 1. Другие показатели и листалка по кнопке BOOT — частично сделано
**Сделано 2026-09-17:** экран оперативной памяти и переключение кнопкой BOOT.
Решения, принятые при реализации, чтобы не пересматривать заново:
- **Тревога в приоритете.** При перегреве плата принудительно возвращается на
экран процессора, и кнопка не работает, пока температура не упадёт. Из трёх
вариантов, перечисленных ниже, выбран самый жёсткий: смысл тревоги в том, что
её нельзя прощёлкать.
- **Выбранный экран не запоминается** при перезагрузке — хранить негде.
- **Тревоги по памяти нет:** загрузка ОЗУ под 100 % на этой машине обычное
состояние.
- **Защита от дребезга** — 50 мс, порог в `config.h`.
- **Экран без данных выпадает из листания.** Если агент не шлёт ключ `M`, экран
памяти недоступен, и кнопка просто ничего не делает. Это же решение подойдёт
для машин без дискретной видеокарты.
**Осталось:** экраны батареи и сети, а также видеокарта — но у неё свой путь,
см. пункт 2.
**Что хочется.** Показывать не только процессор: видеокарту, оперативную память,
диски. Переключать экраны кнопкой **BOOT** на плате — нажал, увидел следующий
набор данных.
**Почему это дёшево со стороны протокола.** Формат `T=67.0;L=34` изначально
сделан расширяемым: прошивка молча игнорирует незнакомые ключи. Агент может
начать слать `G=61;GL=88;M=73` (видеокарта, её загрузка, память), и уже прошитые
платы от этого не сломаются — просто не покажут новое.
**Что придётся решить при реализации:**
- ~~**Кнопка BOOT сидит на GPIO9.**~~ Проверено на практике: чтение через
`INPUT_PULLUP` в работе никак не мешает загрузке платы.
- ~~**Дребезг контактов.**~~ Сделано: 50 мс, `BUTTON_DEBOUNCE_MS`.
- **Свой макет под каждый экран.** На 72×40 не получится «то же самое, только
другие цифры»: у памяти уместнее проценты и гигабайты, у диска — температура
без графика. Каждый экран проектировать отдельно.
- **Отдельная история на каждый показатель.** Сейчас кольцевой буфер один и
хранит температуру CPU. На каждый график нужен свой буфер: 72 байта на штуку,
памяти достаточно.
- **Что показывать, если данных нет.** Например, в системе нет дискретной
видеокарты — экран GPU надо либо пропускать при листании, либо честно писать,
что источника нет.
- ~~**Запоминать ли выбранный экран**~~ Решено не запоминать: хранилища нет.
- ~~**Тревога при листании.**~~ Решено: принудительно перекидывать на экран
процессора и блокировать кнопку.
**Для видеокарты выбран другой путь** — приоритетный экран без кнопки, см.
пункт 2. Листалка остаётся в силе для остального: памяти, батареи, сети.
### Что фактически доступно на этой машине
Проверено перебором датчиков через `LibreHardwareMonitorLib` после установки
PawnIO (ноутбук с i5-11400H и RTX 3050 Ti):
| Железо | Датчики | Права |
|---|---|---|
| Процессор | температура (15 датчиков: пакет, среднее, максимум, по ядрам, запас до предела), мощность | админ |
| Процессор | загрузка (общая, по ядрам, по потокам), частоты | обычные |
| Видеокарта NVIDIA | температура ядра и горячей точки, загрузка, мощность, напряжение, частоты, память, PCIe | **обычные** |
| Встроенная графика Intel | загрузка по блокам (3D, декод видео, копирование) | обычные |
| Оперативная память | занято и свободно в ГБ, загрузка в процентах | обычные |
| Сеть (5 адаптеров) | скорость приёма и отдачи, объём, утилизация | обычные |
| Батарея | заряд, деградация, ток, напряжение, мощность заряда и разряда, ёмкость | обычные |
Важное следствие: **температура видеокарты не требует прав администратора** —
NVIDIA отдаёт её через свой драйвер, ядро для этого не нужно. Админ нужен только
процессору, потому что там чтение MSR.
Чего нет: дисков (раздел не появился даже при включённом `IsStorageEnabled` —
вероятно, тоже упирается в права) и датчиков материнской платы с вентиляторами.
Для ноутбуков второе обычно: доступ к встроенному контроллеру закрыт.
### Разобранные кандидаты на экраны
1. **Видеокарта** — температура и загрузка. Полный аналог текущего экрана,
макет переиспользуется целиком. Работает даже без прав администратора.
2. **Память** — занято в гигабайтах и процент. Меняется медленно, читается с
расстояния, сглаживание не нужно.
3. **Батарея** — заряд и мощность разряда в ваттах: сразу видно, сколько ест
ноутбук прямо сейчас. Для стационарного ПК бесполезно, экран надо скрывать.
4. **Сеть** — скорость приёма и отдачи. Живее всего смотрится на графике, но
единицы прыгают от килобайт до мегабайт, и понадобится автоматическая смена
масштаба — это единственный кандидат, требующий новой логики отрисовки.
Порядок реализации по возрастанию стоимости: видеокарта, память, батарея, сеть.
---
## 2. Мониторинг видеокарты: приоритетный экран
**Выбранный подход.** Не листалка, а **приоритетный экран**: плата сама
показывает то, что ближе к своему порогу. Греется видеокарта — показывает её,
в простое показывает процессор по умолчанию.
Обоснование: устройство стоит на столе и смотрится боковым зрением, от него
нужен ответ на один вопрос — «всё ли в порядке». Листалка заставляет либо жать
кнопку, либо ждать нужного экрана. Значок в углу при этом естественно
превращается в указатель, чьи данные показаны, и понадобится второй значок —
видеокарты.
Совмещённый экран отвергнут по измерениям: свободно 17 пикселей по ширине,
вторая температура влезет только шрифтом 5×7, и смысл крупной цифры теряется.
### Сторона агента
Работа одинакова для любого способа отображения и не ломает совместимость:
прошивка игнорирует незнакомые ключи, поэтому агента можно обновить раньше
прошивки.
- включить `IsGpuEnabled`;
- брать `Temperature/GPU Core` и `Load/GPU Core`;
- добавить в строку ключи `G=` (температура) и `GL=` (загрузка):
`T=67.0;L=34;G=61.0;GL=88`;
- **опрашивать видеокарту реже процессора**, примерно раз в 5 секунд (см. ниже).
Права администратора для видеокарты не нужны — NVIDIA отдаёт температуру через
свой драйвер, а не через MSR.
### Сторона прошивки
- **Свои пороги на каждый показатель.** У процессора 85/82. У ноутбучной
RTX 3050 Ti предел около 87, так что пороги будут другими — подобрать,
а не копировать.
- **Задержка переключения.** Главная ловушка подхода: когда оба показателя
близки к своим порогам, экран начнёт скакать между ними каждую секунду. Нужно
либо минимальное время удержания экрана (5–10 секунд), либо заметный отрыв
по «близости к порогу», при котором происходит переключение.
- **Свой буфер истории на каждый показатель** — по 72 байта, памяти достаточно.
- **Свой значок** для видеокарты, рядом с существующим значком процессора.
- **Устаревание по каждому ключу отдельно.** Если данных по видеокарте нет
дольше N секунд, её экран просто не участвует в выборе. Это же снимает вопрос,
что делать на машине без дискретной видеокарты: экран не появится никогда.
- **Сглаживание.** Температуру видеокарты усреднять так же, как процессорную,
но окно считать в замерах, а не в секундах: при опросе раз в 5 секунд окно
из 5 замеров даст 25 секунд, что слишком много. Вероятно, хватит 2–3.
---
## 2a. Сначала проверить: будит ли опрос видеокарту
**Выполнить до реализации.** У ноутбука переключаемая графика, и в простое
дискретная видеокарта уходит в сон. Опрос её датчиков может будить её и мешать
энергосбережению — то есть мониторинг начнёт сам расходовать батарею и греть
машину. Сейчас она отвечает (39 °C), значит не спит, но в глубоком простое
поведение будет другим.
**Как проверить.** Прямой измеритель уже есть: у батареи читается датчик
`Power/Charge/Discharge Rate` в ваттах, и права администратора для него не
нужны.
1. Отключить ноутбук от сети, оставить машину в простое, не трогать ввод.
2. Замер А: 10 минут писать мощность разряда **без** опроса видеокарты
(`IsGpuEnabled = false`), раз в 10 секунд.
3. Замер Б: 10 минут то же самое **с** опросом видеокарты раз в секунду.
4. Сравнить средние. Заодно записывать `GpuNvidia Power/GPU Package`: если при
опросе он перестаёт падать до нуля — видеокарта не засыпает.
**Что делать с результатом.** Разница в пределах шума — опрашивать раз в
секунду, как процессор. Разница заметная (скажем, больше половины ватта) —
оставить опрос раз в 5 секунд или реже и запомнить это решение здесь.
Оценка: около десяти минут работы плюс двадцать минут ожидания замеров.
---
## 3. Обрезание строки при 100° и 100%
При трёхзначной температуре и трёхзначной загрузке верхняя строка занимает 79
пикселей из 72: `100` — 49 px, зазор 2, `100%` — 28 px. Лишние 7 пикселей
уходят за правый край, и загрузка обрезается.
Дефект существовал с самого начала, проявляется только под предельной нагрузкой.
Для i5-11400H это не гипотеза: предельная температура около 100 °C.
Решение на выбор: при трёхзначной температуре переходить на более мелкий шрифт
загрузки либо убирать знак процента. Оценка — около получаса вместе с проверкой.
Решено оставить как есть: в такой момент экран всё равно мигает тревогой.
---
## 4. Автозапуск не проверен перезагрузкой
Задание Планировщика `CPU Temp Agent` зарегистрировано с триггером «при входе в
систему», запускается вручную и работает. Живой перезагрузкой триггер не
испытан.
Проверится само при следующей перезагрузке: экран должен ожить без участия
пользователя в течение примерно полуминуты.
---
## 5. Скрипт заливки
Цикл «остановить агента → залить прошивку → запустить агента» делается тремя
командами вручную. Пока правок мало, это терпимо; если работа по экрану
продолжится, стоит свести в `flash.ps1`.
Причина, по которой шаг вообще нужен: плата прошивается через тот же COM-порт,
по которому идут данные, и занятый порт валит заливку с `PermissionError`.
---
## 6. Вес обзорного изображения
`docs/promo.png` весит 2,1 МБ при исходниках примерно на 30 КБ. Сжатие до
300–500 КБ не ухудшило бы читаемость, но старый файл всё равно останется в
истории git, так что выигрыш только для будущих клонов.
Решено оставить как есть.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+66
View File
@@ -0,0 +1,66 @@
#include "link.h"
#include <Arduino.h>
#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, -1};
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;
} else if (strcmp(key, "M") == 0) {
// Память необязательна: старый агент её не шлёт, и это не повод
// отбрасывать строку целиком.
parsed.memPct = atoi(val);
}
// прочие ключи игнорируются намеренно: протокол расширяемый
}
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;
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
struct CpuStats {
float tempC;
int loadPct;
int memPct; // -1 = ключа M в строке не было
};
void linkBegin();
// Неблокирующее чтение. Возвращает true, когда собрана и разобрана
// целая строка вида "T=67.0;L=34;M=97". Неизвестные ключи игнорируются.
bool linkPoll(CpuStats &out);
+42
View File
@@ -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);
}
+155
View File
@@ -0,0 +1,155 @@
#include "ui.h"
#include <U8g2lib.h>
#include <string.h>
#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 SCREEN_W = 72;
static const uint8_t GRAPH_W = SCREEN_W; // по пикселю на отсчёт: 72 точки = 72 секунды
static const uint8_t GRAPH_TOP = 28;
static const uint8_t GRAPH_H = 12;
// Значок в правом верхнем углу. Полоса y=0..10 выбрана не случайно: строка под
// цифрами лежит ниже (y=11..24), а сами цифры не дотягиваются сюда даже
// трёхзначными (49 px + знак = 56). Значок не перекроет ничего.
static const uint8_t ICON_W = 11;
static const uint8_t ICON_H = 11;
static const uint8_t ICON_X = SCREEN_W - ICON_W;
static const uint8_t ICON_Y = 0;
// 0 = нет данных, иначе высота столбика 1..GRAPH_H
static uint8_t cpuHistory[GRAPH_W];
static uint8_t ramHistory[GRAPH_W];
void uiBegin() {
display.begin();
display.clearBuffer();
display.setFont(u8g2_font_6x10_tf);
display.drawStr(0, 24, "WAIT");
display.sendBuffer();
}
// Общая часть для обеих историй: нормировать значение и сдвинуть буфер.
static void pushSample(uint8_t *history, float value, float scaleMin, float scaleMax) {
if (value < scaleMin) value = scaleMin;
if (value > scaleMax) value = scaleMax;
const float norm = (value - scaleMin) / (scaleMax - scaleMin);
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;
}
void uiPushCpuSample(float tempC) {
pushSample(cpuHistory, tempC, SCALE_MIN, SCALE_MAX);
}
void uiPushRamSample(int memPct) {
// У памяти шкала естественная, подбирать нечего.
pushSample(ramHistory, (float)memPct, 0.0f, 100.0f);
}
static void drawGraph(const uint8_t *history) {
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 drawCpuIcon(uint8_t x, uint8_t y) {
display.drawFrame(x + 1, y + 1, ICON_W - 2, ICON_H - 2);
display.drawBox(x + 4, y + 4, 3, 3);
for (uint8_t i = 0; i < 3; i++) {
const uint8_t o = 3 + i * 2;
display.drawPixel(x + o, y);
display.drawPixel(x + o, y + ICON_H - 1);
display.drawPixel(x, y + o);
display.drawPixel(x + ICON_W - 1, y + o);
}
}
// Планка памяти: корпус, три микросхемы на ней и контакты снизу.
static void drawRamIcon(uint8_t x, uint8_t y) {
display.drawFrame(x, y + 1, ICON_W, 7);
for (uint8_t i = 0; i < 3; i++) {
display.drawBox(x + 2 + i * 3, y + 3, 2, 3);
}
for (uint8_t i = 0; i < 5; i++) {
display.drawPixel(x + 1 + i * 2, y + 9);
}
}
// Негатив всего кадра. Через буфер, а не командой контроллера: так поведение
// не зависит от конкретной микросхемы дисплея.
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];
}
static void drawNoData() {
display.setFont(u8g2_font_6x10_tf);
display.drawStr(0, 14, "NO");
display.drawStr(0, 26, "DATA");
}
static void drawCpuScreen(float tempC, int loadPct) {
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);
drawCpuIcon(ICON_X, ICON_Y);
drawGraph(cpuHistory);
}
static void drawRamScreen(int memPct) {
char buf[8];
snprintf(buf, sizeof(buf), "%d", memPct);
display.setFont(u8g2_font_logisoso24_tn);
display.drawStr(0, 24, buf);
const int numWidth = display.getStrWidth(buf);
display.setFont(u8g2_font_7x14_tf);
display.drawStr(numWidth + 2, 24, "%");
drawRamIcon(ICON_X, ICON_Y);
drawGraph(ramHistory);
}
void uiRender(UiScreen screen, bool fresh, float tempC, int loadPct, int memPct, bool invert) {
display.clearBuffer();
if (!fresh) {
drawNoData();
} else if (screen == UI_SCREEN_RAM) {
drawRamScreen(memPct);
} else {
drawCpuScreen(tempC, loadPct);
}
if (invert) invertBuffer();
display.sendBuffer();
}
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <stdint.h>
enum UiScreen : uint8_t {
UI_SCREEN_CPU = 0,
UI_SCREEN_RAM = 1,
};
// Инициализирует дисплей и показывает заставку ожидания.
void uiBegin();
// Пополняют историю соответствующего графика. Вызывать раз в секунду и только
// при свежих данных, чтобы пауза связи не рисовалась как провал.
void uiPushCpuSample(float tempC);
void uiPushRamSample(int memPct);
// Перерисовывает экран целиком.
// fresh == false - данные устарели, вместо цифр выводится NO DATA.
// invert == true - кадр показывается в негативе (фаза мигания тревоги).
void uiRender(UiScreen screen, bool fresh, float tempC, int loadPct, int memPct, bool invert);
+6
View File
@@ -14,3 +14,9 @@ A collection of Arduino / ESP microcontroller projects.
panel, and posts events to an HTTPS webhook. Settings live in LittleFS. panel, and posts events to an HTTPS webhook. Settings live in LittleFS.
See its [README](pump_controller_8_2_OLED_DONE/README.md) and See its [README](pump_controller_8_2_OLED_DONE/README.md) and
[MANUAL](pump_controller_8_2_OLED_DONE/MANUAL.md). [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).