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

Онлайн-станция погоды на M5Stack PaperS3 с Open-Meteo

Linux и DevOps

Реализация онлайн-станции погоды на M5Stack PaperS3 строится вокруг трёх задач: стабильное получение данных по Wi-Fi, аккуратная отрисовка на e-ink в портретной ориентации и предсказуемый deep sleep для минимального энергопотребления.

Данные берутся из Open-Meteo без ключей доступа, устройство отображает текущие параметры, 12-часовой график температуры, вероятность осадков и min/max на 3 дня.

Что умеет прошивка

  • Подключение к 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>&current=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 += "&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 "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 обеспечивает минимальное энергопотребление при регулярных обновлениях.

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