Arduino :: Lecture & TIPs

[미세먼지 측정기] IoT 공기질 측정기 만들기 (3)

지난 게시물에서 미세먼지 측정기를 만들 때 필요한 준비물에 대해 알아보았습니다. 미세먼지 측정기를 만들때 필요한 부품들을 다시 정리해보면 아래와 같습니다.

NodeMCU ESP8266-12E 보드
NovaFitness SDS011 미세먼지 센서
uBlox NEO6MV2 GPS 센서
DS18B20 온도 센서
SSD1306 0.96 인치 I2C OLED 디스플레이
IRIVER Tactile 외장 배터리 10000mAh
F-F 점퍼 케이블

하드웨어 조립 및 구성하기.

가장 먼저 ESP8266 MCU 보드에 OLED 디스플레이를 연결합니다. 0.96 인치 화면을 가진 SSD1306 OLED 부품이 정상적으로 작동하기 위해서는 네 개의 케이블 연결이 필요합니다. OLED 부품의 VCC 단자는 ESP8266 MCU 보드의 3v3 단자에, GND 단자는 GND 단자에, SCL 단자는 D2 단자에, SDA 단자는 D3 단자에 연결합니다.

이어서 온도센서의 + 단자는 NodeMCU 보드의 3.3v 단자에, out 단자는 NodeMCU 보드의 D4 단자에, 그리고 – 단자는 NodeMCU 보드의 GND 단자에 각각 연결합니다.

차례로, 미세먼지 센서의 5V 단자는 NodeMCU 보드의 VIN (5V) 단자에, GND 단자는 GND 단자에, TX 단자는 D1 단자에, RX 단자는 D0 단자에 연결해 준 후,

마지막으로 GPS 센서를 연결합니다. GPS 센서의 VCC 단자는 NodeMCU 보드의 3v3 단자에, RX 단자는 D7 단자에, TX 단자는 D6 단자에, GND 단자는 GND 단자에 연결해줍니다.

SSD1306 OLEDNodeMCU ESP8266-12E
VCC3v3
GNDGND
SCLD2
SDAD3
DS18B20 온도 센서NodeMCU ESP8266-12E
+3v3
outD4
GND
SDS011 미세먼지 센서NodeMCU ESP8266-12E
5VVIN (5V)
GNDGND
TXD1
RXD0
NEO6MV2 GPS 센서NodeMCU ESP8266-12E
VCC3v3
RXD7
TXD6
GNDGND

소프트웨어 조립 및 구성하기 : 센서 데이터를 읽은 후 ThingSpeak 서버에 POST API 를 사용하여 데이터를 전송합니다.

여기까지 모든 부품들을 알맞게 연결하셨으면, 소프트웨어 쪽도 작업하여야 미세먼지 측정기가 정상적으로 작동할텐데요, Arduino IDE (통합개발환경) 를 사용해 ESP8266 보드에 대한 펌웨어를 코딩하기 전, 올바른 라이브러리와 COM 드라이버를 가져오도록 합니다.

위에서 언급한 바와 같이, 저의 경우 HTTP 통신 프로토콜을 통해 ThingSpeak IoT 플랫폼에서 제공하는 GET 및 POST 방식과 REST 접근 방식을 통한 과정으로 데이터를 서버에 업로드했습니다. 물론, 이번 포스팅에서 소개드리는 ThingSpeak IoT 플랫폼이나 여타 온라인 플랫폼 (예 : Azure IoT, Google Cloud IoT Core, IBM Watson IoT 플랫폼 등) 을 이용하면 편리하고 간편하다는 장점이 존재하지만, 다수의 IoT 기기를 개발하여 사용하는 경우 데이터 업로드 조건이나 할당량에 대한 제한 또한 존재하는 관계로, 온라인 플랫폼을 활용하지 않고 개인 웹 서버를 직접 운용할 수도 있다는 점을 함께 언급해둡니다.

Arduino IDE 환경 설정하기

https://www.arduino.cc/en/Main/Software 에서 운영체제에 맞는 아두이노 IDE 를 다운받은 후 설치합니다.

상단의 [File] 메뉴 하위의 [Preferences] 항목을 클릭하여 [Additional Board Manager URLs] 항목의 텍스트박스에 http://arduino.esp8266.com/stable/package_esp8266com_index.json 를 입력합니다.

Tools > Board > Board Manager … 에서 esp8266 보드를 설치합니다.

NodeMCU 와 PC 를 케이블로 연결 한 다음, Tools > Board 에서 NodeMCU 1.0 보드를 선택합니다.

Port 에서 NodeMCU 보드에 연결된 시리얼 포트를 선택합니다.

속도는 “115200” 이 기본 설정이며, 상황에 따라 속도를 조정하도록 합니다.

 

Arduino IDE 에 라이브러리 설치하기

Sketch > Include Library > Manage Libraries … 선택 후 Library Manager 창이 화면에 표시되면, “ssd1306” 을 입력하여 “ESP8266 OLED Driver for SSD 1306 Displays” 라이브러리를 검색해 설치합니다.

온도 센서 (DS18B20) 라이브러리인 “DallasTemperature” 라이브러리 역시 동일한 방법으로 검색하여 설치하도록 합니다.

ThingSpeak IoT 플랫폼 (웹 서버) 에 데이터를 전송하려면 앞서 설계한 ESP8266 기반의 미세먼지 측정기가 Wi-Fi 네트워크에 우선적으로 연결되어야 합니다. 해당 기능 구현을 위해 적절한 라이브러리를 가져와 프로젝트에 포함시킨 후 연결할 무선 네트워크 SSID 와 비밀번호를 지정시켜 준 후 올바른 방법을 사용하여 Wi-Fi 를 켜고 연결하도록 구현하여야 합니다.

Wi-Fi 연결을 활성화하기 위해 connect_ap() 메서드에서 호출 한 다음 함수를 사용하여 연결을 초기화합니다.

#include <ESP8266WiFi.h>

boolean connect_ap(char* ssid, char* password) {
  int count = 60;                                 // 최대 60 회 연결 시도 중 wifi 연결하면 성공, 아니면 실패
  Serial.println();
  Serial.print("connecting to ");
  Serial.println(ssid);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
    wifi_oled(count);
    if (!count--) {
      Serial.print("\nNO WIFI");
      return(false);
    }
  }
  Serial.print("\n Got WiFi, IP address: ");
  Serial.println(WiFi.localIP()); 
  return(true);
}

성공적으로 Wi-Fi 네트워크에 연결되면, 콘솔 로그 화면에 “Got WiFi, IP address: ” 문구가 표시되고, 이어서 연결된 IP 주소가 화면에 출력되는데요, 이는 센서 데이터를 ThingSpeak IoT 플랫폼 (웹 서버) 에 전송가능한 상태로 전환되었다는 의미입니다!


센서 데이터 읽어오기

//
//    FILE: FinedustMonitorWithGPS.ino
//  AUTHOR: Jaewoong Mun
// CREATED: November 19, 2019
//
// Released to the public domain
//

#include <TinyGPS++.h>
#include <SoftwareSerial.h>
TinyGPSPlus gps;
SoftwareSerial ss(12, 13);
SoftwareSerial dust(D1, D0, false, 256);            //RX, TX Communication
#include "RunningMedian.h"

RunningMedian pm25s = RunningMedian(19);
RunningMedian pm10s = RunningMedian(19);

char* ssid = "SSID";
char* password = "SSID_PASSWORD";
String api_key = "API_KEY";

//#define PLAIVE_SERVER_ENABLE
#define THINGSPEAK_SERVER_ENABLE

boolean wifi_ready;
float map_x, map_y;
String s_map_x, s_map_y, status;
int pm25i, pm10i;

//초기 세팅 (Initialize.)
void setup() {
  Serial.begin(115200);
  dust.begin(9600);
  ss.begin(9600);
  setup_oled();
  wifi_ready = connect_ap(ssid, password);
  
  if (!wifi_ready) nowifi_oled();

  Serial.println("\nFinedust Sensor Box V1.3, 2019/12/25 HappyBono");
}


void got_dust(int pm25, int pm10) {                 //formula for dust sensor just use!!
  pm25 /= 10;
  pm10 /= 10;
  pm25s.add(pm25);
  pm10s.add(pm10);
  do_oled(pm25, pm10);                              //print pm25, pm10 in oled
}


//서버에 데이터 보내기 (Sending collected data to server.)
void do_interval() {
  if (wifi_ready) {
#ifdef PLAIVE_SERVER_ENABLE
    do_server_plaive(api_key, int(pm25s.getMedian()), int(pm10s.getMedian()), get_temperature(), s_map_x, s_map_y);
#else
#ifdef THINGSPEAK_SERVER_ENABLE
    Serial.println("dst: pm25=" + String(int(pm25s.getMedian())) + " / pm10=" + String(int(pm10s.getMedian())) + "/ s=" + String(status));
    do_server_thingspeak(api_key, int(pm25s.getMedian()), int(pm10s.getMedian()), get_temperature(), s_map_x, s_map_y, status);
#else
    do_server_default(api_key, int(pm25s.getMedian()), int(pm10s.getMedian()), get_temperature(), s_map_x, s_map_y);
#endif
#endif
  }                                                    //wifi is ok
}

unsigned long mark = 0;
boolean got_interval = false;

//아두이노가 반복적으로 작동하는 부분 (Where Arduino works repeatedly.)
void loop() {
  if (ss.available() <= 0) {
    // Serial.println("SIGNAL STATUS : WEAK");
    s_map_x = String(map_x, 6);
    s_map_y = String(map_y, 6);
  }
  else {
    while (ss.available() > 0) {
      //Serial.println("SIGNAL STATUS : GREAT");
      if (gps.encode(ss.read())) {
        //Serial.println("GPS READ");
        Serial.println(ss.read());
        if (gps.location.isValid()) {
          //Serial.println("LOCATION : GREAT");
          map_x = gps.location.lat();
          map_y = gps.location.lng();
          Serial.println(String(map_x, 6));
          Serial.println(String(map_y, 6));
        }
      }
      s_map_x = String(map_x, 6);
      s_map_y = String(map_y, 6);
      yield();
    }
  }
  while (dust.available() > 0) {
    do_dust(dust.read(), got_dust);
    yield();                                          //loop 에서 while 문을 사용하는 경우 yield 를 포함해주어야 합니다.

    //Serial.println(map_x);
    //Serial.print("pm 10 : ");
    //Serial.println(int(pm10s.getMedian()));

    /* AQI (실시간 대기질 지수) 등급 분류를 위한 코드입니다.
       실시간 대기질 기준 수치는 국제 표준인 WHO 대기질 수치 기준으로 계산하였습니다.

       http://www.euro.who.int/__data/assets/pdf_file/0005/78638/E90038.pdf
       https://airnow.gov/index.cfm?action=aqibasics.aqi */


    // 초미세먼지 AQI (실시간 대기질 지수) 등급을 분류합니다.
    //   0 이상   8 이하 : 1
    //   9 이상  16 이하 : 2
    //  17 이상  26 이하 : 3
    //  27 이상  34 이하 : 4
    //  35 이상  43 이하 : 5
    //  44 이상  51 이하 : 6
    //  52 이상  ∞  이하 : 7

    int pm25m = int(pm25s.getMedian());

    if (pm25m < 9) {
      pm25i = 1;
    } else if (pm25m < 17) {
      pm25i = 2;
    } else if (pm25m < 27) {
      pm25i = 3;
    } else if (pm25m < 35) {
      pm25i = 4;
    } else if (pm25m < 44) {
      pm25i = 5;
    } else if (pm25m < 52) {
      pm25i = 6;
    } else {
      pm25i = 7;
    }

    // 미세먼지 AQI (실시간 대기질 지수) 등급을 분류합니다.
    //   0 이상  16 이하 : 1
    //  16 이상  31 이하 : 2
    //  32 이상  51 이하 : 3
    //  52 이상  68 이하 : 4
    //  69 이상  84 이하 : 5
    //  85 이상 101 이하 : 6
    // 102 이상  ∞  이하 : 7

    int pm10m = int(pm10s.getMedian());

    if (pm10m < 16) {
      pm10i = 1;
    } else if (pm10m < 32) {
      pm10i = 2;
    } else if (pm10m < 52) {
      pm10i = 3;
    } else if (pm10m < 69) {
      pm10i = 4;
    } else if (pm10m < 85) {
      pm10i = 5;
    } else if (pm10m < 102) {
      pm10i = 6;
    } else {
      pm10i = 7;
    }

    /* ThingSpeak 채널 내 Status Update (상태 업데이트) 영역에 표시되는 문구이므로,
        종합적인 정보 표현을 위해 초미세먼지와 미세먼지 등급을 비교 한 후
        두 가지 중 높은 등급 기준으로 경고 혹은 권고메시지를 표시합니다. */

    // 분류된 초미세먼지 등급이 미세먼지 등급보다 같거나 높은 경우, 초미세먼지 등급을 기준으로 내용을 표시하기 위하여 아래의 문자열을 status 변수에 저장합니다.

    switch ((pm25i >= pm10i) ? pm25i : pm10i) {
      case 1:
        status = "Excellent (1) : The air quality is excellent. As air pollution poses no threat, conditions are ideal for outdoor activities.";
        break;

      case 2:
        status = "Very Good (2) : The air quality is very good. As air pollution poses little or no risk, conditions very good for outdoor activities.";
        break;

      case 3:
        status = "Moderate (3) : Air quality is acceptable. however, for some pollutants, there may be a moderate health concern for specific people who are unusually sensitive to air pollution.";
        break;

      case 4:
        status = "Satisfactory (4) : Members of sensitive groups may experience health effects, Other people should limit spending time outdoors, especially when they experience symptoms such as cough or sore throat.";
        break;

      case 5:
        status = "Bad (5) : Everyone may begin to experience health effects, members of sensitive groups may experience more serious health effects. Not recommended for outdoor activities.";
        break;

      case 6:
        status = "Severe (6) : Everyone may experience more serious health effects. People at risk should be avoided to go outside and should limit outdoor activities to a minimum. Outdoor activities are discouraged.";
        break;

      case 7:
        status = "Hazardous (7) : People at risk should be avoided going outside and should limit outdoor activities to a minimum. Outdoor activities are strongly discouraged.";
        break;
    }

    //Serial.println("PM2.5 = " + String(int(pm25s.getMedian())) + " / " + String(pm25i));
    //Serial.println("PM10.0  = " + String(int(pm10s.getMedian())) + " / " + String(pm10i));
  }

  if (millis() > mark) {                          //one minute (60000) interval
    mark = millis() + 60000;
    got_interval = true;
  }

  if (got_interval) {
    got_interval = false;
    do_interval();
  }
  yield();
}

SDS011 미세먼지 센서의 스펙 상에 기재된 프로토콜을 참고하여 코드를 작성하여야 합니다. 시작되는 Header 값이 AA 인 것을 확인하여 데이터의 시작 부분을 확인할 수 있고, 이어서 CO 문자 값 이후에 데이터 (Data 1, Data 2… Data 5, Data 6) 가 기록되어 있으니, 데이터 수집을 시작해 총 7 바이트를 받아들이도록 로직을 구현하면 되겠습니다.

데이터를 쭈욱 받아들이다가, 마지막 메시지 (MessageTail) 로 AB 문자 값을 확인한 후 초기 상태로 전환해, 데이터의 첫 시작 부분을 확인하는 로직으로 돌아가도록 합니다.

//
//    FILE: dust.ino
//  AUTHOR: Jaewoong Mun
// CREATED: November 19, 2019
//
// Released to the public domain
//

int stat = 1;
int cnt = 0;
// 버퍼를 10 개 잡습니다.
char buf[10];

void do_dust(char c, void (*function)(int, int)) {
    //Serial.print("stat="+ String(stat) +", "+ "cnt="+ String(cnt) +" ");
    //Serial.print(c, HEX);
    //Serial.println(" ");

    if (stat == 1) {
       // 메시지 시작 AA (Header 부분)
       if (c == 0xAA) stat = 2;
    } else
    if (stat == 2) {    
       // "CO" 가 나오면 데이터 수집을 시작합니다.
       if (c == 0xC0) stat = 3; 
       else stat = 1;
    } else
    if (stat == 3) {
       buf[cnt++] = c;
       // 총 7 바이트 읽어들입니다.
       if (cnt == 7) stat = 4;
    } else
    if (stat == 4) {
      // 스펙상 MessageTail, "AB" 가 나오면 초기 상태로 전환합니다.
      if (c == 0xAB) {
          //checksum
          stat = 1;
       }
       else {
          //Serial.println("Eh? wrong tailer");
       }
       cnt = 0;
       int pm25 = buf[0] + 256*buf[1];
       int pm10 = buf[2] + 256*buf[3];
       function(pm25, pm10);
    }
}

//
//    FILE: temperature.ino
//  AUTHOR: Jaewoong Mun
// CREATED: November 19, 2019
//
// Released to the public domain
//

#include <OneWire.h>
#include <DallasTemperature.h>

#define ONE_WIRE_BUS D4

OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature DS18B20(&oneWire);

void setup_temperature() {
  DS18B20.begin();
}

/* Datatype for floating-point numbers, a number that has a decimal point.
 *  Floating-point numbers are often used to approximate analog and continuous values, 
 *  because they have greater resolution than integers.
 *  Floating-point numbers can be as large as 3.4028235E+38 and as low as -3.4028235E+38.
 */
float get_temperature() {
  float temp;
  do {
    DS18B20.requestTemperatures(); 
    temp = DS18B20.getTempCByIndex(0);
    delay(100);
  } while (temp == 85.0 || temp == (-127.0));
  return temp;
}

서버에 데이터 보내기

https://thingspeak.com/update?key=[API Key]&Field1=50 과 같은 형식으로 ThingSpeak 에 데이터를 업로드 할 수 있습니다. 미세먼지 측정기와 같은 값이 여러 개로 묶인 다중 데이터의 경우 https://thingspeak.com/update?key=[API Key]&Field1=50&Field2=25&Field3=10 과 같이 “&” 문자로 필드 값을 구분해 업로드할 수 있습니다.

//서버에 데이터 보내기 (Sending collected data to server.)
void do_interval() {
  if (wifi_ready) {
#ifdef PLAIVE_SERVER_ENABLE
    do_server_plaive(api_key, int(pm25s.getMedian()), int(pm10s.getMedian()), get_temperature(), s_map_x, s_map_y);
#else
#ifdef THINGSPEAK_SERVER_ENABLE
    Serial.println("dst: pm25=" + String(int(pm25s.getMedian())) + " / pm10=" + String(int(pm10s.getMedian())) + "/ s=" + String(status));
    do_server_thingspeak(api_key, int(pm25s.getMedian()), int(pm10s.getMedian()), get_temperature(), s_map_x, s_map_y, status);
#else
    do_server_default(api_key, int(pm25s.getMedian()), int(pm10s.getMedian()), get_temperature(), s_map_x, s_map_y);
#endif
#endif
  }                                                    //wifi is ok
}

//
//    FILE: server.ino
//  AUTHOR: Jaewoong Mun
// CREATED: November 19, 2019
//
// Released to the public domain
//

const char* host_plaive = "data.plaive.10make.com";
const char* host_thingspeak = "api.thingspeak.com";
const char* host_default = "finedustapi.10make.com";

const int httpPort = 80;

#include <ESP8266WiFi.h>
#include <WiFiClient.h>
WiFiClient client;
String data;
String contentType;

void do_server_plaive(String api_key, int pm25, int pm10, float temperature, String map_x, String map_y) {

  data = "api_key=" + String(api_key) + "&field1=" + String(pm25) + "&field2=" + String(pm10) + "&field3=" + String(temperature) + "&field4=" + String(map_x) + "&field5=" + String(map_y);
  //contentType= "application/x-www-form-urlencoded";

  //서버 통신 공식 client.println 을 사용하여야 합니다.

  /*  Write Data with Get :

      Usage :
      https://data.plaive.10make.com/insert.php?api_key=<write_api_key>&field1=123 */

  if (client.connect(host_plaive, httpPort)) {
    Serial.println("connected");
    client.print("GET /insert.php?");
    client.print(data);
    client.println(" HTTP/1.1");
    client.println("Host: " + String(host_plaive)); // SERVER ADDRESS HERE AS WELL
    client.println("Cache-Control: no-cache");
    //client.println("Content-Type: application/xE-www-form-urlencoded");
    //client.print("Content-Length: ");
    //client.println(data.length());
    client.println("Connection: close");
    client.println();
    //client.print(data);
  }

  //서버 통신이 되지 않으면
  else {
    Serial.println("connection failed: ");
    return;
  }
}

void do_server_thingspeak(String api_key, int pm25, int pm10, float temperature, String map_x, String map_y, String status) {
  if (client.connect(host_thingspeak, httpPort)) {
    data = "api_key=" + String(api_key) + "&field1=" + String(pm25) + "&field2=" + String(pm10) + "&field3=" + String(temperature) + "&field4=" + String(map_x) + "&field5=" + String(map_y) + "&status=" + String(status);
    //contentType= "application/x-www-form-urlencoded";


    //서버 통신 공식 client.println 을 사용하여야 합니다.

    /*  Write Data with Get :
        https://www.mathworks.com/help/thingspeak/writedata.html

        Usage :
        https://api.thingspeak.com/update?api_key=<write_api_key>&field1=123 */

    //Serial.println("data = " + String(data));
    //Serial.println("connected");
    client.print("GET /update?");
    client.print(data);
    client.println(" HTTP/1.1");
    client.println("Host: " + String(host_thingspeak));
    client.println("Cache-Control: no-cache");
    client.println("Connection: close");
    client.println();

    //String answer = getResponse();
    //Serial.println("response = " + String(answer));
  } 
  
    //서버 통신이 되지 않으면
  else {
    Serial.println("connection failed: ");
    return;
  }
}

//String getResponse(){
//  String response;
//  long startTime = millis();
//
//  delay( 200 );
//  while ( client.available() < 1 ){
//        delay( 5 );
//  }
//
//  if( client.available() > 0 ){ // Get response from server.
//     char charIn;
//     do {
//         charIn = client.read(); // Read a char from the buffer.
//         response += charIn;     // Append the char to the string response.
//        } while ( client.available() > 0 );
//    }
//  client.stop();
//
//  return response;
//}

void do_server_default(String api_key, int pm25, int pm10, float temperature, String map_x, String map_y) {

  data = "api_key=" + String(api_key) + "&pm25=" + String(pm25) + "&pm10=" + String(pm10) + "&temp=" + String(temperature) + "&latitude" + String(map_x) + "&longitude" + String(map_y);
  contentType = "application/x-www-form-urlencoded";

  //서버 통신 공식 client.println 을 사용하여야 합니다.

  if (client.connect(host_default, httpPort)) {
    Serial.println("connected");
    client.print("GET /insert.php?");
    client.print(data);
    client.println(" HTTP/1.1");
    client.println("Host: " + String(host_default)); // SERVER ADDRESS HERE AS WELL AS ABOVE.
    client.println("Content-Type: application/xE-www-form-urlencoded");
    client.print("Content-Length: ");
    client.println(data.length());
    client.println("Connection: close");
    client.println();
    //Serial.println(data);
    //client.print(data);
  }

  //서버 통신이 되지 않으면
  else {
    Serial.println("Connection Failed: ");
    return;
  }
}

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: