AirGradient DIY Display

by Achim Haug

When we launched our indoor classroom monitoring with our professional sensors, one of the first feature request from teachers was to have a small display that would show the indoor and outdoor air quality. Instead of having that information directly on the monitor, they wanted to have it as a separate display and able to e.g. put it on their desk.

We thought this might be a great project for makers and put together an easy built with a small display and the Wemos D1 mini.

Above is how teachers and students from the International School Bangkok customized the display enclosures for their school.

Interested to build one yourself? Please follow these instructions:

Components

You just need to get two components:

Soldering

The only soldering work you need to do on this project is to solder the pins onto the D1 Mini. Please make sure you solder them the correct way by comparing the labels on the display with the ones on the D1 Mini. You can also use the display pins as holder during the soldering job.

Arduino Software Instructions and flashing of the D1 Mini with the AirGradient firmware

All software for the AirGradient DIY display is open source and you are free to use it in any way. For flashing the D1 Mini microcontroller with the firmware we will be using the well-known Arduino software. Please read the following blog post on how to install the Arduino software and also on how to install the D1 Mini board and the AirGradient Arduino library.

AirGradient Arduino Software Setup Instructions

Libraries needed

In the Arduino Library manager please install the following libraries:

  • “WifiManager by tzapu, tablatronix” tested with Version 2.0.5-alpha
  • “Adafruit_ILI9341” tested with Version 1.5.10
  • “Adafruit GFX library” tested with Version 1.10.12 (often automatically installed with above ILI9341 library)
  • “ArduinoJSON” by Benoit Blanchon tested with Version 6.18.5

The Configuration

In line 80-82 you can set if you want the display to show temperature in Celcius or Fahrenheit (default is Celcius) and PM2.5 in μg/m³ or US AQI (default is μg/m³).

The Enclosure

There is a nice 3D enclosure at Thingiverse that you can download the STL files for.

The Code

Please copy below code into a new Arduino sketch and flash it to the device.

/*
This is the code for the AirGradient DIY Mini Display with an ESP8266 Microcontroller.
It can be configures to show the outside air quality as well as one indoor location from the AirGradient platform.
For build instructions please visit https://www.airgradient.com/resources/airgradient-diy-display/

The codes needs the following libraries installed:
"WifiManager by tzapu, tablatronix" tested with Version 2.0.5-alpha
"Adafruit_ILI9341" tested with Version 1.5.10
"Adafruit GFX library" tested with Version 1.10.12 (often automatically installed with above ILI9341 library)
"ArduinoJSON" by Benoit Blanchon tested with Version 6.8.5

Configuration:
Please set in the code below if you want to display the PM2.5 values in US AQI and temperature in F.


If you are a school or university contact us for a free trial on the AirGradient platform.
https://www.airgradient.com/schools/

MIT License
*/

// #include <Arduino.h>
#include <WiFiManager.h>
#include <ArduinoJson.h>
#include <ESP8266HTTPClient.h>

#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <Fonts/FreeSans9pt7b.h>
#include <Fonts/FreeSans12pt7b.h>
#include <Fonts/FreeSans18pt7b.h>

// #include <ArduinoOTA.h>
// #include <ESP8266httpUpdate.h>

#define TFT_CS D0
#define TFT_DC D8
#define TFT_RST -1
#define TS_CS D3

Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);

const char* locNameInside; 
const char* locNameOutside;

const char* place_timezone;
const char* location;
bool outdoor_offline;
bool indoor_offline;
const char* outdoor_policy;
const char* outdoor_date;
const char* indoor_date;
boolean prodMode = true;


String deviceID;
const char* timex;
int pm02;
int pi02;
int pi02_outside;
int rco2;
float atmp;
float atmp_outside;
int rhum_outside;
int rhum;
int heat;

const char* pi02_color;
const char* pi02_color_outside;
const char* pi02_category;
const char* pm02_color;
const char* pm02_category;
const char* rco2_color;
const char* rco2_category;
const char* heat_color;
const char* heat_color_outside;
const char* heat_category;

// Configuration
#define API_ROOT "http://hw.airgradient.com/displays/"
boolean inUSaqi=false;
boolean inF=false;

String getDeviceId() {
    return String(ESP.getChipId(), HEX);
}

void setup() {
  Serial.begin(115200);
  Serial.println("Chip ID");
  Serial.println(String(ESP.getChipId(),HEX));
  
  tft.begin();
  tft.setRotation(2);
  while (!Serial && (millis() <= 1000));
  welcomeMessage();
  connectToWifi();

  Serial.print("Connecting");
  while (WiFi.status() != WL_CONNECTED)
  {
    delay(500);
    Serial.print(".");
  }
  Serial.println();

  tft.fillScreen(ILI9341_BLACK);
  delay(2000);
}



void loop() {

 WiFiClient client;
  HTTPClient http;
  http.begin(client,  API_ROOT + getDeviceId());

  int httpCode = http.GET();
  if( httpCode == 200 ) {
    String airData = http.getString();
    payloadToDataInside(airData);
    Serial.print( "airData1 : " );
    Serial.println( airData );
  }
  else {
    Serial.println( "error" );
    Serial.println( httpCode );
  }
  http.end();
  
  delay(1000);
  updateDisplay();
 
  delay(120000);
  tft.fillScreen(ILI9341_BLACK);
  tft.setTextColor(ILI9341_WHITE);
  tft.setFont(&FreeSans12pt7b);
  tft.setCursor(5, 20);
  tft.println("requesting data...");
}


void payloadToDataInside(String payload) {
    const size_t capacity = JSON_ARRAY_SIZE(1) + 2*JSON_OBJECT_SIZE(2) + 2*JSON_OBJECT_SIZE(3) + JSON_OBJECT_SIZE(4) + JSON_OBJECT_SIZE(10) + JSON_OBJECT_SIZE(13) + 530;
    DynamicJsonBuffer jsonBuffer(capacity);
    JsonObject& root = jsonBuffer.parseObject(payload);
    location = root["place"]["name"];
    place_timezone = root["place"]["timezone"];
    JsonObject& outdoor = root["outdoor"];
    locNameOutside = outdoor["name"];
    outdoor_offline = outdoor["offline"];
    outdoor_policy = outdoor["guidelines"][0]["title"];
    JsonObject& outdoor_current = outdoor["current"];
    
    atmp_outside = outdoor_current["atmp"];
    rhum_outside = outdoor_current["rhum"];
    outdoor_date = outdoor_current["date"];
    JsonObject& indoor = root["indoor"];
    locNameInside = indoor["name"];
    indoor_offline = indoor["offline"];
    JsonObject& indoor_current = indoor["current"];

    atmp = indoor_current["atmp"];
    rhum = indoor_current["rhum"];
    rco2 = indoor_current["rco2"];
    indoor_date = indoor_current["date"];
    rco2_color = indoor_current["rco2_clr"];
    rco2_category = indoor_current["rco2_lbl"];

    if (inUSaqi){
      pi02_outside = outdoor_current["pi02"];
      pi02_color_outside = outdoor_current["pi02_clr"];
      pi02_category = outdoor_current["pi02_lbl"];
      pi02 = indoor_current["pi02"];
      pi02_color = indoor_current["pi02_clr"];
      pi02_category = indoor_current["pi02_lbl"];
    } else {
      pi02_outside = outdoor_current["pm02"];
      pi02_color_outside = outdoor_current["pm02_clr"];
      pi02_category = outdoor_current["pm02_lbl"];
      pi02 = indoor_current["pm02"];
      pi02_color = indoor_current["pm02_clr"];
      pi02_category = indoor_current["pm02_lbl"];
    }
   


}

void updateDisplay() {
        int y=25;
        int boxHeight=75;
        int boxWidth=110;
        int radius=8;
        tft.fillScreen(ILI9341_BLACK);

        tft.setFont(&FreeSans9pt7b);
        tft.setTextColor(ILI9341_WHITE, ILI9341_BLACK);
        tft.setCursor(5, y);
        tft.println(location);

        tft.drawLine(0, 35, 250, 35, ILI9341_WHITE);

        y=y+50;

        tft.setFont(&FreeSans9pt7b);
        tft.setTextColor(ILI9341_WHITE, ILI9341_BLACK);
        tft.setCursor(5, y);
        tft.println(locNameOutside);
        tft.setFont(&FreeSans12pt7b);
        
        y=y+12;

        if (String(pi02_color_outside) == "green") {
            tft.fillRoundRect(5,y, boxWidth, boxHeight, radius, ILI9341_GREEN);
        }
        else if (String(pi02_color_outside) == "yellow") {
            tft.fillRoundRect(5,y, boxWidth, boxHeight, radius, ILI9341_YELLOW);
        }
        else if (String(pi02_color_outside) == "orange") {
            tft.fillRoundRect(5,y, boxWidth, boxHeight, radius, ILI9341_ORANGE);
        }
        else if (String(pi02_color_outside) == "red") {
            tft.fillRoundRect(5,y, boxWidth, boxHeight, radius, ILI9341_RED);
        }
        else if (String(pi02_color_outside) == "purple") {
            tft.fillRoundRect(5,y, boxWidth, boxHeight, radius, ILI9341_PURPLE);
        }
        else if (String(pi02_color_outside) == "brown") {
            tft.fillRoundRect(5,y, boxWidth, boxHeight, radius, ILI9341_MAROON);
        }

        if (String(heat_color_outside) == "green") {
            tft.fillRoundRect(5+boxWidth+10,y, boxWidth, boxHeight,radius, ILI9341_GREEN);
        }
        else if (String(heat_color_outside) == "yellow") {
            tft.fillRoundRect(5+boxWidth+10,y, boxWidth, boxHeight,radius, ILI9341_YELLOW);
        }
        else if (String(heat_color_outside) == "orange") {
            tft.fillRoundRect(5+boxWidth+10,y, boxWidth, boxHeight,radius, ILI9341_ORANGE);
        }
        else if (String(heat_color_outside) == "red") {
             tft.fillRoundRect(5+boxWidth+10,y, boxWidth, boxHeight,radius, ILI9341_RED);
        }
        else if (String(heat_color_outside) == "purple") {
             tft.fillRoundRect(5+boxWidth+10,y, boxWidth, boxHeight,radius, ILI9341_PURPLE);
        }
        else if (String(heat_color_outside) == "brown") {
             tft.fillRoundRect(5+boxWidth+10,y, boxWidth, boxHeight,radius, ILI9341_MAROON);
        }

        tft.setFont(&FreeSans9pt7b);
        tft.setTextColor(ILI9341_BLACK, ILI9341_BLACK);
        tft.setCursor(20, y+boxHeight-10);

      if (inUSaqi){
          tft.println("US AQI");
         } else {
          tft.println("ug/m3");
         }

        tft.setFont(&FreeSans18pt7b);
        tft.setTextColor(ILI9341_BLACK, ILI9341_BLACK);
        tft.setCursor(20, y+40);
        tft.println(String(pi02_outside));

        tft.setFont(&FreeSans9pt7b);
        
        tft.setTextColor(ILI9341_WHITE, ILI9341_BLACK);
        
        tft.setCursor(20+boxWidth+10, y+20);


        if (inF){
        tft.println(String((atmp_outside * 9/5) + 32)+"F");
        } else {
        tft.println(String(atmp_outside)+"C");
        }

       

        tft.setCursor(20+boxWidth+10, y+40);
        tft.println(String(rhum_outside)+"%");

        tft.setTextColor(ILI9341_DARKGREY, ILI9341_BLACK);
        tft.setCursor(20+boxWidth+10, y+60);
        tft.println(String(outdoor_date));

        //inside

        y=y+110;

        tft.setFont(&FreeSans9pt7b);
        tft.setTextColor(ILI9341_WHITE, ILI9341_BLACK);
        tft.setCursor(5, y);
        tft.println(locNameInside);
        tft.setFont(&FreeSans12pt7b);

        y=y+12;

        if (String(pi02_color) == "green") {
            tft.fillRoundRect(5,y, boxWidth, boxHeight, radius, ILI9341_GREEN);
        }
        else if (String(pi02_color) == "yellow") {
            tft.fillRoundRect(5,y, boxWidth, boxHeight, radius, ILI9341_YELLOW);
        }
        else if (String(pi02_color) == "orange") {
            tft.fillRoundRect(5,y, boxWidth, boxHeight, radius, ILI9341_ORANGE);
        }
        else if (String(pi02_color) == "red") {
            tft.fillRoundRect(5,y, boxWidth, boxHeight, radius, ILI9341_RED);
        }
        else if (String(pi02_color) == "purple") {
            tft.fillRoundRect(5,y, boxWidth, boxHeight, radius, ILI9341_PURPLE);
        }
        else if (String(pi02_color) == "brown") {
            tft.fillRoundRect(5,y, boxWidth, boxHeight, radius, ILI9341_MAROON);
        }

        if (String(rco2_color) == "green") {
            tft.fillRoundRect(5+boxWidth+10,y, boxWidth, boxHeight,radius, ILI9341_GREEN);
        }
        else if (String(rco2_color) == "yellow") {
            tft.fillRoundRect(5+boxWidth+10,y, boxWidth, boxHeight,radius, ILI9341_YELLOW);
        }
        else if (String(rco2_color) == "orange") {
            tft.fillRoundRect(5+boxWidth+10,y, boxWidth, boxHeight,radius, ILI9341_ORANGE);
        }
        else if (String(rco2_color) == "red") {
             tft.fillRoundRect(5+boxWidth+10,y, boxWidth, boxHeight,radius, ILI9341_RED);
        }
        else if (String(rco2_color) == "purple") {
             tft.fillRoundRect(5+boxWidth+10,y, boxWidth, boxHeight,radius, ILI9341_PURPLE);
        }
        else if (String(rco2_color) == "brown") {
             tft.fillRoundRect(5+boxWidth+10,y, boxWidth, boxHeight,radius, ILI9341_MAROON);
        }

        tft.setFont(&FreeSans9pt7b);
        tft.setTextColor(ILI9341_BLACK, ILI9341_BLACK);
        tft.setCursor(20, y+boxHeight-10);

         if (inUSaqi){
          tft.println("US AQI");
         } else {
          tft.println("ug/m3");
         }
       
        tft.setCursor(20+boxWidth+10, y+boxHeight-10);
        tft.println("CO2 ppm");

        tft.setFont(&FreeSans18pt7b);
        tft.setTextColor(ILI9341_BLACK, ILI9341_BLACK);
        tft.setCursor(20, y+40);
        tft.println(String(pi02));
        tft.setCursor(20+boxWidth+10, y+40);
        tft.println(String(rco2));

         y=y+100;

        tft.setFont(&FreeSans9pt7b);
        tft.setTextColor(ILI9341_DARKGREY, ILI9341_BLACK);
        tft.setCursor(boxWidth-30, y);
        tft.println(String(indoor_date));
}


void welcomeMessage() {
  Serial.println( "Welcome Message 2" );
  tft.setFont(&FreeSans9pt7b);
  tft.fillScreen(ILI9341_BLACK);
  tft.setTextColor(ILI9341_WHITE);

  tft.setCursor(40, 24);
  tft.setFont(&FreeSans12pt7b);
  tft.setCursor(5, 20);
  tft.println("AirGradient");

  tft.setFont(&FreeSans9pt7b);
  tft.setCursor(5, 100);
  tft.println("id: "+String(ESP.getChipId(),HEX));

  tft.setCursor(5, 140);
  tft.println("connecting ...");

  delay(2000);
}

void connectToWifi(){
    delay(2000);

   WiFiManager wifiManager;
   //chWiFi.disconnect(); //to delete previous saved hotspot
   String HOTSPOT = "AIRGRADIENT-DISPLAY-"+String(ESP.getChipId(),HEX);
   wifiManager.setTimeout(120);
   if(!wifiManager.autoConnect((const char*)HOTSPOT.c_str())) {
       Serial.println("failed to connect and hit timeout");
       delay(3000);
       ESP.restart();
       delay(5000);
     } 
}

How to get data displayed?

When you start the sensor, it will display the chip-id of the Wemos D1 mini. On the AirGradient dashboard, please go to “Hardware Administration” (Admin rights required) and then click on the tap “Mini Displays”. Here click on “Add New”, enter the chip-id and select the indoor location you would like to display. Then reboot the device and it should start showing you the data.

Need help?

Feel free to reach out to us in case something does not work. In one of the next updates, we will also transfer that code into our AirGradient Arduino library.

Solutions for Schools

AirGradient offers a sophisticated Air Quality Monitoring Solution for Schools. You can connect these Displays to our platform, integrate with many existing brands or use our professional AirGradient sensor.

If you are interested in a free trial, please contact us.

MIT License

The AirGradient DIY sensor’s and display hardware and software is Open Source and licensed under the MIT license. So feel free to use it any way you like! However, we would be happy to hear from you and also appreciate any link back to our page.

Copyright AirGradient Co. Ltd.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.