Всем привет, в этой статье мы доработаем проект офлайн логгер из прошлой статьи. Добавим датчики и напиши обработчик логов с sd карты!
Введение
Итак, мне нужно знать температуру и влажность за окном и также температуру на выносном датчике со свободным местоположением. Будем использовать htu21d и ds18b20Приступим к подключению!
Подключение
Как вы могли заметить в прошлой статье, на плате есть несколько коннекторов, которые я не использовал. Они и были распаяны для подключения доп. модулей:
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-FiWebServer 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 objectHTU21D htu;#include <OneWire.h>#include <DallasTemperature.h>// Создаем объекты для работы с шиной OneWire и DallasTemperatureOneWire 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.0time after launch:14, temp: 30.2, MmHg: 741.6, humd: 62.6, temp IN: 29.1, temp OUT:26.3, humd OUT: 62.4time after launch:14, temp: 30.2, MmHg: 741.5, humd: 62.7, temp IN: 29.2, temp OUT:24.9, humd OUT: 61.9time after launch:14, temp: 30.2, MmHg: 741.6, humd: 62.8, temp IN: 29.2, temp OUT:26.3, humd OUT: 61.7time 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 pltimport seaborn as snsimport numpy as npsns.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()
Вот как выглядит график:

Итог
Итак, я сделал всё что мне было нужно!
Спасибо за внимание!
Пояснение
Мне нужно узнать показатели климата на протяжении нескольких суток в загородном доме где нет интернета.
ссылка на оригинал статьи https://habr.com/ru/articles/1067992/