/*
xsns_15_mhz19.ino - MH-Z19(B) CO2 sensor support for Tasmota
Copyright (C) 2021 Theo Arends
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
#ifdef USE_MHZ19
/*********************************************************************************************\
* MH-Z19 - CO2 sensor
*
* Adapted from EspEasy plugin P049 by Dmitry (rel22 ___ inbox.ru)
*
* Hardware Serial will be selected if GPIO1 = [MHZ Rx] and GPIO3 = [MHZ Tx]
**********************************************************************************************
* Filter usage
*
* Select filter usage on low stability readings
\*********************************************************************************************/
#define XSNS_15 15
enum MhzFilterOptions {MHZ19_FILTER_OFF, MHZ19_FILTER_OFF_ALLSAMPLES, MHZ19_FILTER_FAST, MHZ19_FILTER_MEDIUM, MHZ19_FILTER_SLOW};
#define MHZ19_FILTER_OPTION MHZ19_FILTER_FAST
/*********************************************************************************************\
* Source: http://www.winsen-sensor.com/d/files/infrared-gas-sensor/mh-z19b-co2-ver1_0.pdf
*
* Automatic Baseline Correction (ABC logic function) is enabled by default but may be disabled with command
* Sensor15 0
* and enabled again with command
* Sensor15 1
*
* ABC logic function refers to that sensor itself do zero point judgment and automatic calibration procedure
* intelligently after a continuous operation period. The automatic calibration cycle is every 24 hours after powered on.
*
* The zero point of automatic calibration is 400ppm.
*
* This function is usually suitable for indoor air quality monitor such as offices, schools and homes,
* not suitable for greenhouse, farm and refrigeratory where this function should be off.
*
* Please do zero calibration timely, such as manual or commend calibration.
\*********************************************************************************************/
#include
#ifndef CO2_LOW
#define CO2_LOW 800 // Below this CO2 value show green light
#endif
#ifndef CO2_HIGH
#define CO2_HIGH 1200 // Above this CO2 value show red light
#endif
#define MHZ19_READ_TIMEOUT 400 // Must be way less than 1000 but enough to read 9 bytes at 9600 bps
#define MHZ19_RETRY_COUNT 8
TasmotaSerial *MhzSerial;
const char kMhzModels[] PROGMEM = "|B";
const char ABC_ENABLED[] = "ABC is Enabled";
const char ABC_DISABLED[] = "ABC is Disabled";
enum MhzCommands { MHZ_CMND_READPPM, MHZ_CMND_ABCENABLE, MHZ_CMND_ABCDISABLE, MHZ_CMND_ZEROPOINT, MHZ_CMND_RESET, MHZ_CMND_RANGE_1000, MHZ_CMND_RANGE_2000, MHZ_CMND_RANGE_3000, MHZ_CMND_RANGE_5000 };
const uint8_t kMhzCommands[][4] PROGMEM = {
// 2 3 6 7
{0x86,0x00,0x00,0x00}, // mhz_cmnd_read_ppm
{0x79,0xA0,0x00,0x00}, // mhz_cmnd_abc_enable
{0x79,0x00,0x00,0x00}, // mhz_cmnd_abc_disable
{0x87,0x00,0x00,0x00}, // mhz_cmnd_zeropoint
{0x8D,0x00,0x00,0x00}, // mhz_cmnd_reset
{0x99,0x00,0x03,0xE8}, // mhz_cmnd_set_range_1000
{0x99,0x00,0x07,0xD0}, // mhz_cmnd_set_range_2000
{0x99,0x00,0x0B,0xB8}, // mhz_cmnd_set_range_3000
{0x99,0x00,0x13,0x88}}; // mhz_cmnd_set_range_5000
uint8_t mhz_type = 1;
uint16_t mhz_last_ppm = 0;
uint8_t mhz_filter = MHZ19_FILTER_OPTION;
bool mhz_abc_must_apply = false;
float mhz_temperature = 0;
uint8_t mhz_retry = MHZ19_RETRY_COUNT;
uint8_t mhz_received = 0;
uint8_t mhz_state = 0;
/*********************************************************************************************/
uint8_t MhzCalculateChecksum(uint8_t *array)
{
uint8_t checksum = 0;
for (uint32_t i = 1; i < 8; i++) {
checksum += array[i];
}
checksum = 255 - checksum;
return (checksum +1);
}
size_t MhzSendCmd(uint8_t command_id)
{
uint8_t mhz_send[9] = { 0 };
mhz_send[0] = 0xFF; // Start byte, fixed
mhz_send[1] = 0x01; // Sensor number, 0x01 by default
memcpy_P(&mhz_send[2], kMhzCommands[command_id], sizeof(uint16_t));
/*
mhz_send[4] = 0x00;
mhz_send[5] = 0x00;
*/
memcpy_P(&mhz_send[6], kMhzCommands[command_id] + sizeof(uint16_t), sizeof(uint16_t));
mhz_send[8] = MhzCalculateChecksum(mhz_send);
// AddLog(LOG_LEVEL_DEBUG, PSTR("Final MhzCommand: %x %x %x %x %x %x %x %x %x"),mhz_send[0],mhz_send[1],mhz_send[2],mhz_send[3],mhz_send[4],mhz_send[5],mhz_send[6],mhz_send[7],mhz_send[8]);
return MhzSerial->write(mhz_send, sizeof(mhz_send));
}
/*********************************************************************************************/
bool MhzCheckAndApplyFilter(uint16_t ppm, uint8_t s)
{
if (1 == s) {
return false; // S==1 => "A" version sensor bootup, do not use values.
}
if (mhz_last_ppm < 400 || mhz_last_ppm > 5000) {
// Prevent unrealistic values during start-up with filtering enabled.
// Just assume the entered value is correct.
mhz_last_ppm = ppm;
return true;
}
int32_t difference = ppm - mhz_last_ppm;
if (s > 0 && s < 64 && mhz_filter != MHZ19_FILTER_OFF) {
// Not the "B" version of the sensor, S value is used.
// S==0 => "B" version, else "A" version
// The S value is an indication of the stability of the reading.
// S == 64 represents a stable reading and any lower value indicates (unusual) fast change.
// Now we increase the delay filter for low values of S and increase response time when the
// value is more stable.
// This will make the reading useful in more turbulent environments,
// where the sensor would report more rapid change of measured values.
difference *= s;
difference /= 64;
}
if (MHZ19_FILTER_OFF == mhz_filter) {
if (s != 0 && s != 64) {
return false;
}
} else {
difference >>= (mhz_filter -1);
}
mhz_last_ppm = static_cast(mhz_last_ppm + difference);
return true;
}
void MhzEverySecond(void)
{
mhz_state++;
if (8 == mhz_state) { // Every 8 sec start a MH-Z19 measuring cycle (which takes 1005 +5% ms)
mhz_state = 0;
if (mhz_retry) {
mhz_retry--;
if (!mhz_retry) {
mhz_last_ppm = 0;
mhz_temperature = 0;
}
}
MhzSerial->flush(); // Sync reception
MhzSendCmd(MHZ_CMND_READPPM);
mhz_received = 0;
}
if ((mhz_state > 2) && !mhz_received) { // Start reading response after 3 seconds every second until received
uint8_t mhz_response[9];
unsigned long start = millis();
uint8_t counter = 0;
while (((millis() - start) < MHZ19_READ_TIMEOUT) && (counter < 9)) {
if (MhzSerial->available() > 0) {
mhz_response[counter++] = MhzSerial->read();
} else {
delay(5);
}
}
AddLogBuffer(LOG_LEVEL_DEBUG_MORE, mhz_response, counter);
if (counter < 9) {
// AddLog(LOG_LEVEL_DEBUG, PSTR(D_LOG_DEBUG "MH-Z19 comms timeout"));
return;
}
uint8_t crc = MhzCalculateChecksum(mhz_response);
if (mhz_response[8] != crc) {
// AddLog(LOG_LEVEL_DEBUG, PSTR(D_LOG_DEBUG "MH-Z19 crc error"));
return;
}
if (0xFF != mhz_response[0] || 0x86 != mhz_response[1]) {
// AddLog(LOG_LEVEL_DEBUG, PSTR(D_LOG_DEBUG "MH-Z19 bad response"));
return;
}
mhz_received = 1;
uint16_t u = (mhz_response[6] << 8) | mhz_response[7];
if (15000 == u) { // During (and only ever at) sensor boot, 'u' is reported as 15000
if (Settings->SensorBits1.mhz19b_abc_disable) {
// After bootup of the sensor the ABC will be enabled.
// Thus only actively disable after bootup.
mhz_abc_must_apply = true;
}
} else {
uint16_t ppm = (mhz_response[2] << 8) | mhz_response[3];
mhz_temperature = ConvertTemp((float)mhz_response[4] - 40);
uint8_t s = mhz_response[5];
mhz_type = (s) ? 1 : 2;
if (MhzCheckAndApplyFilter(ppm, s)) {
mhz_retry = MHZ19_RETRY_COUNT;
#ifdef USE_LIGHT
LightSetSignal(CO2_LOW, CO2_HIGH, mhz_last_ppm);
#endif // USE_LIGHT
if (0 == s || 64 == s) { // Reading is stable.
if (mhz_abc_must_apply) {
mhz_abc_must_apply = false;
if (!Settings->SensorBits1.mhz19b_abc_disable) {
MhzSendCmd(MHZ_CMND_ABCENABLE);
} else {
MhzSendCmd(MHZ_CMND_ABCDISABLE);
}
}
}
}
}
}
}
/*********************************************************************************************\
* Command Sensor15
*
* 0 - ABC Off
* 1 - ABC On (Default)
* 2 - Manual start = ABC Off
* 3 - (Not implemented) Optional filter settings
* 9 - Reset
* 1000 - Range
* 2000 - Range
* 3000 - Range
* 5000 - Range
\*********************************************************************************************/
#define D_JSON_RANGE_1000 "1000 ppm range"
#define D_JSON_RANGE_2000 "2000 ppm range"
#define D_JSON_RANGE_3000 "3000 ppm range"
#define D_JSON_RANGE_5000 "5000 ppm range"
bool MhzCommandSensor(void)
{
bool serviced = true;
switch (XdrvMailbox.payload) {
case 0:
Settings->SensorBits1.mhz19b_abc_disable = true;
MhzSendCmd(MHZ_CMND_ABCDISABLE);
Response_P(S_JSON_SENSOR_INDEX_SVALUE, XSNS_15, ABC_DISABLED);
break;
case 1:
Settings->SensorBits1.mhz19b_abc_disable = false;
MhzSendCmd(MHZ_CMND_ABCENABLE);
Response_P(S_JSON_SENSOR_INDEX_SVALUE, XSNS_15, ABC_ENABLED);
break;
case 2:
MhzSendCmd(MHZ_CMND_ZEROPOINT);
Response_P(S_JSON_SENSOR_INDEX_SVALUE, XSNS_15, D_JSON_ZERO_POINT_CALIBRATION);
break;
case 9:
MhzSendCmd(MHZ_CMND_RESET);
Response_P(S_JSON_SENSOR_INDEX_SVALUE, XSNS_15, D_JSON_RESET);
break;
case 1000:
MhzSendCmd(MHZ_CMND_RANGE_1000);
Response_P(S_JSON_SENSOR_INDEX_SVALUE, XSNS_15, D_JSON_RANGE_1000);
break;
case 2000:
MhzSendCmd(MHZ_CMND_RANGE_2000);
Response_P(S_JSON_SENSOR_INDEX_SVALUE, XSNS_15, D_JSON_RANGE_2000);
break;
case 3000:
MhzSendCmd(MHZ_CMND_RANGE_3000);
Response_P(S_JSON_SENSOR_INDEX_SVALUE, XSNS_15, D_JSON_RANGE_3000);
break;
case 5000:
MhzSendCmd(MHZ_CMND_RANGE_5000);
Response_P(S_JSON_SENSOR_INDEX_SVALUE, XSNS_15, D_JSON_RANGE_5000);
break;
default:
if (!Settings->SensorBits1.mhz19b_abc_disable) {
Response_P(S_JSON_SENSOR_INDEX_SVALUE, XSNS_15, ABC_ENABLED);
} else {
Response_P(S_JSON_SENSOR_INDEX_SVALUE, XSNS_15, ABC_DISABLED);
}
}
return serviced;
}
/*********************************************************************************************/
void MhzInit(void)
{
mhz_type = 0;
if (PinUsed(GPIO_MHZ_RXD) && PinUsed(GPIO_MHZ_TXD)) {
MhzSerial = new TasmotaSerial(Pin(GPIO_MHZ_RXD), Pin(GPIO_MHZ_TXD), 1);
if (MhzSerial->begin(9600)) {
if (MhzSerial->hardwareSerial()) { ClaimSerial(); }
mhz_type = 1;
}
}
}
void MhzShow(bool json)
{
char types[7] = "MHZ19B"; // MHZ19B for legacy reasons. Prefered is MHZ19
char model[3];
GetTextIndexed(model, sizeof(model), mhz_type -1, kMhzModels);
if (json) {
ResponseAppend_P(PSTR(",\"%s\":{\"" D_JSON_MODEL "\":\"%s\",\"" D_JSON_CO2 "\":%d,\"" D_JSON_TEMPERATURE "\":%*_f}"),
types, model, mhz_last_ppm, Settings->flag2.temperature_resolution, &mhz_temperature);
#ifdef USE_DOMOTICZ
if (0 == TasmotaGlobal.tele_period) {
DomoticzSensor(DZ_AIRQUALITY, mhz_last_ppm);
DomoticzFloatSensor(DZ_TEMP, mhz_temperature);
}
#endif // USE_DOMOTICZ
#ifdef USE_WEBSERVER
} else {
WSContentSend_PD(HTTP_SNS_CO2, types, mhz_last_ppm);
WSContentSend_Temp(types, mhz_temperature);
#endif // USE_WEBSERVER
}
}
/*********************************************************************************************\
* Interface
\*********************************************************************************************/
bool Xsns15(uint8_t function)
{
bool result = false;
if (mhz_type) {
switch (function) {
case FUNC_INIT:
MhzInit();
break;
case FUNC_EVERY_SECOND:
MhzEverySecond();
break;
case FUNC_COMMAND_SENSOR:
if (XSNS_15 == XdrvMailbox.index) {
result = MhzCommandSensor();
}
break;
case FUNC_JSON_APPEND:
MhzShow(1);
break;
#ifdef USE_WEBSERVER
case FUNC_WEB_SENSOR:
MhzShow(0);
break;
#endif // USE_WEBSERVER
}
}
return result;
}
#endif // USE_MHZ19