Всем привет, в этой статье мы доработаем проект офлайн логгер из прошлой статьи. Добавим датчики и напиши обработчик логов с sd карты!

Введение

Итак, мне нужно знать температуру и влажность за окном и также температуру на выносном датчике со свободным местоположением. Будем использовать htu21d и ds18b20Приступим к подключению!

ds18b20
ds18b20
HTU21D
HTU21D

Подключение

Как вы могли заметить в прошлой статье, на плате есть несколько коннекторов, которые я не использовал. Они и были распаяны для подключения доп. модулей:

Старое фото
Старое фото
Сейчас
Сейчас

Low-level код

Вот обновлённый код для esp.

// Пины SPI для ESP32-C3 Super Mini (GPIO)
#define SCK  4   // Serial Clock - тактовый сигнал шины SPI
#define MISO 5   // Master In Slave Out - линия данных от карты к контроллеру
#define MOSI 6   // Master Out Slave In - линия данных от контроллера к карте
#define CS   7   // Chip Select - выбор ведомого устройства (SD-карты)

// Указываем пин GPIO 1 для шины OneWire (выносной датчик температуры)
#define ONE_WIRE_BUS 1

// Период опроса датчиков и записи в мс (10 секунд). 
// Внимание: название макроса PRD_WRITE вводит в заблуждение, так как используется и для чтения.
#define PRD_WRITE 10000

#include <WiFi.h>
#include <WebServer.h>

const char *ssid = "ESPstation"; // Имя создаваемой точки доступа Wi-Fi

WebServer server(80); // Создание веб-сервера на стандартном порту HTTP (80)

#include "SPI.h"      // Библиотека аппаратного SPI
#include "SD.h"       // Библиотека для работы с SD-картами через FS API
#include "FS.h"       // Абстракция файловой системы Arduino

#include <GyverBME280.h> // Оптимизированная библиотека для датчика BME280 (температура, давление, влажность)
GyverBME280 bme;
#include <Wire.h>
#include "SparkFunHTU21D.h"

//Create an instance of the object
HTU21D htu;
#include <OneWire.h>
#include <DallasTemperature.h>

// Создаем объекты для работы с шиной OneWire и DallasTemperature
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);

#include <Adafruit_AHTX0.h> // Официальная библиотека Adafruit для AHT10/AHT20 (температура, влажность)
Adafruit_AHTX0 aht;

float dataBus[] = {0.0, 0.0, 0.0, 0.0, 0.0, 0.0};
/* Массив для хранения усредненных показаний:
  dataBus[0] — Температура внутри помещения (°C) [среднее BME + AHT]
  dataBus[1] — Атмосферное давление (мм рт. ст.)
  dataBus[2] — Влажность воздуха (%)
*/

void get_data() {
  sensors_event_t humidity, temp; // Структуры для получения сырых данных от I2C-датчика
  aht.getEvent(&humidity, &temp);
  sensors.requestTemperatures(); // Запуск асинхронного измерения температуры выносным датчиком

  /* Сборка финальных значений в массиве dataBus.
     Важно: температура берется как среднее арифметическое между двумя комнатными датчиками,
     чтобы сгладить погрешности измерений. */
  dataBus[0] = (temp.temperature + bme.readTemperature()) / 2.0;
  dataBus[1] = pressureToMmHg(bme.readPressure());
  dataBus[2] = humidity.relative_humidity;
  dataBus[3] = sensors.getTempCByIndex(0); // Выносной датчик (почва/уличный блок)
  dataBus[4] = htu.readTemperature();      // Резервный канал уличной температуры
  dataBus[5] = htu.readHumidity();         // Резервный канал уличной влажности

  // Открытие файла на SD-карте в режиме дозаписи (если файла нет — он создастся)
  File file = SD.open("/ESPstation_logs.txt", FILE_APPEND);

  if (file) {
    /* Запись данных БЕЗ использования класса String (экономия оперативной памяти!).
       Метод print() автоматически преобразует числа в текстовое представление.
       Использование F("...") поместит строковые литералы во флеш-память вместо RAM. */
    file.print(F("time after launch:"));
    file.print(millis() / 60000); // Время работы в минутах
    file.print(F(", temp: "));
    file.print(dataBus[0], 1);    // 1 знак после запятой
    file.print(F(", MmHg: "));
    file.print(dataBus[1], 1);
    file.print(F(", humd: "));
    file.print(dataBus[2], 1);
    file.print(F(", temp IN: "));
    file.print(dataBus[3], 1);
    file.print(F(", temp OUT:"));
    file.print(dataBus[4], 1);
    file.print(F(", humd OUT: "));
    file.print(dataBus[5], 1);
    file.println();               // Добавляем перенос строки (\n)

    file.close(); // Обязательно закрываем файл, иначе данные могут остаться в буфере и не записаться физически
  } else {
    Serial.println(F("Failed to open file for writing"));
  }
}

void handleRoot() {
  // Формирование HTML-страницы "на лету"
  String html = "<!DOCTYPE html>";
  html += "<html><head>";
  // Автоматическая перезагрузка страницы каждые 3 секунды для обновления данных без JS
  html += "<meta http-equiv='refresh' content='3'>";
  html += "<meta charset='UTF-8'>";

  // Инлайн-стили для оформления интерфейса метеостанции
  html += "<style>";
  html += "body { font-family: Montserrat, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #f0f4f8; }";
  html += "h1 { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }";
  html += "h2 { color: #34495e; margin-top: 30px; }";
  html += ".data-box { background: white; padding: 20px; border-radius: 10px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); margin: 15px 0; }";
  html += "</style>";
  html += "</head><body>";
  html += "<h1>ESPstation: локальная метеостанция</h1>";

  // Блок вывода комнатной температуры и влажности
  html += "<div class='data-box'>";
  html += "<h2>Комната</h2>";
  html += "<p>Температура: " + String(dataBus[0], 1) + " °C</p>";
  html += "<p>Влажность: " + String(dataBus[2], 1) + " %</p>";
  html += "</div>";

  // Блок вывода атмосферного давления
  html += "<div class='data-box'>";
  html += "<h2>Улица</h2>";
  html += "<p>Давление: " + String(dataBus[1], 1) + " мм рт. ст.</p>";
  html += "<p>Температура: " + String(dataBus[4]) + " °C</p>";
  html += "<p>Влажность: " + String(dataBus[5], 1) + " %</p>";
  html += "</div>";

  html += "<div class='data-box'>";
  html += "<h2>Выносной датчик</h2>";
  html += "<p>Температура: " + String(dataBus[3]) + " °C</p>";
  html += "</div>";

  html += "</body></html>";
  server.send(200, "text/html", html); // Отправка сформированного кода браузеру
}

void setup() {
  Serial.begin(115200); // Скорость монитора порта
  delay(2000);          // Задержка для корректного отображения сообщений при подаче питания
  Serial.println("\n=== Start ESPstation ===");

  // Инициализация SPI с явным указанием пинов (актуально для кастомных плат или переназначения GPIO)
  SPI.begin(SCK, MISO, MOSI, CS);
  sensors.begin();
  // Небольшая задержка для стабилизации питания карты после подачи напряжения
  delay(500);

  // Инициализация SD карты.
  // Указываем пин CS и максимальную частоту SPI (4 МГц для стабильности на макетке/длинных проводах)
  if (!SD.begin(CS, SPI, 4000000)) {
    Serial.println(F("Card Mount Failed! Check wiring and power."));
    return; // Выходим из setup, дальнейшая работа невозможна без накопителя
  }

  uint8_t cardType = SD.cardType();
  if (cardType == CARD_NONE) {
    Serial.println(F("No SD card attached"));
    return;
  }

  // Диагностика типа и объема установленной карты
  Serial.print(F("SD Card Type: "));
  if (cardType == CARD_MMC) Serial.println(F("MMC"));
  else if (cardType == CARD_SD) Serial.println(F("SDSC"));
  else if (cardType == CARD_SDHC) Serial.println(F("SDHC"));
  else Serial.println(F("UNKNOWN"));

  uint64_t cardSize = SD.cardSize() / (1024 * 1024);
  Serial.printf(F("SD Card Size: %lluMB\n"), cardSize);

  // Явная инициализация I2C ПЕРЕД использованием библиотек сенсоров
  Wire.begin();
  delay(100);

  // Инициализация BME280 по адресу 0x77 (адрес зависит от подтяжки пина SDO на плате)
  if (!bme.begin(0x77)) {
    Serial.println(F("BME280 not found!"));
  }

  // Инициализация AHT (автоматически ищет адрес на шине)
  if (!aht.begin()) {
    Serial.println(F("AHT not found!"));
  }

  htu.begin();

  // Поднятие собственной точки доступа (SoftAP). Станция подключается напрямую к модулю.
  WiFi.softAP(ssid);
  Serial.print(F("http://"));
  Serial.println(WiFi.softAPIP()); // Выводим IP-адрес точки доступа в монитор

  // Привязка корневого пути ("/") к функции генерации HTML
  server.on("/", handleRoot);
  server.begin(); // Запуск веб-сервера

  Serial.println(F("\nInitialization and test complete!"));
}

void loop() {
  static uint32_t tmr = 0; // Таймер периодических задач (статик сохраняет значение между итерациями)

  // Неблокирующий таймер: проверка раз в секунду (PRD_WRITE)
  if (millis() - tmr >= PRD_WRITE) {
    tmr = millis(); // Сброс опорной метки времени
    get_data();     // Чтение сенсоров и запись на SD
  }

  // Обработка входящих запросов от браузера (обязательно вызывать в цикле)
  server.handleClient();
}

Вот как выглядит лог:

time after launch:14, temp: 30.2, MmHg: 741.5, humd: 62.8, temp IN: 29.3, temp OUT:26.0, humd OUT: 62.0
time after launch:14, temp: 30.2, MmHg: 741.6, humd: 62.6, temp IN: 29.1, temp OUT:26.3, humd OUT: 62.4
time after launch:14, temp: 30.2, MmHg: 741.5, humd: 62.7, temp IN: 29.2, temp OUT:24.9, humd OUT: 61.9
time after launch:14, temp: 30.2, MmHg: 741.6, humd: 62.8, temp IN: 29.2, temp OUT:26.3, humd OUT: 61.7
time after launch:14, temp: 30.2, MmHg: 741.5, humd: 63.1, temp IN: 29.3, temp OUT:25.9, humd OUT: 63.1

Вот так теперь выглядит страница:

GUI на питоне

Вот код:

import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np

sns.set_theme(style="darkgrid")

class ViewData:
    def __init__(self, path):
        self.path = path
        self.count = 0
        self.tal = []
        self.temp = []
        self.mmhg = []
        self.humd = []
        self.tin = []
        self.tout = []
        self.humdout = []
        self.pairs = {}

    def init_data(self):
        count = 0

        with open(self.path, "r", encoding="utf8") as file:
            for line in file:
                count += 1
        self.tal = [0]*count
        self.temp = [0]*count
        self.mmhg = [0]*count
        self.humd = [0]*count
        self.tin = [0]*count
        self.tout = [0]*count
        self.humdout = [0]*count



    def readNparseLog(self):
        self.init_data()
        count = -1
        with open(self.path, "r", encoding="utf8") as file:
            for line in file:
                count +=1
                #        обрабатываешь одну строку
                ln = line.strip()
                for item in ln.split(","):
                    key, value = item.split(":", 1)  # 1 ограничивает количество разделений
                    self.pairs[key.strip()] = value.strip()
                self.tal[count] = float(self.pairs.get('time after launch'))
                self.temp[count] = float(self.pairs.get('temp', 0))
                self.mmhg[count] = float(self.pairs.get('MmHg', 0))
                self.humd[count] = float(self.pairs.get('humd', 0))
                self.tin[count] = float(self.pairs.get('temp IN', 0))
                self.tout[count] = float(self.pairs.get('temp OUT', 0))
                self.humdout[count] = float(self.pairs.get('humd OUT', 0))

    def viewData(self):
        fig, axs = plt.subplots(2, 3, figsize=(12, 8))
        axs = axs.flatten()

        # График 1
        sns.lineplot(x=self.tal, y=self.temp, ax=axs[0], color="blue")
        axs[0].set_title("Температура")

        # График 2
        sns.lineplot(x=self.tal, y=self.mmhg, ax=axs[1], color="green")
        axs[1].set_title("Давление")

        # График 3
        sns.lineplot(x=self.tal, y=self.humd, ax=axs[2], color="red")
        axs[2].set_title("Влажность")

        # График 4
        sns.lineplot(x=self.tal, y=self.tin, ax=axs[3], color="orange")
        axs[3].set_title("Данные на выносном датчике")

        # График 5
        sns.lineplot(x=self.tal, y=self.tout, ax=axs[4], color="cyan")
        axs[4].set_title("Температура на улице")

        # График 6
        sns.lineplot(x=self.tal, y=self.humdout, ax=axs[5], color="purple")
        axs[5].set_title("Влажность на улице")

        plt.tight_layout()
        plt.show()


def main():
    print("Hello from viewdatams!")
    vd = ViewData("ESPstation_logs.txt")

    vd.readNparseLog()
    
    vd.viewData()

if __name__ == "__main__":
    main()

Вот как выглядит график:

Итог

Итак, я сделал всё что мне было нужно!

Спасибо за внимание!

Пояснение

Мне нужно узнать показатели климата на протяжении нескольких суток в загородном доме где нет интернета.