Compare commits
3
Commits
dev
..
2bf49c14ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf49c14ac | ||
|
|
f8294d4aed | ||
|
|
4b59b8936b |
No files matched your search
-23
@@ -1,35 +1,12 @@
|
||||
# Рабочий каталог D:\Soft\Arduino содержит не только проекты этого репозитория,
|
||||
# но и старые версии скетчей, эксперименты, архивы и драйверы. Поэтому здесь
|
||||
# белый список: по умолчанию игнорируется всё, а в репозиторий попадают только
|
||||
# перечисленные ниже проекты.
|
||||
#
|
||||
# Чтобы добавить проект: допишите строку "!/ИмяПапки/" в блок ниже.
|
||||
# Имя папки должно совпадать с именем .ino внутри — требование Arduino IDE.
|
||||
|
||||
/*
|
||||
|
||||
!/.gitignore
|
||||
!/README.md
|
||||
|
||||
!/WeatherPredictor/
|
||||
!/pump_controller_8_2_OLED_DONE/
|
||||
!/CPU_temp/
|
||||
|
||||
# --- Правила ниже действуют внутри разрешённых проектов ---
|
||||
# Идут после белого списка намеренно: более поздний шаблон побеждает, поэтому
|
||||
# артефакты сборки исключаются даже внутри отслеживаемых папок.
|
||||
|
||||
# Arduino / build artifacts
|
||||
build/
|
||||
*.bin
|
||||
*.elf
|
||||
*.hex
|
||||
.vscode/
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
.DS_Store
|
||||
|
||||
# Secrets (Wi-Fi creds are set via WiFiManager, not committed)
|
||||
secrets.h
|
||||
@@ -1,141 +0,0 @@
|
||||
// Индикатор состояния ПК: плата принимает строки "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);
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
# 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
|
||||
доступна только через драйвер уровня ядра.
|
||||
|
||||

|
||||
|
||||
*Две поправки к изображению: экран на этой плате 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% её оперативной памяти.
|
||||
@@ -1,86 +0,0 @@
|
||||
# Агент 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`, потеряв дробную часть.
|
||||
@@ -1,145 +0,0 @@
|
||||
<#
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Все настройки проекта в одном месте: пины, пороги, шкалы, периоды.
|
||||
// Менять поведение прошивки здесь, а не по коду модулей.
|
||||
|
||||
#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; // период перерисовки экрана
|
||||
@@ -1,171 +0,0 @@
|
||||
# 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, а не к разводке
|
||||
встроенного экрана.
|
||||
@@ -1,755 +0,0 @@
|
||||
# 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 можно делать параллельно с прошивкой — она ни от чего не зависит.
|
||||
@@ -1,218 +0,0 @@
|
||||
# Бэклог
|
||||
|
||||
Идеи и незакрытые хвосты по проекту. Не план работ: сюда складывается то, что
|
||||
решено не делать сейчас, чтобы не потерялось.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
Before Width: | Height: | Size: 2.0 MiB |
@@ -1,66 +0,0 @@
|
||||
#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;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
#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);
|
||||
@@ -1,42 +0,0 @@
|
||||
// Одноразовый скетч: ищет, на какой паре пинов висит встроенный 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
@@ -1,155 +0,0 @@
|
||||
#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();
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
#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);
|
||||
@@ -7,16 +7,3 @@ A collection of Arduino / ESP microcontroller projects.
|
||||
- **[WeatherPredictor](WeatherPredictor/)** — autonomous barometric weather
|
||||
station on a Wemos D1 mini (ESP8266): local Zambretti forecast, TFT display
|
||||
and a self-hosted web interface. See its [README](WeatherPredictor/README.md).
|
||||
|
||||
- **[pump_controller_8_2_OLED_DONE](pump_controller_8_2_OLED_DONE/)** — pump
|
||||
station monitor on a Wemos D1 mini (ESP8266): watches two main pumps plus a
|
||||
backup one, drives two alarm relays, shows status on an OLED and in a web
|
||||
panel, and posts events to an HTTPS webhook. Settings live in LittleFS.
|
||||
See its [README](pump_controller_8_2_OLED_DONE/README.md) and
|
||||
[MANUAL](pump_controller_8_2_OLED_DONE/MANUAL.md).
|
||||
|
||||
- **[CPU_temp](CPU_temp/)** — desktop CPU temperature monitor on an ESP32-C3 with
|
||||
a built-in 0.42-inch OLED: a Windows agent reads the sensors and feeds the
|
||||
board over USB serial, which shows the temperature, CPU load and a one-minute
|
||||
history graph, and blinks an overheat alarm. See its
|
||||
[README](CPU_temp/README.md).
|
||||
@@ -43,8 +43,6 @@ void setup() {
|
||||
|
||||
displayBegin();
|
||||
displaySplash("Weather", "Predictor");
|
||||
delay(800);
|
||||
displayDemo();
|
||||
|
||||
if (!sensorsBegin()) Serial.println(F("BMP180 not found!"));
|
||||
if (!rtcBegin()) Serial.println(F("DS3231 not found!"));
|
||||
|
||||
@@ -99,46 +99,6 @@ static void drawIcon(WxCategory c, int x, int y, int W, int H) {
|
||||
}
|
||||
|
||||
// Layout for landscape 160 x 80.
|
||||
// Boot demo: each weather icon slides in from the right to centre, with its
|
||||
// category label below in the matching colour.
|
||||
void displayDemo() {
|
||||
WxCategory cats[5] = {WX_FINE, WX_FAIR, WX_CHANGEABLE, WX_RAIN, WX_STORM};
|
||||
const char* names[5] = {"Fine", "Fair", "Changeable", "Rain", "Storm"};
|
||||
const int W = 64, H = 52, y = 4;
|
||||
const int cxc = (160 - W) / 2; // centred icon x
|
||||
tft.fillScreen(ST77XX_BLACK); // clear the splash before the carousel
|
||||
for (int k = 0; k < 5; k++) {
|
||||
// slide in from the right edge to centre
|
||||
int px = 160 + 12;
|
||||
for (int x = 160; x >= cxc; x -= 12) {
|
||||
tft.fillRect(px, y, W, H + 4, ST77XX_BLACK);
|
||||
drawIcon(cats[k], x, y, W, H);
|
||||
px = x;
|
||||
delay(26);
|
||||
}
|
||||
tft.fillRect(px, y, W, H + 4, ST77XX_BLACK);
|
||||
drawIcon(cats[k], cxc, y, W, H);
|
||||
// label centred below in the category colour
|
||||
int len = 0; while (names[k][len]) len++; // size 2 = 12 px/char
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(catColor(cats[k]));
|
||||
tft.setCursor((160 - len * 12) / 2, 60);
|
||||
tft.print(names[k]);
|
||||
delay(360);
|
||||
tft.fillRect(0, 60, 160, 20, ST77XX_BLACK); // drop the label before it leaves
|
||||
// slide out to the left edge
|
||||
px = cxc;
|
||||
for (int x = cxc - 12; x >= -W; x -= 12) {
|
||||
tft.fillRect(px, y, W, H + 4, ST77XX_BLACK);
|
||||
drawIcon(cats[k], x, y, W, H);
|
||||
px = x;
|
||||
delay(24);
|
||||
}
|
||||
tft.fillRect(px, y, W, H + 4, ST77XX_BLACK); // clear the last remnant
|
||||
}
|
||||
tft.setTextColor(ST77XX_WHITE);
|
||||
}
|
||||
|
||||
void displayRender(const AppState& s) {
|
||||
tft.fillScreen(ST77XX_BLACK);
|
||||
tft.setTextColor(ST77XX_WHITE);
|
||||
|
||||
@@ -4,5 +4,4 @@
|
||||
|
||||
void displayBegin();
|
||||
void displaySplash(const char* line1, const char* line2);
|
||||
void displayDemo(); // boot animation: icons slide in one by one
|
||||
void displayRender(const AppState& s);
|
||||
+38
-135
@@ -39,11 +39,6 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
|
||||
}
|
||||
.lede{color:var(--muted); font-size:.92rem; margin:8px 0 0}
|
||||
.lede b{color:var(--text); font-weight:600}
|
||||
header{position:relative}
|
||||
.lang{position:absolute; top:0; right:0; display:flex; gap:3px}
|
||||
.lang button{padding:4px 9px; font-size:.72rem; font-weight:700; letter-spacing:.06em; font-family:var(--sans);
|
||||
background:transparent; color:var(--muted); border:1px solid var(--line); border-radius:7px; cursor:pointer}
|
||||
.lang button.on{background:var(--brass); color:#221a06; border-color:var(--brass)}
|
||||
|
||||
.panel{
|
||||
background:linear-gradient(180deg, var(--surface), #14203a);
|
||||
@@ -158,10 +153,9 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<div class="lang" id="lang"><button type="button" data-lang="ru">RU</button><button type="button" data-lang="en">EN</button></div>
|
||||
<p class="eyebrow" data-i18n="eyebrow">Barometric Forecaster</p>
|
||||
<h1 data-i18n="title">Weather Predictor</h1>
|
||||
<p class="lede"><span data-i18n="lede">Reading the sky by pressure alone, the Zambretti way — station at</span>
|
||||
<p class="eyebrow">Barometric Forecaster</p>
|
||||
<h1>Weather Predictor</h1>
|
||||
<p class="lede">Reading the sky by pressure alone, the <b>Zambretti</b> way — station at
|
||||
<span id="coords">54.99°N, 82.87°E</span>.</p>
|
||||
</header>
|
||||
|
||||
@@ -170,7 +164,7 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
|
||||
<svg id="gauge" viewBox="0 0 400 232" role="img" aria-label="Barometer dial"></svg>
|
||||
<div class="readout">
|
||||
<span class="pressure"><span id="msl">----</span></span>
|
||||
<span class="unit" data-i18n="unit_msl">hPa · sea level</span>
|
||||
<span class="unit">hPa · sea level</span>
|
||||
<span class="chip" id="chip" style="color:var(--muted)">—</span>
|
||||
</div>
|
||||
<p class="verdict" id="verdict">Reading the sky…</p>
|
||||
@@ -179,18 +173,18 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
|
||||
|
||||
<!-- Stats -->
|
||||
<section class="panel stats">
|
||||
<div class="stat"><div class="k" data-i18n="k_time">Local time</div><div class="v" id="time">--:--</div><div class="s" id="date">—</div></div>
|
||||
<div class="stat"><div class="k" data-i18n="k_temp">Temperature</div><div class="v" id="temp">--</div><div class="s" data-i18n="s_celsius">degrees celsius</div></div>
|
||||
<div class="stat"><div class="k" data-i18n="k_move">3-hour move</div><div class="v" id="delta">—</div><div class="s" id="deltas">—</div></div>
|
||||
<div class="stat"><div class="k">Local time</div><div class="v" id="time">--:--</div><div class="s" id="date">—</div></div>
|
||||
<div class="stat"><div class="k">Temperature</div><div class="v" id="temp">--</div><div class="s">degrees celsius</div></div>
|
||||
<div class="stat"><div class="k">3-hour move</div><div class="v" id="delta">—</div><div class="s" id="deltas">—</div></div>
|
||||
</section>
|
||||
|
||||
<!-- Barograph -->
|
||||
<section class="panel panel-pad">
|
||||
<div class="head">
|
||||
<h2 data-i18n="barograph">Barograph</h2>
|
||||
<h2>Barograph</h2>
|
||||
<div class="legend">
|
||||
<span><i style="background:var(--change)"></i><span data-i18n="pressure">Pressure</span></span>
|
||||
<span><i style="background:var(--fair)"></i><span data-i18n="temperature">Temperature</span></span>
|
||||
<span><i style="background:var(--change)"></i>Pressure</span>
|
||||
<span><i style="background:var(--fair)"></i>Temperature</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="baro">
|
||||
@@ -205,7 +199,7 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
|
||||
<!-- Forecast log -->
|
||||
<section class="panel panel-pad">
|
||||
<div class="head">
|
||||
<h2 data-i18n="flog">Forecast log</h2>
|
||||
<h2>Forecast log</h2>
|
||||
<span class="cap" id="log-cap"></span>
|
||||
</div>
|
||||
<ol class="log" id="log"></ol>
|
||||
@@ -213,27 +207,27 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
|
||||
|
||||
<!-- Settings -->
|
||||
<details class="panel set">
|
||||
<summary><span data-i18n="settings">Station settings</span> <span class="caret" data-i18n="adjust">Adjust</span></summary>
|
||||
<summary>Station settings <span class="caret">Adjust</span></summary>
|
||||
<hr class="rule">
|
||||
<div class="form">
|
||||
<div style="margin-top:18px">
|
||||
<label data-i18n="l_city">City preset</label>
|
||||
<label>City preset</label>
|
||||
<select id="s-city"></select>
|
||||
</div>
|
||||
<div class="grid2" style="margin-top:14px">
|
||||
<div><label data-i18n="l_alt">Altitude · metres</label><input id="s-alt" type="number" step="1"></div>
|
||||
<div><label data-i18n="l_tz">Time zone</label><select id="s-tz"></select></div>
|
||||
<div><label data-i18n="l_lat">Latitude</label><input id="s-lat" type="number" step="0.0001"></div>
|
||||
<div><label data-i18n="l_lon">Longitude</label><input id="s-lon" type="number" step="0.0001"></div>
|
||||
<div><label>Altitude · metres</label><input id="s-alt" type="number" step="1"></div>
|
||||
<div><label>Time zone</label><select id="s-tz"></select></div>
|
||||
<div><label>Latitude</label><input id="s-lat" type="number" step="0.0001"></div>
|
||||
<div><label>Longitude</label><input id="s-lon" type="number" step="0.0001"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="save" data-i18n="save">Save settings</button>
|
||||
<button id="save">Save settings</button>
|
||||
<span class="saved" id="saved"></span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<footer data-i18n="footer">Served from the station · works without internet</footer>
|
||||
<footer>Served from the station · works without internet</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -248,93 +242,6 @@ var ZONES=[
|
||||
];
|
||||
var CATCOLOR={Fine:"#8FBE8A",Fair:"#E4B658",Changeable:"#79C2D0",Rain:"#5C86D6",Storm:"#E4573B"};
|
||||
|
||||
// ---------- i18n ----------
|
||||
var LANG=(function(){ try{ return localStorage.getItem("wp_lang")||"ru"; }catch(e){ return "ru"; } })();
|
||||
var STR={
|
||||
ru:{
|
||||
eyebrow:"Барометрический предсказатель", title:"Предсказатель погоды",
|
||||
lede:"Читаем небо по одному давлению — метод Zambretti. Станция:",
|
||||
unit_msl:"гПа · у моря",
|
||||
k_time:"Местное время", k_temp:"Температура", k_move:"Изм. за 3 ч", s_celsius:"градусы Цельсия",
|
||||
barograph:"Барограф", pressure:"Давление", temperature:"Температура", flog:"Журнал прогнозов",
|
||||
settings:"Настройки станции", adjust:"Изменить",
|
||||
l_city:"Пресет города", l_alt:"Высота · метры", l_tz:"Часовой пояс", l_lat:"Широта", l_lon:"Долгота",
|
||||
custom:"Вручную", save:"Сохранить", footer:"Отдаётся станцией · работает без интернета",
|
||||
warm:"Читаем небо…", warm_chip:"прогрев",
|
||||
warm_sub:"Прогнозу нужно около трёх часов истории давления до первого вывода.",
|
||||
verdict_pre:"Показание Zambretti — ", verdict_post:" за последние три часа.",
|
||||
tr_rising:"давление растёт", tr_falling:"давление падает", tr_steady:"давление стабильно",
|
||||
collecting:"Сбор данных…",
|
||||
hint:function(n,h){return n+" измерений за "+h+" ч · давление в гПа, температура в °C";},
|
||||
log_empty:"Пока прогнозов нет — первый появится примерно через три часа.",
|
||||
log_cap:function(n){var d1=n%10,d2=n%100; return n+((d1===1&&d2!==11)?" смена":((d1>=2&&d1<=4&&(d2<10||d2>=20))?" смены":" смен"));},
|
||||
need3:"нужно 3 ч данных", mv_unit:"гПа · ", mv_rising:"рост", mv_steady:"стабильно", mv_falling:"падение",
|
||||
saved_ok:"Сохранено.", saved_err:"Не удалось сохранить.", saved_net:"Станция недоступна."
|
||||
},
|
||||
en:{
|
||||
eyebrow:"Barometric Forecaster", title:"Weather Predictor",
|
||||
lede:"Reading the sky by pressure alone, the Zambretti way — station at",
|
||||
unit_msl:"hPa · sea level",
|
||||
k_time:"Local time", k_temp:"Temperature", k_move:"3-hour move", s_celsius:"degrees celsius",
|
||||
barograph:"Barograph", pressure:"Pressure", temperature:"Temperature", flog:"Forecast log",
|
||||
settings:"Station settings", adjust:"Adjust",
|
||||
l_city:"City preset", l_alt:"Altitude · metres", l_tz:"Time zone", l_lat:"Latitude", l_lon:"Longitude",
|
||||
custom:"Custom / manual entry", save:"Save settings", footer:"Served from the station · works without internet",
|
||||
warm:"Reading the sky…", warm_chip:"warming up",
|
||||
warm_sub:"The forecaster needs about three hours of pressure history before its first call.",
|
||||
verdict_pre:"Zambretti reading — ", verdict_post:" over the last three hours.",
|
||||
tr_rising:"pressure rising", tr_falling:"pressure falling", tr_steady:"pressure steady",
|
||||
collecting:"Collecting data…",
|
||||
hint:function(n,h){return n+" readings over "+h+" h · pressure in hPa, temperature in °C";},
|
||||
log_empty:"No forecasts yet — the first call appears after about three hours.",
|
||||
log_cap:function(n){return n+(n>1?" changes":" change");},
|
||||
need3:"need 3 h of data", mv_unit:"hPa · ", mv_rising:"rising", mv_steady:"steady", mv_falling:"falling",
|
||||
saved_ok:"Saved.", saved_err:"Could not save.", saved_net:"Could not reach the station."
|
||||
}
|
||||
};
|
||||
function T(k){ var s=STR[LANG]||STR.en; return (k in s)?s[k]:(STR.en[k]!=null?STR.en[k]:k); }
|
||||
var ZONE_RU={STORMY:"БУРЯ",RAIN:"ДОЖДЬ",CHANGE:"ПЕРЕМЕННО",FAIR:"ЯСНО","VERY DRY":"СУШЬ"};
|
||||
function zoneLabel(z){ return LANG==="ru"?(ZONE_RU[z]||z):z; }
|
||||
var CATS={Fine:"Ясно",Fair:"Хорошо",Changeable:"Переменно",Rain:"Дождь",Storm:"Буря"};
|
||||
function trCat(s){ return (LANG==="ru"&&CATS[s])?CATS[s]:s; }
|
||||
var PHRASES={
|
||||
"Settled fine weather":"Устойчиво ясно",
|
||||
"Fine weather":"Ясная погода",
|
||||
"Becoming fine":"Проясняется",
|
||||
"Fine, becoming less settled":"Ясно, погода портится",
|
||||
"Fine, possibly showers":"Ясно, возможны ливни",
|
||||
"Fairly fine, improving":"Довольно ясно, улучшение",
|
||||
"Fairly fine, possibly showers early":"Довольно ясно, ранние ливни",
|
||||
"Fairly fine, showers later":"Довольно ясно, ливни позже",
|
||||
"Showery early, improving":"Ранние ливни, улучшение",
|
||||
"Changeable, improving":"Переменно, улучшение",
|
||||
"Fairly fine, showers likely":"Довольно ясно, вероятны ливни",
|
||||
"Rather unsettled, clearing later":"Неустойчиво, позже прояснение",
|
||||
"Unsettled, probably improving":"Неустойчиво, вероятно улучшение",
|
||||
"Showery, bright intervals":"Ливни, прояснения",
|
||||
"Showery, becoming unsettled":"Ливни, погода портится",
|
||||
"Changeable, some rain":"Переменно, местами дождь",
|
||||
"Unsettled, short fine intervals":"Неустойчиво, краткие прояснения",
|
||||
"Unsettled, rain later":"Неустойчиво, дождь позже",
|
||||
"Unsettled, rain at times":"Неустойчиво, временами дождь",
|
||||
"Very unsettled, finer at times":"Очень неустойчиво, временами лучше",
|
||||
"Rain at times, worse later":"Временами дождь, позже хуже",
|
||||
"Rain at times, becoming very unsettled":"Временами дождь, резко портится",
|
||||
"Rain at frequent intervals":"Частые дожди",
|
||||
"Very unsettled, rain":"Очень неустойчиво, дождь",
|
||||
"Stormy, possibly improving":"Буря, возможно улучшение",
|
||||
"Stormy, much rain":"Буря, сильный дождь"
|
||||
};
|
||||
function trPhrase(s){ return (LANG==="ru"&&PHRASES[s])?PHRASES[s]:s; }
|
||||
function applyLang(){
|
||||
document.documentElement.lang=LANG;
|
||||
document.querySelectorAll("[data-i18n]").forEach(function(el){ el.textContent=T(el.getAttribute("data-i18n")); });
|
||||
ZONES.forEach(function(z){ var lab=document.getElementById("lab-"+z.label); if(lab) lab.textContent=zoneLabel(z.label); });
|
||||
var city=document.getElementById("s-city"); if(city&&city.options.length) city.options[0].textContent=T("custom");
|
||||
document.querySelectorAll("#lang button").forEach(function(b){ b.classList.toggle("on", b.getAttribute("data-lang")===LANG); });
|
||||
}
|
||||
function setLang(l){ LANG=l; try{localStorage.setItem("wp_lang",l);}catch(e){} applyLang(); refresh(); }
|
||||
|
||||
function clamp(v,a,b){return v<a?a:(v>b?b:v);}
|
||||
// pressure -> screen angle (deg): 960=180 (left), 1060=0 (right)
|
||||
function angleOf(p){return 180 - (clamp(p,PMIN,PMAX)-PMIN)/(PMAX-PMIN)*180;}
|
||||
@@ -422,15 +329,15 @@ async function refresh(){
|
||||
// verdict
|
||||
var v=document.getElementById("verdict"), vs=document.getElementById("verdict-sub"), chip=document.getElementById("chip");
|
||||
if(c.haveTrend){
|
||||
v.textContent=trPhrase(c.forecast);
|
||||
v.textContent=c.forecast;
|
||||
var col=CATCOLOR[c.category]||"#93A3BE";
|
||||
chip.textContent=trCat(c.category); chip.style.color=col;
|
||||
var word=c.trend>0?T("tr_rising"):(c.trend<0?T("tr_falling"):T("tr_steady"));
|
||||
vs.textContent=T("verdict_pre")+word+T("verdict_post");
|
||||
chip.textContent=c.category; chip.style.color=col;
|
||||
var word=c.trend>0?"pressure rising":(c.trend<0?"pressure falling":"pressure steady");
|
||||
vs.textContent="Zambretti reading — "+word+" over the last three hours.";
|
||||
}else{
|
||||
v.textContent=T("warm");
|
||||
chip.textContent=T("warm_chip"); chip.style.color="#93A3BE";
|
||||
vs.textContent=T("warm_sub");
|
||||
v.textContent="Reading the sky…";
|
||||
chip.textContent="warming up"; chip.style.color="#93A3BE";
|
||||
vs.textContent="The forecaster needs about three hours of pressure history before its first call.";
|
||||
}
|
||||
document.getElementById("chip").setAttribute("aria-label",c.category||"");
|
||||
// absolute already implied; keep footer clean
|
||||
@@ -449,7 +356,7 @@ async function drawLog(){
|
||||
ol.innerHTML="";
|
||||
if(!data.length){
|
||||
var li=document.createElement("li"); li.className="empty";
|
||||
li.textContent=T("log_empty");
|
||||
li.textContent="No forecasts yet — the first call appears after about three hours.";
|
||||
ol.appendChild(li); cap.textContent=""; return;
|
||||
}
|
||||
data.forEach(function(e){
|
||||
@@ -457,13 +364,13 @@ async function drawLog(){
|
||||
var dot=document.createElement("span"); dot.className="dot";
|
||||
dot.style.background=CATCOLOR[e.cat]||"#93A3BE"; li.appendChild(dot);
|
||||
var lt=document.createElement("div"); lt.className="lt";
|
||||
var b=document.createElement("b"); b.textContent=trPhrase(e.text); lt.appendChild(b);
|
||||
var lc=document.createElement("div"); lc.className="lc"; lc.textContent=trCat(e.cat); lt.appendChild(lc);
|
||||
var b=document.createElement("b"); b.textContent=e.text; lt.appendChild(b);
|
||||
var lc=document.createElement("div"); lc.className="lc"; lc.textContent=e.cat; lt.appendChild(lc);
|
||||
li.appendChild(lt);
|
||||
var tm=document.createElement("time"); tm.textContent=fmtStamp(e.t); li.appendChild(tm);
|
||||
ol.appendChild(li);
|
||||
});
|
||||
cap.textContent=T("log_cap")(data.length);
|
||||
cap.textContent=data.length+(data.length>1?" changes":" change");
|
||||
}
|
||||
function fillAxis(id,mx,mn,deg){
|
||||
var box=document.getElementById(id); box.innerHTML="";
|
||||
@@ -480,7 +387,7 @@ async function drawChart(){
|
||||
var svg=document.getElementById("chart"), W=640,H=200,pad=6;
|
||||
while(svg.firstChild) svg.removeChild(svg.firstChild);
|
||||
if(!data.length){
|
||||
document.getElementById("hint").textContent=T("collecting");
|
||||
document.getElementById("hint").textContent="Collecting data…";
|
||||
document.getElementById("axL").innerHTML=""; document.getElementById("axR").innerHTML="";
|
||||
document.getElementById("axT0").textContent="—"; document.getElementById("axT1").textContent="—";
|
||||
updateSetHand(); return;
|
||||
@@ -505,7 +412,7 @@ async function drawChart(){
|
||||
document.getElementById("axT0").textContent=fmtTime(data[0].t);
|
||||
document.getElementById("axT1").textContent=fmtTime(data[n-1].t);
|
||||
var hrs=((data[n-1].t-data[0].t)/3600);
|
||||
document.getElementById("hint").textContent=T("hint")(n, hrs.toFixed(1));
|
||||
document.getElementById("hint").textContent=n+" readings over "+hrs.toFixed(1)+" h · pressure in hPa, temperature in °C";
|
||||
updateSetHand();
|
||||
}
|
||||
|
||||
@@ -518,13 +425,13 @@ function updateSetHand(){
|
||||
for(var i=0;i<d.length;i++){ if(d[i].t<=target) past=d[i]; }
|
||||
if(!past || (latest.t-past.t)<9000){ sh.setAttribute("opacity",0);
|
||||
document.getElementById("delta").textContent="—";
|
||||
document.getElementById("deltas").textContent=T("need3"); return; }
|
||||
document.getElementById("deltas").textContent="need 3 h of data"; return; }
|
||||
setNeedle("sethand",past.msl);
|
||||
sh.setAttribute("opacity",.7);
|
||||
var diff=latest.msl-past.msl;
|
||||
document.getElementById("delta").textContent=fmtSigned(diff,1);
|
||||
var word=diff>1.6?T("mv_rising"):(diff<-1.6?T("mv_falling"):T("mv_steady"));
|
||||
document.getElementById("deltas").textContent=T("mv_unit")+word;
|
||||
var word=diff>1.6?"rising":(diff<-1.6?"falling":"steady");
|
||||
document.getElementById("deltas").textContent="hPa · "+word;
|
||||
}
|
||||
|
||||
function tzLabel(min){var s=min<0?"-":"+";var a=Math.abs(min);var h=Math.floor(a/60);var m=a%60;return "UTC"+s+h+(m?(":"+(m<10?"0":"")+m):"");}
|
||||
@@ -550,7 +457,7 @@ function setTz(min){
|
||||
}
|
||||
function buildCities(){
|
||||
var sel=document.getElementById("s-city");
|
||||
var o0=document.createElement("option"); o0.value="-1"; o0.textContent=T("custom"); sel.appendChild(o0);
|
||||
var o0=document.createElement("option"); o0.value="-1"; o0.textContent="Custom / manual entry"; sel.appendChild(o0);
|
||||
CITIES.forEach(function(c,i){
|
||||
var o=document.createElement("option"); o.value=i;
|
||||
o.textContent=c.n+" · "+c.alt+" m · "+tzLabel(c.tz); sel.appendChild(o);
|
||||
@@ -590,20 +497,16 @@ document.getElementById("save").addEventListener("click", async function(){
|
||||
var msg=document.getElementById("saved");
|
||||
try{
|
||||
var r=await (await fetch("/api/settings",{method:"POST",body:JSON.stringify(body)})).json();
|
||||
msg.textContent=r.ok?T("saved_ok"):T("saved_err");
|
||||
msg.textContent=r.ok?"Saved.":"Could not save.";
|
||||
msg.style.color=r.ok?"var(--fine)":"var(--storm)";
|
||||
if(r.ok){ loadSettings(); refresh(); }
|
||||
}catch(e){ msg.textContent=T("saved_net"); msg.style.color="var(--storm)"; }
|
||||
}catch(e){ msg.textContent="Could not reach the station."; msg.style.color="var(--storm)"; }
|
||||
setTimeout(function(){msg.textContent="";},4000);
|
||||
});
|
||||
|
||||
document.querySelectorAll("#lang button").forEach(function(b){
|
||||
b.addEventListener("click",function(){ setLang(b.getAttribute("data-lang")); });
|
||||
});
|
||||
buildGauge();
|
||||
buildTz();
|
||||
buildCities();
|
||||
applyLang();
|
||||
loadSettings();
|
||||
refresh();
|
||||
setInterval(refresh,15000);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
build/
|
||||
.build/
|
||||
*.bin
|
||||
*.elf
|
||||
*.hex
|
||||
*.map
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,328 +0,0 @@
|
||||
# Контроллер насосов v8.2 — руководство пользователя
|
||||
|
||||
Документ для монтажника и обслуживающего персонала.
|
||||
Техническое описание кода — в [README.md](README.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Что делает прибор
|
||||
|
||||
Контроллер следит за тремя насосами:
|
||||
|
||||
* **Насос 1** и **Насос 2** — основные;
|
||||
* **Резерв** — аварийный (экстренный) насос.
|
||||
|
||||
Прибор:
|
||||
|
||||
1. показывает состояние на экране;
|
||||
2. включает выход «Авария», если **оба** основных насоса остановлены;
|
||||
3. включает выход «Резерв», когда запускается резервный насос;
|
||||
4. отправляет уведомления на сервер (webhook) через интернет;
|
||||
5. показывает панель мониторинга в браузере в локальной сети.
|
||||
|
||||
---
|
||||
|
||||
## 2. Подключение
|
||||
|
||||
### 2.1 Питание
|
||||
|
||||
5 В через microUSB (блок питания не менее 500 мА).
|
||||
|
||||
### 2.2 Датчики насосов
|
||||
|
||||
К каждому входу подключается **сухой контакт** — свободный контакт пускателя
|
||||
насоса или датчика потока.
|
||||
|
||||
| Клемма платы | Что подключить |
|
||||
|---|---|
|
||||
| `D5` и `GND` | Контакт насоса 1 |
|
||||
| `D6` и `GND` | Контакт насоса 2 |
|
||||
| `D7` и `GND` | Контакт резервного насоса |
|
||||
|
||||
**Контакт замкнут = насос работает.** Полярность не важна, внешние резисторы
|
||||
и питание на контакты подавать не нужно.
|
||||
|
||||
### 2.3 Выходы на реле
|
||||
|
||||
Прибор рассчитан на **релейные модули с активным LOW** — те, у которых реле
|
||||
включается замыканием входа на GND. Это самый распространённый тип модулей для
|
||||
Arduino.
|
||||
|
||||
| Клемма платы | Куда | Реле включается когда |
|
||||
|---|---|---|
|
||||
| `D3` | вход `IN` модуля «Авария» | оба основных насоса стоят |
|
||||
| `D4` | вход `IN` модуля «Резерв» | работает резервный насос |
|
||||
|
||||
Питание модулей — от `5V` и `GND` платы (или от отдельного источника с общим
|
||||
GND). **Сирену, лампу или пускатель напрямую к `D3`/`D4` подключать нельзя** —
|
||||
только через релейный модуль или транзисторный ключ.
|
||||
|
||||
> **Важно: тип модуля должен быть именно active-LOW.** Если поставить модуль с
|
||||
> активным HIGH, реле будет включено в норме и отпускать при аварии — логика
|
||||
> перевернётся. Пины `D3` и `D4` выбраны именно под active-LOW: они удерживают
|
||||
> HIGH во время загрузки, поэтому реле не остаются включёнными на всё время
|
||||
> старта.
|
||||
|
||||
Оговорки, которые лучше знать заранее:
|
||||
|
||||
* **Оба реле кратковременно моргают при каждой перезагрузке прибора** — и при
|
||||
подаче питания, и при заливке прошивки. Так и есть, это нормально: выходы
|
||||
сидят на служебных пинах платы, и пока прошивка не запустилась, уровнем на них
|
||||
управляет не она. Автоматика в этот момент ещё не работает, ложных уведомлений
|
||||
моргание не вызывает.
|
||||
* Если моргание окажется недопустимым (например, сирена успевает пискнуть) —
|
||||
ставится RC-цепочка на вход релейного модуля. Прошивку менять не нужно.
|
||||
* Встроенный светодиод платы повторяет состояние реле «Резерв» — удобно для
|
||||
проверки монтажа.
|
||||
* Схема **не отказобезопасная**: если контроллер обесточен, сигнала аварии нет.
|
||||
Если это критично — снимайте нагрузку с **нормально замкнутого (NC)** контакта
|
||||
реле, тогда пропажа питания читается системой как авария.
|
||||
|
||||
### 2.4 Свободные клеммы
|
||||
|
||||
`D8`, `D0` и `A0` прошивкой не используются. Учтите, что `A0` — только
|
||||
аналоговый вход и цифровым выходом стать не может.
|
||||
|
||||
### 2.5 Экран
|
||||
|
||||
OLED 128×64 (I2C): `SDA → D1`, `SCL → D2`, `VCC → 3V3`, `GND → GND`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Первое включение
|
||||
|
||||
1. Подайте питание. На экране появится `PUMP CTRL v8 / Starting...`.
|
||||
2. Прибор не найдёт сохранённой сети и поднимет свою точку доступа:
|
||||
|
||||
```
|
||||
Имя сети: Pump_Control_Set
|
||||
```
|
||||
|
||||
3. Подключитесь к ней с телефона или ноутбука (пароль не требуется).
|
||||
4. Откройте в браузере `http://192.168.4.1` — откроется страница настройки.
|
||||
5. Выберите вашу Wi-Fi-сеть, введите пароль, нажмите **Save**.
|
||||
6. Прибор перезагрузится и покажет на экране полученный IP-адрес.
|
||||
**Запишите этот адрес** — по нему открывается панель мониторинга.
|
||||
|
||||
Если за 2 минуты сеть не настроена, прибор переходит в автономный режим:
|
||||
экран, выходы аварии и логика работают, webhook и веб-панель — нет.
|
||||
|
||||
---
|
||||
|
||||
## 4. Экран
|
||||
|
||||
```
|
||||
SUPPLY OK
|
||||
────────────────────────
|
||||
P1:RUN P2:OFF BK:OFF
|
||||
ALARM:OFF BACKUP:OFF
|
||||
────────────────────────
|
||||
192.168.1.128
|
||||
```
|
||||
|
||||
**Верхняя строка — главный ответ**, набрана крупно и читается от двери:
|
||||
|
||||
| Надпись | Значение |
|
||||
|---|---|
|
||||
| `SUPPLY OK` | Норма, подача идёт |
|
||||
| `BACKUP RUN` | Работает резерв, основные в порядке — обратите внимание |
|
||||
| `NO SUPPLY` (мигает) | **Авария: оба основных стоят, подачи нет** |
|
||||
| `BACKUP ON` (мигает) | **Авария: основные стоят, подачу держит резерв** |
|
||||
| `STARTING` | Идёт запуск, первые ~4 секунды |
|
||||
|
||||
Вторая строка — датчики: `RUN` насос работает, `OFF` стоит. `P1` и `P2` —
|
||||
основные, `BK` — резерв.
|
||||
|
||||
Третья строка — что подано на выходы: `ON` реле замкнуто, `OFF` разомкнуто.
|
||||
Удобно при пусконаладке: видно, что прибор действительно выдал сигнал.
|
||||
|
||||
Нижняя строка — адрес в сети или `no network`, если связи нет.
|
||||
|
||||
Надписи латиницей: встроенный шрифт дисплея кириллицы не содержит.
|
||||
|
||||
---
|
||||
|
||||
## 5. Панель в браузере
|
||||
|
||||
Откройте `http://<IP-адрес прибора>` в любом браузере в той же сети.
|
||||
|
||||
Панель устроена как световое табло: **сверху крупно главный ответ**, ниже —
|
||||
лампы датчиков и выходов, в самом низу свёрнутые настройки.
|
||||
|
||||

|
||||
|
||||
Так панель выглядит в норме. Здесь блок настроек раскрыт; обычно он свёрнут
|
||||
в одну строку и не отвлекает.
|
||||
|
||||
### 5.1 Главная плашка
|
||||
|
||||
| Плашка | Цвет | Что значит |
|
||||
|---|---|---|
|
||||
| **Подача есть** | зелёный | Норма, работает хотя бы один основной насос |
|
||||
| **Работает резерв** | жёлтый | Резерв в работе, основные в порядке — обратите внимание |
|
||||
| **Насосы стоят** | красный, пульсирует | **Авария: оба основных остановлены** |
|
||||
| **Запуск** | серый | Первые ~4 секунды после включения |
|
||||
|
||||
Под заголовком — уточнение: какой именно насос работает, или «подачу держит
|
||||
резерв», если основные стоят, а резерв качает.
|
||||
|
||||
Жёлтый у резерва выбран намеренно: работающий резерв — это предупреждение,
|
||||
система ещё справляется. Красный оставлен для случая, когда основной подачи нет.
|
||||
|
||||
**Вкладка браузера тоже показывает состояние** — в её заголовке текущий статус,
|
||||
а кружок-значок меняет цвет. Свёрнутую на телефоне вкладку видно, не открывая.
|
||||
|
||||
### 5.2 Лампы
|
||||
|
||||
Три лампы датчиков: **Насос 1**, **Насос 2**, **Резерв** — «работает», «стоит»
|
||||
или «ожидание».
|
||||
|
||||
Две лампы выходов: **Выход «Авария»** и **Выход «Резерв»** — «замкнут» или
|
||||
«разомкнут». Это то, что прибор реально подал на реле.
|
||||
|
||||
Полезно при пусконаладке: если выход показан замкнутым, а нагрузка не сработала,
|
||||
проблема в монтаже или в типе релейного модуля, а не в прошивке.
|
||||
|
||||
### 5.3 Настройки
|
||||
|
||||
Раскрываются по нажатию на строку **Настройки** — в обычной работе они свёрнуты
|
||||
и не отвлекают.
|
||||
|
||||
**Пустое поле означает «не менять»** — можно поправить одну задержку, не трогая
|
||||
остальное. Неверное значение так же игнорируется, поэтому опечатка ничего не
|
||||
испортит.
|
||||
|
||||
| Поле | Что задаёт | Допустимо | По умолчанию |
|
||||
|---|---|---|---|
|
||||
| **Адрес для уведомлений** | куда прибор шлёт сообщения | до 220 символов | не задан |
|
||||
| **Имя прибора в уведомлениях** | по нему различают приборы на сервере | латиница, цифры, `_`, `-`; до 31 символа | `Wemos_D1_Pump` |
|
||||
| **Задержка сигнала — аварии** | сколько оба основных должны стоять до сигнала | 0…3600 с | `5` |
|
||||
| **Задержка сигнала — резерва** | сколько резерв должен работать до сигнала | 0…3600 с | `0` (сразу) |
|
||||
|
||||
Под полем адреса показано только **имя сервера**, а не весь адрес: он содержит
|
||||
секретный ключ, и выводить его на экран, который видно всем в помещении, не
|
||||
нужно. Чтобы сменить адрес — введите новый целиком.
|
||||
|
||||
Кнопка **Сменить сеть Wi-Fi** стирает сохранённую сеть и перезагружает прибор
|
||||
(см. раздел 7).
|
||||
|
||||
Настройки сохраняются в файл во внутренней памяти прибора, переживают
|
||||
отключение питания и смену сети Wi-Fi.
|
||||
|
||||
> Если панель показывает красное предупреждение «Память недоступна» или
|
||||
> настройки пропадают после перезагрузки — причина в настройке `Flash Size`
|
||||
> при прошивке. Она обязана совпадать с фактическим размером памяти платы:
|
||||
> для этих приборов **`2MB (FS:64KB OTA:~992KB)`**. Вариант `4MB` на плате с
|
||||
> 2 МБ приводит ровно к такому симптому.
|
||||
|
||||
> Русские буквы и пробелы в имени прибора **не сохранятся** — они будут
|
||||
> отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки.
|
||||
> Пишите, например, `Nasosnaya_1` или `Pump_Station_A`.
|
||||
|
||||
**На новом приборе адрес уведомлений не задан** — панель покажет «Не задан —
|
||||
уведомления отключены», сообщения отправляться не будут, всё остальное работает.
|
||||
|
||||
---
|
||||
|
||||
## 6. Уведомления на сервер
|
||||
|
||||
При изменении ситуации прибор отправляет сообщение на настроенный адрес:
|
||||
|
||||
В сообщении приходит **та же фраза, что показывает панель** — читать можно
|
||||
без расшифровки кодов:
|
||||
|
||||
| Ситуация | Текст в сообщении |
|
||||
|---|---|
|
||||
| Оба основных стоят, резерв качает | «Насосы стоят, подачу держит резерв» |
|
||||
| Оба основных стоят, резерва нет | «Насосы стоят, подачи нет» |
|
||||
| Работает резерв, основные в порядке | «Работает резерв, основные насосы в работе» |
|
||||
| Работа восстановлена | «Подача есть, работает насос 1» |
|
||||
|
||||
Рядом с текстом передаётся служебное поле для автоматики: `MAIN PUMPS` или
|
||||
`EMERGENCY PUMP` и `ALARM` / `OK` / `STARTED` / `STOPPED`.
|
||||
|
||||
Уведомления отправляются один раз на каждое изменение, а не повторяются.
|
||||
Если в момент события связи не было — уведомление **не будет** отправлено
|
||||
позже, оно теряется.
|
||||
|
||||
На отправку отводится не больше **3 секунд**. Если сервер недоступен или
|
||||
отвечает медленно, прибор прекращает попытку и продолжает работу: экран и
|
||||
кнопка на это время замирают, но **выходы аварии срабатывают сразу**, не
|
||||
дожидаясь сервера.
|
||||
|
||||
---
|
||||
|
||||
## 7. Сброс настроек Wi-Fi
|
||||
|
||||
Нужен при смене роутера или пароля сети. Физической кнопки сброса у прибора нет.
|
||||
|
||||
**Если сеть пропала или сменился пароль** — ничего делать не нужно: при
|
||||
перезагрузке прибор не сможет подключиться и **сам** поднимет точку доступа
|
||||
`Pump_Control_Set`. Подключитесь к ней и введите новые данные (раздел 3).
|
||||
|
||||
**Если надо перевести прибор в другую сеть, а текущая работает** — нажмите
|
||||
**Сменить сеть Wi-Fi** в настройках панели. Прибор перезагрузится и поднимет точку
|
||||
доступа.
|
||||
|
||||
**Если панель недоступна, а сеть работает** — выключите роутер и перезагрузите
|
||||
прибор по питанию. Не найдя сеть, он откроет портал настройки.
|
||||
|
||||
Настройки из блока «Настройки» (адрес уведомлений, имя устройства, выдержки) при
|
||||
сбросе Wi-Fi **не стираются**.
|
||||
|
||||
---
|
||||
|
||||
## 8. Задержки срабатывания
|
||||
|
||||
Задержки заданы намеренно, чтобы не срабатывать на кратковременные пуски и
|
||||
остановки:
|
||||
|
||||
| Событие | Задержка |
|
||||
|---|---|
|
||||
| Фиксация нового состояния датчика | 3 секунды (не настраивается) |
|
||||
| Авария основных насосов (после фиксации) | **Задержка сигнала аварии**, по умолчанию 5 с |
|
||||
| Пуск резерва (после фиксации) | **Задержка сигнала резерва**, по умолчанию 0 с |
|
||||
| Итого до сигнала аварии при значениях по умолчанию | **~8 секунд** |
|
||||
| Итого до сигнала о резерве при значениях по умолчанию | ~3 секунды |
|
||||
|
||||
Обе задержки настраиваются в панели (раздел 5.3) и действуют **одновременно на
|
||||
реле и на уведомление** — отдельно задержать только уведомление нельзя.
|
||||
|
||||
Задержка `0` означает «сразу после фиксации», то есть через те же 3 секунды
|
||||
антидребезга.
|
||||
|
||||
---
|
||||
|
||||
## 9. Диагностика
|
||||
|
||||
| Симптом | Причина / что делать |
|
||||
|---|---|
|
||||
| Экран не горит | Проверьте питание и провода SDA/SCL. Если адрес дисплея `0x3D`, требуется правка прошивки (константа `OLED_ADDR`) |
|
||||
| Экран горит, но насосы всегда `STOPPED` | Контакты датчиков не замыкаются на `GND`; проверьте общий провод |
|
||||
| Насосы всегда `RUNNING` | Вход замкнут на `GND` постоянно — проверьте контакт пускателя |
|
||||
| `WiFi: NO CONNECTION` | Сеть недоступна или сменился пароль → выполните сброс Wi-Fi (раздел 7) |
|
||||
| Панель в браузере не открывается | Проверьте, что компьютер в той же сети; уточните IP на экране прибора |
|
||||
| Уведомления не приходят | Проверьте адрес webhook на панели и доступность сервера. Смотрите лог в мониторе порта (115200 бод): `[HTTP] Код: 200` — доставлено; `[HTTP] DNS не ответил` — проблема с именем сервера или DNS роутера; `[HTTP] Ошибка:` — сервер не ответил вовремя |
|
||||
| Экран замирает на 1-3 с в момент аварии | Так и задумано: отправка уведомления блокирует прибор не дольше 3 секунд. Реле при этом срабатывают сразу, без ожидания сервера |
|
||||
| Панель пишет «Реле включено», но нагрузка не сработала | Проблема в монтаже или в типе модуля. Проверьте, что модуль **active-LOW**, что он питается, и что общий GND соединён |
|
||||
| Реле сработали наоборот: включены в норме, отпускают при аварии | Стоит модуль с активным HIGH вместо active-LOW. Замените модуль либо потребуйте правку прошивки |
|
||||
| Реле моргают при перезагрузке или заливке прошивки | Нормально, см. 2.3. Выходы на служебных пинах платы; пока прошивка не стартовала, уровнем управляет не она. Уведомления при этом не отправляются |
|
||||
| Имя устройства не сохраняется | В нём есть русские буквы, пробелы или знаки препинания — они отбрасываются. Допустимы только латиница, цифры, `_` и `-` |
|
||||
| Задержка не сохраняется | Значение вне диапазона 0…3600 или не целое число — поле игнорируется |
|
||||
| Прибор не стартует после включения | На `D3` или `D4` висит нагрузка, тянущая пин к GND при старте — проверьте релейный модуль и его питание |
|
||||
|
||||
Подробный лог работы выводится в USB-порт: скорость **115200 бод**
|
||||
(Arduino IDE → Инструменты → Монитор порта).
|
||||
|
||||
---
|
||||
|
||||
## 10. Безопасность
|
||||
|
||||
Прибор не имеет пароля на веб-панель. Любой пользователь локальной сети может
|
||||
открыть панель, сбросить Wi-Fi, изменить адрес уведомлений и **увеличить
|
||||
выдержки аварии**, то есть замедлить реакцию сигнализации. Устанавливайте прибор
|
||||
в закрытом технологическом сегменте сети, не в гостевом Wi-Fi.
|
||||
|
||||
Контроллер выполняет **функцию мониторинга и сигнализации**, а не защиты.
|
||||
Он не заменяет штатную автоматику защиты насосной станции. Кроме того, схема не
|
||||
отказобезопасна: обесточенный контроллер сигнала аварии не подаёт (см. 2.3).
|
||||
@@ -1,655 +0,0 @@
|
||||
# Pump Controller v8.2 (OLED)
|
||||
|
||||
Контроллер мониторинга насосной станции на **Wemos D1 Mini (ESP8266)**.
|
||||
|
||||
Следит за состоянием трёх насосов (два основных + резервный), выдаёт сухие
|
||||
контакты аварии, показывает статус на OLED-дисплее, публикует веб-панель в
|
||||
локальной сети и отправляет события на HTTPS-webhook (n8n).
|
||||
|
||||
---
|
||||
|
||||
## 1. Состав репозитория
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `pump_controller_8_2_OLED_DONE.ino` | Логика прошивки |
|
||||
| `web_page.h` | HTML веб-панели в PROGMEM (см. 7.7 — вынесен не для красоты) |
|
||||
| `README.md` | Техническая документация (этот файл) |
|
||||
| `MANUAL.md` | Руководство пользователя / монтажника |
|
||||
| `docs/specs/` | Согласованные проектные решения по крупным изменениям |
|
||||
| `docs/img/` | Снимки экрана для документации |
|
||||
|
||||
---
|
||||
|
||||
## 2. Аппаратная часть
|
||||
|
||||
### 2.1 Плата
|
||||
|
||||
Wemos D1 Mini (ESP8266). Питание 5 В по microUSB или 3.3 В на пин `3V3`.
|
||||
|
||||
**Флеш на целевой плате — 2 МБ.** Это важно: клоны Wemos D1 Mini встречаются с
|
||||
1, 2 и 4 МБ, а размер, выбранный в IDE, обязан совпадать с физическим. Иначе
|
||||
области EEPROM и файловой системы окажутся за пределами микросхемы и настройки
|
||||
не сохранятся — см. раздел 6.1, там разобран ровно этот случай.
|
||||
|
||||
Проверить фактический размер: `ESP.getFlashChipRealSize()`, прошивка печатает
|
||||
его в лог при старте строкой `[FLASH] по настройке IDE: ... физически: ...`.
|
||||
|
||||
### 2.2 Распиновка
|
||||
|
||||
Выходы рассчитаны на релейные модули **active-LOW** (`LOW` на входе = реле
|
||||
включено).
|
||||
|
||||
| Пин | Имя в коде | Режим | Логика |
|
||||
|---|---|---|---|
|
||||
| `D1` | I2C SDA | — | OLED |
|
||||
| `D2` | I2C SCL | — | OLED |
|
||||
| `D5` | `IN_PUMP_1` | `INPUT_PULLUP` | `LOW` = насос 1 работает |
|
||||
| `D6` | `IN_PUMP_2` | `INPUT_PULLUP` | `LOW` = насос 2 работает |
|
||||
| `D7` | `IN_PUMP_EMERGENCY` | `INPUT_PULLUP` | `LOW` = резервный насос работает |
|
||||
| `D3` | `OUT_ALARM_MAIN` | `OUTPUT` | `LOW` = реле «Авария» включено |
|
||||
| `D4` | `OUT_ALARM_EMERGENCY` | `OUTPUT` | `LOW` = реле «Резерв» включено |
|
||||
| `D8`, `D0`, `A0` | — | — | не используются |
|
||||
|
||||
### 2.3 Почему выходы именно на D3 и D4
|
||||
|
||||
Для реле active-LOW безопасное состояние — пин в `HIGH`. Значит выход обязан
|
||||
сидеть на пине, который держит `HIGH` **всю загрузку**, пока `setup()` ещё не
|
||||
выполнился, иначе реле останется включённым на всё время старта.
|
||||
|
||||
| Пин | GPIO | Уровень при загрузке | Реле active-LOW при загрузке |
|
||||
|---|---|---|---|
|
||||
| `D3` | 0 | подтянут `HIGH` (10к) | выключено ✓ |
|
||||
| `D4` | 2 | подтянут `HIGH` (10к) | выключено ✓ |
|
||||
| `D8` | 15 | подтянут **`LOW`** (10к) | **включено** ✗ |
|
||||
| `D1`,`D2`,`D5`,`D6`,`D7` | 5,4,14,12,13 | не определён до `pinMode` | неопределённо |
|
||||
|
||||
Для реле active-**HIGH** вывод был бы обратным: `D8` — лучший пин, `D3`/`D4` —
|
||||
худшие. Логика выходов и выбор пинов связаны жёстко; менять одно без другого
|
||||
нельзя.
|
||||
|
||||
В `setup()` порядок операций тоже важен: `digitalWrite(pin, HIGH)` вызывается
|
||||
**до** `pinMode(pin, OUTPUT)`, иначе защёлка выхода может кратковременно выдать
|
||||
`LOW`.
|
||||
|
||||
Инверсия уровня локализована в одной функции — единственном месте, где она
|
||||
существует:
|
||||
|
||||
```cpp
|
||||
void setRelay(int pin, bool on) { digitalWrite(pin, on ? LOW : HIGH); }
|
||||
```
|
||||
|
||||
#### Что происходит при перезагрузке на самом деле
|
||||
|
||||
Проверено на собранном приборе: **при перезагрузке оба реле кратковременно
|
||||
моргают.** Признано некритичным и оставлено как есть.
|
||||
|
||||
Ранее в этом разделе утверждалось, что реле при загрузке молчат — это оказалось
|
||||
неверно, и формулировка исправлена.
|
||||
|
||||
Наиболее вероятный механизм: оба выхода неизбежно сидят на служебных пинах.
|
||||
`GPIO2` (`D4`) — это ещё и `UART1 TX`, и на нём во время загрузки появляется
|
||||
служебный вывод; `GPIO0` (`D3`) — пин выбора режима загрузки, который дёргается
|
||||
схемой сброса. Пока `setup()` не выполнился, уровнем на них управляет не
|
||||
прошивка. Точную причину не измеряли: осциллографа в проверке не было.
|
||||
|
||||
Часть морганий может приходиться на светодиод релейного модуля, а не на
|
||||
фактическое переключение контакта: короткий импульс успевает зажечь индикатор,
|
||||
но не перебросить якорь. Различить можно тестером на контактах реле, если это
|
||||
когда-нибудь станет важно.
|
||||
|
||||
**Почему решение всё равно верное.** Альтернатива — `D8` — не улучшила бы
|
||||
ситуацию, а ухудшила: он подтянут к `LOW`, и реле было бы уверенно **включено
|
||||
всю загрузку**, а не моргнуло бы. Кратковременное моргание предпочтительнее
|
||||
устойчивого ложного сигнала на несколько секунд.
|
||||
|
||||
**Если моргание когда-нибудь станет проблемой** (например, сирена успевает
|
||||
пискнуть): ставится RC-цепочка на вход релейного модуля, чтобы короткие импульсы
|
||||
не проходили, либо модуль с триггером Шмитта на входе. Прошивку менять не
|
||||
потребуется.
|
||||
|
||||
### 2.4 Прочие особенности ESP8266
|
||||
|
||||
* `D4` (GPIO2) — встроенный светодиод платы, тоже активен от `LOW`. Реле
|
||||
«Резерв» получает бесплатную индикацию на плате.
|
||||
* `D3` (GPIO0) — пин выбора режима загрузки. Схема автосброса USB дёргает его
|
||||
вниз при заливке скетча, поэтому реле «Авария» щёлкает при каждой прошивке.
|
||||
Кроме того, оба реле кратковременно моргают при любой перезагрузке (см. 2.3).
|
||||
Косметика, но пугает.
|
||||
* `A0` — **только аналоговый вход** (0–3.2 В, без внутренней подтяжки). Ни
|
||||
цифровым выходом, ни входом с подтяжкой быть не может, поэтому в бюджет
|
||||
цифровых пинов не входит.
|
||||
* Схема **не fail-safe**: авария включает реле, поэтому обесточенный контроллер
|
||||
сигнала не даёт. Если нужна отказобезопасность — снимайте нагрузку с
|
||||
нормально замкнутого (NC) контакта реле, тогда пропажа питания читается как
|
||||
авария.
|
||||
|
||||
### 2.5 Дисплей
|
||||
|
||||
SSD1306 128×64, I2C, адрес по умолчанию `0x3C` (константа `OLED_ADDR`).
|
||||
Если экран не найден — в лог уходит сообщение, прошивка продолжает работать
|
||||
без дисплея (`oledOK = false`).
|
||||
|
||||
### 2.6 Подключение датчиков
|
||||
|
||||
Датчик — сухой контакт (реле пускателя, датчик потока, контакт КМ).
|
||||
Замкнут на GND → насос считается работающим. Внешние резисторы не нужны,
|
||||
используется внутренняя подтяжка.
|
||||
|
||||
---
|
||||
|
||||
## 3. Зависимости
|
||||
|
||||
Устанавливаются через Arduino Library Manager:
|
||||
|
||||
* Adafruit SSD1306
|
||||
* Adafruit GFX Library
|
||||
* WiFiManager (tzapu)
|
||||
* ArduinoJson (v7)
|
||||
|
||||
Входят в ESP8266 core: `ESP8266WiFi`, `ESP8266WebServer`, `ESP8266HTTPClient`,
|
||||
`WiFiClientSecure`, `LittleFS`, `Wire`.
|
||||
|
||||
Board: **LOLIN(WEMOS) D1 R2 & mini**, Upload speed 921600.
|
||||
|
||||
**Flash Size: `2MB (FS:64KB OTA:~992KB)`** — под физические 2 МБ целевой платы.
|
||||
Именно этот вариант проверен на железе: LittleFS отдаёт 45 056 байт полезного
|
||||
объёма, чего для `settings.json` (несколько сотен байт) с запасом. Подойдёт и
|
||||
любой другой `2MB (FS:...)`, если понадобится больше места под файлы.
|
||||
|
||||
Два требования к этой настройке, оба обязательные:
|
||||
|
||||
* **Размер должен совпадать с физическим чипом.** Выбранные 4 МБ на плате с 2 МБ
|
||||
уводят и EEPROM, и файловую систему за границу микросхемы — настройки молча
|
||||
не сохраняются (раздел 6.1).
|
||||
* **Файловая система должна быть ненулевой.** При `FS:none` сохранять настройки
|
||||
некуда; прошивка скажет об этом в лог при старте и покажет предупреждение в
|
||||
веб-панели.
|
||||
|
||||
Раскладка для 2 МБ (любой из вариантов `2MB (FS:...)`) кладёт файловую систему
|
||||
ниже `0x1FB000`, а EEPROM — в сектор 511, то есть внутрь чипа.
|
||||
|
||||
После смены `Flash Size` заливайте прошивку заново: меняется заголовок
|
||||
загрузчика и границы областей. Учтите, что **сохранённые учётные данные Wi-Fi
|
||||
при этом теряются** и прибор поднимет портал `Pump_Control_Set`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Логика работы
|
||||
|
||||
### 4.1 Антидребезг
|
||||
|
||||
`updatePumpState()` для каждого входа:
|
||||
|
||||
```
|
||||
если чтение изменилось -> сбросить таймер lastDebounceTime
|
||||
если чтение стабильно > 3000 мс -> зафиксировать stableState
|
||||
```
|
||||
|
||||
`debounceDelay = 3000 мс` — длинный намеренно: фильтрует пусковые дребезги
|
||||
пускателя и кратковременные просадки.
|
||||
|
||||
### 4.2 Готовность системы
|
||||
|
||||
`systemReady` становится `true` через `debounceDelay + 500 мс` после старта.
|
||||
До этого момента аварии не формируются и webhook не отправляется — иначе
|
||||
при включении питания система рапортовала бы ложную аварию.
|
||||
|
||||
### 4.3 Основная авария
|
||||
|
||||
Условие: **оба** основных насоса стоят (`!p1 && !p2`).
|
||||
|
||||
```
|
||||
bothStopped -> запуск таймера mainAlarmStartTime
|
||||
выдержка alarmDelaySec (настраивается, по умолчанию 5 с)
|
||||
-> реле «Авария» включено
|
||||
-> webhook {"event":"MAIN PUMPS","status":"ALARM"} (однократно)
|
||||
|
||||
любой насос запустился
|
||||
-> реле «Авария» выключено
|
||||
-> webhook {"event":"MAIN PUMPS","status":"OK"} (однократно)
|
||||
```
|
||||
|
||||
Суммарная задержка от факта остановки до аварии: `3 с` (дребезг, константа
|
||||
`debounceDelay`) + `alarmDelaySec`. При значениях по умолчанию ≈ **8 секунд**.
|
||||
|
||||
### 4.4 Резервный насос
|
||||
|
||||
Логика симметрична основной аварии — та же схема «ожидание, затем реакция»:
|
||||
|
||||
```
|
||||
pE = работает -> запуск таймера emergStartTime
|
||||
выдержка emergDelaySec (настраивается, по умолчанию 0 с)
|
||||
-> реле «Резерв» включено
|
||||
-> webhook EMERGENCY PUMP / STARTED (однократно)
|
||||
|
||||
pE = стоит -> реле «Резерв» выключено
|
||||
-> webhook EMERGENCY PUMP / STOPPED (однократно)
|
||||
```
|
||||
|
||||
При `emergDelaySec = 0` условие `millis() - emergStartTime >= 0` выполняется на
|
||||
следующей же итерации `loop()`, то есть реакция мгновенная.
|
||||
|
||||
### 4.5 Однократность и общая выдержка
|
||||
|
||||
Обе выдержки гасят **и реле, и webhook одновременно** — это одна настройка на
|
||||
канал, а не отдельные таймеры для реле и уведомления.
|
||||
|
||||
Флаги `mainAlarmSent` / `emergencyActiveSent` гарантируют отправку ровно
|
||||
одного webhook на каждый переход состояния.
|
||||
|
||||
---
|
||||
|
||||
## 5. Webhook
|
||||
|
||||
`POST` на `settings.webhookUrl`, `Content-Type: application/json`:
|
||||
|
||||
```json
|
||||
{"event":"MAIN PUMPS","state":"ALARM",
|
||||
"status":"Насосы стоят, подачу держит резерв","device":"Pumps"}
|
||||
```
|
||||
|
||||
| Поле | Назначение |
|
||||
|---|---|
|
||||
| `event` | Канал: `MAIN PUMPS` или `EMERGENCY PUMP` |
|
||||
| `state` | Машиночитаемое: `ALARM` / `OK` / `STARTED` / `STOPPED` |
|
||||
| `status` | **Человеческая фраза**, та же что в панели |
|
||||
| `device` | Имя прибора из настроек |
|
||||
|
||||
Фраза в `status` собирается функцией `statusPhrase()` из текущего состояния на
|
||||
момент отправки — это те же слова, что видит оператор в панели:
|
||||
|
||||
| Ситуация | `status` |
|
||||
|---|---|
|
||||
| Оба основных стоят, резерв качает | `Насосы стоят, подачу держит резерв` |
|
||||
| Оба основных стоят, резерва нет | `Насосы стоят, подачи нет` |
|
||||
| Работает резерв, основные в порядке | `Работает резерв, основные насосы в работе` |
|
||||
| Норма | `Подача есть, работает насос 1` |
|
||||
|
||||
`state` оставлен рядом намеренно: сценарий на сервере должен ветвиться по нему,
|
||||
а не разбирать русский текст. **Если сценарий раньше сравнивал `status` с
|
||||
`"ALARM"`, его нужно переключить на `state`** — это ломающее изменение.
|
||||
|
||||
Формулировки живут в одном месте, в прошивке. Панель получает их готовыми через
|
||||
`/status` (поля `k`, `w`, `s`) и не собирает сама — иначе два набора фраз
|
||||
разошлись бы при первой правке.
|
||||
|
||||
Фразы не содержат кавычек и обратных слэшей, `device` отфильтрован при вводе,
|
||||
поэтому экранирование в JSON не требуется нигде.
|
||||
|
||||
TLS-соединение поднимается через `WiFiClientSecure` с `setInsecure()` —
|
||||
сертификат сервера **не проверяется**. Достаточно для отправки в доверенную
|
||||
локальную/корпоративную инфраструктуру, но не защищает от MITM.
|
||||
|
||||
### 5.1 Бюджет отправки
|
||||
|
||||
Отправка остаётся синхронной — BearSSL выполняет TLS-хендшейк блокирующе, и
|
||||
полностью асинхронного HTTPS на ESP8266 без хрупких сторонних библиотек нет.
|
||||
Вместо этого блокировка **ограничена по времени** бюджетом
|
||||
`WEBHOOK_BUDGET_MS = 3000 мс`, который расходуется по этапам:
|
||||
|
||||
1. **Разбор URL** (`parseWebhookHost`) — извлекает имя хоста, мгновенно.
|
||||
2. **DNS** — `WiFi.hostByName(host, ip, WEBHOOK_DNS_BUDGET_MS)`, лимит 800 мс.
|
||||
3. **TCP + TLS + чтение ответа** — `http.setTimeout(остаток бюджета)`.
|
||||
|
||||
Шаг 2 существует именно ради потолка: `WiFiClientSecureCtx::connect(name, port)`
|
||||
внутри вызывает `WiFi.hostByName()` **без таймаута** и на мёртвом DNS подвешивает
|
||||
`loop()` примерно на 10 секунд. Предварительный резолв с лимитом кладёт адрес в
|
||||
кэш lwIP, после чего внутренний резолв возвращается мгновенно. Соединение
|
||||
по-прежнему устанавливается по имени, поэтому SNI и заголовок `Host` не ломаются
|
||||
(важно, если webhook живёт за реверс-прокси).
|
||||
|
||||
Шаг 3 опирается на то, что `HTTPClient::connect()` вызывает
|
||||
`_client->setTimeout(_tcpTimeout)` **до** `_client->connect()`, а хендшейк
|
||||
BearSSL ограничен тем же `_timeout`. Чтение ответа ограничено отдельно —
|
||||
собственным циклом `HTTPClient` по `_tcpTimeout`.
|
||||
|
||||
Проверять бюджет между фазами внутри `HTTPClient` нельзя, поэтому потолок не
|
||||
строго 3000 мс. Но провал любой фазы обрывает цепочку, так что лимиты не
|
||||
складываются. Реальные худшие случаи:
|
||||
|
||||
| Сценарий | Задержка |
|
||||
|---|---|
|
||||
| Успешная отправка | 300–1500 мс |
|
||||
| DNS не отвечает | ~800 мс |
|
||||
| IP не отвечает (чёрная дыра) | ~800 мс + остаток бюджета |
|
||||
| TLS не поднимается | то же |
|
||||
| Хендшейк прошёл, ответа нет | ~1500 мс + остаток бюджета ≈ 3.5 с |
|
||||
|
||||
До введения бюджета те же сценарии давали 10–15 секунд.
|
||||
|
||||
Фактическое время каждой отправки пишется в лог: `[HTTP] Код: 200 (412 мс)`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Хранение настроек (LittleFS)
|
||||
|
||||
Настройки лежат в файловой системе LittleFS, в файле `/settings.json`:
|
||||
|
||||
```json
|
||||
{"webhook":"https://...","device":"Wemos_D1_Pump","alarm_delay":5,"emerg_delay":0}
|
||||
```
|
||||
|
||||
| Поле структуры `AppSettings` | Ключ JSON | По умолчанию |
|
||||
|---|---|---|
|
||||
| `webhookUrl` (`char[221]`) | `webhook` | пусто |
|
||||
| `deviceName` (`char[32]`) | `device` | `Wemos_D1_Pump` |
|
||||
| `alarmDelaySec` (`uint16`) | `alarm_delay` | `5` |
|
||||
| `emergDelaySec` (`uint16`) | `emerg_delay` | `0` |
|
||||
|
||||
Имена ключей вынесены в константы `KEY_*` и используются и при чтении, и при
|
||||
записи — рассинхронизация имён между load и save самый частый источник багов в
|
||||
таком коде, а через константу компилятор их разойтись не даст.
|
||||
|
||||
Строки хранятся как `char[]`, а не `String`: прибор работает месяцами без
|
||||
перезагрузки, и глобальные `String` фрагментировали бы кучу.
|
||||
|
||||
### 6.1 История: почему настройки не сохранялись
|
||||
|
||||
Реальный случай, разобранный на этом проекте. Симптом: настройки и webhook
|
||||
пропадают после перезагрузки.
|
||||
|
||||
**Корневая причина — несовпадение размера флеша.** В IDE было выбрано
|
||||
`4MB (FS:2MB OTA:~1019KB)`, тогда как физически на плате **2 МБ**:
|
||||
|
||||
| Область | Смещение при настройке 4MB | Внутри чипа 2 МБ (`0x200000`)? |
|
||||
|---|---|---|
|
||||
| Сектор EEPROM | `0x3FB000` = 4 173 824 | нет, вдвое дальше конца |
|
||||
| Начало LittleFS | `0x400000` = 2 097 152 | нет, ровно за границей |
|
||||
|
||||
`EEPROM.commit()` возвращал `false`, потому что `spi_flash_erase_sector()`
|
||||
обращался к несуществующему сектору.
|
||||
|
||||
**Лечится настройкой IDE, а не кодом:** любой вариант `2MB (FS:...)` кладёт
|
||||
файловую систему ниже `0x1FB000`, а EEPROM — в сектор 511, то есть внутрь чипа.
|
||||
С верной настройкой заработали бы оба механизма хранения.
|
||||
|
||||
Что было проверено по пути и оказалось ни при чём: библиотека EEPROM, перекрытие
|
||||
адресов полей, стирание сектора со стороны `WiFiManager::resetSettings()`
|
||||
(вызывает только `WiFi.disconnect()`).
|
||||
|
||||
**Почему всё-таки перешли на LittleFS**, раз причина была не в EEPROM:
|
||||
|
||||
* добавление новой настройки — одна строка, без арифметики адресов и без
|
||||
версионирования раскладки с миграцией;
|
||||
* отсутствующий ключ в старом файле не ломает чтение;
|
||||
* содержимое читается глазами;
|
||||
* тот же механизм используется в соседних проектах — единообразие.
|
||||
|
||||
То есть переход остаётся оправданным по своим качествам, но **проблему решила
|
||||
настройка `Flash Size`**, а не смена хранилища. Не перепутайте это, если
|
||||
столкнётесь с похожим симптомом на другой плате.
|
||||
|
||||
**Диагностика при старте.** Прошивка печатает геометрию и сама указывает на
|
||||
несоответствие:
|
||||
|
||||
```
|
||||
[FLASH] по настройке IDE: 4194304 Б, физически: 2097152 Б -> !!! НЕ СОВПАДАЕТ !!!
|
||||
[FLASH] файловая система: смещение 0x200000, размер 2093056 Б <<< ЗА ПРЕДЕЛАМИ ФИЗИЧЕСКОГО ФЛЕША!
|
||||
```
|
||||
|
||||
### 6.2 Отказоустойчивость
|
||||
|
||||
Загрузка не падает на неполном или повреждённом файле. Порядок в
|
||||
`settingsBegin()`:
|
||||
|
||||
1. `settingsDefaults()` — заполнить структуру значениями по умолчанию;
|
||||
2. `settingsLoad()` — перезаписать полями из JSON; отсутствующий или битый ключ
|
||||
**оставляет значение по умолчанию**;
|
||||
3. если файла нет или JSON не разобрался — `settingsSave()` создаёт его заново.
|
||||
|
||||
Благодаря пункту 2 файл, записанный прошлой версией прошивки, не ломается при
|
||||
добавлении новых полей — старый `/settings.json` просто не содержит нового ключа,
|
||||
и подставляется значение по умолчанию.
|
||||
|
||||
`settingsClamp()` дополнительно приводит значения к допустимым: пустое
|
||||
`deviceName` и выдержка больше `DELAY_MAX_SEC` заменяются значениями по
|
||||
умолчанию. Вызывается и после чтения, и перед записью.
|
||||
|
||||
Если ФС не смонтировалась, выполняется `LittleFS.format()` и повторная попытка.
|
||||
При окончательной неудаче взводится `settingsFsReady = false`: прибор продолжает
|
||||
работать на значениях по умолчанию, но **веб-панель показывает предупреждение**,
|
||||
что настройки не переживут перезагрузку. Молчаливой потери настроек больше нет.
|
||||
|
||||
### 6.3 Ресурс флеша
|
||||
|
||||
Запись происходит только по факту изменения — из обработчика `/set_config`, и
|
||||
только если хотя бы одно поле реально изменилось. Периодической записи нет и
|
||||
добавлять её не следует.
|
||||
|
||||
### 6.4 Как добавить новую настройку
|
||||
|
||||
Синхронно правятся четыре места:
|
||||
|
||||
1. константа `*_DEFAULT` рядом с остальными значениями по умолчанию;
|
||||
2. поле в `struct AppSettings` и строка в `settingsDefaults()`;
|
||||
3. константа `KEY_*`, строка в `settingsLoad()` и строка в `settingsSave()`;
|
||||
4. поле формы в `getHTML()` и его разбор в обработчике `/set_config`.
|
||||
|
||||
Старый `/settings.json` без нового ключа не сломается — подставится значение по
|
||||
умолчанию.
|
||||
|
||||
### 6.5 Что не хранится здесь
|
||||
|
||||
Учётные данные Wi-Fi хранит WiFiManager в своей области флеша, поэтому сброс
|
||||
Wi-Fi настройки из `/settings.json` не затрагивает, и наоборот.
|
||||
|
||||
Пустой `webhookUrl` — легальное состояние. Пока URL не задан,
|
||||
`sendPostWebhook()` сразу выходит и пишет в лог
|
||||
`[HTTP] Webhook URL не задан — отправка пропущена`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Веб-интерфейс
|
||||
|
||||

|
||||
|
||||
*Панель в норме, с раскрытым блоком настроек. Снимок с работающего прибора.*
|
||||
|
||||
Сервер на порту `80`.
|
||||
|
||||
| Маршрут | Метод | Описание |
|
||||
|---|---|---|
|
||||
| `/` | `GET` | Статическая HTML-панель из PROGMEM |
|
||||
| `/status` | `GET` | Живое состояние, JSON |
|
||||
| `/config` | `GET` | Настройки и адрес в сети, JSON |
|
||||
| `/reset_wifi` | `POST` | Стереть настройки Wi-Fi и перезагрузиться |
|
||||
| `/set_config` | `POST` | Параметры `url`, `device`, `alarm_delay`, `emerg_delay` |
|
||||
|
||||
### 7.1 Почему страница статическая
|
||||
|
||||
Раньше `getHTML()` собирал разметку строкой в куче на каждый запрос: около
|
||||
**14 КБ при примерно 40 КБ свободной кучи** у ESP8266. Теперь страница целиком
|
||||
лежит во флеше (`PAGE_HTML[] PROGMEM`, ~8 КБ) и отдаётся через `server.send_P()`,
|
||||
а состояние подставляет JS, забирая `/status` и `/config`. **Куча не тратится.**
|
||||
|
||||
Побочный, но важный эффект: прежняя панель при любом изменении состояния делала
|
||||
`location.reload()`. Если оператор в этот момент вводил адрес уведомлений, ввод
|
||||
пропадал вместе с прокруткой и фокусом. Теперь JS точечно правит DOM, и форма не
|
||||
трогается.
|
||||
|
||||
Ответы:
|
||||
|
||||
```json
|
||||
/status {"p1":1,"p2":0,"pE":0,"rMain":0,"rEmg":0,"rdy":1,
|
||||
"k":"ok","w":"Подача есть","s":"работает насос 1"}
|
||||
/config {"device":"Nasosnaya_1","alarm":5,"emerg":0,
|
||||
"host":"n8n.example.com","fs":1,"ip":"192.168.1.128"}
|
||||
```
|
||||
|
||||
`/config` отдаёт **только имя хоста**, а не полный webhook: URL содержит
|
||||
секретный токен, и выводить его на экран, который видно всем в помещении, незачем.
|
||||
|
||||
### 7.2 Логика статуса и цвета
|
||||
|
||||
Цвета взяты по конвенции сигнальных ламп, а не по вкусу:
|
||||
|
||||
| Состояние | Цвет | Заголовок | Условие |
|
||||
|---|---|---|---|
|
||||
| Запуск | серый | «Запуск» | `rdy = 0` |
|
||||
| Норма | зелёный | «Подача есть» | работает хотя бы один основной |
|
||||
| Внимание | **жёлтый** | «Работает резерв» | работает резерв, основные в порядке |
|
||||
| Авария | красный | «Насосы стоят» | оба основных стоят |
|
||||
|
||||
Резерв намеренно **жёлтый, а не красный**: работающий резерв — предупреждение,
|
||||
система ещё справляется. Красный оставлен для случая, когда основной подачи нет.
|
||||
|
||||
При аварии плашка медленно пульсирует; пульсация отключается при
|
||||
`prefers-reduced-motion`.
|
||||
|
||||
### 7.3 Состояние реле
|
||||
|
||||
`rMain` и `rEmg` — **логическое** состояние из переменных `relayMainOn` /
|
||||
`relayEmergOn`, а не `digitalRead()` с пина. Выходы инверсные, и чтение уровня
|
||||
показало бы на панели обратное действительности.
|
||||
|
||||
Лампа выхода «Авария» — **красная**. В первой версии панели она была зелёной:
|
||||
стиль «включено» был общим для всех ламп, и сработавшая аварийная сигнализация
|
||||
подсвечивалась цветом «всё хорошо».
|
||||
|
||||
### 7.4 Никаких внешних запросов
|
||||
|
||||
Веб-шрифты не подключаются. В первой версии был `@import` с Google Fonts, но
|
||||
сеть на объекте не разрешает внешние имена (раздел 5.1), поэтому шрифты там не
|
||||
загружались никогда — нарисованный макет на месте не появлялся. Сейчас
|
||||
используются системные стеки, а характер задан контрастом кеглей: заголовок
|
||||
статуса `clamp(28px, 8.5vw, 44px)` против меток 11–12 px.
|
||||
|
||||
### 7.5 Подпись состояния во вкладке
|
||||
|
||||
Заголовок вкладки и favicon меняют цвет вместе со статусом: `Насосы стоят ·
|
||||
Nasosnaya_1` с красным кружком. Вкладка, забытая в фоне на телефоне, продолжает
|
||||
сигналить, не требуя переключения на неё.
|
||||
|
||||
### 7.7 Почему разметка лежит в отдельном файле
|
||||
|
||||
`web_page.h` вынесен не ради опрятности, а вынужденно. Генератор прототипов
|
||||
Arduino не понимает сырые строковые литералы (`R"=====( ... )====="`): он находит
|
||||
внутри JavaScript строку `function draw(d){`, принимает её за определение функции
|
||||
C++ и вставляет в начало файла прототип `function draw(d);`. Сборка падает с
|
||||
`error: 'function' does not name a type`, причём директивы `#line` показывают
|
||||
ошибку на строке внутри HTML.
|
||||
|
||||
Заголовочные файлы препроцессор не сканирует, поэтому страница живёт там.
|
||||
|
||||
Важное следствие для проверки: обычный `g++ -fsyntax-only` по `.ino` эту ошибку
|
||||
**не покажет** — он компилирует файл как обычный `.cpp`, минуя препроцессор
|
||||
Arduino. Проверять сборку нужно через `arduino-cli` (раздел 11.1).
|
||||
|
||||
### 7.6 Доступ
|
||||
|
||||
Аутентификации нет: любой в той же сети может сбросить Wi-Fi, подменить адрес
|
||||
уведомлений и **увеличить выдержки**, то есть замедлить реакцию сигнализации.
|
||||
Контроллер рассчитан на изолированный технологический сегмент.
|
||||
|
||||
---
|
||||
|
||||
## 8. OLED
|
||||
|
||||
Обновление раз в секунду (`DISPLAY_INTERVAL`).
|
||||
|
||||
Верхняя строка отдана главному ответу кеглем 2 (12×16 px) — читается от двери.
|
||||
Прежде её постоянно занимал заголовок `PUMP CONTROLLER v8`, который никогда не
|
||||
менялся и ничего не сообщал.
|
||||
|
||||
```
|
||||
NO SUPPLY <- кегль 2, до 10 символов
|
||||
────────────────────────
|
||||
P1:OFF P2:OFF BK:RUN <- датчики, одна строка вместо трёх
|
||||
ALARM:ON BACKUP:ON <- состояние выходов
|
||||
────────────────────────
|
||||
192.168.1.128
|
||||
```
|
||||
|
||||
| Условие | Слово |
|
||||
|---|---|
|
||||
| `!systemReady` | `STARTING` |
|
||||
| оба основных стоят, резерв стоит | `NO SUPPLY` (мигает инверсией, 500 мс) |
|
||||
| оба основных стоят, резерв работает | `BACKUP ON` (мигает) |
|
||||
| работает резерв, основные в порядке | `BACKUP RUN` |
|
||||
| иначе | `SUPPLY OK` |
|
||||
|
||||
Текст только латиницей: встроенный шрифт Adafruit GFX кириллицы не содержит, а
|
||||
тащить её во флеш ради четырёх слов не стоит.
|
||||
|
||||
Состояние обоих реле теперь выводится — освободились две строки после
|
||||
объединения датчиков в одну.
|
||||
|
||||
---
|
||||
|
||||
## 9. Сброс настроек Wi-Fi
|
||||
|
||||
Физической кнопки сброса нет — она удалена осознанно. При сохранённых учётных
|
||||
данных и пропавшей сети `wm.autoConnect()` не подключается и **сам** поднимает
|
||||
портал `Pump_Control_Set`, поэтому для этого сценария кнопка не нужна.
|
||||
|
||||
Остаётся два способа:
|
||||
|
||||
* кнопка **«Сбросить Wi-Fi»** на веб-панели (`POST /reset_wifi`) — работает, пока
|
||||
прибор в сети и панель доступна;
|
||||
* выключить роутер и перезагрузить прибор — `autoConnect()` не найдёт сеть и
|
||||
откроет портал.
|
||||
|
||||
Второй способ нужен для случая «сеть есть, подключение успешно, но прибор надо
|
||||
перевести в другую сеть»: простая перезагрузка портал не откроет, так как
|
||||
подключение проходит успешно.
|
||||
|
||||
---
|
||||
|
||||
## 10. Известные ограничения
|
||||
|
||||
* `setInsecure()` — TLS без проверки сертификата.
|
||||
* Веб-интерфейс и `/reset_wifi` не защищены паролем.
|
||||
* Webhook отправляется синхронно и блокирует цикл, но не дольше бюджета
|
||||
(см. 5.1). Выходы аварии от этого не зависят: `digitalWrite()` выполняется
|
||||
**до** отправки, поэтому реле и сирена не ждут сервер. Подвисает только
|
||||
обновление OLED, опрос кнопки и веб-панель.
|
||||
* События, произошедшие без Wi-Fi или при неудачной отправке, не буферизуются
|
||||
и не повторяются — webhook теряется, флаги `mainAlarmSent` /
|
||||
`emergencyActiveSent` выставляются независимо от результата POST.
|
||||
Ретраи и очередь сознательно не реализованы: контроллер рассчитан на работу
|
||||
и без отправки событий, автономность — штатный режим, а не отказ.
|
||||
* **Пауза мониторинга при старте без сохранённой сети.** `wm.autoConnect()`
|
||||
поднимает портал настройки и блокирует `setup()` в цикле ожидания до
|
||||
истечения `setConfigPortalTimeout(120)`. Пока `setup()` не завершён, `loop()`
|
||||
не выполняется: дребезг не считается, аварии не формируются, выходы не
|
||||
переключаются (они остаются в безопасном `LOW`, выставленном до Wi-Fi).
|
||||
В установке, где сеть не настраивается никогда, эти 2 минуты повторяются
|
||||
при каждом включении питания. Принято как есть; при необходимости лечится
|
||||
`wm.setConfigPortalBlocking(false)` + `wm.process()` в `loop()` — с оговоркой,
|
||||
что в неблокирующем режиме `setConfigPortalTimeout` не применяется и гасить
|
||||
портал придётся своим таймером.
|
||||
* Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией
|
||||
не считается и никак не сигнализируется.
|
||||
* Webhook URL хранится в `/settings.json` в открытом виде и отдаётся
|
||||
веб-панелью всем, кто может её открыть. Если URL содержит секретный токен —
|
||||
доступ к панели равносилен доступу к токену.
|
||||
|
||||
---
|
||||
|
||||
## 11. Сборка и прошивка
|
||||
|
||||
Плата в IDE: **LOLIN(WEMOS) D1 ESP-WROOM-02**, а не `D1 R2 & mini`. У WROOM-02
|
||||
модуль на 2 МБ, и только у этой платы в списке есть нужные варианты `2MB (...)`.
|
||||
`Flash Size` по умолчанию для неё — `2MB (FS:64KB OTA:~992KB)`, он и используется.
|
||||
|
||||
`arduino-cli` отдельно ставить не нужно, он идёт внутри Arduino IDE:
|
||||
|
||||
```bash
|
||||
"C:/Program Files/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe" compile --fqbn esp8266:esp8266:d1_wroom_02:eesz=2M64 --warnings all .
|
||||
```
|
||||
|
||||
```bash
|
||||
"C:/Program Files/Arduino IDE/resources/app/lib/backend/resources/arduino-cli.exe" upload -p COM7 --fqbn esp8266:esp8266:d1_wroom_02:eesz=2M64 .
|
||||
```
|
||||
|
||||
Монитор порта: **115200 бод**. Если заливка падает с
|
||||
`could not open port 'COM7': PermissionError`, порт держит открытый монитор
|
||||
Arduino IDE — закройте его.
|
||||
|
||||
### 11.1 Проверять сборку только через arduino-cli
|
||||
|
||||
Скетч нельзя надёжно проверить, скормив `.ino` напрямую в `g++`: так
|
||||
пропускается препроцессор Arduino, а именно он ломается на некоторых
|
||||
конструкциях (раздел 7.7). Ошибка, которую `g++ -fsyntax-only` не показывает,
|
||||
на реальной сборке останавливает всё.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
@@ -1,185 +0,0 @@
|
||||
# Настраиваемые параметры, состояние реле, ревизия распиновки
|
||||
|
||||
Дата: 2026-08-07
|
||||
Статус: согласовано, к реализации
|
||||
|
||||
## Задачи
|
||||
|
||||
1. Пересмотреть распиновку под доступный набор пинов (A0, D1–D8) и релейные
|
||||
модули active-LOW.
|
||||
2. Вынести в веб-панель три параметра: имя устройства, выдержку аварии основных
|
||||
насосов, выдержку пуска резервного насоса.
|
||||
3. Показать состояние обоих реле в веб-панели.
|
||||
|
||||
## Исходные условия
|
||||
|
||||
* Доступны только A0 и D1–D8. Пина `D0`, который использует текущий код, нет.
|
||||
* A0 — только аналоговый вход: ни цифровым выходом, ни входом с подтяжкой быть
|
||||
не может, поэтому в бюджет цифровых пинов не входит.
|
||||
* Реле — готовые модули для Arduino с обвязкой, **active-LOW** (вход на GND =
|
||||
реле включено). На момент проектирования ещё не подключены, поэтому логику
|
||||
выходов задаём с нуля.
|
||||
|
||||
## 1. Распиновка
|
||||
|
||||
Кнопка сброса Wi-Fi удаляется. Обоснование: при сохранённых учётных данных и
|
||||
пропавшей сети `wm.autoConnect()` не подключается и сам поднимает портал
|
||||
`Pump_Control_Set` — кнопка для этого не нужна. Для случая «сеть есть, но надо
|
||||
переехать в другую» остаётся кнопка на веб-панели; физически то же достигается
|
||||
выключением роутера и перезагрузкой прибора.
|
||||
|
||||
Освободившийся D3 закрывает дефицит пинов.
|
||||
|
||||
Для реле active-LOW безопасное состояние — пин в **HIGH**, поэтому выходы должны
|
||||
сидеть на пинах, уже подтянутых к HIGH во время загрузки, пока `setup()` ещё не
|
||||
выполнился:
|
||||
|
||||
| Пин | Уровень при загрузке | Реле active-LOW при загрузке |
|
||||
|---|---|---|
|
||||
| D3 | подтянут HIGH (10к) | выключено ✓ |
|
||||
| D4 | подтянут HIGH (10к) | выключено ✓ |
|
||||
| D8 | подтянут **LOW** (10к) | включено ✗ ложная авария при каждом старте |
|
||||
| D1/D2/D5/D6/D7 | не определён до `pinMode` | неопределённо |
|
||||
|
||||
Итоговая распиновка:
|
||||
|
||||
```
|
||||
D1 SDA — OLED (без изменений)
|
||||
D2 SCL — OLED (без изменений)
|
||||
D5 IN_PUMP_1 (INPUT_PULLUP, LOW = работает)
|
||||
D6 IN_PUMP_2 (INPUT_PULLUP, LOW = работает)
|
||||
D7 IN_PUMP_EMERGENCY (INPUT_PULLUP, LOW = работает)
|
||||
D3 OUT_ALARM_MAIN (выход, active-LOW: LOW = реле включено)
|
||||
D4 OUT_ALARM_EMERGENCY (выход, active-LOW: LOW = реле включено)
|
||||
D8, A0, D0 — не используются
|
||||
```
|
||||
|
||||
Особенности, принятые осознанно:
|
||||
|
||||
* D4 — встроенный светодиод платы, тоже активен от LOW. Реле «Резерв» получает
|
||||
бесплатную индикацию на плате.
|
||||
* D3 — пин выбора режима прошивки. Схема автосброса USB дёргает его вниз при
|
||||
заливке скетча, поэтому реле «Авария» щёлкает при каждой прошивке. Косметика.
|
||||
* Схема не fail-safe: авария включает реле, поэтому обесточенный контроллер
|
||||
сигнала не даёт. При необходимости решается на монтаже — снятием нагрузки с
|
||||
нормально замкнутого (NC) контакта реле.
|
||||
|
||||
Инверсия уровня локализуется в одной функции, чтобы её нельзя было применить
|
||||
дважды или забыть:
|
||||
|
||||
```cpp
|
||||
void setRelay(int pin, bool on) { digitalWrite(pin, on ? LOW : HIGH); }
|
||||
```
|
||||
|
||||
В `setup()` `digitalWrite(pin, HIGH)` вызывается **до** `pinMode(pin, OUTPUT)`:
|
||||
иначе защёлка выхода может кратковременно выдать LOW и щёлкнуть реле.
|
||||
|
||||
## 2. Конфигурация в EEPROM
|
||||
|
||||
Три новых параметра не влезают в оставшиеся 34 байта (текущая раскладка занимает
|
||||
222 из 256), поэтому `EEPROM_SIZE` увеличивается до 512.
|
||||
|
||||
| Адрес | Размер | Поле | По умолчанию |
|
||||
|---|---|---|---|
|
||||
| 0 | 2 | magic `0xA55B` | — |
|
||||
| 2 | 220 | `webhook_url` | пусто |
|
||||
| 222 | 32 | `device_name` | `Wemos_D1_Pump` |
|
||||
| 254 | 2 | `alarm_delay_sec` (uint16) | 5 |
|
||||
| 256 | 2 | `emerg_delay_sec` (uint16) | 0 |
|
||||
|
||||
Значения по умолчанию совпадают с текущим поведением прошивки: `5` — нынешний
|
||||
`alarmDelay = 5000 мс`, `0` — нынешняя мгновенная реакция на резерв,
|
||||
`Wemos_D1_Pump` — нынешнее захардкоженное значение поля `device` в теле хука.
|
||||
|
||||
Миграция по magic:
|
||||
|
||||
* `0xA55B` — читаются все поля.
|
||||
* `0xA55A` (старая раскладка) — сохраняется `webhook_url`, новые поля получают
|
||||
значения по умолчанию, magic перезаписывается. Прошитые приборы не теряют
|
||||
адрес webhook.
|
||||
* иное — первый старт, все поля по умолчанию.
|
||||
|
||||
## 3. Валидация
|
||||
|
||||
`device_name` попадает в тело JSON, поэтому кавычка или обратный слэш сломали бы
|
||||
запрос. Вместо экранирования — ограничение набора символов: разрешены
|
||||
`A-Z a-z 0-9 _ -`, до 31 символа, остальные символы отбрасываются. Если после
|
||||
фильтрации строка пуста, поле не меняется. Это же снимает вопрос с выводом
|
||||
значения в HTML.
|
||||
|
||||
Задержки задаются целыми секундами, допустимый диапазон 0…3600. Значение вне
|
||||
диапазона или нечисловое — поле не меняется.
|
||||
|
||||
Пустой `webhook_url` — легальное состояние: отправка событий пропускается,
|
||||
автономная работа это штатный режим.
|
||||
|
||||
## 4. Логика задержек
|
||||
|
||||
Одна выдержка на канал, гасит и реле, и вебхук одновременно.
|
||||
|
||||
`alarm_delay_sec` заменяет константу `alarmDelay` в существующей паре
|
||||
`mainAlarmPending` / `mainAlarmStartTime` — поведение основных насосов не
|
||||
меняется, кроме настраиваемости.
|
||||
|
||||
Для резерва добавляется симметричная пара `emergPending` / `emergStartTime`,
|
||||
которой сейчас нет:
|
||||
|
||||
```
|
||||
pE работает:
|
||||
выдержка не запущена -> запустить
|
||||
выдержка истекла -> setRelay(EMERG, true) + вебхук STARTED (однократно)
|
||||
pE стоит:
|
||||
setRelay(EMERG, false) + вебхук STOPPED (однократно), выдержка сброшена
|
||||
```
|
||||
|
||||
При `emerg_delay_sec = 0` условие выполняется на следующей же итерации, то есть
|
||||
нынешнее мгновенное поведение сохраняется.
|
||||
|
||||
Полная задержка до сигнала = 3 с (дребезг, константа `debounceDelay`) плюс
|
||||
настроенная выдержка.
|
||||
|
||||
## 5. Веб-интерфейс
|
||||
|
||||
`/set_webhook` заменяется на `/set_config` — одна форма на четыре поля (URL, имя
|
||||
устройства, две задержки). Ломающее изменение для маршрута; внешних потребителей
|
||||
у него нет.
|
||||
|
||||
`/status` расширяется:
|
||||
|
||||
```json
|
||||
{"p1":0,"p2":0,"pE":0,"rMain":0,"rEmg":0}
|
||||
```
|
||||
|
||||
Состояние реле берётся из логических переменных `relayMainOn` / `relayEmergOn`,
|
||||
а не из `digitalRead` — иначе на панели легко получить инвертированную неправду.
|
||||
Побочный эффект: скрипт панели сравнивает JSON целиком, поэтому страница теперь
|
||||
перезагружается и при изменении состояния реле.
|
||||
|
||||
На панель добавляются:
|
||||
|
||||
* ряд из двух индикаторов реле (существующие стили `.spanel`);
|
||||
* блок настроек с текущими значениями полей.
|
||||
|
||||
## 6. Вне объёма
|
||||
|
||||
* OLED не меняется — строки для реле там нет без переработки макета.
|
||||
* Ретраи и очередь вебхуков не добавляются (решение принято ранее).
|
||||
* Пауза мониторинга до 2 минут при старте без сохранённой сети остаётся как есть.
|
||||
* Попутно удаляется неиспользуемая переменная `isEmergency` в `getHTML()` —
|
||||
единственное предупреждение компилятора в скетче.
|
||||
|
||||
## 7. Проверка
|
||||
|
||||
Автоматически:
|
||||
|
||||
* Синтаксическая сборка `xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra`
|
||||
против ядра 3.1.2 — в скетче не должно остаться предупреждений.
|
||||
* Логика фильтрации имени устройства и разбора чисел — таблицей граничных
|
||||
случаев.
|
||||
|
||||
На железе (остаётся за пользователем):
|
||||
|
||||
* при подаче питания оба реле молчат;
|
||||
* миграция EEPROM сохранила ранее заданный webhook;
|
||||
* заданные выдержки соблюдаются, при 0 резерв реагирует сразу;
|
||||
* панель показывает состояние реле, совпадающее с фактическим щелчком.
|
||||
@@ -1,877 +0,0 @@
|
||||
/*
|
||||
============================================================
|
||||
Контроллер насосов v8 — Wemos D1 Mini
|
||||
============================================================
|
||||
Распиновка (реле — модули active-LOW: LOW на входе = реле включено):
|
||||
D1 — OLED SDA (I2C)
|
||||
D2 — OLED SCL (I2C)
|
||||
D5 — IN_PUMP_1 (датчик, INPUT_PULLUP, LOW = работает)
|
||||
D6 — IN_PUMP_2 (датчик, INPUT_PULLUP, LOW = работает)
|
||||
D7 — IN_PUMP_EMERGENCY (датчик, INPUT_PULLUP, LOW = работает)
|
||||
D3 — OUT_ALARM_MAIN (выход, LOW = реле включено)
|
||||
D4 — OUT_ALARM_EMERGENCY (выход, LOW = реле включено)
|
||||
D8, D0, A0 — не используются
|
||||
|
||||
Почему выходы именно на D3 и D4: для реле active-LOW безопасное состояние —
|
||||
пин в HIGH, а D3 и D4 подтянуты к HIGH внешними резисторами платы и держат
|
||||
этот уровень всю загрузку, пока setup() ещё не выполнился. D8 подтянут к LOW
|
||||
и щёлкал бы реле при каждом включении питания.
|
||||
|
||||
Особенности:
|
||||
- D4 — встроенный светодиод платы (тоже активен от LOW), даёт бесплатную
|
||||
индикацию реле «Резерв».
|
||||
- D3 — пин выбора режима прошивки; схема автосброса USB дёргает его вниз,
|
||||
поэтому реле «Авария» щёлкает при заливке скетча. Косметика.
|
||||
- Схема не fail-safe: обесточенный контроллер сигнала аварии не даёт.
|
||||
При необходимости решается на монтаже — нагрузка снимается с нормально
|
||||
замкнутого (NC) контакта реле.
|
||||
|
||||
Настройки хранятся в LittleFS, файл /settings.json.
|
||||
|
||||
Библиотеки (установить через Library Manager):
|
||||
- Adafruit SSD1306
|
||||
- Adafruit GFX Library
|
||||
- WiFiManager (tzapu)
|
||||
- ArduinoJson (v7)
|
||||
- ESP8266WiFi / ESP8266WebServer / ESP8266HTTPClient / LittleFS
|
||||
(часть ESP8266 core)
|
||||
|
||||
ВАЖНО про Flash Size в IDE. Целевая плата имеет 2 МБ флеша, ставить нужно
|
||||
«2MB (FS:64KB OTA:~992KB)». Выбранный размер обязан совпадать с физическим:
|
||||
при настройке 4MB на чипе 2MB области EEPROM и файловой системы уходят за
|
||||
границу микросхемы, и настройки молча не сохраняются. Прошивка печатает
|
||||
фактический размер в лог при старте строкой [FLASH].
|
||||
============================================================
|
||||
*/
|
||||
|
||||
#include <ESP8266WiFi.h>
|
||||
#include <ESP8266WebServer.h>
|
||||
#include <ESP8266HTTPClient.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <WiFiManager.h>
|
||||
#include <LittleFS.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <Wire.h>
|
||||
#include <flash_hal.h> // FS_PHYS_ADDR / FS_PHYS_SIZE — только для диагностики
|
||||
#include <Adafruit_GFX.h>
|
||||
#include <Adafruit_SSD1306.h>
|
||||
|
||||
// --- OLED ---
|
||||
#define SCREEN_WIDTH 128
|
||||
#define SCREEN_HEIGHT 64
|
||||
#define OLED_RESET -1 // нет отдельного RESET
|
||||
#define OLED_ADDR 0x3C // обычный адрес SSD1306; попробуй 0x3D если дисплей не найден
|
||||
|
||||
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
|
||||
bool oledOK = false;
|
||||
|
||||
// --- КОНФИГУРАЦИЯ ---
|
||||
const char* config_ap_name = "Pump_Control_Set";
|
||||
|
||||
// --- ЗНАЧЕНИЯ ПО УМОЛЧАНИЮ ---
|
||||
// Webhook намеренно пуст: URL содержит секретный токен, ему не место в исходнике.
|
||||
const char* WEBHOOK_URL_DEFAULT = "";
|
||||
const char* DEVICE_NAME_DEFAULT = "Wemos_D1_Pump";
|
||||
const uint16_t ALARM_DELAY_DEFAULT_SEC = 5; // выдержка аварии основных насосов
|
||||
const uint16_t EMERG_DELAY_DEFAULT_SEC = 0; // выдержка пуска резерва (0 = сразу)
|
||||
const uint16_t DELAY_MAX_SEC = 3600;
|
||||
|
||||
const int WEBHOOK_MAX_LEN = 220;
|
||||
const int DEVICE_MAX_LEN = 31;
|
||||
|
||||
// --- ХРАНИЛИЩЕ НАСТРОЕК ---
|
||||
// LittleFS, а не EEPROM. Важно не перепутать причины: EEPROM переставала
|
||||
// сохранять из-за неверного Flash Size в IDE (см. шапку файла), а не потому,
|
||||
// что механизм плох — с верной настройкой работали бы оба. LittleFS выбран
|
||||
// за то, что новое поле добавляется одной строкой, без арифметики адресов и
|
||||
// версионирования раскладки, а старый файл не ломается при этом.
|
||||
// Разбор случая — в README, раздел 6.1.
|
||||
const char* SETTINGS_PATH = "/settings.json";
|
||||
|
||||
// Имена ключей вынесены в константы намеренно: рассинхронизация имён между
|
||||
// чтением и записью — самый частый источник багов в таком коде, а так
|
||||
// компилятор не даст им разойтись.
|
||||
const char* KEY_WEBHOOK = "webhook";
|
||||
const char* KEY_DEVICE = "device";
|
||||
const char* KEY_ALARM_DELAY = "alarm_delay";
|
||||
const char* KEY_EMERG_DELAY = "emerg_delay";
|
||||
|
||||
// Строки хранятся как char[], а не String: прибор работает месяцами без
|
||||
// перезагрузки, и глобальные String фрагментируют кучу.
|
||||
struct AppSettings {
|
||||
char webhookUrl[WEBHOOK_MAX_LEN + 1];
|
||||
char deviceName[DEVICE_MAX_LEN + 1];
|
||||
uint16_t alarmDelaySec;
|
||||
uint16_t emergDelaySec;
|
||||
};
|
||||
|
||||
AppSettings settings;
|
||||
bool settingsFsReady = false; // false = ФС недоступна, настройки не переживут перезагрузку
|
||||
|
||||
// --- ПИНЫ ---
|
||||
const int IN_PUMP_1 = D5;
|
||||
const int IN_PUMP_2 = D6;
|
||||
const int IN_PUMP_EMERGENCY = D7;
|
||||
const int OUT_ALARM_MAIN = D3; // реле active-LOW
|
||||
const int OUT_ALARM_EMERGENCY = D4; // реле active-LOW
|
||||
|
||||
// Логическое состояние реле. Держим отдельно от уровня на пине: выводить в
|
||||
// панель digitalRead() с инверсной логикой — верный способ показать неправду.
|
||||
bool relayMainOn = false;
|
||||
bool relayEmergOn = false;
|
||||
|
||||
ESP8266WebServer server(80);
|
||||
|
||||
// --- СТРУКТУРА НАСОСА ---
|
||||
typedef struct {
|
||||
int pin;
|
||||
String name;
|
||||
bool stableState;
|
||||
bool lastReading;
|
||||
unsigned long lastDebounceTime;
|
||||
} Pump;
|
||||
|
||||
Pump p1 = {IN_PUMP_1, "PUMP_1", false, true, 0};
|
||||
Pump p2 = {IN_PUMP_2, "PUMP_2", false, true, 0};
|
||||
Pump pE = {IN_PUMP_EMERGENCY, "PUMP_EMERGENCY", false, true, 0};
|
||||
|
||||
const unsigned long debounceDelay = 3000;
|
||||
|
||||
// --- БЮДЖЕТ ОТПРАВКИ WEBHOOK ---
|
||||
// Полностью неблокирующий HTTPS на ESP8266 невозможен: BearSSL делает хендшейк
|
||||
// синхронно. Поэтому блокировка не убирается, а ограничивается по времени.
|
||||
// HTTPClient не позволяет проверять бюджет между своими фазами (TCP connect /
|
||||
// TLS / чтение ответа), но неудачная фаза обрывает цепочку, поэтому лимиты
|
||||
// не складываются. Худший реальный случай — успешный хендшейк плюс зависшее
|
||||
// чтение ответа, около 3.5 с вместо прежних 10-15 с.
|
||||
const unsigned long WEBHOOK_BUDGET_MS = 3000; // общий бюджет на отправку
|
||||
const unsigned long WEBHOOK_DNS_BUDGET_MS = 800; // из него — на резолв имени
|
||||
|
||||
bool mainAlarmSent = false;
|
||||
bool emergencyActiveSent = false;
|
||||
bool systemReady = false;
|
||||
unsigned long mainAlarmStartTime = 0;
|
||||
bool mainAlarmPending = false;
|
||||
unsigned long emergStartTime = 0;
|
||||
bool emergPending = false;
|
||||
|
||||
// Для обновления дисплея не чаще раза в секунду
|
||||
unsigned long lastDisplayUpdate = 0;
|
||||
const unsigned long DISPLAY_INTERVAL = 1000;
|
||||
|
||||
// ============================================================
|
||||
// НАСТРОЙКИ (LittleFS + JSON)
|
||||
// ============================================================
|
||||
|
||||
void settingsDefaults() {
|
||||
strlcpy(settings.webhookUrl, WEBHOOK_URL_DEFAULT, sizeof(settings.webhookUrl));
|
||||
strlcpy(settings.deviceName, DEVICE_NAME_DEFAULT, sizeof(settings.deviceName));
|
||||
settings.alarmDelaySec = ALARM_DELAY_DEFAULT_SEC;
|
||||
settings.emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
|
||||
}
|
||||
|
||||
// Приводит значения к допустимым. Нужно и после чтения файла, и после
|
||||
// миграции: мусор в файле не должен превращаться в неверную выдержку.
|
||||
static void settingsClamp() {
|
||||
settings.webhookUrl[WEBHOOK_MAX_LEN] = '\0';
|
||||
settings.deviceName[DEVICE_MAX_LEN] = '\0';
|
||||
if (settings.deviceName[0] == '\0') {
|
||||
strlcpy(settings.deviceName, DEVICE_NAME_DEFAULT, sizeof(settings.deviceName));
|
||||
}
|
||||
if (settings.alarmDelaySec > DELAY_MAX_SEC) settings.alarmDelaySec = ALARM_DELAY_DEFAULT_SEC;
|
||||
if (settings.emergDelaySec > DELAY_MAX_SEC) settings.emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
|
||||
}
|
||||
|
||||
// Загрузка отказоустойчива: отсутствующий или битый ключ оставляет значение,
|
||||
// уже проставленное settingsDefaults(). Благодаря этому файл, записанный
|
||||
// прошлой версией прошивки, не ломается при добавлении новых полей.
|
||||
static bool settingsLoad() {
|
||||
File f = LittleFS.open(SETTINGS_PATH, "r");
|
||||
if (!f) {
|
||||
Serial.println(F("[SET] Файл настроек не найден"));
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, f);
|
||||
f.close();
|
||||
|
||||
if (err) {
|
||||
Serial.printf("[SET] ОШИБКА разбора JSON: %s\n", err.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* v;
|
||||
v = doc[KEY_WEBHOOK];
|
||||
if (v) strlcpy(settings.webhookUrl, v, sizeof(settings.webhookUrl));
|
||||
v = doc[KEY_DEVICE];
|
||||
if (v) strlcpy(settings.deviceName, v, sizeof(settings.deviceName));
|
||||
settings.alarmDelaySec = doc[KEY_ALARM_DELAY] | settings.alarmDelaySec;
|
||||
settings.emergDelaySec = doc[KEY_EMERG_DELAY] | settings.emergDelaySec;
|
||||
|
||||
settingsClamp();
|
||||
|
||||
Serial.printf("[SET] Загружено: device=%s, авария=%u с, резерв=%u с\n",
|
||||
settings.deviceName, settings.alarmDelaySec, settings.emergDelaySec);
|
||||
Serial.println("[SET] URL: " + String(settings.webhookUrl[0] ? settings.webhookUrl : "(не задан)"));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Перезаписывает файл целиком. true только если запись реально удалась.
|
||||
bool settingsSave() {
|
||||
if (!settingsFsReady) {
|
||||
Serial.println(F("[SET] ОШИБКА: файловая система недоступна, сохранять некуда"));
|
||||
return false;
|
||||
}
|
||||
|
||||
settingsClamp();
|
||||
|
||||
JsonDocument doc;
|
||||
doc[KEY_WEBHOOK] = settings.webhookUrl;
|
||||
doc[KEY_DEVICE] = settings.deviceName;
|
||||
doc[KEY_ALARM_DELAY] = settings.alarmDelaySec;
|
||||
doc[KEY_EMERG_DELAY] = settings.emergDelaySec;
|
||||
|
||||
File f = LittleFS.open(SETTINGS_PATH, "w");
|
||||
if (!f) {
|
||||
Serial.println(F("[SET] ОШИБКА: не удалось открыть файл на запись"));
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t written = serializeJson(doc, f);
|
||||
f.close();
|
||||
|
||||
if (written == 0) {
|
||||
Serial.println(F("[SET] ОШИБКА: в файл записано 0 байт"));
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("[SET] Сохранено (%u Б): device=%s, авария=%u с, резерв=%u с\n",
|
||||
(unsigned)written, settings.deviceName,
|
||||
settings.alarmDelaySec, settings.emergDelaySec);
|
||||
Serial.println("[SET] URL: " + String(settings.webhookUrl[0] ? settings.webhookUrl : "(не задан)"));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Монтирует ФС и заполняет settings. Вызывать первым из всего, что работает
|
||||
// с LittleFS: повторный LittleFS.begin() в других местах не нужен.
|
||||
void settingsBegin() {
|
||||
settingsDefaults();
|
||||
|
||||
if (!LittleFS.begin()) {
|
||||
Serial.println(F("[FS] Не смонтирована, форматирую..."));
|
||||
if (!LittleFS.format() || !LittleFS.begin()) {
|
||||
Serial.println(F("[FS] ОШИБКА: файловая система недоступна."));
|
||||
Serial.println(F("[FS] Прибор работает, но настройки не переживут перезагрузку."));
|
||||
settingsFsReady = false;
|
||||
return;
|
||||
}
|
||||
Serial.println(F("[FS] Отформатирована успешно"));
|
||||
}
|
||||
settingsFsReady = true;
|
||||
|
||||
FSInfo info;
|
||||
if (LittleFS.info(info)) {
|
||||
Serial.printf("[FS] Смонтирована: всего %u Б, занято %u Б\n",
|
||||
(unsigned)info.totalBytes, (unsigned)info.usedBytes);
|
||||
}
|
||||
|
||||
if (!settingsLoad()) {
|
||||
Serial.println(F("[SET] Создаю файл со значениями по умолчанию"));
|
||||
settingsSave();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OLED - макет экрана 128x64
|
||||
// ============================================================
|
||||
/*
|
||||
Верхняя строка отдана главному ответу кеглем 2 (12x16 px) - читается от
|
||||
двери. Прежний постоянный заголовок "PUMP CONTROLLER v8" её занимал, но
|
||||
ничего не сообщал. При аварии строка мигает инверсией раз в 500 мс.
|
||||
|
||||
Текст только латиницей: встроенный шрифт Adafruit GFX кириллицы не
|
||||
содержит, а тащить её во флеш ради четырёх слов не стоит.
|
||||
|
||||
+--------------------------+
|
||||
| NO SUPPLY | кегль 2, до 10 символов
|
||||
|--------------------------|
|
||||
| P1:OFF P2:OFF BK:RUN | датчики, одна строка
|
||||
| ALARM:ON BACKUP:ON | состояние выходов
|
||||
|--------------------------|
|
||||
| 192.168.1.128 |
|
||||
+--------------------------+
|
||||
|
||||
Слова статуса: STARTING / SUPPLY OK / BACKUP RUN / BACKUP ON / NO SUPPLY
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// Реле аварий (модули active-LOW: LOW = включено)
|
||||
// ============================================================
|
||||
// Единственное место, где живёт инверсия уровня. Логическое состояние
|
||||
// дублируется в переменную, чтобы веб-панель показывала смысл, а не уровень.
|
||||
void setRelay(int pin, bool on) {
|
||||
digitalWrite(pin, on ? LOW : HIGH);
|
||||
if (pin == OUT_ALARM_MAIN) relayMainOn = on;
|
||||
else if (pin == OUT_ALARM_EMERGENCY) relayEmergOn = on;
|
||||
}
|
||||
|
||||
void updateDisplay() {
|
||||
if (!oledOK) return;
|
||||
|
||||
bool alarm = (!p1.stableState && !p2.stableState);
|
||||
|
||||
display.clearDisplay();
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
|
||||
// --- Главный ответ, кегль 2 (12x16 px, до 10 символов) ---
|
||||
// Верхняя строка отдана статусу: прежний заголовок "PUMP CONTROLLER v8"
|
||||
// занимал её постоянно, но никогда не менялся и ничего не сообщал.
|
||||
const __FlashStringHelper* word;
|
||||
if (!systemReady) word = F("STARTING");
|
||||
else if (alarm) word = pE.stableState ? F("BACKUP ON") : F("NO SUPPLY");
|
||||
else if (pE.stableState) word = F("BACKUP RUN");
|
||||
else word = F("SUPPLY OK");
|
||||
|
||||
// При аварии строка мигает инверсией каждые 500 мс
|
||||
bool blink = alarm && ((millis() / 500) % 2 == 0);
|
||||
if (blink) {
|
||||
display.fillRect(0, 0, SCREEN_WIDTH, 18, SSD1306_WHITE);
|
||||
display.setTextColor(SSD1306_BLACK);
|
||||
}
|
||||
display.setTextSize(2);
|
||||
display.setCursor(0, 1);
|
||||
display.print(word);
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
display.setTextSize(1);
|
||||
|
||||
display.drawFastHLine(0, 20, SCREEN_WIDTH, SSD1306_WHITE);
|
||||
|
||||
// --- Датчики: три поля в одной строке вместо трёх строк ---
|
||||
display.setCursor(0, 25);
|
||||
display.print(F("P1:"));
|
||||
display.print(p1.stableState ? F("RUN") : F("OFF"));
|
||||
display.print(F(" P2:"));
|
||||
display.print(p2.stableState ? F("RUN") : F("OFF"));
|
||||
display.print(F(" BK:"));
|
||||
display.print(pE.stableState ? F("RUN") : F("OFF"));
|
||||
|
||||
// --- Выходы на реле ---
|
||||
display.setCursor(0, 37);
|
||||
display.print(F("ALARM:"));
|
||||
display.print(relayMainOn ? F("ON ") : F("OFF"));
|
||||
display.print(F(" BACKUP:"));
|
||||
display.print(relayEmergOn ? F("ON") : F("OFF"));
|
||||
|
||||
display.drawFastHLine(0, 49, SCREEN_WIDTH, SSD1306_WHITE);
|
||||
|
||||
// --- Адрес в сети ---
|
||||
display.setCursor(0, 54);
|
||||
if (WiFi.status() == WL_CONNECTED) display.print(WiFi.localIP().toString());
|
||||
else display.print(F("no network"));
|
||||
|
||||
display.display();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HTTPS POST
|
||||
// ============================================================
|
||||
|
||||
// Вытаскивает имя хоста из URL вида https://host[:port][/path] —
|
||||
// оно нужно, чтобы разрешить адрес заранее с собственным таймаутом.
|
||||
// Порт отбрасывается: соединение всё равно устанавливает HTTPClient по URL.
|
||||
// Форма user@host не поддерживается — в webhook-адресах не встречается.
|
||||
static bool parseWebhookHost(const char* url, String &host) {
|
||||
String u(url);
|
||||
|
||||
int schemeEnd = u.indexOf("://");
|
||||
if (schemeEnd < 0) return false;
|
||||
|
||||
String scheme = u.substring(0, schemeEnd);
|
||||
scheme.toLowerCase();
|
||||
if (scheme != "https" && scheme != "http") return false;
|
||||
|
||||
// Границы authority: до первого '/', '?' или '#'
|
||||
int authStart = schemeEnd + 3;
|
||||
int authEnd = u.length();
|
||||
for (int i = authStart; i < (int)u.length(); i++) {
|
||||
char c = u[i];
|
||||
if (c == '/' || c == '?' || c == '#') { authEnd = i; break; }
|
||||
}
|
||||
|
||||
String auth = u.substring(authStart, authEnd);
|
||||
|
||||
int colon = auth.indexOf(':');
|
||||
if (colon >= 0) auth = auth.substring(0, colon);
|
||||
|
||||
if (auth.length() == 0) return false;
|
||||
|
||||
host = auth;
|
||||
return true;
|
||||
}
|
||||
|
||||
void sendPostWebhook(String eventName, String state) {
|
||||
if (settings.webhookUrl[0] == '\0') {
|
||||
Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена"));
|
||||
return;
|
||||
}
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
Serial.println(F("[HTTP] Нет Wi-Fi"));
|
||||
return;
|
||||
}
|
||||
|
||||
const unsigned long started = millis();
|
||||
|
||||
String host;
|
||||
if (!parseWebhookHost(settings.webhookUrl, host)) {
|
||||
Serial.println("[HTTP] Не удалось разобрать URL: " + String(settings.webhookUrl));
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Шаг 1: DNS с явным лимитом ---
|
||||
// WiFiClientSecure::connect(name, port) резолвит имя без таймаута и способен
|
||||
// подвесить loop() примерно на 10 с. Разрешаем сами, с бюджетом; результат
|
||||
// ложится в кэш lwIP, поэтому внутренний резолв дальше уже мгновенный
|
||||
// (и SNI по-прежнему передаётся, так как HTTPClient коннектится по имени).
|
||||
IPAddress ip;
|
||||
if (!WiFi.hostByName(host.c_str(), ip, WEBHOOK_DNS_BUDGET_MS)) {
|
||||
Serial.printf("[HTTP] DNS не ответил за %lu мс: %s\n",
|
||||
WEBHOOK_DNS_BUDGET_MS, host.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Шаг 2: остаток бюджета на TCP + TLS + чтение ответа ---
|
||||
unsigned long elapsed = millis() - started;
|
||||
if (elapsed >= WEBHOOK_BUDGET_MS) {
|
||||
Serial.println(F("[HTTP] Бюджет исчерпан на резолве DNS"));
|
||||
return;
|
||||
}
|
||||
const uint16_t phaseBudget = (uint16_t)(WEBHOOK_BUDGET_MS - elapsed);
|
||||
|
||||
yield(); // отдать время стеку Wi-Fi перед блокирующей фазой
|
||||
|
||||
WiFiClientSecure client;
|
||||
client.setInsecure();
|
||||
client.setTimeout(phaseBudget);
|
||||
|
||||
HTTPClient http;
|
||||
http.setTimeout(phaseBudget); // ограничивает и connect+TLS, и чтение ответа
|
||||
http.setReuse(false); // не держать TLS-сокет после отправки
|
||||
|
||||
Serial.print(F("[HTTP] POST: "));
|
||||
Serial.println(settings.webhookUrl);
|
||||
|
||||
if (!http.begin(client, settings.webhookUrl)) {
|
||||
Serial.println(F("[HTTP] begin() не удался"));
|
||||
return;
|
||||
}
|
||||
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
// status — человеческая фраза, та же что в панели: «Насосы стоят, подачу
|
||||
// держит резерв». state оставлен машиночитаемым (ALARM / OK / STARTED /
|
||||
// STOPPED), чтобы сценарий на сервере ветвился по нему, а не разбирал текст.
|
||||
// settings.deviceName отфильтрован при вводе до [A-Za-z0-9_-], фразы кавычек
|
||||
// и обратных слэшей не содержат — экранирование в JSON нигде не нужно.
|
||||
String body = "{\"event\":\"" + eventName +
|
||||
"\",\"state\":\"" + state +
|
||||
"\",\"status\":\"" + statusPhrase() +
|
||||
"\",\"device\":\"" + String(settings.deviceName) + "\"}";
|
||||
int code = http.POST(body);
|
||||
|
||||
if (code > 0) Serial.printf("[HTTP] Код: %d (%lu мс)\n", code, millis() - started);
|
||||
else Serial.printf("[HTTP] Ошибка: %s (%lu мс)\n",
|
||||
http.errorToString(code).c_str(), millis() - started);
|
||||
http.end();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Валидация настроек из веб-формы
|
||||
// ============================================================
|
||||
|
||||
// Оставляет только [A-Za-z0-9_-] и обрезает до DEVICE_MAX_LEN.
|
||||
// Такой набор символов безопасен и в теле JSON, и в HTML, поэтому
|
||||
// экранирование нигде не нужно. Пустой результат = поле не менять.
|
||||
static String sanitizeDeviceName(const String &raw) {
|
||||
String out;
|
||||
out.reserve(DEVICE_MAX_LEN);
|
||||
for (unsigned int i = 0; i < raw.length() && (int)out.length() < DEVICE_MAX_LEN; i++) {
|
||||
char c = raw[i];
|
||||
bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '_' || c == '-';
|
||||
if (ok) out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Разбирает выдержку в секундах. Принимает только строку целиком из цифр
|
||||
// в диапазоне 0..DELAY_MAX_SEC. false = поле не менять.
|
||||
static bool parseDelaySec(const String &raw, uint16_t &out) {
|
||||
String s(raw);
|
||||
s.trim();
|
||||
if (s.length() == 0 || s.length() > 4) return false;
|
||||
for (unsigned int i = 0; i < s.length(); i++) {
|
||||
if (s[i] < '0' || s[i] > '9') return false;
|
||||
}
|
||||
long v = s.toInt();
|
||||
if (v < 0 || v > (long)DELAY_MAX_SEC) return false;
|
||||
out = (uint16_t)v;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Дребезг + обновление стабильного состояния
|
||||
// ============================================================
|
||||
void updatePumpState(Pump &p) {
|
||||
bool currentReading = digitalRead(p.pin);
|
||||
if (currentReading != p.lastReading) {
|
||||
p.lastDebounceTime = millis();
|
||||
}
|
||||
if ((millis() - p.lastDebounceTime) > debounceDelay) {
|
||||
bool logicalState = (currentReading == LOW);
|
||||
if (logicalState != p.stableState) {
|
||||
p.stableState = logicalState;
|
||||
Serial.printf("[SENSOR] %s -> %s\n", p.name.c_str(), p.stableState ? "РАБОТАЕТ" : "СТОИТ");
|
||||
}
|
||||
}
|
||||
p.lastReading = currentReading;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Словесное описание состояния
|
||||
// ============================================================
|
||||
// Единственное место, где живёт формулировка. Её берут и веб-панель (через
|
||||
// /status), и тело webhook — иначе два набора фраз разошлись бы при первой
|
||||
// же правке. Фразы не содержат кавычек и обратных слэшей, поэтому безопасно
|
||||
// вставляются в JSON без экранирования.
|
||||
|
||||
// Вид состояния: им панель выбирает цвет плашки.
|
||||
static const char* statusKind() {
|
||||
if (!systemReady) return "init";
|
||||
if (!p1.stableState && !p2.stableState) return "flt";
|
||||
if (pE.stableState) return "warn";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
// Главное слово — крупная строка в панели и начало фразы в уведомлении.
|
||||
static const __FlashStringHelper* statusWord() {
|
||||
if (!systemReady) return F("Запуск");
|
||||
if (!p1.stableState && !p2.stableState) return F("Насосы стоят");
|
||||
if (pE.stableState) return F("Работает резерв");
|
||||
return F("Подача есть");
|
||||
}
|
||||
|
||||
// Уточнение — вторая строка в панели и хвост фразы в уведомлении.
|
||||
static const __FlashStringHelper* statusSub() {
|
||||
if (!systemReady) return F("идёт проверка датчиков");
|
||||
if (!p1.stableState && !p2.stableState) {
|
||||
return pE.stableState ? F("подачу держит резерв") : F("подачи нет");
|
||||
}
|
||||
if (pE.stableState) return F("основные насосы в работе");
|
||||
if (p1.stableState && p2.stableState) return F("работают оба насоса");
|
||||
return p1.stableState ? F("работает насос 1") : F("работает насос 2");
|
||||
}
|
||||
|
||||
// Готовая фраза целиком: «Насосы стоят, подачу держит резерв».
|
||||
static String statusPhrase() {
|
||||
return String(statusWord()) + ", " + String(statusSub());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ВЕБ-ПАНЕЛЬ
|
||||
// ============================================================
|
||||
// Разметка вынесена в web_page.h намеренно, а не для красоты: генератор
|
||||
// прототипов Arduino не понимает сырые строковые литералы и принимает
|
||||
// "function draw(d){" внутри JS за определение функции C++, после чего
|
||||
// вставляет для него прототип и сборка падает. Заголовочные файлы он не
|
||||
// сканирует. Обычный g++ такую ошибку не показывает — она видна только
|
||||
// при сборке через arduino-cli или IDE.
|
||||
#include "web_page.h"
|
||||
;
|
||||
|
||||
// ============================================================
|
||||
// SETUP
|
||||
// ============================================================
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(500);
|
||||
Serial.println(F("\n\n--- СТАРТ КОНТРОЛЛЕРА v8 ---"));
|
||||
|
||||
// I2C: SDA=D1, SCL=D2 (по умолчанию для Wemos D1 Mini)
|
||||
Wire.begin(D1, D2);
|
||||
|
||||
// Инит OLED
|
||||
if (display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
|
||||
oledOK = true;
|
||||
display.clearDisplay();
|
||||
display.setTextSize(1);
|
||||
display.setTextColor(SSD1306_WHITE);
|
||||
display.setCursor(20, 20);
|
||||
display.print(F("PUMP CTRL v8"));
|
||||
display.setCursor(20, 34);
|
||||
display.print(F("Starting..."));
|
||||
display.display();
|
||||
Serial.println(F("[OLED] Дисплей OK"));
|
||||
} else {
|
||||
Serial.println(F("[OLED] Дисплей не найден! Проверь адрес (0x3C/0x3D) и провода."));
|
||||
}
|
||||
|
||||
// Диагностика флеша. Если «размер по настройке IDE» больше физического, то
|
||||
// сектор EEPROM оказывается за пределами микросхемы и запись молча теряется —
|
||||
// выглядит это ровно как «настройки не сохраняются».
|
||||
uint32_t flashIde = ESP.getFlashChipSize();
|
||||
uint32_t flashReal = ESP.getFlashChipRealSize();
|
||||
Serial.printf("[FLASH] по настройке IDE: %u Б, физически: %u Б -> %s\n",
|
||||
flashIde, flashReal,
|
||||
flashIde == flashReal ? "совпадает" : "!!! НЕ СОВПАДАЕТ !!!");
|
||||
Serial.printf("[FLASH] скетч %u Б, свободно под скетч %u Б\n",
|
||||
ESP.getSketchSize(), ESP.getFreeSketchSpace());
|
||||
|
||||
// Где лежит файловая система. Если область выходит за размер микросхемы,
|
||||
// LittleFS не смонтируется и настройки сохранять будет некуда.
|
||||
Serial.printf("[FLASH] файловая система: смещение 0x%06X, размер %u Б%s\n",
|
||||
FS_PHYS_ADDR, FS_PHYS_SIZE,
|
||||
FS_PHYS_ADDR + FS_PHYS_SIZE <= flashReal
|
||||
? "" : " <<< ЗА ПРЕДЕЛАМИ ФИЗИЧЕСКОГО ФЛЕША!");
|
||||
|
||||
if (flashIde != flashReal) {
|
||||
Serial.println(F("[FLASH] ВНИМАНИЕ: размер флеша в IDE не соответствует"));
|
||||
Serial.println(F("[FLASH] микросхеме. Выберите верный в Tools -> Flash Size,"));
|
||||
Serial.println(F("[FLASH] иначе настройки сохраняться не будут."));
|
||||
}
|
||||
if (FS_PHYS_SIZE == 0) {
|
||||
Serial.println(F("[FLASH] ВНИМАНИЕ: в Tools -> Flash Size выбрано FS:none."));
|
||||
Serial.println(F("[FLASH] Нужен вариант с ненулевой файловой системой."));
|
||||
}
|
||||
|
||||
settingsBegin();
|
||||
|
||||
pinMode(p1.pin, INPUT_PULLUP);
|
||||
pinMode(p2.pin, INPUT_PULLUP);
|
||||
pinMode(pE.pin, INPUT_PULLUP);
|
||||
|
||||
// Порядок важен: сначала задаём HIGH (реле active-LOW выключено), только
|
||||
// потом переводим пин в OUTPUT. Наоборот защёлка выхода может кратковременно
|
||||
// выдать LOW и щёлкнуть реле.
|
||||
digitalWrite(OUT_ALARM_MAIN, HIGH);
|
||||
digitalWrite(OUT_ALARM_EMERGENCY, HIGH);
|
||||
pinMode(OUT_ALARM_MAIN, OUTPUT);
|
||||
pinMode(OUT_ALARM_EMERGENCY, OUTPUT);
|
||||
setRelay(OUT_ALARM_MAIN, false);
|
||||
setRelay(OUT_ALARM_EMERGENCY, false);
|
||||
|
||||
Serial.println(F("[SYSTEM] Пины инициализированы. Запуск Wi-Fi..."));
|
||||
|
||||
// Web-сервер
|
||||
// Страница отдаётся прямо из флеша, минуя кучу.
|
||||
server.on("/", HTTP_GET, []() {
|
||||
server.send_P(200, PSTR("text/html"), PAGE_HTML);
|
||||
});
|
||||
|
||||
// Живое состояние. Опрашивается раз в 2 секунды, поэтому держим ответ коротким.
|
||||
server.on("/status", HTTP_GET, []() {
|
||||
String json = "{";
|
||||
json += "\"p1\":" + String(p1.stableState ? "1" : "0") + ",";
|
||||
json += "\"p2\":" + String(p2.stableState ? "1" : "0") + ",";
|
||||
json += "\"pE\":" + String(pE.stableState ? "1" : "0") + ",";
|
||||
json += "\"rMain\":" + String(relayMainOn ? "1" : "0") + ",";
|
||||
json += "\"rEmg\":" + String(relayEmergOn ? "1" : "0") + ",";
|
||||
json += "\"rdy\":" + String(systemReady ? "1" : "0") + ",";
|
||||
// Формулировки приходят готовыми, а не собираются в браузере: та же
|
||||
// фраза уходит в webhook, и второй набор строк в JS неизбежно бы разошёлся.
|
||||
json += "\"k\":\"" + String(statusKind()) + "\",";
|
||||
json += "\"w\":\"" + String(statusWord()) + "\",";
|
||||
json += "\"s\":\"" + String(statusSub()) + "\"";
|
||||
json += "}";
|
||||
server.send(200, "application/json", json);
|
||||
});
|
||||
|
||||
// Настройки. Забираются один раз при загрузке страницы.
|
||||
// Webhook отдаётся только именем хоста: полный URL содержит секретный токен,
|
||||
// и выводить его на экран, который видно всем в помещении, незачем.
|
||||
server.on("/config", HTTP_GET, []() {
|
||||
String host;
|
||||
if (!parseWebhookHost(settings.webhookUrl, host)) host = "";
|
||||
|
||||
String json = "{";
|
||||
json += "\"device\":\"" + String(settings.deviceName) + "\",";
|
||||
json += "\"alarm\":" + String(settings.alarmDelaySec) + ",";
|
||||
json += "\"emerg\":" + String(settings.emergDelaySec) + ",";
|
||||
json += "\"host\":\"" + host + "\",";
|
||||
json += "\"fs\":" + String(settingsFsReady ? "1" : "0") + ",";
|
||||
json += "\"ip\":\"" + (WiFi.status() == WL_CONNECTED
|
||||
? WiFi.localIP().toString() : String("нет сети")) + "\"";
|
||||
json += "}";
|
||||
server.send(200, "application/json", json);
|
||||
});
|
||||
server.on("/reset_wifi", HTTP_POST, []() {
|
||||
server.send(200, "text/plain", "WiFi Reset. Rebooting...");
|
||||
delay(2000);
|
||||
WiFiManager wm; wm.resetSettings(); ESP.restart();
|
||||
});
|
||||
// Настройки одной формой. Пустое или неверное поле означает «не менять» —
|
||||
// так частично заполненная форма не сбрасывает остальные параметры.
|
||||
server.on("/set_config", HTTP_POST, []() {
|
||||
bool changed = false;
|
||||
|
||||
if (server.hasArg("url")) {
|
||||
String v = server.arg("url"); v.trim();
|
||||
if (v.length() > 0 && v.length() <= WEBHOOK_MAX_LEN) {
|
||||
v.toCharArray(settings.webhookUrl, WEBHOOK_MAX_LEN + 1);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (server.hasArg("device")) {
|
||||
String v = sanitizeDeviceName(server.arg("device"));
|
||||
if (v.length() > 0 && v != String(settings.deviceName)) {
|
||||
v.toCharArray(settings.deviceName, DEVICE_MAX_LEN + 1);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (server.hasArg("alarm_delay")) {
|
||||
uint16_t v;
|
||||
if (parseDelaySec(server.arg("alarm_delay"), v) && v != settings.alarmDelaySec) {
|
||||
settings.alarmDelaySec = v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (server.hasArg("emerg_delay")) {
|
||||
uint16_t v;
|
||||
if (parseDelaySec(server.arg("emerg_delay"), v) && v != settings.emergDelaySec) {
|
||||
settings.emergDelaySec = v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) settingsSave();
|
||||
else Serial.println(F("[SET] Изменений нет, запись пропущена"));
|
||||
|
||||
server.sendHeader("Location", "/");
|
||||
server.send(303);
|
||||
});
|
||||
|
||||
// WiFiManager с заставкой на OLED
|
||||
if (oledOK) {
|
||||
display.clearDisplay();
|
||||
display.setCursor(0, 10);
|
||||
display.print(F("WiFi: connecting..."));
|
||||
display.setCursor(0, 24);
|
||||
display.print(F("AP: "));
|
||||
display.print(config_ap_name);
|
||||
display.setCursor(0, 38);
|
||||
display.print(F("Connect & open:"));
|
||||
display.setCursor(0, 48);
|
||||
display.print(F("192.168.4.1"));
|
||||
display.display();
|
||||
}
|
||||
|
||||
WiFiManager wm;
|
||||
wm.setConfigPortalTimeout(120);
|
||||
if (wm.autoConnect(config_ap_name)) {
|
||||
server.begin();
|
||||
Serial.println("[WIFI] Подключено. IP: " + WiFi.localIP().toString());
|
||||
if (oledOK) {
|
||||
display.clearDisplay();
|
||||
display.setCursor(0, 16);
|
||||
display.print(F("WiFi: CONNECTED"));
|
||||
display.setCursor(0, 30);
|
||||
display.print(F("IP: "));
|
||||
display.print(WiFi.localIP().toString());
|
||||
display.display();
|
||||
delay(2000);
|
||||
}
|
||||
} else {
|
||||
Serial.println(F("[WIFI] Работа в автономном режиме."));
|
||||
if (oledOK) {
|
||||
display.clearDisplay();
|
||||
display.setCursor(0, 16);
|
||||
display.print(F("WiFi: NO CONNECTION"));
|
||||
display.setCursor(0, 30);
|
||||
display.print(F("Offline mode active"));
|
||||
display.display();
|
||||
delay(2000);
|
||||
}
|
||||
}
|
||||
|
||||
Serial.println(F("[SYSTEM] Инициализация завершена."));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LOOP
|
||||
// ============================================================
|
||||
void loop() {
|
||||
server.handleClient();
|
||||
|
||||
updatePumpState(p1);
|
||||
updatePumpState(p2);
|
||||
updatePumpState(pE);
|
||||
|
||||
if (!systemReady) {
|
||||
if (millis() > debounceDelay + 500) {
|
||||
systemReady = true;
|
||||
Serial.println(F("[SYSTEM] Автоматика готова."));
|
||||
}
|
||||
// Обновляем дисплей даже во время инита
|
||||
if (millis() - lastDisplayUpdate >= DISPLAY_INTERVAL) {
|
||||
updateDisplay();
|
||||
lastDisplayUpdate = millis();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Основная авария ---
|
||||
// Выдержка одна на реле и вебхук: при settings.alarmDelaySec = 0 условие выполняется
|
||||
// на следующей же итерации, то есть реакция мгновенная.
|
||||
bool bothStopped = (!p1.stableState && !p2.stableState);
|
||||
if (bothStopped) {
|
||||
if (!mainAlarmPending) {
|
||||
mainAlarmPending = true;
|
||||
mainAlarmStartTime = millis();
|
||||
} else if (millis() - mainAlarmStartTime >= (unsigned long)settings.alarmDelaySec * 1000UL) {
|
||||
setRelay(OUT_ALARM_MAIN, true);
|
||||
if (!mainAlarmSent) {
|
||||
Serial.println(F("[ALERT] Авария основных насосов"));
|
||||
sendPostWebhook("MAIN PUMPS", "ALARM");
|
||||
mainAlarmSent = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setRelay(OUT_ALARM_MAIN, false);
|
||||
mainAlarmPending = false;
|
||||
if (mainAlarmSent) {
|
||||
Serial.println(F("[INFO] Восстановление: основные насосы OK"));
|
||||
sendPostWebhook("MAIN PUMPS", "OK");
|
||||
mainAlarmSent = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Резервный насос ---
|
||||
if (pE.stableState) {
|
||||
if (!emergPending) {
|
||||
emergPending = true;
|
||||
emergStartTime = millis();
|
||||
} else if (millis() - emergStartTime >= (unsigned long)settings.emergDelaySec * 1000UL) {
|
||||
setRelay(OUT_ALARM_EMERGENCY, true);
|
||||
if (!emergencyActiveSent) {
|
||||
Serial.println(F("[WARN] Запущен экстренный насос"));
|
||||
sendPostWebhook("EMERGENCY PUMP", "STARTED");
|
||||
emergencyActiveSent = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setRelay(OUT_ALARM_EMERGENCY, false);
|
||||
emergPending = false;
|
||||
if (emergencyActiveSent) {
|
||||
Serial.println(F("[INFO] Резерв остановлен"));
|
||||
sendPostWebhook("EMERGENCY PUMP", "STOPPED");
|
||||
emergencyActiveSent = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Обновление дисплея раз в секунду ---
|
||||
if (millis() - lastDisplayUpdate >= DISPLAY_INTERVAL) {
|
||||
updateDisplay();
|
||||
lastDisplayUpdate = millis();
|
||||
}
|
||||
|
||||
delay(10);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
// ============================================================
|
||||
// ВЕБ-ПАНЕЛЬ
|
||||
// ============================================================
|
||||
// Страница статическая и лежит целиком во флеше (PROGMEM). Состояние в неё
|
||||
// подставляет JS, забирая /status и /config. Раньше HTML собирался строкой
|
||||
// в куче на каждый запрос (~14 КБ при ~40 КБ свободной кучи) — теперь куча
|
||||
// не тратится вообще, а ввод в форме не сбрасывается при обновлении данных.
|
||||
//
|
||||
// Дизайн — световое табло аварийной сигнализации, а не дашборд: главный ответ
|
||||
// («есть подача или нет») занимает верх страницы и читается с расстояния,
|
||||
// остальное подчинено ему. Цвета по конвенции сигнальных ламп: зелёная
|
||||
// «работа», ЖЁЛТАЯ «внимание» для резерва (резерв в работе — предупреждение,
|
||||
// система ещё справляется), красная «авария» только при остановке основных.
|
||||
//
|
||||
// Веб-шрифты намеренно не подключаются: сеть на объекте не разрешает внешние
|
||||
// имена, и @import с Google Fonts там не грузится никогда. Характер задан
|
||||
// контрастом кеглей, а не гарнитурой.
|
||||
|
||||
static const char PAGE_HTML[] PROGMEM = R"=====(<!DOCTYPE html><html lang="ru"><head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Насосная</title><link id="fav" rel="icon" href="data:,">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{--bg:#0e1216;--sf:#161b21;--ln:#232a32;--ink:#dde3e9;--dim:#8b97a3;
|
||||
--run:#35c46a;--warn:#f0a516;--flt:#e23c2e;--off:#2a323a}
|
||||
body{background:var(--bg);color:var(--ink);min-height:100vh;padding:16px;
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;
|
||||
font-size:15px;line-height:1.4;display:flex;justify-content:center}
|
||||
.wrap{width:100%;max-width:520px;display:flex;flex-direction:column;gap:10px}
|
||||
.top{display:flex;align-items:baseline;justify-content:space-between;gap:12px;
|
||||
font-size:13px;color:var(--dim)}
|
||||
.top b{font-weight:600;color:var(--ink);font-size:14px}
|
||||
.ip{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
|
||||
.st{border-radius:14px;padding:20px;border:1px solid var(--ln);background:var(--sf)}
|
||||
.st b{display:block;font-size:clamp(28px,8.5vw,44px);line-height:1.05;
|
||||
letter-spacing:-.02em;font-weight:800}
|
||||
.st span{display:block;margin-top:6px;font-size:14px;color:var(--dim)}
|
||||
.st.ok{background:rgba(53,196,106,.10);border-color:rgba(53,196,106,.35)}
|
||||
.st.ok b{color:var(--run)}
|
||||
.st.warn{background:rgba(240,165,22,.10);border-color:rgba(240,165,22,.4)}
|
||||
.st.warn b{color:var(--warn)}
|
||||
.st.flt{background:rgba(226,60,46,.12);border-color:rgba(226,60,46,.45);
|
||||
animation:beat 1.6s ease-in-out infinite}
|
||||
.st.flt b{color:var(--flt)}
|
||||
@keyframes beat{50%{background:rgba(226,60,46,.26)}}
|
||||
@media(prefers-reduced-motion:reduce){.st.flt{animation:none}}
|
||||
.row{display:grid;gap:8px}
|
||||
.r3{grid-template-columns:repeat(3,1fr)}
|
||||
.r2{grid-template-columns:repeat(2,1fr)}
|
||||
.c{background:var(--sf);border:1px solid var(--ln);border-radius:12px;padding:12px}
|
||||
.c i{display:block;width:13px;height:13px;border-radius:50%;background:var(--off);
|
||||
margin-bottom:10px;transition:background .2s}
|
||||
.c.on i{background:var(--run);box-shadow:0 0 10px rgba(53,196,106,.7)}
|
||||
.c.on.w i{background:var(--warn);box-shadow:0 0 10px rgba(240,165,22,.7)}
|
||||
.c.on.f i{background:var(--flt);box-shadow:0 0 10px rgba(226,60,46,.7)}
|
||||
.c em{font-style:normal;display:block;font-size:11px;letter-spacing:.08em;
|
||||
text-transform:uppercase;color:var(--dim)}
|
||||
.c b{display:block;margin-top:3px;font-size:14px;font-weight:600}
|
||||
details{background:var(--sf);border:1px solid var(--ln);border-radius:12px}
|
||||
summary{padding:12px 14px;cursor:pointer;font-size:14px;font-weight:600;
|
||||
list-style:none;display:flex;justify-content:space-between;align-items:center}
|
||||
summary::-webkit-details-marker{display:none}
|
||||
summary::after{content:"▾";color:var(--dim)}
|
||||
details[open] summary::after{content:"▴"}
|
||||
.body{padding:0 14px 14px;display:flex;flex-direction:column;gap:12px}
|
||||
label{display:block;font-size:12px;color:var(--dim);margin-bottom:5px}
|
||||
input{width:100%;padding:9px 10px;background:#0f1418;border:1px solid var(--ln);
|
||||
border-radius:8px;color:var(--ink);font:inherit;font-size:14px}
|
||||
input:focus{outline:2px solid rgba(53,196,106,.5);outline-offset:1px}
|
||||
.two{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.hint{font-size:12px;color:var(--dim)}
|
||||
button{width:100%;padding:11px;border-radius:8px;border:1px solid;font:inherit;
|
||||
font-size:14px;font-weight:600;cursor:pointer}
|
||||
.save{background:rgba(53,196,106,.14);border-color:rgba(53,196,106,.45);color:var(--run)}
|
||||
.wifi{background:transparent;border-color:var(--ln);color:var(--dim);margin-top:2px}
|
||||
.warnbox{background:rgba(226,60,46,.12);border:1px solid rgba(226,60,46,.4);
|
||||
border-radius:8px;padding:10px;font-size:13px;color:#f2a49c}
|
||||
.hide{display:none}
|
||||
</style></head><body><div class="wrap">
|
||||
|
||||
<div class="top"><b id="dev">—</b><span class="ip" id="ip"></span></div>
|
||||
|
||||
<div class="st init" id="st"><b id="word">Связь…</b><span id="sub">получаем состояние прибора</span></div>
|
||||
|
||||
<div class="row r3">
|
||||
<div class="c" id="l1"><i></i><em>Насос 1</em><b>—</b></div>
|
||||
<div class="c" id="l2"><i></i><em>Насос 2</em><b>—</b></div>
|
||||
<div class="c w" id="lE"><i></i><em>Резерв</em><b>—</b></div>
|
||||
</div>
|
||||
|
||||
<div class="row r2">
|
||||
<div class="c f" id="o1"><i></i><em>Выход «Авария»</em><b>—</b></div>
|
||||
<div class="c w" id="o2"><i></i><em>Выход «Резерв»</em><b>—</b></div>
|
||||
</div>
|
||||
|
||||
<details><summary>Настройки</summary><div class="body">
|
||||
<div class="warnbox hide" id="fsw">Память недоступна — настройки не сохранятся до перезагрузки.</div>
|
||||
<form method="POST" action="/set_config">
|
||||
<div><label for="u">Адрес для уведомлений</label>
|
||||
<input id="u" name="url" type="text" placeholder="оставьте пустым, чтобы не менять">
|
||||
<div class="hint" id="uh" style="margin-top:5px"></div></div>
|
||||
<div style="margin-top:12px"><label for="d">Имя прибора в уведомлениях</label>
|
||||
<input id="d" name="device" maxlength="31">
|
||||
<div class="hint" style="margin-top:5px">Латиница, цифры, дефис и подчёркивание</div></div>
|
||||
<div style="margin-top:12px"><label>Задержка сигнала, секунд</label>
|
||||
<div class="two">
|
||||
<div><input id="a" name="alarm_delay" type="number" min="0" max="3600" aria-label="Задержка аварии">
|
||||
<div class="hint" style="margin-top:5px">аварии</div></div>
|
||||
<div><input id="e" name="emerg_delay" type="number" min="0" max="3600" aria-label="Задержка резерва">
|
||||
<div class="hint" style="margin-top:5px">резерва</div></div></div></div>
|
||||
<button class="save" type="submit" style="margin-top:14px">Сохранить</button>
|
||||
</form>
|
||||
<form method="POST" action="/reset_wifi"><button class="wifi" type="submit">Сменить сеть Wi-Fi</button></form>
|
||||
</div></details>
|
||||
|
||||
</div><script>
|
||||
var F={ok:"#35c46a",warn:"#f0a516",flt:"#e23c2e",init:"#8b97a3"};
|
||||
function lamp(id,on,txt){var e=document.getElementById(id);
|
||||
e.classList.toggle("on",!!on);e.querySelector("b").textContent=txt}
|
||||
function fav(c){document.getElementById("fav").href="data:image/svg+xml,"+
|
||||
encodeURIComponent("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='13' fill='"+c+"'/></svg>")}
|
||||
var dev="";
|
||||
function draw(d){
|
||||
lamp("l1",d.p1,d.p1?"работает":"стоит");
|
||||
lamp("l2",d.p2,d.p2?"работает":"стоит");
|
||||
lamp("lE",d.pE,d.pE?"работает":"ожидание");
|
||||
lamp("o1",d.rMain,d.rMain?"замкнут":"разомкнут");
|
||||
lamp("o2",d.rEmg,d.rEmg?"замкнут":"разомкнут");
|
||||
document.getElementById("st").className="st "+d.k;
|
||||
document.getElementById("word").textContent=d.w;
|
||||
document.getElementById("sub").textContent=d.s;
|
||||
document.title=d.w+(dev?" · "+dev:"");
|
||||
fav(F[d.k])}
|
||||
function poll(){fetch("/status").then(function(r){return r.json()}).then(draw).catch(function(){})}
|
||||
fetch("/config").then(function(r){return r.json()}).then(function(c){
|
||||
dev=c.device;document.getElementById("dev").textContent=c.device;
|
||||
document.getElementById("ip").textContent=c.ip;
|
||||
document.getElementById("d").value=c.device;
|
||||
document.getElementById("a").value=c.alarm;
|
||||
document.getElementById("e").value=c.emerg;
|
||||
document.getElementById("uh").textContent=c.host?"Сейчас: "+c.host:"Не задан — уведомления отключены";
|
||||
if(!c.fs)document.getElementById("fsw").classList.remove("hide")}).catch(function(){});
|
||||
poll();setInterval(poll,2000);
|
||||
</script></body></html>)====="
|
||||
Reference in new issue
Block a user