Проект превращает M5Stack PaperS3 в автономный e-ink-информер: экран показывает погоду, прогноз, заметку, заряд батареи и IP-адрес для веб-настройки.
Данные берутся через Open-Meteo без API-ключа. Заметка и Wi-Fi-конфигурация сохраняются во flash-памяти через Preferences.
- Что умеет станция погоды на ESP32
- Почему M5Stack PaperS3 подходит для погодного информера?
- Ключевые настройки проекта
- Как хранить Wi-Fi-настройки безопасно?
- Как работает Open-Meteo ESP32-запрос?
- Как редактировать заметку через браузер?
- Deep sleep и энергосбережение
- Финальный код
- Best practices
- Типовые проблемы
- Кириллица отображается квадратами
- Неверное время
- Wi-Fi не подключается
- Заключение
Что умеет станция погоды на ESP32
- показывает текущую температуру, влажность, ветер и давление;
- строит график температуры на 24 часа;
- выводит min/max прогноз на 3 дня;
- поддерживает кириллицу на e-ink-дисплее;
- редактирует заметку через браузер;
- сохраняет Wi-Fi отдельно от прошивки;
- показывает уровень батареи и статус зарядки;
- уходит в deep sleep после окна редактирования.
Почему M5Stack PaperS3 подходит для погодного информера?
PaperS3 использует ESP32-S3 и 4,7-дюймовый e-ink-дисплей 960×540. Такой экран сохраняет изображение без постоянной перерисовки, поэтому устройство подходит для low-power IoT, мониторинга и домашних production-ready панелей.
Оптимальная схема работы: короткое включение Wi-Fi, загрузка прогноза, запуск веб-сервера на 1–2 минуты, затем deep sleep до следующего обновления.
Ключевые настройки проекта
Основные параметры вынесены в начало скетча. Координаты, интервал обновления, окно редактирования и retries изменяются без переписывания логики.
static const double LAT = 55.000000;
static const double LON = 37.000000;
static const int UPDATE_MINUTES = 59;
static const int HOURS = 24;
static const int NOTE_EDIT_WINDOW_SECONDS = 120;
static const int WIFI_CONNECT_TIMEOUT_MS = 15000;
static const int WEATHER_FETCH_RETRIES = 3;
static const int WIFI_RECONNECT_INTERVAL_MS = 15000;
static const int WIFI_RECONNECT_MAX_RETRIES = 5; Координаты лучше хранить как конфигурацию проекта. Для публичного кода не стоит оставлять точный домашний адрес, внутренние SSID, пароли или приватные URL.
Как хранить Wi-Fi-настройки безопасно?
SSID и пароль не должны находиться в прошивке. В проекте они сохраняются в namespace wifi через Preferences.
bool loadCredentials(String &ssid, String &pass) {
prefs.begin("wifi", true);
ssid = prefs.getString("ssid", "");
pass = prefs.getString("pass", "");
prefs.end();
if (ssid.length() == 0) {
ssid = "YOUR_WIFI_SSID";
pass = "YOUR_WIFI_PASSWORD";
return false;
}
return true;
}
bool saveCredentials(const String &ssid, const String &pass) {
prefs.begin("wifi", false);
prefs.putString("ssid", ssid);
prefs.putString("pass", pass);
prefs.end();
return true;
} Как работает Open-Meteo ESP32-запрос?
URL собирается с параметрами current, hourly, daily и timezone=auto. API возвращает JSON с текущей погодой, почасовым прогнозом и дневными min/max значениями.
String buildOpenMeteoUrl(double lat, double lon) {
String url = "https://api.open-meteo.com/v1/forecast?latitude=";
url += String(lat, 6);
url += "&longitude=";
url += String(lon, 6);
url += "¤t=temperature_2m,apparent_temperature,relative_humidity_2m,surface_pressure,wind_speed_10m,weather_code";
url += "&hourly=temperature_2m,precipitation_probability";
url += "&daily=temperature_2m_max,temperature_2m_min";
url += "&forecast_days=3";
url += "&timezone=auto";
return url;
} Как редактировать заметку через браузер?
После обновления экрана устройство показывает локальный IP. В течение окна редактирования открывается веб-интерфейс:
http://<IP> Через страницу можно изменить заметку и сохранить Wi-Fi-настройки без перепрошивки.
Deep sleep и энергосбережение
После завершения окна редактирования веб-сервер останавливается, а ESP32-S3 уходит в deep sleep по таймеру. Такой режим снижает расход батареи и уменьшает ненужную активность Wi-Fi.
void goToSleepMinutes(int minutes) {
uint64_t us = (uint64_t)minutes * 60ULL * 1000000ULL;
esp_sleep_enable_timer_wakeup(us);
esp_deep_sleep_start();
} Финальный код
#include <M5Unified.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <WebServer.h>
#include <Preferences.h>
#include <time.h>
#include <math.h>
// ====== SETTINGS ======
static const double LAT = 55.000000;
static const double LON = 37.000000;
static const int UPDATE_MINUTES = 59;
static const int HOURS = 24;
static const int NOTE_EDIT_WINDOW_SECONDS = 120;
static const int WIFI_CONNECT_TIMEOUT_MS = 15000;
static const int WEATHER_FETCH_RETRIES = 3;
static const int WIFI_RECONNECT_INTERVAL_MS = 15000;
static const int WIFI_RECONNECT_MAX_RETRIES = 5;
// ======================
struct WeatherData {
bool ok = false;
float tempC = NAN;
float feelsC = NAN;
int humPct = -1;
float pressHPa = NAN;
float windMs = NAN;
int wcode = -1;
float hourlyTemp[HOURS];
int hourlyPop[HOURS];
float dayMax[3];
float dayMin[3];
};
enum IconKind {
ICON_SUN,
ICON_CLOUD,
ICON_RAIN,
ICON_SNOW,
ICON_THUNDER,
ICON_FOG,
ICON_UNKNOWN
};
WebServer server(80);
Preferences prefs;
String noteText = "";
String deviceIp = "";
WeatherData lastWeather;
void fontDefault() {
M5.Display.setFont(&fonts::Font2);
}
void fontRU() {
M5.Display.setFont(&fonts::efontCN_24);
}
String weatherCodeToTextRU(int code) {
switch (code) {
case 0: return "Ясно";
case 1: return "Почти ясно";
case 2: return "Переменная облачность";
case 3: return "Пасмурно";
case 45: return "Туман";
case 48: return "Иней, туман";
case 51: return "Морось слабая";
case 53: return "Морось";
case 55: return "Морось сильная";
case 61: return "Дождь слабый";
case 63: return "Дождь";
case 65: return "Дождь сильный";
case 71: return "Снег слабый";
case 73: return "Снег";
case 75: return "Снег сильный";
case 77: return "Снежная крупа";
case 80: return "Ливень слабый";
case 81: return "Ливень";
case 82: return "Сильный ливень";
case 85: return "Снегопад";
case 86: return "Сильный снегопад";
case 95: return "Гроза";
case 96: return "Гроза с градом";
case 99: return "Сильная гроза";
default: return "Неизвестно";
}
}
String buildOpenMeteoUrl(double lat, double lon) {
String url = "https://api.open-meteo.com/v1/forecast?latitude=";
url += String(lat, 6);
url += "&longitude=";
url += String(lon, 6);
url += "¤t=temperature_2m,apparent_temperature,relative_humidity_2m,surface_pressure,wind_speed_10m,weather_code";
url += "&hourly=temperature_2m,precipitation_probability";
url += "&daily=temperature_2m_max,temperature_2m_min";
url += "&forecast_days=3";
url += "&timezone=auto";
return url;
}
String getLocalTimeString() {
struct tm t;
if (!getLocalTime(&t, 1500)) return "время ?";
char buf[32];
strftime(buf, sizeof(buf), "%d.%m.%Y %H:%M", &t);
return String(buf);
}
bool loadCredentials(String &ssid, String &pass) {
prefs.begin("wifi", true);
ssid = prefs.getString("ssid", "");
pass = prefs.getString("pass", "");
prefs.end();
if (ssid.length() == 0) {
ssid = "YOUR_WIFI_SSID";
pass = "YOUR_WIFI_PASSWORD";
return false;
}
return true;
}
bool saveCredentials(const String &ssid, const String &pass) {
prefs.begin("wifi", false);
prefs.putString("ssid", ssid);
prefs.putString("pass", pass);
prefs.end();
return true;
}
int getBatteryLevel(bool &charging) {
int level = M5.Power.getBatteryLevel();
if (level < 0) level = 0;
charging = M5.Power.isCharging();
return level;
}
void drawBattery(int x, int y, int w, int h, int level, bool charging) {
M5.Display.drawRect(x, y, w, h, TFT_BLACK);
M5.Display.fillRect(x + w, y + (h / 4), 3, h / 2, TFT_BLACK);
int fillW = (w - 4) * level / 100;
uint16_t color = TFT_BLACK;
if (level < 20) color = TFT_RED;
else if (level < 50) color = TFT_ORANGE;
M5.Display.fillRect(x + 2, y + 2, fillW, h - 4, color);
if (charging) {
fontDefault();
M5.Display.setTextSize(1);
M5.Display.setCursor(x + w + 6, y + h / 3);
M5.Display.print("+");
}
}
bool ensureWifiConnected() {
if (WiFi.status() == WL_CONNECTED) return true;
WiFi.disconnect();
delay(100);
for (int i = 0; i < WIFI_RECONNECT_MAX_RETRIES; i++) {
unsigned long start = millis();
WiFi.reconnect();
while (WiFi.status() != WL_CONNECTED && millis() - start < WIFI_RECONNECT_INTERVAL_MS) {
delay(100);
}
if (WiFi.status() == WL_CONNECTED) {
deviceIp = WiFi.localIP().toString();
return true;
}
if (i < WIFI_RECONNECT_MAX_RETRIES - 1) {
delay(1000 * (i + 1));
}
}
return false;
}
bool fetchWeather(WeatherData &wd) {
wd.ok = false;
for (int i = 0; i < HOURS; i++) {
wd.hourlyTemp[i] = NAN;
wd.hourlyPop[i] = -1;
}
for (int d = 0; d < 3; d++) {
wd.dayMax[d] = NAN;
wd.dayMin[d] = NAN;
}
HTTPClient http;
http.begin(buildOpenMeteoUrl(LAT, LON));
http.setTimeout(12000);
int httpCode = http.GET();
if (httpCode <= 0) {
http.end();
return false;
}
String payload = http.getString();
http.end();
DynamicJsonDocument doc(32000);
DeserializationError err = deserializeJson(doc, payload);
if (err) return false;
JsonObject cur = doc["current"];
if (cur.isNull()) return false;
wd.tempC = cur["temperature_2m"] | NAN;
wd.feelsC = cur["apparent_temperature"] | NAN;
wd.humPct = cur["relative_humidity_2m"] | -1;
wd.pressHPa = cur["surface_pressure"] | NAN;
wd.windMs = cur["wind_speed_10m"] | NAN;
wd.wcode = cur["weather_code"] | -1;
JsonObject hourly = doc["hourly"];
if (!hourly.isNull()) {
JsonArray ht = hourly["temperature_2m"].as<JsonArray>();
JsonArray pop = hourly["precipitation_probability"].as<JsonArray>();
int n = min((int)ht.size(), HOURS);
for (int i = 0; i < n; i++) wd.hourlyTemp[i] = ht[i] | NAN;
n = min((int)pop.size(), HOURS);
for (int i = 0; i < n; i++) wd.hourlyPop[i] = pop[i] | -1;
}
JsonObject daily = doc["daily"];
if (!daily.isNull()) {
JsonArray mx = daily["temperature_2m_max"].as<JsonArray>();
JsonArray mn = daily["temperature_2m_min"].as<JsonArray>();
int n = min((int)mx.size(), 3);
for (int i = 0; i < n; i++) wd.dayMax[i] = mx[i] | NAN;
n = min((int)mn.size(), 3);
for (int i = 0; i < n; i++) wd.dayMin[i] = mn[i] | NAN;
}
wd.ok = true;
return true;
}
String htmlEscape(String s) {
s.replace("&", "&");
s.replace("<", "<");
s.replace(">", ">");
s.replace("\"", """);
s.replace("'", "'");
return s;
}
void loadNote() {
prefs.begin("notes", true);
noteText = prefs.getString("note", "Нет заметки");
prefs.end();
}
void saveNote(const String &text) {
prefs.begin("notes", false);
prefs.putString("note", text);
prefs.end();
noteText = text;
}
void handleNoteRoot() {
String html = "<!doctype html><html><head><meta charset='utf-8'>";
html += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
html += "<title>PaperS3 Note</title>";
html += "<style>";
html += "body{font-family:-apple-system,BlinkMacSystemFont,Arial,sans-serif;margin:20px;max-width:720px}";
html += "textarea{width:100%;font-size:18px;padding:10px;box-sizing:border-box}";
html += "button{font-size:20px;padding:10px 20px}";
html += ".cfg{margin-top:30px;border-top:1px solid #ccc;padding-top:20px}";
html += "input{font-size:16px;padding:8px;width:100%;box-sizing:border-box}";
html += ".ok{color:green}";
html += "</style></head><body>";
html += "<h2>Заметка PaperS3</h2>";
html += "<form method='POST' action='/save'>";
html += "<textarea name='note' rows='8'>";
html += htmlEscape(noteText);
html += "</textarea><br><br>";
html += "<button>Сохранить</button>";
html += "</form>";
html += "<p>После сохранения экран обновится автоматически.</p>";
html += "<div class='cfg'>";
html += "<h3>WiFi настройки</h3>";
html += "<form method='POST' action='/wifi'>";
html += "<input name='ssid' placeholder='SSID' value='";
prefs.begin("wifi", true);
html += htmlEscape(prefs.getString("ssid", ""));
prefs.end();
html += "'><br><br>";
html += "<input name='pass' type='password' placeholder='Пароль'><br><br>";
html += "<button>Сохранить WiFi</button>";
html += "</form>";
html += "<p class='ok'>WiFi сохраняется отдельно от заметки</p>";
html += "</div>";
html += "</body></html>";
server.send(200, "text/html", html);
}
void handleNoteSave() {
if (server.hasArg("note")) {
String n = server.arg("note");
n.trim();
if (n.length() == 0) n = "Нет заметки";
if (n.length() > 240) n = n.substring(0, 240);
saveNote(n);
}
drawScreen(lastWeather);
server.send(
200,
"text/html",
"<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"</head><body>"
"<h2>Сохранено</h2>"
"<p>Заметка сохранена, экран обновлён.</p>"
"<a href='/'>Назад</a>"
"</body></html>"
);
}
void handleWifiSave() {
if (server.hasArg("ssid") && server.hasArg("pass")) {
String ssid = server.arg("ssid");
String pass = server.arg("pass");
ssid.trim();
pass.trim();
if (ssid.length() > 0) {
saveCredentials(ssid, pass);
}
}
server.send(
200,
"text/html",
"<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"</head><body>"
"<h2>Сохранено</h2>"
"<p>WiFi credentials updated. Reboot to connect to new network.</p>"
"<a href='/'>Назад</a>"
"</body></html>"
);
}
void setupNoteServer() {
server.on("/", HTTP_GET, handleNoteRoot);
server.on("/note", HTTP_GET, handleNoteRoot);
server.on("/save", HTTP_POST, handleNoteSave);
server.on("/wifi", HTTP_POST, handleWifiSave);
server.begin();
}
void waitForNoteEditWindow() {
unsigned long start = millis();
while (millis() - start < (unsigned long)NOTE_EDIT_WINDOW_SECONDS * 1000UL) {
server.handleClient();
delay(10);
}
server.stop();
}
IconKind weatherCodeToIcon(int code) {
if (code == 0 || code == 1) return ICON_SUN;
if (code == 2 || code == 3) return ICON_CLOUD;
if (code == 45 || code == 48) return ICON_FOG;
if ((code >= 51 && code <= 55) || (code >= 61 && code <= 65) || (code >= 80 && code <= 82)) return ICON_RAIN;
if ((code >= 71 && code <= 77) || (code >= 85 && code <= 86)) return ICON_SNOW;
if (code == 95 || code == 96 || code == 99) return ICON_THUNDER;
return ICON_UNKNOWN;
}
static void drawSun(int x, int y, int s) {
int cx = x + s / 2;
int cy = y + s / 2;
int r = s / 5;
M5.Display.fillCircle(cx, cy, r - 1, TFT_BLACK);
for (int i = 0; i < 8; i++) {
float a = i * 3.14159f / 4.0f;
int x1 = cx + (int)((r + 4) * cos(a));
int y1 = cy + (int)((r + 4) * sin(a));
int x2 = cx + (int)((r + 14) * cos(a));
int y2 = cy + (int)((r + 14) * sin(a));
M5.Display.drawLine(x1, y1, x2, y2, TFT_BLACK);
}
}
static void drawCloud(int x, int y, int s) {
int baseY = y + (s * 3) / 5;
int left = x + s / 6;
int right = x + (s * 5) / 6;
M5.Display.fillCircle(x + s / 3, y + s / 2, s / 5, TFT_BLACK);
M5.Display.fillCircle(x + s / 2, y + s / 3, s / 4, TFT_BLACK);
M5.Display.fillCircle(x + (s * 2) / 3, y + s / 2, s / 5, TFT_BLACK);
M5.Display.fillRect(left, baseY - s / 10, right - left, s / 5, TFT_BLACK);
}
static void drawRain(int x, int y, int s) {
drawCloud(x, y, s);
int dropTop = y + (s * 3) / 5 + 6;
int startX = x + s / 4;
for (int i = 0; i < 4; i++) {
int dx = startX + i * (s / 6);
M5.Display.drawLine(dx, dropTop, dx - 6, dropTop + 16, TFT_BLACK);
}
}
static void drawSnow(int x, int y, int s) {
drawCloud(x, y, s);
int sy = y + (s * 3) / 5 + 10;
int startX = x + s / 4;
for (int i = 0; i < 3; i++) {
int cx = startX + i * (s / 5);
M5.Display.drawLine(cx - 5, sy, cx + 5, sy, TFT_BLACK);
M5.Display.drawLine(cx, sy - 5, cx, sy + 5, TFT_BLACK);
M5.Display.drawLine(cx - 4, sy - 4, cx + 4, sy + 4, TFT_BLACK);
M5.Display.drawLine(cx - 4, sy + 4, cx + 4, sy - 4, TFT_BLACK);
}
}
static void drawThunder(int x, int y, int s) {
drawCloud(x, y, s);
int bx = x + s / 2;
int by = y + (s * 3) / 5 + 6;
M5.Display.drawLine(bx, by, bx - 10, by + 18, TFT_BLACK);
M5.Display.drawLine(bx - 10, by + 18, bx + 2, by + 18, TFT_BLACK);
M5.Display.drawLine(bx + 2, by + 18, bx - 12, by + 38, TFT_BLACK);
}
static void drawFog(int x, int y, int s) {
drawCloud(x, y, s);
int fy = y + (s * 3) / 5 + 6;
for (int i = 0; i < 3; i++) {
M5.Display.drawFastHLine(x + s / 6, fy + i * 10, (s * 4) / 6, TFT_BLACK);
}
}
void drawWeatherIcon(int x, int y, int size, int weatherCode) {
IconKind k = weatherCodeToIcon(weatherCode);
switch (k) {
case ICON_SUN: drawSun(x, y, size); break;
case ICON_CLOUD: drawCloud(x, y, size); break;
case ICON_RAIN: drawRain(x, y, size); break;
case ICON_SNOW: drawSnow(x, y, size); break;
case ICON_THUNDER: drawThunder(x, y, size); break;
case ICON_FOG: drawFog(x, y, size); break;
default:
fontRU();
M5.Display.setTextSize(2);
M5.Display.setCursor(x + size / 3, y + size / 4);
M5.Display.print("?");
break;
}
}
void drawBox(int x, int y, int w, int h) {
M5.Display.drawRect(x, y, w, h, TFT_BLACK);
}
void drawSparkline(int x, int y, int w, int h, const float *vals, int n) {
float vmin = 1e9;
float vmax = -1e9;
for (int i = 0; i < n; i++) {
if (isnan(vals[i])) continue;
vmin = min(vmin, vals[i]);
vmax = max(vmax, vals[i]);
}
if (vmin > vmax - 0.001f) {
vmin -= 1.0f;
vmax += 1.0f;
}
drawBox(x, y, w, h);
auto mapY = [&](float v) -> int {
float t = (v - vmin) / (vmax - vmin);
t = constrain(t, 0.0f, 1.0f);
int top = y + 8;
int bot = y + h - 18;
return bot - (int)((bot - top) * t);
};
int prevX = -1;
int prevY = -1;
for (int i = 0; i < n; i++) {
if (isnan(vals[i])) continue;
int px = x + 8 + (int)((w - 16) * (i / (float)(n - 1)));
int py = mapY(vals[i]);
M5.Display.fillCircle(px, py, 2, TFT_BLACK);
if (prevX >= 0) {
M5.Display.drawLine(prevX, prevY, px, py, TFT_BLACK);
}
prevX = px;
prevY = py;
}
}
void drawNoteBox(int x, int y, int w, int h) {
drawBox(x, y, w, h);
fontRU();
M5.Display.setTextSize(1);
M5.Display.setCursor(x + 12, y + 8);
M5.Display.print("Заметка");
String s = noteText;
s.replace("\r", " ");
s.replace("\n", " ");
if (s.length() > 260) {
s = s.substring(0, 260) + "...";
}
M5.Display.setTextWrap(true, true);
M5.Display.setCursor(x + 12, y + 38);
M5.Display.print(s);
M5.Display.setTextWrap(false);
}
void drawScreen(const WeatherData &wd) {
M5.Display.startWrite();
M5.Display.fillScreen(TFT_WHITE);
M5.Display.setTextColor(TFT_BLACK);
const int W = M5.Display.width();
const int H = M5.Display.height();
const int PAD = 18;
bool charging = false;
int battery = getBatteryLevel(charging);
drawBattery(W - PAD - 50, PAD - 4, 46, 20, battery, charging);
fontRU();
M5.Display.setTextSize(1);
M5.Display.setCursor(PAD, PAD);
M5.Display.print("Погодный информер");
M5.Display.setCursor(PAD, PAD + 28);
M5.Display.print("Обновлено: ");
M5.Display.print(getLocalTimeString());
M5.Display.drawFastHLine(PAD, PAD + 50, W - PAD * 2, TFT_BLACK);
if (!wd.ok) {
fontRU();
M5.Display.setTextSize(1);
M5.Display.setCursor(PAD, 100);
M5.Display.print("Ошибка погоды");
M5.Display.setCursor(PAD, 140);
M5.Display.print("Проверь Wi-Fi / DNS");
drawNoteBox(PAD, 220, W - PAD * 2, H - 300);
fontDefault();
M5.Display.setTextSize(1);
M5.Display.setCursor(PAD, H - PAD);
M5.Display.printf("IP: %s | edit %ds", deviceIp.c_str(), NOTE_EDIT_WINDOW_SECONDS);
M5.Display.endWrite();
return;
}
int tempY = 78;
fontDefault();
M5.Display.setTextSize(8);
M5.Display.setCursor(PAD, tempY);
M5.Display.printf("%.1fC", wd.tempC);
int iconSize = 120;
drawWeatherIcon(W - PAD - iconSize, tempY - 4, iconSize, wd.wcode);
fontRU();
M5.Display.setTextSize(1);
M5.Display.setCursor(PAD, tempY + 112);
M5.Display.print(weatherCodeToTextRU(wd.wcode));
int metricsY = tempY + 150;
int boxGap = 8;
int boxW = (W - PAD * 2 - boxGap * 3) / 4;
int boxH = 72;
int x = PAD;
drawBox(x, metricsY, boxW, boxH);
fontRU();
M5.Display.setCursor(x + 8, metricsY + 8);
M5.Display.print("Ощущ.");
fontDefault();
M5.Display.setCursor(x + 8, metricsY + 38);
M5.Display.printf("%.1fC", wd.feelsC);
x += boxW + boxGap;
drawBox(x, metricsY, boxW, boxH);
fontRU();
M5.Display.setCursor(x + 8, metricsY + 8);
M5.Display.print("Влажн.");
fontDefault();
M5.Display.setCursor(x + 8, metricsY + 38);
M5.Display.printf("%d%%", wd.humPct);
x += boxW + boxGap;
drawBox(x, metricsY, boxW, boxH);
fontRU();
M5.Display.setCursor(x + 8, metricsY + 8);
M5.Display.print("Ветер");
fontDefault();
M5.Display.setCursor(x + 8, metricsY + 38);
M5.Display.printf("%.1f", wd.windMs);
x += boxW + boxGap;
drawBox(x, metricsY, boxW, boxH);
fontRU();
M5.Display.setCursor(x + 8, metricsY + 8);
M5.Display.print("Давл.");
fontDefault();
M5.Display.setCursor(x + 8, metricsY + 38);
M5.Display.printf("%.0f", wd.pressHPa);
int noteY = metricsY + boxH + 18;
int chartTitleH = 24;
int chartH = 115;
int daysH = 32;
int footerH = 30;
int gaps = 16 + 14 + 12;
int reservedBottom = chartTitleH + chartH + daysH + footerH + gaps;
int noteH = H - noteY - reservedBottom;
if (noteH < 180) noteH = 180;
drawNoteBox(PAD, noteY, W - PAD * 2, noteH);
int chartY = noteY + noteH + 16;
fontRU();
M5.Display.setCursor(PAD, chartY);
M5.Display.print("Температура 24ч");
drawSparkline(PAD, chartY + 28, W - PAD * 2, chartH, wd.hourlyTemp, HOURS);
int daysY = chartY + 28 + chartH + 14;
fontRU();
M5.Display.setCursor(PAD, daysY);
M5.Display.print("3 дня: ");
fontDefault();
for (int d = 0; d < 3; d++) {
M5.Display.printf("%.0f/%.0f ", wd.dayMin[d], wd.dayMax[d]);
}
fontDefault();
M5.Display.setTextSize(1);
M5.Display.setCursor(PAD, H - PAD);
M5.Display.printf("IP: %s | edit %ds | next %d min",
deviceIp.c_str(),
NOTE_EDIT_WINDOW_SECONDS,
UPDATE_MINUTES);
M5.Display.endWrite();
}
void drawConnectingScreen(const char* ssid, int attempt) {
M5.Display.fillScreen(TFT_WHITE);
M5.Display.setTextColor(TFT_BLACK);
fontRU();
M5.Display.setTextSize(1);
M5.Display.setCursor(20, 20);
M5.Display.print("Подключение к Wi-Fi...");
M5.Display.setCursor(20, 50);
M5.Display.print("SSID: ");
M5.Display.print(ssid);
if (attempt > 0) {
M5.Display.setCursor(20, 80);
M5.Display.printf("Попытка %d...", attempt);
}
M5.Display.setCursor(20, 120);
M5.Display.print("Батарея: ");
bool charging = false;
int battery = getBatteryLevel(charging);
M5.Display.printf("%d%%", battery);
if (charging) M5.Display.print(" (зарядка)");
}
void goToSleepMinutes(int minutes) {
uint64_t us = (uint64_t)minutes * 60ULL * 1000000ULL;
esp_sleep_enable_timer_wakeup(us);
esp_deep_sleep_start();
}
void goToSleepSeconds(int seconds) {
uint64_t us = (uint64_t)seconds * 1000000ULL;
esp_sleep_enable_timer_wakeup(us);
esp_deep_sleep_start();
}
void setup() {
auto cfg = M5.config();
M5.begin(cfg);
M5.Display.setRotation(0);
loadNote();
String wifiSsid;
String wifiPass;
loadCredentials(wifiSsid, wifiPass);
WiFi.mode(WIFI_STA);
WiFi.begin(wifiSsid.c_str(), wifiPass.c_str());
drawConnectingScreen(wifiSsid.c_str(), 0);
unsigned long start = millis();
int attempt = 0;
bool wifiConnected = false;
while (WiFi.status() != WL_CONNECTED && millis() - start < WIFI_CONNECT_TIMEOUT_MS) {
delay(250);
if (millis() - start > (unsigned long)attempt * 3000) {
attempt++;
if (attempt > 1) {
drawConnectingScreen(wifiSsid.c_str(), attempt);
}
}
}
if (WiFi.status() == WL_CONNECTED) {
deviceIp = WiFi.localIP().toString();
wifiConnected = true;
} else {
deviceIp = "no wifi";
WiFi.disconnect();
}
if (!wifiConnected) {
goToSleepSeconds(30);
return;
}
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
struct tm timeinfo;
int retries = 0;
while (!getLocalTime(&timeinfo) && retries < 20) {
delay(500);
retries++;
}
bool fetched = false;
for (int i = 0; i < WEATHER_FETCH_RETRIES && !fetched; i++) {
if (i > 0) delay(2000);
fetched = fetchWeather(lastWeather);
}
lastWeather.ok = fetched;
drawScreen(lastWeather);
delay(700);
if (ensureWifiConnected()) {
setupNoteServer();
waitForNoteEditWindow();
}
goToSleepMinutes(UPDATE_MINUTES);
}
void loop() {} Best practices
- не хранить реальные SSID и пароли в репозитории;
- ограничивать длину заметки перед записью во flash;
- использовать retries для Wi-Fi и HTTP-запросов;
- задавать HTTP timeout, чтобы устройство не зависало;
- обновлять прогноз примерно раз в час, чтобы не тратить батарею;
- проверять совместимость ArduinoJson: для v7 предпочтителен
JsonDocumentвместо устаревшегоDynamicJsonDocument; - не публиковать точные координаты приватных объектов.
В публичной версии скетча нужно заменить реальные координаты, SSID, пароли, внутренние домены и локальные адреса на плейсхолдеры.
Типовые проблемы
Кириллица отображается квадратами
Для русскоязычного интерфейса проверьте шрифт. В некоторых сборках помогает замена китайского efont на японский efont.
M5.Display.setFont(&fonts::efontJA_24); Неверное время
Для фиксированного часового пояса используйте configTzTime() и POSIX-строку.
configTzTime("UTC-3", "pool.ntp.org", "time.nist.gov"); Wi-Fi не подключается
Проверьте SSID, пароль, уровень сигнала и доступность DNS. При ошибке устройство должно уходить в короткий сон, а не оставаться в активном цикле.
Заключение
M5Stack PaperS3 подходит для автономной e-ink станции погоды: Open-Meteo убирает зависимость от API-ключей, веб-интерфейс упрощает настройку, а deep sleep снижает latency обновлений без постоянного расхода батареи.









