Реализация онлайн-станции погоды на M5Stack PaperS3 строится вокруг трёх задач: стабильное получение данных по Wi-Fi, аккуратная отрисовка на e-ink в портретной ориентации и предсказуемый deep sleep для минимального энергопотребления.
Данные берутся из Open-Meteo без ключей доступа, устройство отображает текущие параметры, 12-часовой график температуры, вероятность осадков и min/max на 3 дня.
- Что умеет прошивка
- Аппаратная и программная база
- Железо
- Софт
- Open-Meteo: параметры запроса и данные
- Ключевые блоки прошивки
- Структура данных
- Векторные иконки
- График и бары вероятности осадков
- Deep sleep
- Параметры конфигурации
- Полный production-ready пример (.ino)
- Best practices 2025: стабильность, безопасность, производительность
- Сеть и таймауты
- Память и latency
- E-ink и ресурс дисплея
- Секреты и публикация кода
- Заключение
Что умеет прошивка
- Подключение к Wi-Fi в режиме STA и запрос к Open-Meteo по HTTPS
- Текущая температура, ощущаемая, влажность, давление, ветер, weather_code
- Почасовой прогноз (12 часов): температура и вероятность осадков
- Дневной прогноз (3 дня): min/max
- Векторные погодные иконки без загрузки изображений
- Портретная ориентация интерфейса e-ink
- Корректная деградация при ошибках сети/API
- Deep sleep по таймеру (интервал в минутах)
Аппаратная и программная база
Железо
- M5Stack PaperS3 (ESP32-S3, e-ink дисплей)
- USB-C кабель с передачей данных
- Wi-Fi 2.4 ГГц
Софт
- Arduino IDE 2.x
- Пакет плат M5Stack
- Библиотеки:
M5Unified,M5GFX,ArduinoJson - Системные:
WiFi,HTTPClient,time
Open-Meteo: параметры запроса и данные
Запрос собирается динамически на основе координат и включает блоки current, hourly, daily. Включение timezone=auto обеспечивает согласование времени на стороне API.
https://api.open-meteo.com/v1/forecast?latitude=<LAT>&longitude=<LON>¤t=temperature_2m,apparent_temperature,relative_humidity_2m,surface_pressure,wind_speed_10m,weather_code&hourly=temperature_2m,precipitation_probability&daily=temperature_2m_max,temperature_2m_min&forecast_days=3&timezone=auto Обработка выполняется через ArduinoJson с заранее выделенным буфером, чтобы избежать фрагментации кучи на embedded-устройстве.
Ключевые блоки прошивки
Структура данных
Данные агрегируются в структуре, которая хранит текущие значения, массивы для 12 часов и min/max на 3 дня. Это упрощает отрисовку и минимизирует количество обращений к JSON.
Векторные иконки
Иконки строятся примитивами (линии, окружности, прямоугольники), что экономит память и ускоряет cold-start. Выбор иконки основан на weather_code.
График и бары вероятности осадков
График температуры реализован как sparkline с авто-нормализацией по min/max. Вероятность осадков визуализируется столбцами, масштабированными к 0–100%.
Deep sleep
Таймер настраивается через esp_sleep_enable_timer_wakeup(), затем вызывается esp_deep_sleep_start(). Для e-ink это оптимально: изображение остаётся на экране без питания.
Параметры конфигурации
Перед сборкой задаются Wi-Fi параметры, координаты, интервал обновления и (опционально) правило локального времени. Чувствительные значения должны храниться вне репозитория (например, в отдельном приватном файле или через build-флаги).
// ====== SETTINGS ======
const char* WIFI_SSID = "<WIFI_SSID>";
const char* WIFI_PASS = "<WIFI_PASSWORD>";
// Location
static const double LAT = <LATITUDE>;
static const double LON = <LONGITUDE>;
// Update interval (minutes)
static const int UPDATE_MINUTES = 30;
// Local time rule (optional if timezone=auto is used for API)
static const char* TZ_INFO = "<TZ_RULE>";
// Hours to show
static const int HOURS = 12;
// ====================== Не рекомендуется хранить SSID/пароль в коде, который публикуется. Для production-ready сценариев используется вынос секретов и исключение конфигурационных файлов из публичных репозиториев.
Полный production-ready пример (.ino)
Ниже — цельный пример для Arduino IDE. Он включает: запрос Open-Meteo, парсинг JSON, портретный интерфейс, графики и deep sleep. Значения Wi-Fi и координаты представлены плейсхолдерами.
#include <M5Unified.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <time.h>
#include <math.h>
// ====== SETTINGS ======
const char* WIFI_SSID = "<WIFI_SSID>";
const char* WIFI_PASS = "<WIFI_PASSWORD>";
static const double LAT = <LATITUDE>;
static const double LON = <LONGITUDE>;
static const int UPDATE_MINUTES = 30;
// Optional: local time rule for NTP formatting.
// If not needed, keep TZ_INFO empty and rely on UTC formatting.
static const char* TZ_INFO = "<TZ_RULE>";
static const int HOURS = 12;
// ======================
// ---------- TYPES ----------
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
};
// ---------------------------
// ---------- PROTOTYPES ----------
String weatherCodeToTextEN(int code);
String buildOpenMeteoUrl(double lat, double lon);
String getLocalTimeString();
bool fetchWeather(WeatherData &wd);
IconKind weatherCodeToIcon(int code);
void drawWeatherIcon(int x, int y, int size, int weatherCode);
void drawBox(int x, int y, int w, int h);
void drawSparkline(int x, int y, int w, int h, const float *vals, int n);
void drawPopBars(int x, int y, int w, int h, const int *pop, int n);
void drawScreen(const WeatherData &wd);
void goToSleepMinutes(int minutes);
// ------------------------------
// ====== TEXT / URL / TIME ======
String weatherCodeToTextEN(int code) {
switch (code) {
case 0: return "Clear";
case 1: return "Mainly clear";
case 2: return "Partly cloudy";
case 3: return "Overcast";
case 45: return "Fog";
case 48: return "Rime fog";
case 51: return "Drizzle (light)";
case 53: return "Drizzle (moderate)";
case 55: return "Drizzle (dense)";
case 61: return "Rain (slight)";
case 63: return "Rain (moderate)";
case 65: return "Rain (heavy)";
case 71: return "Snow (slight)";
case 73: return "Snow (moderate)";
case 75: return "Snow (heavy)";
case 77: return "Snow grains";
case 80: return "Showers (slight)";
case 81: return "Showers (moderate)";
case 82: return "Showers (violent)";
case 85: return "Snow showers (slight)";
case 86: return "Snow showers (heavy)";
case 95: return "Thunderstorm";
case 96: return "Thunderstorm + hail";
case 99: return "Thunderstorm + heavy hail";
default: return "Unknown";
}
}
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 "time ?";
char buf[32];
strftime(buf, sizeof(buf), "%d.%m.%Y %H:%M", &t);
return String(buf);
}
// ====== FETCH ======
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;
String url = buildOpenMeteoUrl(LAT, LON);
http.begin(url);
http.setTimeout(10000);
int httpCode = http.GET();
if (httpCode <= 0) { http.end(); return false; }
String payload = http.getString();
http.end();
StaticJsonDocument<16384> doc;
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;
}
// ====== ICONS (vector) ======
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, 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:
M5.Display.setTextSize(3);
M5.Display.setCursor(x + size/3, y + size/4);
M5.Display.print("?");
break;
}
}
// ====== UI HELPERS ======
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, 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, 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;
}
M5.Display.setTextSize(1);
M5.Display.setCursor(x + 10, y + 6);
M5.Display.printf("min %.1f", vmin);
M5.Display.setCursor(x + w - 90, y + 6);
M5.Display.printf("max %.1f", vmax);
}
void drawPopBars(int x, int y, int w, int h, const int *pop, int n) {
drawBox(x, y, w, h);
int leftPad = 10, rightPad = 10, topPad = 10, botPad = 14;
int innerW = w - leftPad - rightPad;
int innerH = h - topPad - botPad;
int gap = 6;
int barW = (innerW - gap * (n - 1)) / n;
if (barW < 6) barW = 6;
for (int i = 0; i < n; i++) {
int p = pop[i];
if (p < 0) p = 0;
if (p > 100) p = 100;
int barH = (innerH * p) / 100;
int bx = x + leftPad + i * (barW + gap);
int by = y + topPad + (innerH - barH);
M5.Display.drawRect(bx, y + topPad, barW, innerH, TFT_BLACK);
if (barH > 2) M5.Display.fillRect(bx + 1, by + 1, barW - 2, barH - 2, TFT_BLACK);
}
M5.Display.setTextSize(1);
M5.Display.setCursor(x + 10, y + h - 12);
M5.Display.print("Precip prob (12h)");
}
// ====== VERTICAL SCREEN (PORTRAIT) ======
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;
// Header
M5.Display.setTextSize(2);
M5.Display.setCursor(PAD, PAD);
M5.Display.print("Weather Station");
M5.Display.setTextSize(1);
M5.Display.setCursor(PAD, PAD + 28);
M5.Display.print("Updated: ");
M5.Display.print(getLocalTimeString());
M5.Display.drawFastHLine(PAD, PAD + 48, W - PAD*2, TFT_BLACK);
if (!wd.ok) {
M5.Display.setTextSize(3);
M5.Display.setCursor(PAD, PAD + 80);
M5.Display.print("Failed to fetch data");
M5.Display.setTextSize(2);
M5.Display.setCursor(PAD, PAD + 130);
M5.Display.print("Check Wi-Fi / DNS");
M5.Display.endWrite();
return;
}
// Big temp + icon
int topY = PAD + 62;
M5.Display.setTextSize(7);
M5.Display.setCursor(PAD, topY);
M5.Display.printf("%.1fC", wd.tempC);
int iconSize = 120;
int iconX = W - PAD - iconSize;
int iconY = topY - 6;
drawWeatherIcon(iconX, iconY, iconSize, wd.wcode);
// Status
M5.Display.setTextSize(2);
M5.Display.setCursor(PAD, topY + 92);
M5.Display.print(weatherCodeToTextEN(wd.wcode));
// Metrics 2x2
int boxY = topY + 130;
int gap = 14;
int boxW = (W - PAD*2 - gap) / 2;
int boxH = 88;
int x0 = PAD;
int x1 = PAD + boxW + gap;
int y0 = boxY;
int y1 = boxY + boxH + gap;
drawBox(x0, y0, boxW, boxH);
M5.Display.setTextSize(2);
M5.Display.setCursor(x0 + 12, y0 + 10);
M5.Display.print("Feels");
M5.Display.setTextSize(3);
M5.Display.setCursor(x0 + 12, y0 + 40);
M5.Display.printf("%.1fC", wd.feelsC);
drawBox(x1, y0, boxW, boxH);
M5.Display.setTextSize(2);
M5.Display.setCursor(x1 + 12, y0 + 10);
M5.Display.print("Humidity");
M5.Display.setTextSize(3);
M5.Display.setCursor(x1 + 12, y0 + 40);
M5.Display.printf("%d%%", wd.humPct);
drawBox(x0, y1, boxW, boxH);
M5.Display.setTextSize(2);
M5.Display.setCursor(x0 + 12, y1 + 10);
M5.Display.print("Pressure");
M5.Display.setTextSize(3);
M5.Display.setCursor(x0 + 12, y1 + 40);
M5.Display.printf("%.0f", wd.pressHPa);
M5.Display.setTextSize(1);
M5.Display.setCursor(x0 + 12, y1 + 70);
M5.Display.print("hPa");
drawBox(x1, y1, boxW, boxH);
M5.Display.setTextSize(2);
M5.Display.setCursor(x1 + 12, y1 + 10);
M5.Display.print("Wind");
M5.Display.setTextSize(3);
M5.Display.setCursor(x1 + 12, y1 + 40);
M5.Display.printf("%.1f", wd.windMs);
M5.Display.setTextSize(1);
M5.Display.setCursor(x1 + 12, y1 + 70);
M5.Display.print("m/s");
// Hourly temp chart
int chartTitleY = y1 + boxH + 22;
M5.Display.setTextSize(2);
M5.Display.setCursor(PAD, chartTitleY);
M5.Display.print("Temp next 12h");
int chartY = chartTitleY + 26;
int chartH = 90;
drawSparkline(PAD, chartY, W - PAD*2, chartH, wd.hourlyTemp, HOURS);
// POP bars
int popY = chartY + chartH + 16;
int popH = 96;
drawPopBars(PAD, popY, W - PAD*2, popH, wd.hourlyPop, HOURS);
// 3-day min/max + footer
int bottomY = popY + popH + 14;
M5.Display.setTextSize(2);
M5.Display.setCursor(PAD, bottomY);
M5.Display.print("3 days (min/max):");
M5.Display.setCursor(PAD, bottomY + 24);
for (int d = 0; d < 3; d++) {
M5.Display.printf("%.0f/%.0f ", wd.dayMin[d], wd.dayMax[d]);
}
M5.Display.setTextSize(1);
M5.Display.setCursor(PAD, H - PAD);
M5.Display.printf("Next update in %d min (deep sleep)", UPDATE_MINUTES);
M5.Display.endWrite();
}
// ====== SLEEP ======
void goToSleepMinutes(int minutes) {
uint64_t us = (uint64_t)minutes * 60ULL * 1000000ULL;
esp_sleep_enable_timer_wakeup(us);
esp_deep_sleep_start();
}
// ====== SETUP / LOOP ======
void setup() {
auto cfg = M5.config();
M5.begin(cfg);
// Portrait (vertical)
M5.Display.setRotation(0);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
M5.Display.fillScreen(TFT_WHITE);
M5.Display.setTextColor(TFT_BLACK);
M5.Display.setTextSize(2);
M5.Display.setCursor(20, 20);
M5.Display.print("Connecting Wi-Fi...");
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 12000) {
delay(250);
}
if (TZ_INFO && strlen(TZ_INFO) > 0) {
setenv("TZ", TZ_INFO, 1);
tzset();
}
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
WeatherData wd;
if (WiFi.status() == WL_CONNECTED) wd.ok = fetchWeather(wd);
else wd.ok = false;
drawScreen(wd);
delay(700);
goToSleepMinutes(UPDATE_MINUTES);
}
void loop() {} Best practices 2025: стабильность, безопасность, производительность
Сеть и таймауты
- Ограничение времени подключения к Wi-Fi (например, 10–15 секунд) для предсказуемого старта
- HTTP timeout на запрос API (пример: 10 секунд) для защиты от зависаний
- Переход в deep sleep даже при ошибке, чтобы избежать разряда аккумулятора при деградации
Память и latency
- Фиксированный размер
StaticJsonDocumentпод ожидаемый JSON для снижения фрагментации - Минимизация количества полей в запросе Open-Meteo под реальные UI-блоки
- Единый проход по массивам с ограничением
min(size, HOURS)для защиты от изменения схемы
E-ink и ресурс дисплея
- Избегать частых обновлений: интервал 15–60 минут снижает количество перерисовок
- Держать UI монохромным и простым: линии/контуры дают читаемость и экономят время отрисовки
- Не выполнять анимации на e-ink
Секреты и публикация кода
- Вынос SSID/пароля в приватный конфиг, который исключён из публикации
- Использование build-флагов/профилей для разных окружений (дом/офис/полевой стенд)
Заключение
M5Stack PaperS3 позволяет собрать энергоэффективную онлайн-станцию погоды без локальных датчиков: Open-Meteo даёт прогноз без ключей, e-ink сохраняет изображение без питания, а deep sleep обеспечивает минимальное энергопотребление при регулярных обновлениях.









