Панель как световое табло, а не дашборд: главный ответ занимает верх страницы и читается с расстояния. Прежде самым крупным элементом были часы браузера - единственное, что не несло информации о приборе, - а ответ "есть подача или нет" был набран 11 кеглем в панели размером с кнопку. Норма и авария выглядели почти одинаково. Исправлены три содержательные ошибки: - лампа выхода "Авария" горела ЗЕЛЁНЫМ при срабатывании: стиль "включено" был общим для всех ламп, и сработавшая сигнализация подсвечивалась цветом "всё хорошо"; - страница делала location.reload() при любом изменении состояния, стирая недописанный адрес в форме вместе с прокруткой и фокусом; - CSS подключал шрифты с Google Fonts, тогда как сеть на объекте не разрешает внешние имена - нарисованный макет там не появлялся никогда. Страница стала статической и уехала во флеш (PAGE_HTML PROGMEM, ~8 КБ, отдаётся через send_P). Раньше HTML собирался строкой в куче на каждый запрос: ~14 КБ при ~40 КБ свободной кучи. Состояние подставляет JS, забирая /status и новый /config. В /status добавлено поле rdy. /config отдаёт только имя хоста webhook: полный URL содержит секретный токен, и выводить его на экран, видимый всем в помещении, незачем. Цвета по конвенции сигнальных ламп: резерв теперь ЖЁЛТЫЙ, а не красный - работающий резерв это предупреждение, система ещё справляется. Красный оставлен для остановки основных. Убраны декоративные коды SYS_OK / 0xFF, не кодировавшие ничего, и тройное дублирование состояния резерва. Настройки свёрнуты в details. OLED: верхняя строка отдана статусу кеглем 2 - прежде её постоянно занимал заголовок "PUMP CONTROLLER v8", который никогда не менялся. Три строки датчиков сжаты в одну, освободившееся место отдано состоянию обоих реле. Текст остаётся латиницей: встроенный шрифт Adafruit GFX кириллицы не содержит. Проверено: сборка -Wall -Wextra без предупреждений; все три состояния панели просмотрены в браузере на 375 и 800 px. На железе не проверялось. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
570 lines
34 KiB
Markdown
570 lines
34 KiB
Markdown
# Pump Controller v8.2 (OLED)
|
||
|
||
Контроллер мониторинга насосной станции на **Wemos D1 Mini (ESP8266)**.
|
||
|
||
Следит за состоянием трёх насосов (два основных + резервный), выдаёт сухие
|
||
контакты аварии, показывает статус на OLED-дисплее, публикует веб-панель в
|
||
локальной сети и отправляет события на HTTPS-webhook (n8n).
|
||
|
||
---
|
||
|
||
## 1. Состав репозитория
|
||
|
||
| Файл | Назначение |
|
||
|---|---|
|
||
| `pump_controller_8_2_OLED_DONE.ino` | Весь прошивочный код (single-file Arduino sketch) |
|
||
| `README.md` | Техническая документация (этот файл) |
|
||
| `MANUAL.md` | Руководство пользователя / монтажника |
|
||
| `docs/specs/` | Согласованные проектные решения по крупным изменениям |
|
||
|
||
---
|
||
|
||
## 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); }
|
||
```
|
||
|
||
### 2.4 Прочие особенности ESP8266
|
||
|
||
* `D4` (GPIO2) — встроенный светодиод платы, тоже активен от `LOW`. Реле
|
||
«Резерв» получает бесплатную индикацию на плате.
|
||
* `D3` (GPIO0) — пин выбора режима загрузки. Схема автосброса USB дёргает его
|
||
вниз при заливке скетча, поэтому реле «Авария» щёлкает при каждой прошивке.
|
||
Косметика, но пугает.
|
||
* `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","status":"ALARM","device":"Wemos_D1_Pump"}
|
||
```
|
||
|
||
| `event` | `status` |
|
||
|---|---|
|
||
| `MAIN PUMPS` | `ALARM` / `OK` |
|
||
| `EMERGENCY PUMP` | `STARTED` / `STOPPED` |
|
||
|
||
Поле `device` берётся из настройки `settings.deviceName` (задаётся через веб-панель,
|
||
по умолчанию `Wemos_D1_Pump`). Значение отфильтровано при вводе до
|
||
`[A-Za-z0-9_-]`, поэтому экранирование в 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}
|
||
/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.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. Сборка
|
||
|
||
```bash
|
||
arduino-cli compile --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino
|
||
```
|
||
|
||
```bash
|
||
arduino-cli upload -p COM3 --fqbn esp8266:esp8266:d1_mini pump_controller_8_2_OLED_DONE.ino
|
||
```
|
||
|
||
Монитор порта: 115200 бод.
|