There is not always a necessity for highly accurate and expensive measuring devices to determine indoor air quality. A rough reading and limit monitoring can also be very helpful if you simply want to know when it is appropriate to ventilate an intensively used room. Such a device becomes even more useful when its simple local display is supplemented by an alarm output to the smartphones that are constantly carried around today.
A highly integrated sensor device, a modern microcontroller and a little BASIC programming make it possible to use this eCO2 monitor and its various display modes to indicate poor air quality with very little technical effort in four ways :
- local NeoPixel LED(s) , as air quality traffic light
- web interface for local devices with a web browser
- manual query via Telegram messenger
- Telegram alert message sent to a dedicated Telegram user

.
The Hardware: The simple circuit is based on an eCO2-Sensor CCS811, an ESP32-SoC-module. The air quality is represented by one ore more NeoPixel LEDs. A single button is connected to one input pin. The sensor and the Soc communicate via a two wire I2C-bus. The NeoPixel uses only one data wire at GPIO27, no matter if a single LED dot or a matrix is addressed.
Almost any ESP32-module with exposed I2C-pins will do the job. But I recommend a M5Stack “ATOM matrix” or “ATOM lite”. This devices contain in a small protective housing an ESP32-Pico-D4 with an antenna , a build-in NeoPixel-matrix or single NeoPixel dot and a built-in push button. Thus only two hardware components get placed on a simple grid board. If the CCS811 and the ATOM are plugged to corresponding sockets and pin headers respectively, the necessary few wires are very easy to connect. The downside of the grid board gets isolated by adhesive tape.
The circuit is powered from a USB power supply (5V@max 500mA) via the USB-C connector, the HY2.0 Grove port or the downside sockets.




The eCO2-Sensor CCS811:
The CCS811 sensor cannot measure the CO2 content directly! It calculates the equivalent CO2 content (eCO2) by measuring the tVOC (total Volatile Organic Compound), where the main source of VOCs is the air exhaled by humans.
This cheap type of sensor searches a baseline of relative “good” air by determining the best condition in a longer interval and then assuming the sensor being in fresh unpolluted air at that time.
But: The sensor does not store this value by itself.
Additionally the sensor sensitivity can change over the time and under different environmental conditions like temperature and humidity.
Thus for giving reliable eCO2 readings the sensor needs:
- a one-time burn-in-time of more than 48 hours
- a minimum run-in of about 20minutes after each cold start.
The datasheet contains more detailed information about the gas sensor.
The Software:
The Program was developed with and runs within ANNEX32 – a BASIC interpreter for ESP32. After burning into the flash RAM of the ESP32 device via an installation program using a USB serial port, the interpreter and its development environment run completely in the ESP32. Only a Chrome or Firefox browser is needed to load, edit, test and (automatically) execute the BASIC script. The minimum required Annex version is V1.435, as this includes CCS811 and Telegram messenger support. The online help file of Annex32 is a helpful introduction to this BASIC interpreter.
The main tasks of the BASIC code:
- The CCS811 is initialized and asked for the eCO2 level once per second.
- The air eCO2 status gets categorized as GREEN, AMBER or RED.
- The build-in NeoPixel-Matrix or a single NeoPixel LED locally indicates the category by its color.
- A Webinterface displays the eCO2-Value and the category via a browser on the (W)LAN.
- The category and eCO2 value can be queried manually via Telegram messenger, as the program works with a Telegram bot and fetches incoming user commands from the Telegram server
- A Telegram warning message is automatically sent to the last Telegram chat_id when the air quality is in the RED range.
- The baseline of the air quality can be stored manually by pressing the front button of the ATOM-module or by an incoming Telegram command


The Telegram-Bot routine regularly requests the telegram servers for incoming commands. It responds to this commands:
- /e returns eCO2 value and category [GREEN|YELLOW|RED]
- /s stores the baseline in /baseline.txt
- /r restores the baseline from /baseline.txt
- /i returns the local IP-settings of the module
- [any other character] same as /e
How to get your own TELEGRAM_Token:
To use the Telegram functions in the BASIC program you first have to create your own Telegram-Bot by following the instructions of BotFather at https://t.me/BotFather in your Telegram APP. This will provide your personal Telegram token and a bot name. These can be inserted in the BASIC-code lines to set the appropriate variables.
The use of ANNEX32-BASIC has certainly at least the advantage, even for inexperienced programmers, to be well readable and also adaptable to own needs. For a better understanding of the functions, the code is abundantly provided with comments.
This script can be pasted into the web-based editor of ANNEX32 and stored into the ESP32 module as an autorun file in e.g. /default.bas .
DON’T FORGET TO INSERT YOUR OWN TELEGRAM TOKEN

'####################################################################### ' eCO2-Telegram-Bot ' ################## '# Shows AIR QUALITY via the estimated concentration of carbon dioxide '# calculated from known TVOC concentration. This is based on the '# assumption that the VOC produced by human lungs is proportional to their exhaled CO2. ' '# USED HARDWARE: ' - ESP32-device; best: M55tack "ATOM lite" with one built-in NeoPixel ' or "ATOM matrix" with buit-in 5*5-NeoPixels ' - CCS811 eCO2-TVOC-Sensor at I2C-pins of the ESP32 ' - switch-button (e.g. the built-in front button of the M5Stack ATOM) ' '# SOFTWARE: BASIC-interpreter Annex32 V1.48.22 @ http://cicciocb.com/forum ' '# OUTPUT of eCO2-ppm via ... '- NEOPIXEL_LED with at least 1 pixel to indicate the range [GREEN|AMBER|RED] of eCO2 ppm '- WebInterface to display eCO2-ppm value and air condition [GREEN|AMBER|RED] '- TELEGRAM-BOT message-on-demand with eCO2-ppm value and air condition [GREEN|AMBER|RED] '- TELEGRAM-BOT alert message, if condition reaches RED eCO2-range for more than x measures values '# Saves the BASELINE of the CCS811 as "GOOD-AIR-CONDITION" in file ' /baseline.txt if switch-button is pressed for more than 3 seconds '# Sends a TELEGRAM Alert message to current CHAT_ID if RED condition is detected '# Respons to TELEGRAM-BOT commands (with or without leading /) : ' /a => RED-alerts will from now on be sent to the user who has send this command (until next reboot) ' /i => return local IP-Settings (useful to identify the local dynamic IP-Address of the module) ' /s => store the current baseline representing the "clean-air-condition" ' in file /baseline.txt ' /r => restore the baseline from file /baseline.txt ' /e or [any other character] returns the eCO2-ppm-value ' and the air condition [GREEN|amber|RED] '####################################################################### ' Peter Neufeld 2023/01; DB9JG@me.com Version$ = "V2.6" '------SETTINGS------------- RESTORE_BASELINE = 1 '1 => after a run-in time: restores the baseline from data file if available SAVE_BASELINE = 0 '1 => after a run-in time: save current air condition as the "clean air" baseline SILENT = 0 '1 => suppress all messages 0 => show some status-messages via wlog LOCATION$ = "ROOM #1" 'a description of the sensor location etc TELEGRAM_activated = 1 '1 => activates the Telegram-BOT (TOKEN AND NAME NEEDED!!!!) TELEGRAM_TOKEN$ = "xxxxxxxxxx:AAHQ3qTIj248QCU0ao_GcetggK3l_nhANj4" TELEGRAM_BOTNAME$ = "yyyyyyyyyyyyyyyy" T_CHAT_ID_Alert$ = "123456789" 'My own private chat ID as default destination for alerts T_CHAT_ID$ = T_CHAT_ID_Alert$ 'if <> "" then alerts will immediately go there TELEGRAM_MSG_Threshold = 5 'Message if bad-air-condition for too long TELEGRAM_MAX_FAILED_COUNTER = 0 TELEGRAM_MAX_FAILED = 10 'reboot if too many failed communication TELEGRAM_is_still_running = 0 LL_GREEN = 400 'lower limit for the green LED; usually ~400 LL_AMBER = 1000 'lower limit for the AMBER LED; usually ~1000 LL_RED = 1800 'lower limit for the red LED; usually ~1800 HYST = 0.07 'Hysteresis factor to stabilize the RED alert status LL_RED_HYST = 0 'temporay hysteresis correction '--------------------------- eCO2 = 0 eTVOC = 0 CONDITION$ = "GREEN" COND_COL$ = CONDITION$ CONDITION_OLD$ = CONDITION$ T$ = time$ STR_eCO2$ = "" gosub SETUP_PERIPHERAL_HARDWARE IF RESTORE_BASELINE = 1 gosub CCS811_RESTORE_BL_FROM_FILE gosub MAKE_WEBPAGE onhtmlreload MAKE_WEBPAGE onHtmlChange MAKE_WEBPAGE IF TELEGRAM_activated gosub TELEGRAM_INIT timer0 1000, MAIN 'the main program is called once per second wait end '!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! '#################################################################################### '#################################################################################### MAIN: '------------- gosub READ_eCO2 gosub SHOW_eCO2 T$ = time$ STR_eCO2$ = str$(eCO2) if CONDITION$ <> CONDITION_OLD$ then gosub MAKE_WEBPAGE 'change the webpage IF condition$ = "RED" and T_CHAT_ID$ <>"" then RED_COUNT = RED_COUNT +1 else 'reset counter if good condition returns before reaching threshold RED_COUNT = 0 endif '===Send a telegram alert message to latest CHAT_ID if too many RED conditions----- IF RED_COUNT = TELEGRAM_MSG_Threshold then gosub TELEGRAM_send_alert '===------------------------------------------------------------------------------- '++++restore the latest baseline regularly after a run-in time of 10 minutes count=(count +1) mod (10*60) 'if count = (10*60)-1 then gosub CCS811_SAVE_BASELINE_TO_FILE if count = (10*60)-1 gosub CCS811_RESTORE_BL_FROM_FILE '++++-------------------------------------------------------- '---SAVE baseline if frontbutton pressed for more then 3s IF pin(FRONT_BUTTON) = PRESSED then PRESSED_COUNT = (PRESSED_COUNT + 1) mod 4 else PRESSED_COUNT = 0 ENDIF IF PRESSED_COUNT = 3 gosub CCS811_SAVE_BASELINE_TO_FILE '--- return '#################################################################################### MAKE_WEBPAGE: '------------- 'create the autorefreshing html page with dynamic display values and matching colors A$ = "" A$ = A$ + "<H1>eCO2 " + LOCATION$ A$ = A$ + "</H1>" A$ = A$ + "Time :" + textbox$(T$) + "<br>" A$ = A$ + "eCO2: " + textbox$(STR_eCO2$) + "<br>" A$ = A$ + |<span style="color:| + COND_COL$ + |">| A$ = A$ + "<H1>Condition: "+ CONDITION$+ "</H1>" A$ = A$ + "</span>" cls autorefresh 1000 html A$ return '#################################################################################### SHOW_eCO2: '--------- x=5-x 'toggle the LED-brigthness +0 or +5 just to show some activity CONDITION_OLD$=CONDITION$ select case eCO2 case 0 to LL_AMBER 'GREEN R=0 : G=x+20 : B=0 CONDITION$ = "GREEN" COND_COL$ = CONDITION$ LL_RED_HYST = 0 case LL_AMBER to (LL_RED - LL_RED_HYST)'AMBER R=x+15 : G=x+10 : B=0 CONDITION$ = "AMBER" COND_COL$ = "yellow" LL_RED_HYST = 0 CASE (LL_RED - LL_RED_HYST)to 99999 'RED R=x+20 : G=0 : B=0 CONDITION$ = "RED" COND_COL$ = CONDITION$ LL_RED_HYST = (LL_RED * HYST) 'set a hysteresis to stabilize the condition End select neo.strip 0,NEO_END,R,G,B 'show the condition at NeoPixel-stripe wlog "eCO2 = "; eCO2, " Condition: "; CONDITION$ return '#################################################################################### READ_eCO2: '--------- if ccs811.avail = 1 then a = ccs811.read eCO2 = CCS811.CO2 eTVOC = CCS811.TVOC end if return '#################################################################################### SETUP_PERIPHERAL_HARDWARE: '------------------------- 'I2C for CCS811 i2c.setup 21, 22 ' sda_pin: 21, scl_pin: 22 'built-in front-button to store the current air condition as clean air condition FRONT_BUTTON = 39 'pin39 for build-in button of M5Stack ATOM xxxx PRESSED = 0 'the built-in switch pulls down at ATOM devices PRESSED_COUNT = 0 pin.mode FRONT_BUTTON, input 'initialize the NEOPIXELs NEO_PIN = 27 'NeoPixel data-pin for M5stack "ATOM xxxx" devices NEO_NUM = 1 ' Number of Neopixels for a M5stack "ATOM lite" if bas.device = 103 then '103 is a M5Stack "ATOM matrix" NEO_PIN = 27 NEO_NUM = 25 'Number of Neopixels endif NEO_END = NEO_NUM - 1 R=20 : G=20 : B=20 'initial colors for neopixel(s) neo.setup NEO_PIN, NEO_NUM neo.strip 0,NEO_END,R,G,B 'CCS811 eCO2-Sensor if ccs811.Setup(&h5a) <> 0 then wlog ccs811.Setup(&h5a) print "CCS811 not found. Program stopped " wlog "CCS811 not found. Program stopped " end '!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! endif wlog ccs811.setdrivemode(1) 'update the sensor every second return '#################################################################################### CCS811_SAVE_BASELINE_TO_FILE: '---------------------------- neo.strip 0,NEO_END,0,0,150 'set Neopixels color to blue to indicate reaction BASELINE$ = str$(CCS811.GETBASELINE) File.save "/BASELINE.txt", BASELINE$ wlog "Saved baseline ";BASELINE$ ; " to file /BASELINE.txt" neo.strip 0,NEO_END,R,G,B 'Back to the former colors return '#################################################################################### CCS811_RESTORE_BL_FROM_FILE: '--------------------------- BASELINE$ = "39103" 'default value if no file if file.exists("/BASELINE.txt") then BASELINE$ = File.read$("/BASELINE.txt") if val(BASELINE$) >0 then wlog "CCS811.SETBASELINE returned: "; CCS811.SETBASELINE(val(baseline$)) wlog "Restored baseline ";BASELINE$ ; " from file /BASELINE1.txt" endif endif return '#################################################################################### TELEGRAM_INIT: '------------- WLOG "TELEGRAM_INIT" telegram.settoken TELEGRAM_TOKEN$ telegram.setwait 10 telegram.setmode 0 onwgetasync TELEGRAM_asynco 'send a start message to my own account if T_CHAT_ID$ <> "" then tt$="eCO2-Sensor at "+location$ +" is running now at local IP-Address"+ word$(IP$,1) WLOG telegram.sendmessage$(val(T_CHAT_ID$),tt$ ) endif 'Get the update each 10 seconds, to limit internet traffic timer1 10000, TELEGRAM_getMessage return '#################################################################################### TELEGRAM_getMessage: '------------------- WLOG time$;": TELEGRAM_getMessage:" telegram.GetUpdatesAsync return '#################################################################################### TELEGRAM_send_alert: '--------------- 'send an ALERT message to latest seen telegram chat-id 'ATTENTION! No RED-alert-message, if there was no previous request from a client. 'You can provide a fixed chat_id to overcome this if TELEGRAM_is_still_running then return 'to avoid a second call while still running TELEGRAM_is_still_running = 1 onwgetasync off '!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! If T_CHAT_ID$ <> "" then tt$ = "RED-ALERT condition for " + LOCATION$ +" at "+ time$ + ": eCO2 = " + str_eCO2$ + " Condition: " + CONDITION$ neo.strip 0,NEO_END,30,30,30 'white Neopixels to indicate a TELEGRAM transmission WLOG telegram.sendmessage$(val(T_CHAT_ID$),tt$ ) WLOG tt$ neo.strip 0,NEO_END,R,G,B 'back to former condition TELEGRAM_is_still_running = 0 onwgetasync TELEGRAM_asynco endif return '#################################################################################### TELEGRAM_asynco: '--------------- 'Receive the messages and respond according to included command-string if TELEGRAM_is_still_running then return TELEGRAM_is_still_running = 1 onwgetasync off '!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! r$ = WGETRESULT$ WLOG "The TELEGRAM_BOT-Service returns at "; Time$; ": "; r$ if instr(lcase$(r$)," failed") then TELEGRAM_MAX_FAILED_COUNTER = TELEGRAM_MAX_FAILED_COUNTER + 1 WLOG "The TELEGRAM_BOT-Service failed; REBOOT countdown initialized : ", str$(TELEGRAM_MAX_FAILED - TELEGRAM_MAX_FAILED_COUNTER) if TELEGRAM_MAX_FAILED_COUNTER = TELEGRAM_MAX_FAILED then WLOG "REBOOT NOW..." reboot endif endif if instr(r$,|"ok"|) then TELEGRAM_MAX_FAILED_COUNTER = 0 c$ = json$(r$, "chat.id") 'get the chat_id if c$ <>"not found" then T_CHAT_ID$=c$ tt$ = LOCATION$ +" at "+ time$ + ": eCO2 = " + str_eCO2$ + " Condition: " + CONDITION$ text$ = json$(r$, "text") if (text$ <> "not found") then text$=replace$(text$,"/","") 'delete / select case left$(lcase$(text$),1) case "i" : tt$=" Local IP-Address is " + word$(IP$,1) case "r" gosub CCS811_RESTORE_BL_FROM_FILE tt$="Restored the BASELINE from file" case "s" gosub CCS811_SAVE_BASELINE_TO_FILE tt$="Saved the BASELINE to file" case "a" T_CHAT_ID_Alert$ = T_CHAT_ID$ tt$= "OK! From now on all RED-alerts will go to this Telegram-user" end select neo.strip 0,NEO_END,30,30,30 'white Neopixels to indicate a TELEGRAM transmission WLOG telegram.sendmessage$(val(T_CHAT_ID$),tt$ ) neo.strip 0,NEO_END,R,G,B 'back to former condition end if TELEGRAM_is_still_running = 0 onwgetasync TELEGRAM_asynco return '####################################################################################
- …….
- ……
- …..
- ….
- …
- ..
- .
- ..
- …
- ….
- …..
- ……
- …….
- ……..