Что будем делать

Давно хотел посмотреть в работе популярный у самодельщиков сенсор качества воздуха CCS811 и сравнить его показания с SGP30 или SGPC3. Важно понимать, что это сенсор TVOC и номинальное присутствие eCO2 не делает его сенсором углекислого газа.

Тестовый стенд у меня построен на контроллере Wiren Board с родным протоколом Modbus RTU, поэтому и моё устройство будет работать по этому протоколу.

Сперва мы с вами сделаем простое Modbus RTU устройство с изменяемыми настройками подключения, которое потом может послужить основой для других разработок.

Потом мы подключим сенсор CCS811 и научимся сохранять BaseLine.

И в самом конце подключим устройство к контроллеру Wiren Board: составим шаблон для драйвера wb‑mqtt‑serial и напишем простой сценарий автоматизации.

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

Важно! Я программист-самозванец, поэтому весь код и советы стоит рассматривать через призму критического мышления.

Что за зверь Modbus RTU

Modbus RTU — это промышленный протокол связи между устройствами, работающий поверх шины RS-485.

Контроллер автоматизации обычно является клиентом, а периферия — серверами. Ещё года два назад клиент был мастером (master), а сервер слейвом (slave), но разработчик протокола изменил названия.

Серверы имеют свой уникальный адрес в рамках одного сегмента шины и много modbus‑регистров, то есть ячеек памяти со своими адресами и типами данных, доступных снаружи.

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

Архитектура Клиент-Сервер, которая лежит в основе протокола Modbus

Доступные нам типы регистров:

  • Coils (1 бит, чтение‑запись, 1 или 0) — дискретный выход, не используем.

  • Discrete Input (1 бит, только чтение, 1 или 0) — дискретный вход, используем для ошибок инициализации.

  • Input Register (16 бит, только чтение, 0...65 535) — регистр ввода, используем для данных с датчика.

  • Holding (16 бит, чтение‑запись, 0...65 535) — будем хранить настройки связи.

Для каждого типа регистра есть своя функция работы с ним, я расскажу только про те, которые будем использовать:

  • 0×02 — чтение Discrete Input.

  • 0×03 — чтение Holding.

  • 0×04 — чтение Input Register

  • 0×06 — запись Holding.

Этой информации нам достаточно, чтобы начать работу, подробную информацию о протоколе можно найти в этой статье.

Железки

Теперь, когда нам понятна задача, выбираем железки.

Нам понадобится сенсор качества воздуха (TVOC) — я возьму CCS811.

В качестве микроконтроллера для устройства я возьму ESP8266 — он довольно недорогой, имеет много периферии, вполне производительный и он у меня есть. Чтобы было удобно собирать прототип, я возьму платку NodeMCU с модулем ESP12F (ESP8266), но вы можете использовать почти любой доступный вариант с нужным количеством выведенных наружу GPIO.

Распиновка NodeMCU, взято с lastminuteengineers.com

Чтобы подключить ESP8266 к шине RS-485, нам понадобится преобразователь уровней UART → RS-485, например, собранный на микросхеме MAX485 HW-97 и его многочисленные китайские братья.

Ещё нам понадобится преобразователь USB‑RS485, чтобы опрашивать наше устройство и проверять его работу. Можно использовать абсолютно любой с Aliexpress, я возьму WB‑USB485. Во время разработки плату я буду питать от USB‑порта напряжением 5 В.

Для реализации сервисного режима нам понадобится любая тактовая кнопка, на схеме она подключена к D5 и GND.

Железки собрали, подключаем по схеме ниже и переходим к самому интересному — созданию прошивки устройства. Сенсор CCS811 пока лежит в сторонке.

Схема подключения
Макетная плата на столе

Прошивка

Я программист-самозванец, поэтому писать прошивку устройства буду в среде Arduino, которая позволяет писать программы (скетчи) для Embedded на языке C++.

Подготовка среды разработки

Писать мы будем под ESP8266, которой нет в стандартной поставке среды, поэтому перед началом надо сделать пару шагов:

  1. Скачиваем и устанавливаем Arduino.

  2. Добавляем в менеджер плат ESP8266, инструкция.

  3. Выбираем плату в меню Инструменты → Плата → Generic ESP8266 Module или плату, которая у вас на руках.

  4. И устанавливаем нужную нам библиотеку: меню Инструменты → Управлять библиотеками. В менеджере библиотек ищем и устанавливаем modbus‑esp8266.

Всё, мы готовы творить. В коде я указывал номера GPIO, а не специфичные для моей платы имена выводов — по задумке, это должно облегчить использование других плат.

Простой Modbus-сервер

Сделаем modbus‑сервер с адресом 1, параметрами подключения 9600 8N2, в котором будет один тестовый holding‑регистр с числом 3.

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

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

// Подключаем библиотеку modbus-esp8266
#include <ModbusRTU.h>

// Настройка Modbus
#define SLAVE_ID 1 // адрес нашего сервера
#define PIN_FLOW 4 // пин контроля направления приёма/передачи,
// если в вашем преобразователе UART-RS485 такого нет —
// закоменнтируйте строку

// Номера Modbus регистров
#define REG_TEST 0       // тестовый регистр с номером 0

ModbusRTU mb;

void setup() {
  modbus_setup();
}

void loop() {
  mb.task();
}

void modbus_setup() {
  Serial.begin(9600, SERIAL_8N2); // задаём парамеры связи
  mb.begin(&Serial);
  mb.begin(&Serial, PIN_FLOW); // включаем контроль направления приёма/передачи
  mb.slave(SLAVE_ID); // указываем адрес нашего сервера

  mb.addHreg(REG_TEST); // описываем регистр REG_TEST типа Holding
  mb.Hreg(REG_TEST, 3); // записываем в наш регистр REG_TEST число 3 —
  // его мы должны будем увидеть при опросе устройства
}

Ссылка на файл ver1.ino.

Подключаем свою плату к компьютеру и нажимаем Ctrl+U — это скомпилировать скетч и залить его в контроллер.

Вот они, заветные слова:

Leaving...
Hard resetting via RTS pin...

А что дальше? Светодиод никакой мы не подключали, экрана нет — как понять, что всё работает? Да просто — подключиться по Modbus и посмотреть.

Для этого нам понадобится программа для работы с Modbus RTU. В зависимости от типа операционной системы могу порекомендовать:

  • Windows: Rilheva Modbus Poll.

  • Linux: консольный modbus_client или графический QMaster.

  • MacOS давно не работал, но говорят, что там есть утилита cu.

Принцип работы всех программ один: подключаете к компьютеру устройство через переходник USB‑RS485, запускаете программу, указываете параметры подключения, адрес устройства, номер нужного регистра и функцию, с помощью которой надо читать или записывать байтики.

У нас это:

  • Параметры подключения — 9600 8N2

  • Адрес — 1

  • Регистр Holding с адресом 0.

Обычно в программах надо выбрать функцию чтения/записи регистра. Для holding‑регистров это 0×03 — чтение и 0×06 запись.

Я использую Linux, поэтому буду работать с устройством через modbus_client, оно и нагляднее. Подключённое устройство определилось на порту dev/ttyACM0 — у вас он может быть другой.

Считаем наш регистр:

$ modbus_client -mrtu -b9600 -d8 -pnone -s2 /dev/ttyACM0 -a1 -t0x03 -r0

SUCCESS: read 1 of elements:
        Data: 0x0003 

А теперь запишем туда что-нибудь своё, например, число 1 и не забудем сменить функцию с 0x03 (чтение holding) на 0x06 (запись holding):

$ modbus_client -mrtu -b9600 -d8 -pnone -s2 /dev/ttyACM0 -a1 -t0x06 -r0 1                         

SUCCESS: written 1 elements!

И снова считаем:

modbus_client -mrtu -b9600 -d8 -pnone -s2 /dev/ttyACM0 -a1 -t0x03 -r0                                       

SUCCESS: read 1 of elements:
        Data: 0x0001

Ура! Теперь в регистре у нас единичка. Таким примитивным способом контроллер может общаться с парой сотен устройств на шине.

Проверка скетча ver1.ino

Изменяемые настройки связи

Казалось бы, добавляй сенсор и всё готово! Но нет, есть один важный момент: у нашего устройства фиксированные настройки связи, а значит для изменения настроек нам придётся каждый раз собирать прошивку и это неудобно.

Чтобы пользователь нашего устройства не мучился, дадим ему возможность менять настройки связи через Modbus‑регистры. Так он без проблем сможет использовать наше устройство на одной шине с другими.

Чаще всего нужно менять скорость подключения, количество стоповых битов и адрес, поэтому нам понадобиться сделать три holding‑регистра, дадим им номера подальше от нашего тестового, чтобы случайно их не изменить:

// Адреса Modbus-регистров с настройками связи
#define REG_MB_ADDRESS    100 // адрес устройства на шине
#define REG_MB_STOP_BITS  101 // количество стоповых битов
#define REG_MB_BAUDRATE   102 // скорость подключения

Так как при отключении питания значения регистров сбрасываются, то нам понадобится место, где мы будем хранить настройки — EEPROM:

#define EEPROM_SIZE           6 // мы займём 6 ячеек памяти: 3*2=6
#define EEPROM_MB_ADDRESS     0 // номер ячейки с адресом устройства
#define EEPROM_MB_STOP_BITS   2 // номер ячейки со стоп-битами
#define EEPROM_MB_BAUDRATE    4 // номер ячейки со скоростью

На ESP8266/ESP32 тип данных uint16_t занимает 4 байта, то есть является аналогом unsigned long (источник). Поэтому каждое число займёт две ячейки EEPROM, а значит нам нужно будет 6 ячеек.

Ещё не забудем в самом начале программы описать значение настроек по умолчанию, у меня будет 9600 8N2:

#define DEFAULT_MB_ADDRESS    1  // адрес нашего сервера
#define DEFAULT_MB_STOP_BITS  2  // количество стоповых битов
#define DEFAULT_MB_BAUDRATE   96 // скорость подключения/100

Максимальное значение скорости 115 200 — это больше максимального значения, которое помещается в один регистр: 65 535. Чтобы не разбивать значение на два регистра, мы будем хранить и использовать при настройке значение скорости, поделённое на 100, например, при скорости 9600 у нас будет храниться 9600/100 = 96.

Теперь объявим переменные, куда мы будем читать настройки связи из EEPROM и откуда их будем записывать в modbus‑регистры, а также использовать при настройке подключения:

uint16_t mbAddress = DEFAULT_MB_ADDRESS; // modbus адрес устройства
uint16_t mbStopBits = DEFAULT_MB_STOP_BITS; // количество стоповых битов
uint16_t mbBaudrate = DEFAULT_MB_BAUDRATE; // скорость подключения modbus

Осталось три шага: чтение настроек из EEPROM, создание holding-регистров и запись настроек в EEPROM при изменении значений в регистрах.

Перед использованием EEPROM нужно инициализировать:

void eeprom_setup() {
  EEPROM.begin(EEPROM_SIZE);
}

Для чтения из EEPROM напишем функцию read_modbus_settings(), если в ячейках EEPROM пусто, то записываем в переменные значения по умолчанию:

void read_modbus_settings() {
  EEPROM.get(EEPROM_MB_ADDRESS, mbAddress);
  if (mbAddress == 0xffff) {
    mbAddress = DEFAULT_MB_ADDRESS;
  }

  EEPROM.get(EEPROM_MB_STOP_BITS, mbStopBits);
  if (mbStopBits == 0xffff) {
    mbStopBits = DEFAULT_MB_STOP_BITS;
  }

  EEPROM.get(EEPROM_MB_BAUDRATE, mbBaudrate);
  if (mbBaudrate == 0xffff) {
    mbBaudrate = DEFAULT_MB_BAUDRATE;
  };
}

Обратите внимание, я использую функцию get, а не read — это, чтобы не считать ячейки. Она сразу посмотрит размер переменных и считывает столько ячеек, сколько нужно.

Далее описываем функцию настройки modbus modbus_setup():

void modbus_setup() {
  Serial.begin(convert_baudrate(mbBaudrate), convert_stop_bits_to_config(mbStopBits)); // задаём парамеры связи
  mb.begin(&Serial);
  mb.begin(&Serial, PIN_FLOW); // включаем контроль направления приёма/передачи
  mb.slave(mbAddress); // указываем адрес нашего сервера

  // описываем три holding регистра
  mb.addHreg(REG_MB_ADDRESS);   // адрес устройства на шине
  mb.addHreg(REG_MB_STOP_BITS); // стоповые биты
  mb.addHreg(REG_MB_BAUDRATE);  // скорость подключения

  // записываем в регистры значения адреса, стоповых битов и скорости
  mb.Hreg(REG_MB_ADDRESS, mbAddress);
  mb.Hreg(REG_MB_STOP_BITS, mbStopBits);
  mb.Hreg(REG_MB_BAUDRATE, mbBaudrate);

  // описываем колбек функцию, которая будет вызвана при записи регистров
  // параметров подключения
  mb.onSetHreg(REG_MB_ADDRESS, callback_set_mb_reg);
  mb.onSetHreg(REG_MB_STOP_BITS, callback_set_mb_reg);
  mb.onSetHreg(REG_MB_BAUDRATE, callback_set_mb_reg);
}

Здесь мы описали инициализацию соединения, добавили holding‑регистры, отправили в них значения переменных, которые мы записали в функции read_modbus_settings().

convert_stop_bits_to_config — это функция, которая прикрывает мою лень полностью описывать настройки подключения. Суть в том, что реализация Serial в Adruino не даёт удобной настройки каждого параметра подключения отдельно (или я не нашёл), а заставляет всё, кроме адреса и скорость упаковывать в конструкцию типа SerialConfig. А так, как настройки чётности и количества битов данных всё равно почти не меняют, я решил сделать просто:

SerialConfig convert_stop_bits_to_config(uint16_t stopBits) {
  return (stopBits == 2) ? SERIAL_8N2 : SERIAL_8N1;
}

Ещё выше мы добавили свою реакцию на запись значений в три регистра: адрес, стоповые биты и скорость. Вызываемая при этих событиях колбек функция будет выглядеть так:

uint16_t callback_set_mb_reg(TRegister* reg, uint16_t val) {
  switch (reg->address.address) {
    case REG_MB_ADDRESS: // если записываем регистр с адресом
      if (val > 0 && val < 247) { // проверяем, что записываемое число корректно
        write_eeprom(EEPROM_MB_ADDRESS, val); // записываем значение в EEPROM
      } else {
        val = reg->value; // этот трюк сгенерирует ошибку записи, что нам и нужно, так как значение неверное
      }
      break;
    case REG_MB_STOP_BITS: // если регистр со стоповыми битами
      if (val == 1 || val == 2) {
        write_eeprom(EEPROM_MB_STOP_BITS, val);
      } else {
        val = reg->value;
      }
      break;
    case REG_MB_BAUDRATE: // если регистр со скоростью
      uint16_t correctBaudRates[] = {12, 24, 48, 96, 192, 384, 576, 1152};
      if (contains(val, correctBaudRates, 8)) {
        write_eeprom(EEPROM_MB_BAUDRATE, val);
      } else {
        val = reg->value;
      }
      break;
  }
  return val;
}

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

Ну и в коде настроек modbus ещё засветилась пара вспомогательных функций: запись значения в EEPROM и проверка вхождения числа в массив.

// Запись значения в EEPROM
void write_eeprom(uint8_t eepromm_address, uint16_t val) {
  EEPROM.put(eepromm_address, val);
  EEPROM.commit();
}

// Функция, которая находит вхождение числа в массив
bool contains(uint16_t a, uint16_t arr[], uint8_t arr_size) {
  for (uint8_t i = 0; i < arr_size; i++) if (a == arr[i]) return true;
  return false;
}

Так как мы инициализацию всего и вся разложили по функциям, то функция инициализации у нас выглядит просто и лаконично:

void setup() {
  eeprom_setup(); // настраиваем EEPROM
  modbus_setup();  // настраиваем Modbus
}

Фух, вроде всё, проверяем: читаем регистры с 100 по 102, потом записываем в регистр 102 число 1152 (мы делим скорость на 100 для записи в регистры в EEPROM) и снова читаем регистры. В регистре 102 число изменилось, теперь там 0×0480 — это 1152 в шестнадцатеричной системе счисления. Новые настройки будут работать после перезапуска устройства.

Проверка скетча ver2.ino

Ссылку на полный код ищите ниже, а я пока расскажу, как всё это работает.

При старте мы читаем настройки из EEPROM и, если там ничего нет, то для соединения используем настройки связи по умолчанию. Далее создаём holding‑регистры, куда записываем текущие настройки. Кроме этого мы назначаем колбек функцию на запись значений в регистры, чтобы записанные пользователем настройки отправить в ячейки EEPROM.

Обещанный код смотрите в файле ver2.ino.

Черный ход или загрузка в сервисном режиме

Но есть одна проблема — если мы изменили настройки по умолчанию и забыли их, то для доступа к устройству его надо перепрошить. Это жутко неудобно, поэтому оставим лазейку, чтобы всё исправить.

Основная идея такая: зажимаем кнопку на устройстве, подаём питание и устройство загружается в режиме с известными нам настройками по умолчанию. Далее мы сможем записать в регистры новые значения и после перезапуска устройства подключиться уже по ним. Просто и элегантно.

В самом начале статьи мы подключили кнопку на GPIO14 — её и будем использовать. Проверять состояние кнопки я буду при старте устройства.

Объявляем нашу кнопку в начале файла, рядом со всеми define:

#define BTN_SAFE_MODE 14 // D5 на NodeMCU. Кнопка сброса настроек подключения

Настраиваем кнопку на вход:

void io_setup() {
  pinMode(BTN_SAFE_MODE, INPUT_PULLUP);
}

Пишем функцию, которая будет опрашивать кнопку и, если она не нажата, то загружать настройки из EEPROM:

void check_safe_mode() {
  if (digitalRead(BTN_SAFE_MODE)) { // если кнопка не нажата, то читаем настройки из EEPROM, иначе будут использованы настройки по умолчанию
    read_modbus_settings(); // чтение настроек
  }
}

Так как кнопка у нас замыкает GPIO на землю, то сигнал ненажатой кнопки будет равен 1, что мы и учли в нашем блоке if.

Всё, осталось подправить функцию настроек при старте и можно спокойно забывать заданные настройки связи:

void setup() {
  io_setup();  // настраиваем входы/выходы
  eeprom_setup(); // настраиваем EEPROM
  check_safe_mode(); // проверяем, не надо ли нам в безопасный режим с дефолтными настройками
  modbus_setup();  // настраиваем Modbus
}

Как вы уже привыкли — полный файл скетча ver3.ino.

На этом этапе у нас есть рабочий прототип modbus‑сервера, который мы можем использовать для разработки любых устройств. Всё, что ниже — это продолжение работы над датчиком.

Проверка скетча ver3.ino

Делаем датчик качества воздуха

У нас есть рабочий modbus‑сервер, который ничего не умеет, кроме как хранить настройки связи в регистрах и записывать пользовательские настройки в EEPROM. Штука интересная, но пока довольно бесполезная.

Давайте это исправим и добавим в прошивку работу с сенсором CCS811. Сам сенсор очень мал, поэтому я буду использовать готовую платку с этим сенсором.

CJMCU-811 — плата с сенсором CCS811

Подключаем сенсор к ESP8266

По умолчанию I2C у нас назначен на пины GPIO5 (D1) и GPIO4 (D2), поэтому сюда и подключим наш датчик. GPIO4 у нас занят выходом Flow Control, поэтому мы освободим его. Теперь Flow Control подключён у нас на GPIO12 (D6).

Новая схема со всеми изменениями ниже.

Схема подключения датчика CCS811 и преобразователя UART-RS485

Добавляем в прошивку поддержку сенсора CCS811

Для начала учтём изменение в схеме: Flow Control теперь подключён у нас к другому выходу, меняем в константах GPIO4 на GPIO12.

#define PIN_FLOW 12 // GPIO12(D6)

Для работы с датчиком мы будем использовать библиотеку «SparkFun CCS811 Arduino library», которую надо установить в менеджере библиотек. После этого подключите библиотеку в коде вместе с библиотекой Wire:

#include <Wire.h>            // Wire
#include <SparkFunCCS811.h>  // SparkFun CCS811 Arduino library

Далее описываем номера Modbus‑регистров:

#define REG_SENSOR_ERROR  0 // ошибка инициализации сенсора CCS811
#define REG_SENSOR_ECO2   1 // eCO2
#define REG_SENSOR_TVOC   2 // TVOC
#define REG_SENSOR_BL     3 // Baseline

Здесь мы задали адреса двух регистров с измерениями: eCO2 и TVOC, а также два служебных: Baseline и ошибка инициализации сенсора. Baseline — это калибровочное значение, которое рассчитывается во время автокалибровки сенсора и используется при подготовке итоговых значений для пользователя.

Описываем в функции modbus_setup() создание и инициализацию регистров:

  mb.addIsts(REG_SENSOR_ERROR); // ошибка инициализации сенсора
  mb.addIreg(REG_SENSOR_ECO2);  // eCO2
  mb.addIreg(REG_SENSOR_TVOC);  // TVOC
  mb.addIreg(REG_SENSOR_BL);    // Baseline
  
  mb.Ists(REG_SENSOR_ERROR, 0); // ошибка
  mb.Ireg(REG_SENSOR_ECO2, 0);  // eCO2
  mb.Ireg(REG_SENSOR_TVOC, 0);  // TVOC
  mb.Ireg(REG_SENSOR_BL, 0);    // Baseline

Далее надо указать адрес сенсора на шине и создать объект для работы с сенсором:

#define CCS811_ADDR 0x5A // Указываем адрес устройства I2C, по умолчанию 0x5A, второй адрес устройства 0x5B

CCS811 ccs811(CCS811_ADDR);  // создаем объект для работы с сенсором CCS811

Сам сенсор устроен таким образом, что при настройках по умолчанию производит изменение качества воздуха каждую секунду.

Работа с сенсором происходит так: при настройках по умолчанию, сенсор измеряет качество воздуха и по мере готовности отдаёт измеренные значения по шине I2C. Нам остаётся только периодически проверять, есть ли новые значения и, если есть, читать их. Да, на сенсоре есть выход INT, который можно повесить на прерывание и по нему читать сенсор, но я решил пойти другим путём и сэкономить одну ногу.

Раз нам надо что‑то делать периодически, то нам понадобится простой таймер, возьмём для этого библиотеку SimpleTimer (второе слово с большой буквы) и установим её через менеджер библиотек. Теперь подключим библиотеку в скетче и создадим объект таймера с интервалом 10 мс:

#include <SimpleTimer.h>  // простой таймер

SimpleTimer sysTimer(2000); // запускаем таймер с интервалом 2c (2000мс)

Теперь нам надо инициализировать шину i2c, а заодно обеспечим обратную связь об ошибках:

void i2c_setup() {
  Wire.begin();  // инициализация i2c

  if (!ccs811.begin()) {  // инициализация CCS811
    mb.Ists(REG_SENSOR_ERROR, 1); // если не получилось — ошибка, пишем в регистр ошибок
  } else {
    mb.Ists(REG_SENSOR_ERROR, 0);
  };
}

Не забудьте добавить вызов инициализации сенсора в функцию setup():

void setup() {
  .....
  i2c_setup();          // инициализация i2c
}

Таймер мы описали, регистры создали, шину настроили, теперь напишем функции чтения данных из сенсора и проверки таймера.

Чтение данных:

// Опрос сенсора
void read_sensor() {
  unsigned int baseLine;
  
  if (ccs811.dataAvailable()) {                 // проверяем, есть ли нвоые данные
    ccs811.readAlgorithmResults();              // считываем
    mb.Ireg(REG_SENSOR_ECO2, ccs811.getCO2());  // записываем в регистр eCO2
    mb.Ireg(REG_SENSOR_TVOC, ccs811.getTVOC()); // записываем в регистр TVOC

    baseLine = ccs811.getBaseline();
    mb.Ireg(REG_SENSOR_BL, baseLine);    // Baseline
  }
}

Проверять таймер мы будем в функции check_timer(), которую вызовем в главном цикле loop():

void check_timer() {
  if (sysTimer.isReady()) { // выполняется раз в 2 c
    read_sensor(); // опрашиваем сенсор
    sysTimer.reset(); // сбрасываем таймер
  }
}

void loop() {
  mb.task();
  check_timer(); 
}

По традиции, полный скетч по ссылке ver4.ino.

Проверка скетча ver4.ino. Видно, что значение Baseline сбрасывается при перезапуске устройства

Сохранение Baseline

Baseline — это внутреннее калибровочное значение, которое меняется сенсором во время его автокалибровки. Но есть нюанс — оно сбрасывается при отключении питания, а значит сенсор теряет калибровку и начинает цикл заново.

Поэтому мы будем сохранять значение Baseline во время работы сенсора, а при старте восстанавливать. Здесь обязательно надо помнить, что в процессе калибровки значение Baseline может меняться довольно часто, а сама автокалибровка происходит раз в сутки.

Сохранять мы это значение будем в EEPROM по аналогии с настройками Modbus.

Добавляем к выделенному нами размеру EEPROM ещё две ячейки и указываем номер ячейки для хранения Baseline:

#define EEPROM_SIZE           8 // теперь мы займём 8 ячеек памяти
#define EEPROM_BASELINE       6 // номер ячейки с Baseline

Объявляем переменную для хранения Baseline:

uint16_t sensorBaseLine = 0; // переменная со значение BaseLine

Добавляем таймер:

SimpleTimer blTimer(30000);   // запускаем таймер с интервалом 30 с

И добавляем его обработку:

void check_timer() {
...

  if (blTimer.isReady()) {
    save_baseline(); // записываем значение Baseline в EEPROM
    blTimer.reset();
  }
}

Функция чтения Baseline из EEPROM:

// Чтение Baseline из EEPROM
void read_baseline() {
  EEPROM.get(EEPROM_BASELINE, sensorBaseLine);
  if (sensorBaseLine != 0xffff) {
    mb.Ireg(REG_SENSOR_BL, sensorBaseLine);

    if (write_baseline(sensorBaseLine)) {
      mb.Ists(REG_SENSOR_ERROR, 0);
    } else {
      mb.Ists(REG_SENSOR_ERROR, 1);
    }
  }
}

Функция записи Baseline в сенсор:

// Запись baseline в сенсор
bool write_baseline(uint16_t baseline) {
  CCS811Core::CCS811_Status_e errorStatus;

  errorStatus = ccs811.setBaseline(baseline);
  if (errorStatus != CCS811Core::CCS811_Stat_SUCCESS)
  {
    return false;
  };
  return true;
}

Функция сохранения Baseline в EEPROM:

// Запись Baseline в EEPROM
void save_baseline(){
  write_eeprom(EEPROM_BASELINE, sensorBaseLine);
}

И добавляем в функцию setup() чтение Baseline из EEPROM:

void setup() {
  ...
  read_baseline();    // чтение Baseline из EEPROM
}

Полный скетч ver5.ino.

Проверка работы скетча ver5.ino — значение Baseline восстанавливается при старте
Сохранение Baseline в файловую систему (работа над ошибками)

Это было в изначальном варианте статьи, потом заменено на EEPROM.

Меня внизу в комментариях поругали (@av0000), что я неправильно рассказываю: в ESP EEPROM эмулируется через флеш, поэтому нет никакой надобности городить историю про файловую систему. Поэтому Baseline можно просто хранить в ячейке EEPROM. Раздел я оставлю, так как пример работы с файловой системы может быть полезен для других задач.

Baseline — это внутреннее калибровочное значение, которое меняется сенсором во время его автокалибровки. Но есть нюанс — оно сбрасывается при отключении питания, а значит сенсор теряет калибровку и начинает цикл заново.

Поэтому мы будем сохранять значение Baseline во время работы сенсора, а при старте восстанавливать. Здесь обязательно надо помнить, что в процессе калибровки значение Baseline может меняться довольно часто, а сама автокалибровка происходит раз в сутки.

Конечно, можно сохранять это значение в EEPROM по аналогии с настройками Modbus, но её срок службы ограничен, а значит надо хранить значение где‑то в другом месте.

В микроконтроллере ESP8266 есть встроенная файловая система, в которую можно сохранять файлы и срок её службы довольно большой. Туда то мы и будем сохранять значение Baseline в файле JSON.

Чтобы работать с файловой системой, нам надо сказать среде Arduino об этом, выберите в меню ИнструментыFlash Size значение, отличное от FS:None, например, FS:1MB;. Подробную информацию по файловой системе найдёте ссылке.

Для работы с JSON нам понадобится библиотека ArduinoJson, поэтому установите её в менеджере библиотек и подключите вместе с библиотекой работы с файловой системы:

#include <FS.h> // SPIFFS будем использовать
#include <ArduinoJson.h>        //Установить из менеджера библиотек

Говорят, что SPIFFS устарела, но также говорят, что с ней меньше накладных расходов. В документации есть оба примера, поэтому я использовал её. Вы можете выбрать другую, тем более, что там пару вывозов поменять.

Добавляем константу с адресом регистра, где мы будем хранить ошибку инициализации файловой системы:

#define REG_FS_ERROR      4 // ошибка файловой системы

Теперь опишем нужные нам флаги:

uint16_t sensorBaseLine = 0; // переменная со значение BaseLine
String configFile = "/config.json"; // имя файла конфигурации

Несмотря на то, что файловая система имеет огромный ресурс на запись, накладные расходы на запись в файл довольно большие. Поэтому мы будем писать данные периодически. Заведём для этой цели ещё один таймер, который будет срабатывать раз в 10 секунд (1000 мс):

SimpleTimer blTimer(10000);   // запускаем таймер с интервалом 10 с

Инициализируем файловую систему и кладём в регистр флаг ошибки, если не удалось:

// Инициализация файловой системы
void fs_setup() {
  if (SPIFFS.begin()) {
    mb.Ists(REG_FS_ERROR, 0);
  } else {
    mb.Ists(REG_FS_ERROR, 1);
  }
}

Теперь немного изменим функцию check_timer():

void check_timer() {
  if (sysTimer.isReady()) {
    read_sensor(); // опрашиваем сенсор
    sysTimer.reset(); // сбрасываем таймер
  }
if (blTimer.isReady()) {
write_config(); // записываем значение Baseline в файл
blTimer.reset();
}
}

И опишем функции чтения-записи конфига:

// Чтение конфига из файла
void read_config() {
DynamicJsonDocument doc(200);
String jsonConfig = "";
File cfgFile = SPIFFS.open(configFile, "r");
if (cfgFile) {
while (cfgFile.available()) {
jsonConfig += (char)cfgFile.read();
}
}
cfgFile.close();
deserializeJson(doc, jsonConfig);
sensorBaseLine = doc["baseLine"];
mb.Ireg(REG_SENSOR_BL, sensorBaseLine);
if (write_baseline(sensorBaseLine)) {
mb.Ists(REG_SENSOR_ERROR, 0);
} else {
mb.Ists(REG_SENSOR_ERROR, 1);
}
}
// Запись baseline в сенсор
bool write_baseline(uint16_t baseline) {
CCS811Core::CCS811_Status_e errorStatus;
errorStatus = ccs811.setBaseline(baseline);
if (errorStatus != CCS811Core::CCS811_Stat_SUCCESS)
{
return false;
};
return true;
}
// Запись конфига в файл
void write_config() {
DynamicJsonDocument doc(200);
String jsonConfig;
doc["baseLine"] = sensorBaseLine;
serializeJson(doc, jsonConfig);
File cfgFile = SPIFFS.open(configFile, "w");
if (cfgFile) {
cfgFile.print(jsonConfig);
cfgFile.close();
mb.Ists(REG_FS_ERROR, 0);
} else {
mb.Ists(REG_FS_ERROR, 1);
}
}

Компилируем, зашиваем, проверяем.

Полный скетч примера ver5-filesystem.ino.

Проверка скетча ver5.ino

Интеграция в систему автоматизации на Wiren Board

План интеграции

Прототип готов, пора подключить его к контроллеру — выведем значения в веб‑интерфейс.

У Wiren Board есть собственный драйвер работы с Modbus‑устройствами wb‑mqtt‑serial, для которого можно написать свой шаблон и он будет работать с нашим устройством, как с родным.

Заодно мы напишем простой сценарий, который будет включать зуммер контроллера, если значение сенсора TVOC превысит определённое значение.

Устройство подключено к порту RS485 контроллера Wiren Board

Шаблон устройства

Шаблон драйвера wb‑mqtt‑serial — это json‑файл с описанием регистров Modbus‑устройства, подробное описание структуры шаблона можно найти в репозитории wb‑mqtt‑serial.

Перед тем, как делать шаблон, давайте вспомним карту регистров нашего устройства.

Адрес

Тип

Описание

0

Discrete Input

Ошибка инициализации сенсора CCS811

1

Input Register

Значение eCO2

2

Input Register

Значение TVOC

3

Input Register

Значение Baseline

100

Holding

Modbus-адрес устройства

101

Holding

Стоп биты

102

Holding

Скорость обмена, делённая на 100

Упрощённо шаблон устройства выглядит так:

{
    "device_type": "ad-sv",     // тип устройства — уникальный идентификатор
    "title": "AD-SV",               // отображаемое название
    "group": "g-climate-sensor",    // группа, в которой будет отображаться шаблон. Список групп смотрите в документации
    "device": {                     
        "name": "AD-SV",    // имя устройства, используется в MQTT
        "id": "ad-sv",
        "groups": [ ],              // группы параметров и каналов        
        "channels": [ ],            // каналы, доступно в скриптах и на вкладке Устройства
        "parameters": [ ],          // параметры, можно менять в настройках устройства
        "translations": { }         // переводы 
    }
}

AD‑SV — сокращённое имя устройства.

Из карты регистров нашего устройства видно:

  • регистры 0...3 — это каналы (channels) устройства: их мы выведем веб‑интерфейс и сможем использовать в скрипте автоматизации;

  • регистры 100...102 — это параметры (parameters) устройства, их мы добавим на страницу настроек.

Группы у нас будет две:

  • Inputs — сюда войдут каналы;

  • Settings — параметры.

Также мы сделаем наш шаблон двуязычным: en/ru.

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

{
    "device_type": "ad-sv",
    "title": "AD-SV",
    "group": "g-climate-sensor",
    "device": {
        "name": "AD-SV",
        "id": "ad-sv",
        "groups": [
            {
                "title": "Inputs",
                "id": "inputs",
                "order": 0
            },
            {
                "title": "Settings",
                "id": "settings",
                "order": 1
            }
        ],
        "channels": [            
            {
                "name": "eCO2",
                "reg_type": "input",
                "address": 1,
                "type": "value",
                "group": "inputs"
            },
            {
                "name": "TVOC",
                "reg_type": "input",
                "address": 2,
                "type": "value",
                "group": "inputs"
            },
            {
                "name": "Baseline",
                "reg_type": "input",
                "address": 3,
                "type": "value",
                "group": "inputs"
            },
            {
                "name": "CCS811 Error",
                "reg_type": "discrete",
                "address": 0,
                "type": "switch",
                "group": "inputs"
            }
        ],
        "parameters": [
            {
                "id": "address",
                "title": "Address",
                "reg_type": "holding",                
                "address": 100,
                "default": 1,
                "min": 1,
                "max": 247,
                "group": "settings"
            },
            {
                "id": "stop-bits",
                "title": "Stop Bits",
                "reg_type": "holding",                
                "address": 101,
                "default": 2,
                "enum": [1, 2],
                "enum_titles": ["1", "2"],
                "group": "settings"
            },
            {
                "id": "baudrate",
                "title": "Baudrate",
                "reg_type": "holding",                
                "address": 102,
                "default": 96,
                "enum": [12, 24, 48, 96, 192, 384, 576, 1152],
                "enum_titles": ["1200", "2400", "4800", "9600", "19200", "38400", "57600", "115200"],
                "group": "settings"
            }
        ],
        "translations": {
            "ru": {
                "Inputs": "Входы",
                "Settings": "Настройки",
                "eCO2": "eCO2",
                "TVOC": "TVOC",
                "Baseline": "Baseline",
                "CCS811 Error": "Ошибка CCS811",
                "File System Error": "Ошибка файловой системы",
                "Address": "Адрес устройства",
                "Stop Bits": "Стоп биты",
                "Baudrate": "Скорость обмена",
            }
        }
    }
}

Теперь подключаем устройство к контроллеру и настраиваем:

  1. Копируем файл шаблона на контроллер в папку пользовательских шаблонов /etc/wb-mqtt-serial.conf.d/templates,

  2. Идём в веб‑интерфейсе Настройки → Конфигурационные файлы → Настройка драйвера serial‑устройств.

  3. Параметры порта оставляем по умолчанию.

  4. Добавляем новое устройство и в списке шаблонов выбираем наше устройство. Если его нет в списке, нажимаем Ctrl+F5, чтобы сбросить кеш браузера.

  5. В поле сразу под выбором шаблона указываем адрес устройства — 1.

  6. Сохраняем настройки.

Теперь на вкладке Устройства мы должны увидеть карточку с нашим сенсором.

Файл шаблона на контроллере
Настройки устройства и выбранный шаблон AD-SV
Представление устройства в веб-интерфейсе контроллера Wiren Board

Сценарии автоматизации

В контроллере Wiren Board из коробки есть движок правил wb‑rules, на нём мы и будем писать нашу автоматизацию.

Алгоритм: если значение TVOC превысит 2000 единиц, то включить зуммер контроллера и выключить его после того, как значение снизится. Подобный алгоритм можно использовать для управления вентиляцией или для оповещения персонала об опасности, например, на вино‑водочном заводе. Вроде ничего сложного.

Добавляем файл нашего скрипта:

  1. Переходим на вкладку «Правила».

  2. Создаём новый скрипт и называем его tvoc‑control.js.

В правилах используются контролы устройств, которые выглядят как устройство_адрес/контрол. Узнать нужный контрол можно кликнув на имени канала в карточке устройства на вкладке «Устройства».

Копирование адреса топика для скрипта

Например, у меня значения TVOC ad-sv_1/TVOC, а зуммер контроллера в топике buzzer/enabled.

Пишем правило:

defineRule({
    whenChanged: "ad-sv_1/TVOC", // если значение сенсора изменилось
    then: function(newValue, devName, cellName) {
    if (newValue >2000) {
        dev["buzzer/enabled"] = true;       
    } else {
        dev["buzzer/enabled"] = false;
    }
    }
});

Всё, записываем наше правило в скрипт tvoc-control.js и нажимаем в веб-интерфейсе контроллера кнопку «Сохранить».

Скрипт автоматизации в редакторе

Итак, сохранили, ничего не ругнулось — давайте проверять!

Берём что‑то органическое с сильным запахом, например, спирт и подносим эту жидкость к датчику. Я взял обезжириватель универсальный, открутил крышку и поднёс её в зону, где находится датчик — мгновенно запищал зуммер. Стоило убрать крышку с обезжиривателем — значение стало стремительно падать — в комнате, где я проводил эксперимент, открыто окно и из него неплохо дует. Как только концентрация паров упала ниже 2000 ppb, выключился зуммер.

Кстати, обратите внимание, как подскочило расчётное значение eCO2 в момент сработки зуммера.

Значение TVOC около нуля — зуммер выключен
Значение TVOC выше пороговых 2000 ppb — зуммер включен
Значение TVOC меньше пороговых 2000 ppb — зуммер выключен

Что дальше

Сейчас у нас есть прототип устройства, которое:

  • отправляет и принимает данные по протоколу Modbus RTU;

  • измеряет уровень летучих органических веществ в воздухе;

  • хранит в энергонезевисимой памяти настройки подключения и калибровочное значение сенсора;

  • позволяет изменять основные настройки связи и имеет сервисный режим, на случай, если настройки забыли;

  • интегрировано в систему автоматизации на базе контроллера Wiren Board.

Вроде неплохо, но впереди ещё много работы:

  1. Сенсор CCS811 в процессе работы нагревается и его показания начинают «плыть», поэтому желательно поставить сенсор температуры, например, BMP280 и настроить термокомпенсацию.

  2. Можно собирать устройство и на макетке, но гораздо удобнее будет развести и изготовить печатную плату. Не забудьте по преобразователь питания, обычно в шине RS-485 от 12 до 24 В.

  3. Нужно предусмотреть процедуру обновления прошивки, например, это можно сделать по Modbus — в примерах используемой нами библиотеки есть готовая реализация. Ещё можно включать в нужное время встроенный Wi‑Fi и обновлять прошивку по OTA.

На этом позвольте откланяться, до встречи в новых статьях!

Обновление: читайте комментарии, там пишут много интересного, особенно в этой ветке.

Ссылки: