Generic selectors
Exact matches only
Search in title
Search in content
Post Type Selectors

M5Stack PaperS3 погода и заметки с deep sleep

Linux и DevOps

Проект превращает M5Stack PaperS3 в автономный e-ink-информер: экран показывает погоду, прогноз, заметку, заряд батареи и IP-адрес для веб-настройки.

Данные берутся через Open-Meteo без API-ключа. Заметка и Wi-Fi-конфигурация сохраняются во flash-памяти через Preferences.

Что умеет станция погоды на 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 += "&current=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("&", "&amp;");
  s.replace("<", "&lt;");
  s.replace(">", "&gt;");
  s.replace("\"", "&quot;");
  s.replace("'", "&#39;");
  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 обновлений без постоянного расхода батареи.

Оцените статью
ctrllife.ru
Подписаться
Уведомить о
guest
0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
0
Оставьте комментарий! Напишите, что думаете по поводу статьи.x