Перенести настройки из EEPROM в LittleFS
EEPROM.commit() на целевой плате возвращает false — падает сам вызов SDK. Проверкой исходников исключены ошибка в библиотеке, перекрытие адресов и стирание сектора со стороны WiFiManager. Физический размер флеша тоже ни при чём: LittleFS на этом же чипе работает, что подтверждено соседним проектом на такой же плате. Наиболее вероятный механизм: при раскладке 4MB/FS:2MB сектор EEPROM (1019, 0x3FB000) попадает в щель между концом ФС (0x3FA000) и служебной областью SDK (0x3FC000) и оказывается защищён от стирания. Доказать не удалось, поэтому в документации гипотеза помечена как гипотеза. Переход на LittleFS выбран не как обход симптома, а как переезд на подтверждённо работающий на этом железе механизм. Настройки теперь в /settings.json (ArduinoJson v7). Загрузка отказоустойчива: отсутствующий или битый ключ оставляет значение по умолчанию, поэтому файл от прошлой версии прошивки не ломается при добавлении полей. Имена ключей вынесены в константы KEY_*, используемые и при чтении, и при записи. Молчаливой потери настроек больше нет: при недоступной ФС взводится settingsFsReady = false, и веб-панель показывает предупреждение, что настройки не переживут перезагрузку. В лог при старте выводится геометрия ФС и предупреждение при выборе FS:none. Проверено: xtensa-lx106-elf-g++ -fsyntax-only -Wall -Wextra против ядра 3.1.2 — предупреждений нет. На железе не проверялось. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -169,8 +169,12 @@ STATUS: OK
|
||||
| **Выдержка аварии, с** | сколько оба основных насоса должны стоять до сигнала | 0…3600 | `5` |
|
||||
| **Выдержка резерва, с** | сколько резерв должен работать до сигнала | 0…3600 | `0` (сразу) |
|
||||
|
||||
Настройки сохраняются в памяти прибора, переживают отключение питания и сброс
|
||||
Wi-Fi.
|
||||
Настройки сохраняются в файл во внутренней памяти прибора, переживают
|
||||
отключение питания и сброс Wi-Fi.
|
||||
|
||||
> Если панель показывает красное предупреждение «Файловая система недоступна»,
|
||||
> настройки работают только до перезагрузки. Причина почти всегда в настройке
|
||||
> `Flash Size` при прошивке — нужен вариант с файловой системой, не `FS:none`.
|
||||
|
||||
> Русские буквы и пробелы в имени устройства **не сохранятся** — они будут
|
||||
> отброшены. Это не ошибка: ограничение защищает формат уведомления от поломки.
|
||||
|
||||
@@ -105,11 +105,16 @@ SSD1306 128×64, I2C, адрес по умолчанию `0x3C` (констан
|
||||
* Adafruit SSD1306
|
||||
* Adafruit GFX Library
|
||||
* WiFiManager (tzapu)
|
||||
* ArduinoJson (v7)
|
||||
|
||||
Входят в ESP8266 core: `ESP8266WiFi`, `ESP8266WebServer`, `ESP8266HTTPClient`,
|
||||
`WiFiClientSecure`, `EEPROM`, `Wire`.
|
||||
`WiFiClientSecure`, `LittleFS`, `Wire`.
|
||||
|
||||
Board: **LOLIN(WEMOS) D1 R2 & mini**, Upload speed 921600, Flash size 4MB.
|
||||
Board: **LOLIN(WEMOS) D1 R2 & mini**, Upload speed 921600.
|
||||
|
||||
**Flash Size обязательно с файловой системой** — например
|
||||
`4MB (FS:2MB OTA:~1019KB)`. При `FS:none` настройки сохранять некуда; прошивка
|
||||
сообщит об этом в лог при старте и покажет предупреждение в веб-панели.
|
||||
|
||||
---
|
||||
|
||||
@@ -180,7 +185,7 @@ pE = стоит -> реле «Резерв» выключено
|
||||
|
||||
## 5. Webhook
|
||||
|
||||
`POST` на `webhook_url`, `Content-Type: application/json`:
|
||||
`POST` на `settings.webhookUrl`, `Content-Type: application/json`:
|
||||
|
||||
```json
|
||||
{"event":"MAIN PUMPS","status":"ALARM","device":"Wemos_D1_Pump"}
|
||||
@@ -191,7 +196,7 @@ pE = стоит -> реле «Резерв» выключено
|
||||
| `MAIN PUMPS` | `ALARM` / `OK` |
|
||||
| `EMERGENCY PUMP` | `STARTED` / `STOPPED` |
|
||||
|
||||
Поле `device` берётся из настройки `device_name` (задаётся через веб-панель,
|
||||
Поле `device` берётся из настройки `settings.deviceName` (задаётся через веб-панель,
|
||||
по умолчанию `Wemos_D1_Pump`). Значение отфильтровано при вводе до
|
||||
`[A-Za-z0-9_-]`, поэтому экранирование в JSON не требуется — сломать тело
|
||||
запроса ему нечем.
|
||||
@@ -241,44 +246,99 @@ BearSSL ограничен тем же `_timeout`. Чтение ответа о
|
||||
|
||||
---
|
||||
|
||||
## 6. Хранение настроек (EEPROM)
|
||||
## 6. Хранение настроек (LittleFS)
|
||||
|
||||
Эмулируемая EEPROM, 512 байт. Занято 258.
|
||||
Настройки лежат в файловой системе LittleFS, в файле `/settings.json`:
|
||||
|
||||
| Адрес | Размер | Содержимое | По умолчанию |
|
||||
|---|---|---|---|
|
||||
| `0` | 2 | Magic `0xA55B` — версия раскладки 2 | — |
|
||||
| `2` | 220 | `webhook_url`, `\0`-терминированный | пусто |
|
||||
| `222` | 31 | `device_name` | `Wemos_D1_Pump` |
|
||||
| `254` | 2 | `alarm_delay_sec` (`uint16`) | `5` |
|
||||
| `256` | 2 | `emerg_delay_sec` (`uint16`) | `0` |
|
||||
```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` |
|
||||
|
||||
### 6.1 Версионирование и миграция
|
||||
Имена ключей вынесены в константы `KEY_*` и используются и при чтении, и при
|
||||
записи — рассинхронизация имён между load и save самый частый источник багов в
|
||||
таком коде, а через константу компилятор их разойтись не даст.
|
||||
|
||||
Magic одновременно служит номером версии раскладки:
|
||||
Строки хранятся как `char[]`, а не `String`: прибор работает месяцами без
|
||||
перезагрузки, и глобальные `String` фрагментировали бы кучу.
|
||||
|
||||
| Прочитанный magic | Действие |
|
||||
|---|---|
|
||||
| `0xA55B` | читаются все поля |
|
||||
| `0xA55A` | **миграция v1 → v2**: `webhook_url` сохраняется, новые поля получают значения по умолчанию, magic перезаписывается |
|
||||
| иное | первый старт, все поля по умолчанию |
|
||||
### 6.1 Почему не EEPROM
|
||||
|
||||
Миграция нужна, чтобы уже прошитые приборы не потеряли настроенный webhook при
|
||||
обновлении прошивки.
|
||||
Изначально настройки хранились в эмулируемой EEPROM. На целевой плате это **не
|
||||
работает**: `EEPROM.commit()` возвращает `false`, то есть падает сам вызов SDK
|
||||
(`spi_flash_erase_sector` либо `spi_flash_write`), и настройки молча теряются.
|
||||
|
||||
При чтении v2 значения дополнительно проверяются: пустое `device_name` и
|
||||
выдержка больше `DELAY_MAX_SEC` заменяются значениями по умолчанию — иначе мусор
|
||||
во флеше дал бы заведомо неверную выдержку.
|
||||
Проверкой исходников исключены: ошибка в библиотеке EEPROM, перекрытие адресов,
|
||||
стирание сектора со стороны `WiFiManager::resetSettings()`. Физический размер
|
||||
флеша тоже ни при чём — LittleFS на том же чипе работает.
|
||||
|
||||
Пустой `webhook_url` — легальное состояние. Пока URL не задан,
|
||||
Наиболее вероятный механизм: при раскладке `4MB (FS:2MB)` сектор EEPROM (1019,
|
||||
смещение `0x3FB000`) попадает в узкую щель между концом ФС (`0x3FA000`) и
|
||||
служебной областью SDK (`0x3FC000`) и на этой связке платы и SDK оказывается
|
||||
защищён от стирания. Доказать это не удалось, поэтому формулировка гипотетическая.
|
||||
Переход на LittleFS выбран не как обход симптома, а как переезд на механизм,
|
||||
работоспособность которого на этом железе подтверждена.
|
||||
|
||||
Практическое следствие: **в IDE обязателен вариант `Flash Size` с ненулевой
|
||||
файловой системой**. При `FS:none` сохранять настройки будет некуда, и прошивка
|
||||
скажет об этом в лог при старте.
|
||||
|
||||
### 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 не задан — отправка пропущена`.
|
||||
|
||||
Учётные данные Wi-Fi хранит WiFiManager в своей области флеша, не в этой EEPROM,
|
||||
поэтому сброс Wi-Fi настройки из этой таблицы не затрагивает.
|
||||
|
||||
---
|
||||
|
||||
## 7. Веб-интерфейс
|
||||
@@ -295,7 +355,7 @@ Magic одновременно служит номером версии раск
|
||||
### 7.1 `/set_config`
|
||||
|
||||
Одна форма на все настройки. **Пустое или неверное поле означает «не менять»** —
|
||||
так частично заполненная форма не обнуляет остальные параметры. Запись в EEPROM
|
||||
так частично заполненная форма не обнуляет остальные параметры. Запись в файл
|
||||
выполняется только если что-то реально изменилось.
|
||||
|
||||
| Параметр | Валидация | При отказе |
|
||||
@@ -403,9 +463,9 @@ STATUS: OK
|
||||
портал придётся своим таймером.
|
||||
* Логика аварии — «оба стоят»: остановка одного насоса штатной ситуацией
|
||||
не считается и никак не сигнализируется.
|
||||
* Webhook URL хранится в EEPROM в открытом виде и отдаётся веб-панелью всем,
|
||||
кто может её открыть. Если URL содержит секретный токен — доступ к панели
|
||||
равносилен доступу к токену.
|
||||
* Webhook URL хранится в `/settings.json` в открытом виде и отдаётся
|
||||
веб-панелью всем, кто может её открыть. Если URL содержит секретный токен —
|
||||
доступ к панели равносилен доступу к токену.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -26,11 +26,18 @@
|
||||
При необходимости решается на монтаже — нагрузка снимается с нормально
|
||||
замкнутого (NC) контакта реле.
|
||||
|
||||
Настройки хранятся в LittleFS, файл /settings.json.
|
||||
|
||||
Библиотеки (установить через Library Manager):
|
||||
- Adafruit SSD1306
|
||||
- Adafruit GFX Library
|
||||
- WiFiManager (tzapu)
|
||||
- ESP8266WiFi / ESP8266WebServer / ESP8266HTTPClient (часть ESP8266 core)
|
||||
- ArduinoJson (v7)
|
||||
- ESP8266WiFi / ESP8266WebServer / ESP8266HTTPClient / LittleFS
|
||||
(часть ESP8266 core)
|
||||
|
||||
В IDE обязателен вариант Flash Size с ненулевой файловой системой,
|
||||
например «4MB (FS:2MB OTA:~1019KB)» — иначе настройки сохранять некуда.
|
||||
============================================================
|
||||
*/
|
||||
|
||||
@@ -39,9 +46,10 @@
|
||||
#include <ESP8266HTTPClient.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <WiFiManager.h>
|
||||
#include <EEPROM.h>
|
||||
#include <LittleFS.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <Wire.h>
|
||||
#include <flash_hal.h> // EEPROM_start / FS_PHYS_ADDR — только для диагностики
|
||||
#include <flash_hal.h> // FS_PHYS_ADDR / FS_PHYS_SIZE — только для диагностики
|
||||
#include <Adafruit_GFX.h>
|
||||
#include <Adafruit_SSD1306.h>
|
||||
|
||||
@@ -57,31 +65,42 @@ bool oledOK = false;
|
||||
// --- КОНФИГУРАЦИЯ ---
|
||||
const char* config_ap_name = "Pump_Control_Set";
|
||||
|
||||
// Адрес webhook задаётся через веб-панель (/set_config) и хранится в EEPROM.
|
||||
// В исходнике намеренно пусто: URL содержит секретный токен.
|
||||
// --- ЗНАЧЕНИЯ ПО УМОЛЧАНИЮ ---
|
||||
// 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;
|
||||
|
||||
// Раскладка EEPROM. magic 0xA55B — версия 2; 0xA55A это версия 1 (только URL),
|
||||
// она распознаётся и переносится, чтобы прошитые приборы не теряли webhook.
|
||||
const int EEPROM_SIZE = 512;
|
||||
const int EEPROM_MAGIC_ADDR = 0;
|
||||
const uint16_t EEPROM_MAGIC_V2 = 0xA55B;
|
||||
const uint16_t EEPROM_MAGIC_V1 = 0xA55A;
|
||||
const int EEPROM_URL_ADDR = 2;
|
||||
const int WEBHOOK_MAX_LEN = 220;
|
||||
const int EEPROM_DEVICE_ADDR = 222;
|
||||
const int DEVICE_MAX_LEN = 31; // +1 байт на '\0' = 32 байта поля
|
||||
const int EEPROM_ALARM_ADDR = 254;
|
||||
const int EEPROM_EMERG_ADDR = 256;
|
||||
const int DEVICE_MAX_LEN = 31;
|
||||
|
||||
char webhook_url[WEBHOOK_MAX_LEN + 1];
|
||||
char device_name[DEVICE_MAX_LEN + 1];
|
||||
uint16_t alarmDelaySec = ALARM_DELAY_DEFAULT_SEC;
|
||||
uint16_t emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
|
||||
// --- ХРАНИЛИЩЕ НАСТРОЕК ---
|
||||
// LittleFS, а не EEPROM: запись в сектор EEPROM на этой плате проваливается
|
||||
// (commit() возвращает false), тогда как файловая система на том же чипе
|
||||
// работает. Подробности — в README, раздел «Хранение настроек».
|
||||
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;
|
||||
@@ -135,120 +154,125 @@ unsigned long lastDisplayUpdate = 0;
|
||||
const unsigned long DISPLAY_INTERVAL = 1000;
|
||||
|
||||
// ============================================================
|
||||
// EEPROM
|
||||
// НАСТРОЙКИ (LittleFS + JSON)
|
||||
// ============================================================
|
||||
// Запись строки фиксированной длины с добивкой нулями.
|
||||
static void eepromWriteStr(int addr, int fieldLen, const char* value) {
|
||||
int len = (int)strlen(value);
|
||||
for (int i = 0; i < fieldLen; i++) {
|
||||
EEPROM.write(addr + i, i < len ? value[i] : 0);
|
||||
}
|
||||
|
||||
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 eepromReadStr(int addr, int fieldLen, char* dst) {
|
||||
for (int i = 0; i < fieldLen; i++) dst[i] = EEPROM.read(addr + i);
|
||||
dst[fieldLen] = '\0';
|
||||
// Приводит значения к допустимым. Нужно и после чтения файла, и после
|
||||
// миграции: мусор в файле не должен превращаться в неверную выдержку.
|
||||
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;
|
||||
}
|
||||
|
||||
// Возвращает true только если запись подтверждена перечитыванием из флеша.
|
||||
// Проверка нужна потому, что EEPROM.commit() умеет вернуть false (например при
|
||||
// неверно выбранном размере флеша в IDE), и молчаливая потеря настроек выглядит
|
||||
// точно так же, как «не сохраняется».
|
||||
bool eepromWriteConfig() {
|
||||
EEPROM.put(EEPROM_MAGIC_ADDR, EEPROM_MAGIC_V2);
|
||||
eepromWriteStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url);
|
||||
eepromWriteStr(EEPROM_DEVICE_ADDR, DEVICE_MAX_LEN, device_name);
|
||||
EEPROM.put(EEPROM_ALARM_ADDR, alarmDelaySec);
|
||||
EEPROM.put(EEPROM_EMERG_ADDR, emergDelaySec);
|
||||
|
||||
if (!EEPROM.commit()) {
|
||||
Serial.println(F("[EEPROM] ОШИБКА: commit() вернул false, настройки НЕ сохранены"));
|
||||
// Загрузка отказоустойчива: отсутствующий или битый ключ оставляет значение,
|
||||
// уже проставленное settingsDefaults(). Благодаря этому файл, записанный
|
||||
// прошлой версией прошивки, не ломается при добавлении новых полей.
|
||||
static bool settingsLoad() {
|
||||
File f = LittleFS.open(SETTINGS_PATH, "r");
|
||||
if (!f) {
|
||||
Serial.println(F("[SET] Файл настроек не найден"));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Повторный begin() перечитывает сектор из флеша в буфер — это и есть проверка,
|
||||
// что записанное действительно легло на флеш, а не только в ОЗУ.
|
||||
EEPROM.begin(EEPROM_SIZE);
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, f);
|
||||
f.close();
|
||||
|
||||
uint16_t magicChk, alarmChk, emergChk;
|
||||
char urlChk[WEBHOOK_MAX_LEN + 1];
|
||||
char devChk[DEVICE_MAX_LEN + 1];
|
||||
EEPROM.get(EEPROM_MAGIC_ADDR, magicChk);
|
||||
eepromReadStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, urlChk);
|
||||
eepromReadStr(EEPROM_DEVICE_ADDR, DEVICE_MAX_LEN, devChk);
|
||||
EEPROM.get(EEPROM_ALARM_ADDR, alarmChk);
|
||||
EEPROM.get(EEPROM_EMERG_ADDR, emergChk);
|
||||
|
||||
bool ok = (magicChk == EEPROM_MAGIC_V2) &&
|
||||
(strcmp(urlChk, webhook_url) == 0) &&
|
||||
(strcmp(devChk, device_name) == 0) &&
|
||||
(alarmChk == alarmDelaySec) &&
|
||||
(emergChk == emergDelaySec);
|
||||
|
||||
if (!ok) {
|
||||
Serial.println(F("[EEPROM] ОШИБКА: запись не подтвердилась при перечитывании!"));
|
||||
Serial.printf("[EEPROM] magic: записан 0x%04X, прочитан 0x%04X\n",
|
||||
EEPROM_MAGIC_V2, magicChk);
|
||||
Serial.printf("[EEPROM] device: записан '%s', прочитан '%s'\n", device_name, devChk);
|
||||
Serial.printf("[EEPROM] выдержки: записаны %u/%u, прочитаны %u/%u\n",
|
||||
alarmDelaySec, emergDelaySec, alarmChk, emergChk);
|
||||
Serial.println("[EEPROM] URL записан: " + String(webhook_url));
|
||||
Serial.println("[EEPROM] URL прочитан: " + String(urlChk));
|
||||
if (err) {
|
||||
Serial.printf("[SET] ОШИБКА разбора JSON: %s\n", err.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
Serial.printf("[EEPROM] Сохранено и проверено: device=%s, авария=%u с, резерв=%u с\n",
|
||||
device_name, alarmDelaySec, emergDelaySec);
|
||||
Serial.println("[EEPROM] URL: " + String(webhook_url[0] ? webhook_url : "(не задан)"));
|
||||
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;
|
||||
}
|
||||
|
||||
static void configSetDefaults() {
|
||||
strncpy(webhook_url, WEBHOOK_URL_DEFAULT, WEBHOOK_MAX_LEN);
|
||||
webhook_url[WEBHOOK_MAX_LEN] = '\0';
|
||||
strncpy(device_name, DEVICE_NAME_DEFAULT, DEVICE_MAX_LEN);
|
||||
device_name[DEVICE_MAX_LEN] = '\0';
|
||||
alarmDelaySec = ALARM_DELAY_DEFAULT_SEC;
|
||||
emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
|
||||
// Перезаписывает файл целиком. true только если запись реально удалась.
|
||||
bool settingsSave() {
|
||||
if (!settingsFsReady) {
|
||||
Serial.println(F("[SET] ОШИБКА: файловая система недоступна, сохранять некуда"));
|
||||
return false;
|
||||
}
|
||||
|
||||
void eepromReadConfig() {
|
||||
uint16_t magic;
|
||||
EEPROM.get(EEPROM_MAGIC_ADDR, magic);
|
||||
settingsClamp();
|
||||
|
||||
if (magic == EEPROM_MAGIC_V2) {
|
||||
eepromReadStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url);
|
||||
eepromReadStr(EEPROM_DEVICE_ADDR, DEVICE_MAX_LEN, device_name);
|
||||
EEPROM.get(EEPROM_ALARM_ADDR, alarmDelaySec);
|
||||
EEPROM.get(EEPROM_EMERG_ADDR, emergDelaySec);
|
||||
JsonDocument doc;
|
||||
doc[KEY_WEBHOOK] = settings.webhookUrl;
|
||||
doc[KEY_DEVICE] = settings.deviceName;
|
||||
doc[KEY_ALARM_DELAY] = settings.alarmDelaySec;
|
||||
doc[KEY_EMERG_DELAY] = settings.emergDelaySec;
|
||||
|
||||
// Защита от мусора во флеше: подставляем значения по умолчанию, а не
|
||||
// уходим в заведомо неверную выдержку.
|
||||
if (device_name[0] == '\0') {
|
||||
strncpy(device_name, DEVICE_NAME_DEFAULT, DEVICE_MAX_LEN);
|
||||
device_name[DEVICE_MAX_LEN] = '\0';
|
||||
File f = LittleFS.open(SETTINGS_PATH, "w");
|
||||
if (!f) {
|
||||
Serial.println(F("[SET] ОШИБКА: не удалось открыть файл на запись"));
|
||||
return false;
|
||||
}
|
||||
if (alarmDelaySec > DELAY_MAX_SEC) alarmDelaySec = ALARM_DELAY_DEFAULT_SEC;
|
||||
if (emergDelaySec > DELAY_MAX_SEC) emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
|
||||
|
||||
Serial.printf("[EEPROM] Загружено: device=%s, авария=%u с, резерв=%u с\n",
|
||||
device_name, alarmDelaySec, emergDelaySec);
|
||||
Serial.println("[EEPROM] URL: " + String(webhook_url[0] ? webhook_url : "(не задан)"));
|
||||
size_t written = serializeJson(doc, f);
|
||||
f.close();
|
||||
|
||||
} else if (magic == EEPROM_MAGIC_V1) {
|
||||
// Миграция v1 -> v2: URL сохраняем, новые поля получают значения по умолчанию.
|
||||
eepromReadStr(EEPROM_URL_ADDR, WEBHOOK_MAX_LEN, webhook_url);
|
||||
strncpy(device_name, DEVICE_NAME_DEFAULT, DEVICE_MAX_LEN);
|
||||
device_name[DEVICE_MAX_LEN] = '\0';
|
||||
alarmDelaySec = ALARM_DELAY_DEFAULT_SEC;
|
||||
emergDelaySec = EMERG_DELAY_DEFAULT_SEC;
|
||||
eepromWriteConfig();
|
||||
Serial.println(F("[EEPROM] Миграция v1 -> v2 выполнена, webhook сохранён."));
|
||||
if (written == 0) {
|
||||
Serial.println(F("[SET] ОШИБКА: в файл записано 0 байт"));
|
||||
return false;
|
||||
}
|
||||
|
||||
} else {
|
||||
configSetDefaults();
|
||||
eepromWriteConfig();
|
||||
Serial.println(F("[EEPROM] Первый старт, записаны значения по умолчанию."));
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,7 +407,7 @@ static bool parseWebhookHost(const char* url, String &host) {
|
||||
}
|
||||
|
||||
void sendPostWebhook(String eventName, String status) {
|
||||
if (webhook_url[0] == '\0') {
|
||||
if (settings.webhookUrl[0] == '\0') {
|
||||
Serial.println(F("[HTTP] Webhook URL не задан — отправка пропущена"));
|
||||
return;
|
||||
}
|
||||
@@ -395,8 +419,8 @@ void sendPostWebhook(String eventName, String status) {
|
||||
const unsigned long started = millis();
|
||||
|
||||
String host;
|
||||
if (!parseWebhookHost(webhook_url, host)) {
|
||||
Serial.println("[HTTP] Не удалось разобрать URL: " + String(webhook_url));
|
||||
if (!parseWebhookHost(settings.webhookUrl, host)) {
|
||||
Serial.println("[HTTP] Не удалось разобрать URL: " + String(settings.webhookUrl));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -431,18 +455,18 @@ void sendPostWebhook(String eventName, String status) {
|
||||
http.setReuse(false); // не держать TLS-сокет после отправки
|
||||
|
||||
Serial.print(F("[HTTP] POST: "));
|
||||
Serial.println(webhook_url);
|
||||
Serial.println(settings.webhookUrl);
|
||||
|
||||
if (!http.begin(client, webhook_url)) {
|
||||
if (!http.begin(client, settings.webhookUrl)) {
|
||||
Serial.println(F("[HTTP] begin() не удался"));
|
||||
return;
|
||||
}
|
||||
|
||||
http.addHeader("Content-Type", "application/json");
|
||||
// device_name отфильтрован при вводе до [A-Za-z0-9_-], поэтому экранирование
|
||||
// settings.deviceName отфильтрован при вводе до [A-Za-z0-9_-], поэтому экранирование
|
||||
// в JSON не требуется — сломать тело запроса ему нечем.
|
||||
String body = "{\"event\":\"" + eventName + "\",\"status\":\"" + status +
|
||||
"\",\"device\":\"" + String(device_name) + "\"}";
|
||||
"\",\"device\":\"" + String(settings.deviceName) + "\"}";
|
||||
int code = http.POST(body);
|
||||
|
||||
if (code > 0) Serial.printf("[HTTP] Код: %d (%lu мс)\n", code, millis() - started);
|
||||
@@ -642,20 +666,24 @@ String getHTML() {
|
||||
|
||||
// --- Настройки ---
|
||||
html += F("<div class='cfg-row'><div class='cfg-panel'><div class='clabel'>Настройки</div>");
|
||||
if (!settingsFsReady) {
|
||||
html += F("<div class='smsg crit' style='margin-bottom:8px'>"
|
||||
"Файловая система недоступна — настройки не переживут перезагрузку!</div>");
|
||||
}
|
||||
html += "<div class='wh-url'>Webhook: " +
|
||||
String(webhook_url[0] ? webhook_url : "не задан - уведомления отключены") + "</div>";
|
||||
String(settings.webhookUrl[0] ? settings.webhookUrl : "не задан - уведомления отключены") + "</div>";
|
||||
html += F("<form action='/set_config' method='POST' style='margin:0'><div class='cfg-grid'>");
|
||||
html += F("<div class='cfg-f cfg-wide'><div class='cfg-l'>Webhook URL</div>"
|
||||
"<input class='wh-input' type='text' name='url' placeholder='https://... (пусто = не менять)' maxlength='220'></div>");
|
||||
html += "<div class='cfg-f cfg-wide'><div class='cfg-l'>Имя устройства (A-Z a-z 0-9 _ -)</div>"
|
||||
"<input class='wh-input' type='text' name='device' value='" + String(device_name) +
|
||||
"<input class='wh-input' type='text' name='device' value='" + String(settings.deviceName) +
|
||||
"' maxlength='31'></div>";
|
||||
html += "<div class='cfg-f'><div class='cfg-l'>Выдержка аварии, с</div>"
|
||||
"<input class='wh-input' type='number' name='alarm_delay' min='0' max='3600' value='" +
|
||||
String(alarmDelaySec) + "'></div>";
|
||||
String(settings.alarmDelaySec) + "'></div>";
|
||||
html += "<div class='cfg-f'><div class='cfg-l'>Выдержка резерва, с</div>"
|
||||
"<input class='wh-input' type='number' name='emerg_delay' min='0' max='3600' value='" +
|
||||
String(emergDelaySec) + "'></div>";
|
||||
String(settings.emergDelaySec) + "'></div>";
|
||||
html += F("</div><button type='submit' class='cbtn-save' style='margin-top:8px'>Сохранить</button>"
|
||||
"</form></div></div>");
|
||||
|
||||
@@ -723,14 +751,8 @@ void setup() {
|
||||
Serial.printf("[FLASH] скетч %u Б, свободно под скетч %u Б\n",
|
||||
ESP.getSketchSize(), ESP.getFreeSketchSpace());
|
||||
|
||||
// Куда именно EEPROM собирается писать. Если это смещение выходит за размер
|
||||
// физической микросхемы, spi_flash_erase_sector() провалится и commit()
|
||||
// вернёт false — то есть настройки не сохранятся никогда.
|
||||
uint32_t eepromOffset = EEPROM_start - 0x40200000;
|
||||
Serial.printf("[FLASH] EEPROM пишет в сектор %u (смещение 0x%06X)%s\n",
|
||||
eepromOffset / SPI_FLASH_SEC_SIZE, eepromOffset,
|
||||
eepromOffset + SPI_FLASH_SEC_SIZE <= flashReal
|
||||
? "" : " <<< ЗА ПРЕДЕЛАМИ ФИЗИЧЕСКОГО ФЛЕША!");
|
||||
// Где лежит файловая система. Если область выходит за размер микросхемы,
|
||||
// LittleFS не смонтируется и настройки сохранять будет некуда.
|
||||
Serial.printf("[FLASH] файловая система: смещение 0x%06X, размер %u Б%s\n",
|
||||
FS_PHYS_ADDR, FS_PHYS_SIZE,
|
||||
FS_PHYS_ADDR + FS_PHYS_SIZE <= flashReal
|
||||
@@ -738,12 +760,15 @@ void setup() {
|
||||
|
||||
if (flashIde != flashReal) {
|
||||
Serial.println(F("[FLASH] ВНИМАНИЕ: размер флеша в IDE не соответствует"));
|
||||
Serial.println(F("[FLASH] микросхеме. Ни EEPROM, ни LittleFS работать не будут,"));
|
||||
Serial.println(F("[FLASH] пока в Tools -> Flash Size не выбран верный размер."));
|
||||
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] Нужен вариант с ненулевой файловой системой."));
|
||||
}
|
||||
|
||||
EEPROM.begin(EEPROM_SIZE);
|
||||
eepromReadConfig();
|
||||
settingsBegin();
|
||||
|
||||
pinMode(p1.pin, INPUT_PULLUP);
|
||||
pinMode(p2.pin, INPUT_PULLUP);
|
||||
@@ -788,34 +813,34 @@ void setup() {
|
||||
if (server.hasArg("url")) {
|
||||
String v = server.arg("url"); v.trim();
|
||||
if (v.length() > 0 && v.length() <= WEBHOOK_MAX_LEN) {
|
||||
v.toCharArray(webhook_url, WEBHOOK_MAX_LEN + 1);
|
||||
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(device_name)) {
|
||||
v.toCharArray(device_name, DEVICE_MAX_LEN + 1);
|
||||
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 != alarmDelaySec) {
|
||||
alarmDelaySec = 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 != emergDelaySec) {
|
||||
emergDelaySec = v;
|
||||
if (parseDelaySec(server.arg("emerg_delay"), v) && v != settings.emergDelaySec) {
|
||||
settings.emergDelaySec = v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) eepromWriteConfig();
|
||||
else Serial.println(F("[CONFIG] Изменений нет, запись в EEPROM пропущена"));
|
||||
if (changed) settingsSave();
|
||||
else Serial.println(F("[SET] Изменений нет, запись пропущена"));
|
||||
|
||||
server.sendHeader("Location", "/");
|
||||
server.send(303);
|
||||
@@ -891,14 +916,14 @@ void loop() {
|
||||
}
|
||||
|
||||
// --- Основная авария ---
|
||||
// Выдержка одна на реле и вебхук: при alarmDelaySec = 0 условие выполняется
|
||||
// Выдержка одна на реле и вебхук: при settings.alarmDelaySec = 0 условие выполняется
|
||||
// на следующей же итерации, то есть реакция мгновенная.
|
||||
bool bothStopped = (!p1.stableState && !p2.stableState);
|
||||
if (bothStopped) {
|
||||
if (!mainAlarmPending) {
|
||||
mainAlarmPending = true;
|
||||
mainAlarmStartTime = millis();
|
||||
} else if (millis() - mainAlarmStartTime >= (unsigned long)alarmDelaySec * 1000UL) {
|
||||
} else if (millis() - mainAlarmStartTime >= (unsigned long)settings.alarmDelaySec * 1000UL) {
|
||||
setRelay(OUT_ALARM_MAIN, true);
|
||||
if (!mainAlarmSent) {
|
||||
Serial.println(F("[ALERT] Авария основных насосов"));
|
||||
@@ -921,7 +946,7 @@ void loop() {
|
||||
if (!emergPending) {
|
||||
emergPending = true;
|
||||
emergStartTime = millis();
|
||||
} else if (millis() - emergStartTime >= (unsigned long)emergDelaySec * 1000UL) {
|
||||
} else if (millis() - emergStartTime >= (unsigned long)settings.emergDelaySec * 1000UL) {
|
||||
setRelay(OUT_ALARM_EMERGENCY, true);
|
||||
if (!emergencyActiveSent) {
|
||||
Serial.println(F("[WARN] Запущен экстренный насос"));
|
||||
|
||||
Reference in New Issue
Block a user