
Про измерение CO2 и его важность на Geektimes было уже немало публикаций (ссылки в конце статьи). Здесь же хочу описать проект компактного монитора уровня CO2, а также температуры, влажности и давления с Wi-Fi, обновлением прошивки по воздуху и интерфейсом в мобильном приложении. Сердца системы модуль на базе esp8266, сенсор CO2 MH-Z19 и фреймворк esp8266-arduino. И так, включим устройство в USB-розетку:

Cперва оно попытается подключиться к сохраненной ранее сети и успешно выполнив подключение, начинает работу. Если же wifi сеть не обнаружится, то монитор загрузится в режиме ожидания настройки и поднимет точку доступа. Подключившись к ней и открыв в браузере http://192.168.4.1 можно указать данные реальной сети, а также ключ для подключения к серверу и имя устройства.

На сервере и для мобильного клиента использован открытый проект Blynk. Он уже упоминался пару раз на Geektimes. Это opensource сервер, который имеет библиотеки для разных встраиваемых систем и позволяет в несколько строчек подключить общение со сторонним сервисом. Можно установить его на свой сервер, например запустив docker-контейнер, a можно использовать и публичный сервер разработчика.
Интерфейс для blynk строится из готовых кирпичиков в мобильном приложении доступном для iOS и Android. Web-интерфейса, к сожалению нет. Через него можно посмотреть историю измерения всех параметров, настроить нотификации или web hook на по условию (здесь — превышению уровня CO2), обновить прошивку и сброситься к настройкам по умолчанию.
Blynk позволяет распространять приложения через внушительных размеров QR-код, в котором сохранена конфигурация всех модулей.


Как собрать подобное устройство?
Для начала нужно купить все необходимые модули, например, на aliexpress. Поскольку конкретные предложения и продавцы постоянно меняются, поэтому в таблице указаны лишь поисковые запросы, по которым найдется то, что нужно.
| Компонент | Поисковая фраза | Ориентировочная цена |
|---|---|---|
| Esp-12 NodeMCU модуль* | esp8266 nodemcu cp2102 | $3.50 |
| Датчик CO2 mh-z19 | mh-z19 | $22 |
| Датчик давления**,*** | bmp280 | $1 |
| Датчик температуры и влажности**,*** | gy-21 SI7021 i2c | $2.2 |
| Экран | 0.96" 128x64 i2c OLED blue | $2.7 |
| Провода | dupont female-female 10cm | $1 |
| 3д-печать корпуса | - | $8 |
| Итого: | $40.4 |
* — На китайском рынке существуют две версии модуля NodeMCU. Они отличаются в первую очередь USB-UART преобразователем cp2102 и ch340g, но и размером — версия с ch340g шире на 3 мм и уже не влезет в этот корпус. Также, версия с cp2102 может быть интереснее, потому что у этого преобразователя нет проблем с драйверами под любую операционку.
** — В текущей версии использовался bmp-085, который сейчас уже не купить. Но модули с bmp-280 имеют те же размеры и все, что нужно сделать, это заменить используемую библиотеку на https://github.com/adafruit/Adafruit_BMP280_Library — все методы у них одинаковые.
*** — Вместо пары датчиков bmp280+si7021, можно использовать один bme280, который измеряет все три показателя: температуру, влажность и давление. На aliexpress его можно найти за $3.5
Корпус
Хотелось сделать с одной стороны устройство из готовых модулей, с другой не хотелось делать его размером с буханку хлеба. Для этого в Tinkercad был нарисован корпус пригодный для 3д-печати. Большая проблема tinkercad в том, что очень тяжело вносить изменения в уже готовые проекты. Если бы я начинал сейчас, то воспользовался бы openSCAD

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

Недостаток такой плотной компановки в том, что тепло от микроконтроллера и dc-dc преобразователя нагревают воздух внутри на ~3 градуса выше реальной температуры, это же вредит точности показаний влажности. С другой стороны это создает конвекцию, которая обновляет воздух в сенсоре CO2 быстрее.
Отчасти эту проблему можно решить, поработав над потреблением микроконтроллера, например реже передавать данные по сети.
Подключение
Электрическая схема очень проста — датчик давления, датчик температуры и влажности и дисплей питаются от 3.3в и подключаются через двухпроводной интерфейс I2C. Он позволяет подключать все устройства параллельно: SDA каждого устройства к ножке D1 (GPIO5), а SCL — к ножке D2 (GPIO4)
Датчик CO2 питается от 5в (в нашем случае Vin — питание от USB входа) и подключается через UART. Для этого используются ноги D7 (GPIO13) — RX (который подключается к TX сенсора) и D8 (GPIO15) — TX (который подключается к RX сенсора)

Программная часть
Все файлы проекта были разработаны в среде разработки PlatformIO и лежат на Github-e.
#include <FS.h> #include <Arduino.h> #include <ESP8266WiFi.h> //https://github.com/esp8266/Arduino // Wifi Manager #include <DNSServer.h> #include <ESP8266WebServer.h> #include <WiFiManager.h> //https://github.com/tzapu/WiFiManager // HTTP requests #include <ESP8266HTTPClient.h> // OTA updates #include <ESP8266httpUpdate.h> // Blynk #include <BlynkSimpleEsp8266.h> // JSON #include <ArduinoJson.h> //https://github.com/bblanchon/ArduinoJson // GPIO Defines #define I2C_SDA 5 // D1 Orange #define I2C_SCL 4 // D2 Yellow // Humidity/Temperature SI7021 #include <SI7021.h> SI7021 si7021; #include <Wire.h> // Pressure and Temperature #include <Adafruit_BMP085.h> // Use U8g2 for i2c OLED Lib #include <SPI.h> #include <U8g2lib.h> U8G2_SSD1306_128X64_NONAME_F_SW_I2C u8g2(U8G2_R0, I2C_SCL, I2C_SDA, U8X8_PIN_NONE); byte x {0}; byte y {0}; // Handy timers #include <SimpleTimer.h> // CO2 SERIAL #define DEBUG_SERIAL Serial1 #define SENSOR_SERIAL Serial byte cmd[9] = {0xFF,0x01,0x86,0x00,0x00,0x00,0x00,0x00,0x79}; unsigned char response[7]; // Pressure and temperature Adafruit_BMP085 bme; // Blynk token char blynk_token[33] {"Blynk token"}; char blynk_server[64] {"blynk-cloud.com"}; const uint16_t blynk_port {8442}; // Device Id char device_id[17] = "Device ID"; const char fw_ver[17] = "0.1.0"; // Handy timer SimpleTimer timer; // Setup Wifi connection WiFiManager wifiManager; // Network credentials String ssid { "ku_" + String(ESP.getChipId())}; String pass {"ku_" + String(ESP.getFlashChipId()) }; //flag for saving data bool shouldSaveConfig = false; // Sensors data int t {-100}; int p {-1}; int h {-1}; int co2 {-1}; char loader[4] {'.'}; //callback notifying the need to save config void saveConfigCallback() { DEBUG_SERIAL.println("Should save config"); shouldSaveConfig = true; } void factoryReset() { wifiManager.resetSettings(); SPIFFS.format(); ESP.reset(); } void printString(String str) { DEBUG_SERIAL.println(str); } void readCO2() { // CO2 bool header_found {false}; char tries {0}; SENSOR_SERIAL.write(cmd, 9); memset(response, 0, 7); // Looking for packet start while(SENSOR_SERIAL.available() && (!header_found)) { if(SENSOR_SERIAL.read() == 0xff ) { if(SENSOR_SERIAL.read() == 0x86 ) header_found = true; } } if (header_found) { SENSOR_SERIAL.readBytes(response, 7); byte crc = 0x86; for (char i = 0; i < 6; i++) { crc+=response[i]; } crc = 0xff - crc; crc++; if ( !(response[6] == crc) ) { DEBUG_SERIAL.println("CO2: CRC error: " + String(crc) + " / "+ String(response[6])); } else { unsigned int responseHigh = (unsigned int) response[0]; unsigned int responseLow = (unsigned int) response[1]; unsigned int ppm = (256*responseHigh) + responseLow; co2 = ppm; DEBUG_SERIAL.println("CO2:" + String(co2)); } } else { DEBUG_SERIAL.println("CO2: Header not found"); } } void sendMeasurements() { // Read data // Temperature float tf = si7021.getCelsiusHundredths() / 100.0; float t2f =bme.readTemperature(); t = static_cast<int>((tf + t2f) / 2); // Humidity h = si7021.getHumidityPercent(); // Pressure (in mmHg) p = static_cast<int>(bme.readPressure() * 760.0 / 101325); // CO2 readCO2(); // Send to server Blynk.virtualWrite(V1, t); Blynk.virtualWrite(V2, h); Blynk.virtualWrite(V4, p); Blynk.virtualWrite(V5, co2); // Write to debug console printString("H: " + String(h) + "%"); printString("T: " + String(t) + "C"); printString("P: " + String(p) + "mmHg"); printString("CO2: " + String(co2) + "ppm"); } void loading() { long unsigned int count {(millis() / 500) % 4}; memset(loader, '.', count); memset(&loader[count], 0, 1); } void draw() { u8g2.clearBuffer(); // CO2 if (co2 > -1) { char co2a [5]; sprintf (co2a, "%i", co2); u8g2.setFont(u8g2_font_inb19_mf); x = (128 - u8g2.getStrWidth(co2a))/2; y = u8g2.getAscent() - u8g2.getDescent(); u8g2.drawStr(x, y, co2a); const char ppm[] {"ppm CO2"}; u8g2.setFont(u8g2_font_6x12_mf); x = (128 - u8g2.getStrWidth(ppm)) / 2; y = y + 2 + u8g2.getAscent() - u8g2.getDescent(); u8g2.drawStr(x, y, ppm); } else { loading(); u8g2.setFont(u8g2_font_inb19_mf); x = (128 - u8g2.getStrWidth(loader)) / 2; y = u8g2.getAscent() - u8g2.getDescent(); u8g2.drawStr(x, y, loader); } // Cycle other meauserments String measurement {"..."}; const char degree {176}; // Switch every 3 seconds switch((millis() / 3000) % 3) { case 0: if (t > -100) { measurement = "T: " + String(t) + degree + "C"; } break; case 1: if (h > -1) { measurement = "H: " + String(h) + "%"; } break; default: if (p > -1) { measurement = "P: " + String(p) + " mmHg"; } } char measurementa [12]; measurement.toCharArray(measurementa, 12); u8g2.setFont(u8g2_font_9x18_mf); x = (128 - u8g2.getStrWidth(measurementa))/2; y = 64 + u8g2.getDescent(); u8g2.drawStr(x, y, measurementa); u8g2.sendBuffer(); } void drawBoot(String msg = "Loading...") { u8g2.clearBuffer(); u8g2.setFont(u8g2_font_9x18_mf); x = (128 - u8g2.getStrWidth(msg.c_str())) / 2; y = 32 + u8g2.getAscent() / 2; u8g2.drawStr(x, y, msg.c_str()); u8g2.sendBuffer(); } void drawConnectionDetails(String ssid, String pass, String url) { String msg {""}; u8g2.clearBuffer(); msg = "Connect to WiFi:"; u8g2.setFont(u8g2_font_7x13_mf); x = (128 - u8g2.getStrWidth(msg.c_str())) / 2; y = u8g2.getAscent() - u8g2.getDescent(); u8g2.drawStr(x, y, msg.c_str()); msg = "net: " + ssid; x = (128 - u8g2.getStrWidth(msg.c_str())) / 2; y = y + 1 + u8g2.getAscent() - u8g2.getDescent(); u8g2.drawStr(x, y, msg.c_str()); msg = "pw: "+ pass; x = (128 - u8g2.getStrWidth(msg.c_str())) / 2; y = y + 1 + u8g2.getAscent() - u8g2.getDescent(); u8g2.drawStr(x, y, msg.c_str()); msg = "Open browser:"; x = (128 - u8g2.getStrWidth(msg.c_str())) / 2; y = y + 1 + u8g2.getAscent() - u8g2.getDescent(); u8g2.drawStr(x, y, msg.c_str()); // URL // u8g2.setFont(u8g2_font_6x12_mf); x = (128 - u8g2.getStrWidth(url.c_str())) / 2; y = y + 1 + u8g2.getAscent() - u8g2.getDescent(); u8g2.drawStr(x, y, url.c_str()); u8g2.sendBuffer(); } bool loadConfig() { File configFile = SPIFFS.open("/config.json", "r"); if (!configFile) { DEBUG_SERIAL.println("Failed to open config file"); return false; } size_t size = configFile.size(); if (size > 1024) { DEBUG_SERIAL.println("Config file size is too large"); return false; } // Allocate a buffer to store contents of the file. std::unique_ptr<char[]> buf(new char[size]); // We don't use String here because ArduinoJson library requires the input // buffer to be mutable. If you don't use ArduinoJson, you may as well // use configFile.readString instead. configFile.readBytes(buf.get(), size); StaticJsonBuffer<200> jsonBuffer; JsonObject &json = jsonBuffer.parseObject(buf.get()); if (!json.success()) { DEBUG_SERIAL.println("Failed to parse config file"); return false; } // Save parameters strcpy(device_id, json["device_id"]); strcpy(blynk_token, json["blynk_token"]); } void configModeCallback (WiFiManager *wifiManager) { String url {"http://192.168.4.1"}; printString("Connect to WiFi:"); printString("net: " + ssid); printString("pw: "+ pass); printString("Open browser:"); printString(url); printString("to setup device"); drawConnectionDetails(ssid, pass, url); } void setupWiFi() { //set config save notify callback wifiManager.setSaveConfigCallback(saveConfigCallback); // Custom parameters WiFiManagerParameter custom_device_id("device_id", "Device name", device_id, 16); WiFiManagerParameter custom_blynk_server("blynk_server", "Blynk server", blynk_server, 64); WiFiManagerParameter custom_blynk_token("blynk_token", "Blynk token", blynk_token, 34); wifiManager.addParameter(&custom_blynk_server); wifiManager.addParameter(&custom_blynk_token); wifiManager.addParameter(&custom_device_id); // wifiManager.setTimeout(180); wifiManager.setAPCallback(configModeCallback); if (!wifiManager.autoConnect(ssid.c_str(), pass.c_str())) { DEBUG_SERIAL.println("failed to connect and hit timeout"); } //save the custom parameters to FS if (shouldSaveConfig) { DEBUG_SERIAL.println("saving config"); DynamicJsonBuffer jsonBuffer; JsonObject &json = jsonBuffer.createObject(); json["device_id"] = custom_device_id.getValue(); json["blynk_server"] = custom_blynk_server.getValue(); json["blynk_token"] = custom_blynk_token.getValue(); File configFile = SPIFFS.open("/config.json", "w"); if (!configFile) { DEBUG_SERIAL.println("failed to open config file for writing"); } json.printTo(DEBUG_SERIAL); json.printTo(configFile); configFile.close(); //end save } //if you get here you have connected to the WiFi DEBUG_SERIAL.println("WiFi connected"); DEBUG_SERIAL.print("IP address: "); DEBUG_SERIAL.println(WiFi.localIP()); } // Virtual pin update FW BLYNK_WRITE(V22) { if (param.asInt() == 1) { DEBUG_SERIAL.println("Got a FW update request"); char full_version[34] {""}; strcat(full_version, device_id); strcat(full_version, "::"); strcat(full_version, fw_ver); t_httpUpdate_return ret = ESPhttpUpdate.update("http://romfrom.space/get", full_version); switch (ret) { case HTTP_UPDATE_FAILED: DEBUG_SERIAL.println("[update] Update failed."); break; case HTTP_UPDATE_NO_UPDATES: DEBUG_SERIAL.println("[update] Update no Update."); break; case HTTP_UPDATE_OK: DEBUG_SERIAL.println("[update] Update ok."); break; } } } // Virtual pin reset settings BLYNK_WRITE(V23) { factoryReset(); } void setup() { // Init serial ports DEBUG_SERIAL.begin(115200); SENSOR_SERIAL.begin(9600); SENSOR_SERIAL.swap(); // GPIO15 (TX) and GPIO13 (RX) // Init I2C interface Wire.begin(I2C_SDA, I2C_SCL); // Init display u8g2.begin(); drawBoot(); // Init Humidity/Temperature sensor si7021.begin(I2C_SDA, I2C_SCL); // Init Pressure/Temperature sensor if (!bme.begin()) { DEBUG_SERIAL.println("Could not find a valid BMP085 sensor, check wiring!"); } // Init filesystem if (!SPIFFS.begin()) { DEBUG_SERIAL.println("Failed to mount file system"); ESP.reset(); } // Setup WiFi setupWiFi(); // Load config drawBoot(); if (!loadConfig()) { DEBUG_SERIAL.println("Failed to load config"); factoryReset(); } else { DEBUG_SERIAL.println("Config loaded"); } // Start blynk Blynk.config(blynk_token, blynk_server, blynk_port); // Setup a function to be called every 10 second timer.setInterval(10000L, sendMeasurements); sendMeasurements(); } void loop() { Blynk.run(); timer.run(); draw(); }
Разработка сильно упростилась благодаря наличию хороших примеров и наличию функциональных библиотек:
- Работа с подключением к WiFi и сбор конфигурации
- Работа с монохромным дисплеем
- Библиотека датчика температуры и влажности
- Библиотека датчика давления
Для работы с CO2 датчиком был использован чутка улучшенный пример из datasheet-а.
Что можно сделать лучше?
Здесь есть еще над чем поработать:
1) Решить проблему избыточного тепла в корусе
2) Сделать его еще компактнее — разведя плату и избавившись от проводов
3) Сделать работу в offline режиме
Что еще можно сделать? Какие у вас идеи? Если кто-то соберется сделать что-то подобное буду рад помочь и ответить на вопросы.
