Compare commits

..
20 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 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 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
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
10 changed files with 343 additions and 62 deletions

No files matched your search

+47 -4
View File
@@ -1,13 +1,14 @@
// Индикатор температуры CPU: плата принимает строки "T=67.0;L=34" по USB-COM
// Индикатор состояния ПК: плата принимает строки "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};
static CpuStats stats = {0.0f, 0, -1};
static uint32_t lastData = 0;
static uint32_t lastSample = 0;
static uint32_t lastDraw = 0;
@@ -16,6 +17,8 @@ static bool alarmActive = false;
static bool wasFresh = false;
static float tempShown = 0.0f; // усреднённое значение, именно оно на экране
static UiScreen screen = UI_SCREEN_CPU;
// Скользящее среднее температуры. Сумма пересчитывается заново, а не копится:
// на пяти числах это бесплатно, зато нет накопления ошибки округления за сутки
// непрерывной работы.
@@ -41,6 +44,7 @@ static float tempAvgPush(float v) {
void setup() {
pinMode(PIN_LED, OUTPUT);
digitalWrite(PIN_LED, !LED_ACTIVE_LEVEL);
pinMode(PIN_BUTTON, INPUT_PULLUP);
linkBegin();
uiBegin();
@@ -52,6 +56,38 @@ static void updateAlarm(float tempC) {
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)) {
@@ -75,6 +111,12 @@ void loop() {
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;
@@ -88,11 +130,12 @@ void loop() {
// Историю дописываем только при живой связи: пауза не должна рисоваться провалом.
if (isFresh && millis() - lastSample >= SAMPLE_MS) {
lastSample = millis();
uiPushSample(stats.tempC); // график по сырым: 5 градусов на пиксель, шум там не виден
uiPushCpuSample(stats.tempC); // график по сырым: 5 градусов на пиксель, шум там не виден
if (ramAvailable()) uiPushRamSample(stats.memPct);
}
if (millis() - lastDraw >= DRAW_MS) {
lastDraw = millis();
uiRender(isFresh, tempShown, stats.loadPct, alarmActive && blinkOn);
uiRender(screen, isFresh, tempShown, stats.loadPct, stats.memPct, alarmActive && blinkOn);
}
}
+43 -11
View File
@@ -1,12 +1,13 @@
# CPU Temp — индикатор температуры процессора на ESP32-C3
Небольшая плата ESP32-C3 со встроенным OLED-экраном 0.42″ стоит на столе и
показывает температуру процессора компьютера: крупная цифра, загрузка CPU рядом
и график за последнюю минуту. При перегреве экран мигает негативом.
показывает состояние компьютера: крупная цифра, мелкий показатель рядом и график
за последнюю минуту. Экранов два — процессор и оперативная память, переключаются
кнопкой BOOT. При перегреве процессора экран мигает негативом.
```
LibreHardwareMonitorLib.dll ──> PowerShell-агент ──COM (USB CDC)──> ESP32-C3 ──I2C──> OLED 72×40
(через драйвер PawnIO) раз в секунду "T=67.0;L=34" U8g2
(через драйвер PawnIO) раз в секунду "T=67.0;L=34;M=97" U8g2
```
**Плата ничего не измеряет сама.** Она принимает готовые строки по USB и рисует
@@ -172,8 +173,8 @@ powershell -ExecutionPolicy Bypass -File "D:\Soft\Arduino\CPU_temp\agent\cpu_tem
```
connected: COM4
T=47.0;L=24
T=48.0;L=14
T=47.0;L=24;M=97
T=48.0;L=14;M=97
```
Одновременно на экране платы появится температура. Остановить — Ctrl+C.
@@ -240,6 +241,8 @@ Start-ScheduledTask -TaskName 'CPU Temp Agent'
| `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 |
@@ -283,14 +286,34 @@ Start-ScheduledTask -TaskName 'CPU Temp Agent'
## Что показывает экран
Экранов два, переключаются нажатием кнопки **BOOT** (длительность не важна):
| Экран | Крупно | Мелко | График |
|---|---|---|---|
| Процессор | температура, °C | загрузка, % | температура, шкала 30–90 °C |
| Память | загрузка, % | знак процента | загрузка, шкала 0–100 % |
Значок в правом верхнем углу говорит, какой экран открыт: микросхема — процессор,
планка памяти — ОЗУ.
| Состояние | Экран | Светодиод GPIO8 |
|---|---|---|
| Норма | температура, загрузка, график | погашен |
| Перегрев | то же, мигает негативом | мигает синхронно с экраном |
| Норма | выбранный экран | погашен |
| Перегрев | **принудительно** экран процессора, мигает негативом | мигает синхронно с экраном |
| Нет данных дольше 5 с | `NO DATA` | горит ровно |
В режиме `NO DATA` история графика не пополняется — иначе обрыв связи выглядел бы
как резкое падение температуры.
**Тревога в приоритете.** При перегреве плата сама возвращается на экран
процессора, и кнопка перестаёт работать до тех пор, пока температура не упадёт.
Смысл тревоги в том, что её нельзя прощёлкать.
Выбранный экран **не запоминается** при перезагрузке платы: энергонезависимого
хранилища в прошивке нет вовсе, после сброса всегда показывается процессор.
Тревога считается только по температуре процессора. По памяти её нет намеренно:
загрузка ОЗУ под 100 % — обычное рабочее состояние, мигать на него бессмысленно.
В режиме `NO DATA` история графиков не пополняется — иначе обрыв связи выглядел
бы как резкое падение значений.
---
@@ -299,13 +322,22 @@ Start-ScheduledTask -TaskName 'CPU Temp Agent'
Агент шлёт плате по одной строке в секунду, в конце — перевод строки:
```
T=67.0;L=34
T=67.0;L=34;M=97
```
| Ключ | Смысл |
|---|---|
| `T` | температура CPU Package, °C |
| `L` | общая загрузка CPU, % |
| `M` | загрузка оперативной памяти, % |
Ключ `M` показывается на отдельном экране памяти — третьему показателю на экране
72×40 места нет. Если агент старый и ключа `M` не шлёт, экран памяти из листания
выпадает сам, и кнопка ничего не делает.
Берётся он у железа с именем `Total Memory` — это физическая память. У соседнего
`Virtual Memory` датчик называется точно так же, но считает вместе с файлом
подкачки и показывает заниженную цифру.
Неизвестные ключи прошивка молча игнорирует, поэтому протокол расширяем: можно
добавить, например, `G=` для видеокарты, не трогая уже прошитые платы.
@@ -350,7 +382,7 @@ $sp.Close()
| Путь | Что это |
|---|---|
| `config.h` | все настройки |
| `CPU_temp.ino` | свежесть данных, гистерезис тревоги, светодиод |
| `CPU_temp.ino` | свежесть данных, гистерезис тревоги, кнопка, выбор экрана, светодиод |
| `link.h` / `link.cpp` | неблокирующее чтение строки из Serial и её разбор |
| `ui.h` / `ui.cpp` | отрисовка через U8g2, кольцевой буфер истории, негатив |
| `tools/i2c_scan/` | одноразовый скетч поиска пинов дисплея |
+1 -1
View File
@@ -1,7 +1,7 @@
# Агент CPU Temp
Читает температуру и загрузку процессора и раз в секунду отдаёт их плате
ESP32-C3 по USB-COM строкой вида `T=67.0;L=34`.
ESP32-C3 по USB-COM строкой вида `T=67.0;L=34;M=97`.
## Что нужно установить
+14 -1
View File
@@ -29,6 +29,7 @@ Add-Type -Path $LhmPath
$computer = New-Object LibreHardwareMonitor.Hardware.Computer
$computer.IsCpuEnabled = $true
$computer.IsMemoryEnabled = $true
$computer.Open()
function Get-CpuStats {
@@ -36,10 +37,15 @@ function Get-CpuStats {
$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 }
}
}
@@ -48,6 +54,7 @@ function Get-CpuStats {
return @{
Temp = $temp
Load = $load
Mem = $mem
}
}
@@ -73,7 +80,13 @@ function Assert-SensorsAvailable {
# Форматирование только через InvariantCulture: в русской локали вышло бы
# "T=67,0", и atof на плате прочитал бы 67, молча потеряв дробную часть.
function Format-StatsLine($stats) {
return [string]::Format([cultureinfo]::InvariantCulture, 'T={0:F1};L={1}', [double]$stats.Temp, [int][math]::Round([double]$stats.Load))
$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
}
# Номер порта не хардкодим: он меняется при перетыкании в другой разъём.
+10
View File
@@ -37,6 +37,16 @@ static const uint8_t PIN_LED = 8;
// высокий уровень и нужен.
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 градус,
+145 -17
View File
@@ -5,7 +5,25 @@
---
## 1. Другие показатели и листалка по кнопке BOOT
## 1. Другие показатели и листалка по кнопке BOOT — частично сделано
**Сделано 2026-09-17:** экран оперативной памяти и переключение кнопкой BOOT.
Решения, принятые при реализации, чтобы не пересматривать заново:
- **Тревога в приоритете.** При перегреве плата принудительно возвращается на
экран процессора, и кнопка не работает, пока температура не упадёт. Из трёх
вариантов, перечисленных ниже, выбран самый жёсткий: смысл тревоги в том, что
её нельзя прощёлкать.
- **Выбранный экран не запоминается** при перезагрузке — хранить негде.
- **Тревоги по памяти нет:** загрузка ОЗУ под 100 % на этой машине обычное
состояние.
- **Защита от дребезга** — 50 мс, порог в `config.h`.
- **Экран без данных выпадает из листания.** Если агент не шлёт ключ `M`, экран
памяти недоступен, и кнопка просто ничего не делает. Это же решение подойдёт
для машин без дискретной видеокарты.
**Осталось:** экраны батареи и сети, а также видеокарта — но у неё свой путь,
см. пункт 2.
**Что хочется.** Показывать не только процессор: видеокарту, оперативную память,
диски. Переключать экраны кнопкой **BOOT** на плате — нажал, увидел следующий
@@ -18,11 +36,9 @@
**Что придётся решить при реализации:**
- **Кнопка BOOT сидит на GPIO9.** Это пин начальной загрузки: при сбросе он
должен быть в высоком уровне, иначе плата уйдёт в режим прошивки. Читать его
как `INPUT_PULLUP` в работе безопасно, но проверить фактически.
- **Дребезг контактов.** Нужна программная защита, иначе одно нажатие пролистает
три экрана.
- ~~**Кнопка BOOT сидит на GPIO9.**~~ Проверено на практике: чтение через
`INPUT_PULLUP` в работе никак не мешает загрузке платы.
- ~~**Дребезг контактов.**~~ Сделано: 50 мс, `BUTTON_DEBOUNCE_MS`.
- **Свой макет под каждый экран.** На 72×40 не получится «то же самое, только
другие цифры»: у памяти уместнее проценты и гигабайты, у диска — температура
без графика. Каждый экран проектировать отдельно.
@@ -32,18 +48,130 @@
- **Что показывать, если данных нет.** Например, в системе нет дискретной
видеокарты — экран GPU надо либо пропускать при листании, либо честно писать,
что источника нет.
- **Запоминать ли выбранный экран** между перезагрузками. Если да, понадобится
энергонезависимое хранилище, которого у прошивки сейчас нет вовсе.
- **Тревога при листании.** Сейчас перегрев виден всегда, потому что экран один.
С листалкой можно стоять на экране памяти и не увидеть, что горит процессор.
Нужно решить: принудительно перекидывать на экран CPU, показывать тревогу
поверх любого экрана или мигать только светодиодом.
- ~~**Запоминать ли выбранный экран**~~ Решено не запоминать: хранилища нет.
- ~~**Тревога при листании.**~~ Решено: принудительно перекидывать на экран
процессора и блокировать кнопку.
Последний пункт — самый важный: он затрагивает смысл устройства, а не удобство.
**Для видеокарты выбран другой путь** — приоритетный экран без кнопки, см.
пункт 2. Листалка остаётся в силе для остального: памяти, батареи, сети.
### Что фактически доступно на этой машине
Проверено перебором датчиков через `LibreHardwareMonitorLib` после установки
PawnIO (ноутбук с i5-11400H и RTX 3050 Ti):
| Железо | Датчики | Права |
|---|---|---|
| Процессор | температура (15 датчиков: пакет, среднее, максимум, по ядрам, запас до предела), мощность | админ |
| Процессор | загрузка (общая, по ядрам, по потокам), частоты | обычные |
| Видеокарта NVIDIA | температура ядра и горячей точки, загрузка, мощность, напряжение, частоты, память, PCIe | **обычные** |
| Встроенная графика Intel | загрузка по блокам (3D, декод видео, копирование) | обычные |
| Оперативная память | занято и свободно в ГБ, загрузка в процентах | обычные |
| Сеть (5 адаптеров) | скорость приёма и отдачи, объём, утилизация | обычные |
| Батарея | заряд, деградация, ток, напряжение, мощность заряда и разряда, ёмкость | обычные |
Важное следствие: **температура видеокарты не требует прав администратора** —
NVIDIA отдаёт её через свой драйвер, ядро для этого не нужно. Админ нужен только
процессору, потому что там чтение MSR.
Чего нет: дисков (раздел не появился даже при включённом `IsStorageEnabled` —
вероятно, тоже упирается в права) и датчиков материнской платы с вентиляторами.
Для ноутбуков второе обычно: доступ к встроенному контроллеру закрыт.
### Разобранные кандидаты на экраны
1. **Видеокарта** — температура и загрузка. Полный аналог текущего экрана,
макет переиспользуется целиком. Работает даже без прав администратора.
2. **Память** — занято в гигабайтах и процент. Меняется медленно, читается с
расстояния, сглаживание не нужно.
3. **Батарея** — заряд и мощность разряда в ваттах: сразу видно, сколько ест
ноутбук прямо сейчас. Для стационарного ПК бесполезно, экран надо скрывать.
4. **Сеть** — скорость приёма и отдачи. Живее всего смотрится на графике, но
единицы прыгают от килобайт до мегабайт, и понадобится автоматическая смена
масштаба — это единственный кандидат, требующий новой логики отрисовки.
Порядок реализации по возрастанию стоимости: видеокарта, память, батарея, сеть.
---
## 2. Обрезание строки при 100° и 100%
## 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 пикселей
@@ -59,7 +187,7 @@
---
## 3. Автозапуск не проверен перезагрузкой
## 4. Автозапуск не проверен перезагрузкой
Задание Планировщика `CPU Temp Agent` зарегистрировано с триггером «при входе в
систему», запускается вручную и работает. Живой перезагрузкой триггер не
@@ -70,7 +198,7 @@
---
## 4. Скрипт заливки
## 5. Скрипт заливки
Цикл «остановить агента → залить прошивку → запустить агента» делается тремя
командами вручную. Пока правок мало, это терпимо; если работа по экрану
@@ -81,7 +209,7 @@
---
## 5. Вес обзорного изображения
## 6. Вес обзорного изображения
`docs/promo.png` весит 2,1 МБ при исходниках примерно на 30 КБ. Сжатие до
300–500 КБ не ухудшило бы читаемость, но старый файл всё равно останется в
+5 -1
View File
@@ -16,7 +16,7 @@ void linkBegin() {
static bool parseLine(char *line, CpuStats &out) {
bool haveTemp = false;
bool haveLoad = false;
CpuStats parsed = {0.0f, 0};
CpuStats parsed = {0.0f, 0, -1};
char *save = nullptr;
for (char *tok = strtok_r(line, ";", &save); tok; tok = strtok_r(nullptr, ";", &save)) {
@@ -32,6 +32,10 @@ static bool parseLine(char *line, CpuStats &out) {
} else if (strcmp(key, "L") == 0) {
parsed.loadPct = atoi(val);
haveLoad = true;
} else if (strcmp(key, "M") == 0) {
// Память необязательна: старый агент её не шлёт, и это не повод
// отбрасывать строку целиком.
parsed.memPct = atoi(val);
}
// прочие ключи игнорируются намеренно: протокол расширяемый
}
+2 -1
View File
@@ -3,10 +3,11 @@
struct CpuStats {
float tempC;
int loadPct;
int memPct; // -1 = ключа M в строке не было
};
void linkBegin();
// Неблокирующее чтение. Возвращает true, когда собрана и разобрана
// целая строка вида "T=67.0;L=34". Неизвестные ключи игнорируются.
// целая строка вида "T=67.0;L=34;M=97". Неизвестные ключи игнорируются.
bool linkPoll(CpuStats &out);
+66 -22
View File
@@ -8,20 +8,23 @@
// Порядок параметров: (rotation, reset, clock, data) — SCL идёт раньше SDA.
static U8G2_SSD1306_72X40_ER_F_HW_I2C display(DISPLAY_ROTATION, U8X8_PIN_NONE, PIN_SCL, PIN_SDA);
static const uint8_t GRAPH_W = 72; // по пикселю на отсчёт: 72 точки = 72 секунды
static const uint8_t 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). Значит значок не перекроет
// ничего ни при каких значениях. Размеры измерены на плате, не на глаз.
// Значок в правом верхнем углу. Полоса 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 = 72 - ICON_W;
static const uint8_t ICON_X = SCREEN_W - ICON_W;
static const uint8_t ICON_Y = 0;
static uint8_t history[GRAPH_W]; // 0 = нет данных, иначе высота столбика 1..GRAPH_H
// 0 = нет данных, иначе высота столбика 1..GRAPH_H
static uint8_t cpuHistory[GRAPH_W];
static uint8_t ramHistory[GRAPH_W];
void uiBegin() {
display.begin();
@@ -31,11 +34,12 @@ void uiBegin() {
display.sendBuffer();
}
void uiPushSample(float tempC) {
if (tempC < SCALE_MIN) tempC = SCALE_MIN;
if (tempC > SCALE_MAX) tempC = SCALE_MAX;
// Общая часть для обеих историй: нормировать значение и сдвинуть буфер.
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 = (tempC - SCALE_MIN) / (SCALE_MAX - SCALE_MIN);
const float norm = (value - scaleMin) / (scaleMax - scaleMin);
uint8_t h = (uint8_t)(norm * GRAPH_H + 0.5f);
if (h < 1) h = 1;
@@ -43,7 +47,16 @@ void uiPushSample(float tempC) {
history[GRAPH_W - 1] = h;
}
static void drawGraph() {
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;
@@ -64,6 +77,17 @@ static void drawCpuIcon(uint8_t x, uint8_t y) {
}
}
// Планка памяти: корпус, три микросхемы на ней и контакты снизу.
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() {
@@ -72,21 +96,15 @@ static void invertBuffer() {
for (size_t i = 0; i < n; i++) b[i] = (uint8_t)~b[i];
}
void uiRender(bool fresh, float tempC, int loadPct, bool invert) {
display.clearBuffer();
if (!fresh) {
static void drawNoData() {
display.setFont(u8g2_font_6x10_tf);
display.drawStr(0, 14, "NO");
display.drawStr(0, 26, "DATA");
if (invert) invertBuffer();
display.sendBuffer();
return;
}
}
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);
@@ -102,8 +120,34 @@ void uiRender(bool fresh, float tempC, int loadPct, bool invert) {
display.drawStr(numWidth + 2, 24, buf);
drawCpuIcon(ICON_X, ICON_Y);
drawGraph(cpuHistory);
}
drawGraph();
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();
+10 -4
View File
@@ -2,14 +2,20 @@
#include <stdint.h>
enum UiScreen : uint8_t {
UI_SCREEN_CPU = 0,
UI_SCREEN_RAM = 1,
};
// Инициализирует дисплей и показывает заставку ожидания.
void uiBegin();
// Добавляет значение в кольцевой буфер истории. Вызывать раз в секунду
// и только при свежих данных, чтобы пауза связи не рисовалась как провал.
void uiPushSample(float tempC);
// Пополняют историю соответствующего графика. Вызывать раз в секунду и только
// при свежих данных, чтобы пауза связи не рисовалась как провал.
void uiPushCpuSample(float tempC);
void uiPushRamSample(int memPct);
// Перерисовывает экран целиком.
// fresh == false - данные устарели, вместо цифр выводится NO DATA.
// invert == true - кадр показывается в негативе (фаза мигания тревоги).
void uiRender(bool fresh, float tempC, int loadPct, bool invert);
void uiRender(UiScreen screen, bool fresh, float tempC, int loadPct, int memPct, bool invert);