본문 바로가기
Hardware/MCU(Arduino,ESP8266)

ESP01 모듈로 만드는 IoT 웹서버 2.0 - 웹소켓, 타이머 적용

by lovey25 2020. 10. 23.
반응형

오늘은 기존 프로젝트의 업그레이 후기입니다. 

ESP01 모듈과 릴레이를 활용해서 거실에서 사용하는 보조등을 와이파이로 켜고 끌 수 있는 컨트롤러를 만들어 봤었는데요.

 

ESP01 모듈로 만드는 IoT 웹서버 - WIFI로 전등 켜기

요즘은 ESP8266 보드를 아두이노 보다 더 많이 사용하고 있습니다. USB 포트가 없어서 별도로 USB-UART 변환 도구가 있어야 하는 불편함이 있지만 크기도 작고 와이파이도 사용할 수 있기 때문에 실생

kwonkyo.tistory.com

내용을 보면 아시겠지만 기존의 방법은 웹서버를 구성하는 HTML 코드에 치환자를 넣어두고 HTTP 요청에 따라 치환자를 교체해 가면서 버튼이 눌려지는 효과를 흉내 내고 기능을 구현해 봤습니다. 그래서 일반적으로 우리가 인터넷을 사용하면서 보고 있는 인터넷 페이지의 작동방식과는 약간 차이가 있으며 그리고 몇 가지 한계점이 있었습니다.

가장 큰 단점은 ESP8266 모듈의 상태가 컨트롤을 하는 핸드폰에 즉각적으로 반영이 되지 않는다는 것입니다. 위 그림에서 A라는 핸드폰에서 스위치를 켜는 명령어를 전송하는 경우를 생각해 보겠습니다. A에서 스위치를 켜는 명령을 요청하면 ESP8266에서는 명령에 따라 스위치를 켜고 스위치가 켜져 있다는 상패를 표시하는 웹페이지는 A에게 회신합니다. 그래서 A 핸드폰에는 전원이 켜져 있음이 표시되는데 하지만 B핸드폰은 여전히 꺼짐 상태로 표시됩니다. 당연히 웹서버는 요청이 없으면 아무런 메시지도 보내지 않기 때문이죠. 그래서 B 핸드폰에서 지금 상태를 확인하기 위해서는 먼저 새로고침을 해야지만 지금의 상태를 정확하게 확인할 수 있습니다.

그리고 이런 웹서버의 일방적인 통신방법 때문에 서버의 상태에 따라 여러 조건의 동작을 구현하고 싶을 때 어려움이 발생하는데요. 이럴때 웹페이지의 새로고침 없이 필요한 정보를 서로 주고받을 수 있는 기술이 바로 웹소켓이라고 합니다. 그래서 이번에는 지난번에 구현한 와이파이 컨트롤러에 웹소켓 기술을 적용하여 실시간으로 ESP8266의 상태를 모든 클라이언트에서 확인할 수 있도록 업그레이드를 하였고 추가적으로 타이머를 적용해서 일정 시간이 지나면 전등을 꺼지게 하는 기능도 추가하였습니다. 그리고 OTA를 이용해서 무선으로 펌웨어를 업로드할 수 있도록해서 지금처럼 펌웨어의 수정사항이 생겼을 때 컨트롤러 하우징을 분해하는 수고를 없앴습니다. (상세 내용은 여기에서)

하드웨어

하드웨어의 구성은 동일합니다. 손댈부분이 없으니 어떻게 생겼는지만 보고 넘어가겠습니다. ^^

소프트웨어 (펌웨어)

이전과 동일하게 아두이노 파일과 HTML코드를 정의한 변수용 파일로 구분되어 구성하였습니다. 그리고 코드의 길이가 길어져서 가독성을 위해서 Setup 함수에 들어갈 내용 중 WIFI, 웹서버/소켓, OTA를 처리하는 부분을 사용자 함수로 만들어서 코드 뒷부분에 모아두었습니다. 바로 코드 보시겠습니다.

ESP01_Webserver.ino 파일

먼저 메인 소스코드입니다. 정말 주석 자세히 달았기 때문에 추가 설명은 필요없을거라 생각해요. 한가지 큰 변화는 이전에는 WiFilient.h, ESP8266WebServer.h 헤더를 사용했었는데 이번에는 ESPAsyncTCP.h, ESPAsyncWebServer.h를 사용했습니다. 이유는 그냥 단순히 제가 이해하기 쉬운걸 선택했어요. ㅡ.,ㅡ

모든 로직은 웹 클라이언트의 요청에 따라 응답하면서 동작하고 loop() 함수에는 타이머를 처리하는 로직만 있습니다.

#include <ESP8266WiFi.h>
#include "mainPage.html"
#include <ArduinoOTA.h>
#include <ESPAsyncTCP.h>
#include <ESPAsyncWebServer.h>

const char *ssid = "[SSID]";
const char *password = "[PASSWORD]";

#define load0  0                    // Realy 컨트롤 핀

bool L1Status = HIGH;               // HIGH=Realy OFF, LOW=Realy ON
AsyncWebServer server(80);          // server on port 80
AsyncWebSocket webSocket("/ws");    // websocket

int minuteToOff = 0;                  // 타이머 잔여시간을 저장할 변수
unsigned long prevTimer = 0;          // 타이머 설정시간을 저장할 변수

void setup() {                      
  pinMode(load0, OUTPUT);             // Realy 컨트롤용 핀 지정
  digitalWrite(load0, L1Status);      // 기동시 Realy Off 상태로 설정

  Serial.begin(115200);               // 디버깅용 시리얼 포트

  setupCONNECTION();                  // 와이파이 네트워크 접속
  setupOTA();                         // OTA 기동
  setupWEBSOCKET();                   // Websocket 활성화
  setupMAINPAGE();                    // 웹서버 기동
}

void loop() {                           
  ArduinoOTA.handle();
  webSocket.cleanupClients();

  unsigned long currentMillis = millis();

  // 타이머 설정 후 1분 경과때마다 타이머를 1분식 감소시키고 
  // 0분이 되면 릴레이 OFF & 현상태를 접속한 클라이언트에 통보
  if (minuteToOff > 0 && currentMillis - prevTimer > 60000) { 
    prevTimer = currentMillis;
    minuteToOff--;                                    
    if (minuteToOff == 0) {                           
      L1Status = HIGH;
      digitalWrite(load0, L1Status);                  
    }
    noticeForClient(); 
  }
}

그리고 아래는 사용자 함수부분입니다. 

void setupCONNECTION() {
  WiFi.begin(ssid, password);          // WIFI네트워크에 접속 시작!
  Serial.println("\n");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }  
  Serial.println('\n');
  Serial.println("Connected!");
  Serial.print("IP address:\t");     
  Serial.println(WiFi.localIP());     // 할당받은 IP주소 표시
}

void setupMAINPAGE() {
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send_P(200, "text/html", MAIN_page);
  });
  server.begin();
}

void setupOTA() {
    // Port defaults to 8266
  ArduinoOTA.setPort(8266);

  // Hostname defaults to esp8266-[ChipID]
  // ArduinoOTA.setHostname("myesp8266");

  // No authentication by default
  ArduinoOTA.setPassword("admin");

  // Password can be set with it's md5 value as well
  // MD5(admin) = 21232f297a57a5a743894a0e4a801fc3
  // ArduinoOTA.setPasswordHash("21232f297a57a5a743894a0e4a801fc3");

  ArduinoOTA.onStart([]() {
    String type;
    if (ArduinoOTA.getCommand() == U_FLASH) {
      type = "sketch";
    } else { // U_FS
      type = "filesystem";
    }

    // NOTE: if updating FS this would be the place to unmount FS using FS.end()
    Serial.println("Start updating " + type);
  });
  ArduinoOTA.onEnd([]() {
    Serial.println("\nEnd");
  });
  ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
    Serial.printf("Progress: %u%%\r", (progress / (total / 100)));
  });
  ArduinoOTA.onError([](ota_error_t error) {
    Serial.printf("Error[%u]: ", error);
    if (error == OTA_AUTH_ERROR) {
      Serial.println("Auth Failed");
    } else if (error == OTA_BEGIN_ERROR) {
      Serial.println("Begin Failed");
    } else if (error == OTA_CONNECT_ERROR) {
      Serial.println("Connect Failed");
    } else if (error == OTA_RECEIVE_ERROR) {
      Serial.println("Receive Failed");
    } else if (error == OTA_END_ERROR) {
      Serial.println("End Failed");
    }
  });
  ArduinoOTA.begin();
  Serial.println("OTA Ready");
}

void setupWEBSOCKET() {
  webSocket.onEvent(webSocketEvent);          // if there's an incomming websocket message, go to function 'webSocketEvent'
  server.addHandler(&webSocket);
  Serial.println("WebSocket server started.");
}

// 릴레이 컨트 핀의 상태와 타이머 잔여분을 #으로 구분해서 웹소켓으로 전송
// ex) "0#59" 
void noticeForClient() {
  webSocket.textAll(String(L1Status)+"#"+String(minuteToOff));
}

// 클라이언트에서 오는 웹소켓 메시지를 처리하는 함수
void handleWebSocketMessage(void *arg, uint8_t *data, size_t len) {
  AwsFrameInfo *info = (AwsFrameInfo*)arg;
  if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
    data[len] = 0;
    if (strcmp((char*)data, "toggle") == 0) {       // "toggle"이 전송되면
      L1Status = !L1Status;                         // 릴레이 상태 변수를 전환하고
      minuteToOff = (L1Status == LOW) ? 60 : 0;     // LOW 상태이면 타이머 1시간, 아니면 0으로 설정
    }
    else if (data[0]=='@') {                        // "@"로 시작하는 문자열이 전송되면
      String s = (const char*)&data[1];             // 두번째 문자에서 시작하는 문자열을 추출해서
      minuteToOff = s.toInt();                      // 문자를 정수로 변환하고 타이머 변수에 저장
    }
    digitalWrite(load0, L1Status);                  // 릴레이 상태 변수에 따라 핀 상태 전환
    noticeForClient();                              // 웹소켓 메시지가 오면 무조건 현상태를 회신
  }
}

// 웹소켓 이벤트를 구분하는 함수
void webSocketEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type,
                    void *arg, uint8_t *data, size_t len) { // When a WebSocket message is received
  switch (type) {
    case WS_EVT_CONNECT:
        Serial.printf("WebSocket client #%u connected from %s\n", client->id(), client->remoteIP().toString().c_str());
        break;
      case WS_EVT_DISCONNECT:
        Serial.printf("WebSocket client #%u disconnected\n", client->id());
        break;
      case WS_EVT_DATA:
        handleWebSocketMessage(arg, data, len);
        break;
      case WS_EVT_PONG:
      case WS_EVT_ERROR:
        break;
  }
}

여기서 설명이 필요한 부분은 아마 아래 2개 함수가 아닐까 싶습니다.

handleWebSocketMessage()

클라이언트에서 발생시키는 웹소켓 메시지에 따라서 어떤 동작을 수행할지 판단하는 함수입니다. 문자로 "toggle"이라는 메시지가 텍스트 형태로 수신되면 릴레이를 컨트롤하는 핀의 상태 정보를 담고 있는 변수 "L1Status" 반전합니다. 그러니까 0이면 1로 만들고 1이면 0으로 만드는 거죠. 그리고 문자열 첫 번째 자리에 "@"가 들어가는 문자가 수신되는 경우에는 그 뒤에 따라온 숫자를 읽어서 타이머 값으로 세팅합니다. 마지막으로 앞에서 언급한 2가지 경우를 제외한 나머지 그 어떤 메시지라도 있으면 "notiveForClient()"함수를 호출합니다.

noticeForClient()

웹소켓을 통해서 이번에는 ESP8266에서 클라이언트 쪽으로 메시지를 보내는 함수입니다. 메시지의 내용은 릴레이를 컨트롤하는 필의 현재상태와 설정된 타이머의 남은 시간(분)입니다. 그리고 이 두 가지 정보는 "#"으로 연결된 문자열로 되어있습니다. 그래서 "0#59"라는 메시지는 L1Status=0이고 minuteToOff=59라는 의미입니다. 그리고 이 정보는 웹페이지의 자바스크립트에서 디코딩됩니다.

mainPage.html 파일

그럼 계속해서 HTML코드 살펴보겠습니다.

const char MAIN_page[] PROGMEM = R"=====(
<!DOCTYPE html>
<html lang="ko">
<head>
<meta name="viewport"content="width=device-width,initial-scale=1,user-scalable=no"/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<link rel="icon" href="data:,">
<style>
body{text-align:center;font-family:verdana;}
button{border:0;border-radius:0.3rem;color:rgb(236, 93, 201);line-height:2.4rem;font-size:1.2rem;width:100%}
</style>
</head>

<TITLE>
WIFI Controler
</TITLE>

<BODY>
<div style="text-align:center;display:inline-block;min-width:260px;">
<CENTER>
<h2>우리집 IOT System</h2>
<h3>거실스탠드</h3>
<p><button id="button" class="button"><sapn id="state">OFF</sapn></button></p>
<p>
  타이머
  <input id="timer_minute" type="text" value="0">     
  <span>분</span> 
  <input id="timer" type="submit" value="설정"></inpit>
</p>
</CENTER>
</BODY>
</HTML>

<script>
  var gateway = `ws://${window.location.hostname}/ws`;
  var websocket;
  window.addEventListener('load', onLoad);
  function initWebSocket() {
    console.log('Trying to open a WebSocket connection...');
    websocket = new WebSocket(gateway);
    websocket.onopen    = onOpen;
    websocket.onclose   = onClose;
    websocket.onmessage = onMessage;
  }
  function onOpen(event) {
    console.log('Connection opened');
    websocket.send('status');
  }
  function onClose(event) {
    console.log('Connection closed');
    setTimeout(initWebSocket, 2000);
  }

  function onMessage(event) {
    var state
    var msg = event.data.split('#');
    var buttonColor;
    if (msg[0] == "0"){
      state = "ON";
      buttonColor = "#fdfa3d"
    }
    else if (msg[0] == "1") {
      state = "OFF";
      buttonColor = "#1fa3ec"
    }
    document.getElementById('state').innerHTML = state;
    document.getElementById('timer_minute').value = msg[1];
    button.style.backgroundColor = buttonColor;
  }
  function onLoad(event) {
    initWebSocket();
    initButton();
  }
  function initButton() {
    document.getElementById('button').addEventListener('click', toggle);
    document.getElementById('timer').addEventListener('click', timer);
  }
  function toggle(){
    websocket.send('toggle');
  }
  function timer(){
    var msg = "@" + document.getElementById("timer_minute").value;
    console.log(msg);
    websocket.send(msg);
  }


</script>
)=====";

32행 앞부분은 웹페이지 표현 코드니까 건너뛰고, 자바스크립트 부분만 살펴보겠습니다. 그리고 저는 자바스크립트를 잘 몰라서 간단하게 흐름만 말씀드리겠습니다.

37행: addEventListener('load', onLoad) 함수는 웹페이지 로딩이 완료되면 호출되는 함수로 다시 70행의 "onLoad"함수를 호출합니다.

그리고 38~44행까지의 initWebSocket()함수는 발생되는 웹소켓에 대한 콜백 함수를 정의하는 부분입니다. 웹소켓을 초기화하면 콘솔에 디버그 메시지를 출력하고 구축된 웹소켓을 "websocket"이라는 변수에 연결합니다. 그리고 웹소켓이 열리면 onOpen, 닫히면 onClose, 메시지가 있으면 onMessage함수를 호출하라고 정의하고 있습니다.

45행은 웹소켓이 열리면 호출되는 함수죠. 그래서 웹소켓이 열리면 46행에서 디버깅 메시지를 보내고 47행에서 ESP8266으로 'status'라는 텍스트 메시지를 보냅니다. ESP8266에서는 status 메시지에 대응되는 별도의 로직은 없지만 앞에서 handleWebSocketMessage() 함수에서 조건문에 걸리지 않으면 무조건 noticeForClient() 함수를 호출하게 되어 있기 때문에 웹소켓이 연결되면 ESP8266 상태를 전송받게 됩니다.

54~69행은 웹소켓 메시지를 처리하는 부분입니다. 앞에서 noticeForClient()라는 함수는 "#"로 연결된 2가지 코드의 연결 값을 보낸다고 했었죠. 이 메시지를 56행에서 #를 기준으로 앞과 뒤로 2개의 문자열로 분리합니다. 그리고 첫 번째 문자가 만약 "0"이라면 59~60행이 수행되고 "1"이면 63~64행이 수행되겠죠. 이것은 전등의 ON/OFF상태에 따라 웹페이지에 표시될 버튼의 스타일 값을 교체해주는 부분입니다.

67행에서는 #뒷부분에 있는 문자열인 타이머의 남은시간(분)을 웹페이지에 표시값으로 치환하는 부분입니다.

78, 81행은 웹페이지에서 버튼이 눌려졌을 때 어떤 동작을 할지를 정의하고 있는데 전등을 컨트롤하는 버튼이 눌려지면 'toggle'이라는 메시지가 발생하고 "설정" 버튼이 눌려지면 "@"뒤에 타이머 입력란에 있는 숫자를 붙여서 '@59'와 같은 메시지를 발생시킵니다.

이제 코드에 대해서는 대략 설명이 된것 같네요.

35행 gateway: 지금 설정으로는 동일 네트워크 안에서만 사용이 가능했습니다. 제경우 포트포워딩으로 외부에서 접속을 시도했을 때 웹소켓 메시지가 정상적으로 전달이 되지 않았습니다. 그래서 이게 맞는 방법인지는 모르겠지만 `ws://${window.location.hostname}/ws` 를 `공유기IP or DDNS주소:포트번호로 수정해봤더니 잘되네요. (ex: 
`ws://myddns.iptime.org:8888/ws`) 혹시 유사증상이 있으시면 시도해 보세요.

platformio.ini 파일

참고로 platformio IDE에서 제가 사용하는 설정입니다. 요건 참고만 하시면 될것 같아요.

[env:esp8266]
platform = espressif8266
board = esp01_1m
framework = arduino
monitor_speed = 115200
board_build.flash_mode = dio
upload_resetmethod = nodemcu
upload_speed = 115200 
lib_deps = 
  ottowinter/ESPAsyncWebServer-esphome @ ^1.2.7
  ottowinter/ESPAsyncTCP-esphome @ ^1.2.3

[env:esp8266OTA]
platform = espressif8266
board = esp01_1m
framework = arduino
monitor_speed = 115200
board_build.flash_mode = dio
upload_resetmethod = nodemcu
upload_protocol = espota
upload_port = 10.11.85.27
upload_flags =
  --port=8266
  --auth=admin
lib_deps = 
  ottowinter/ESPAsyncWebServer-esphome @ ^1.2.7
  ottowinter/ESPAsyncTCP-esphome @ ^1.2.3

결과

업로드를 하고 웹서버에 접속해보면 아래 그림처럼 전등을 컨트롤하는 동시에 상태 값을 즉시 알 수 있는 페이지를 확인할 수 있습니다.

복수의 클라이언트가 접속을 했을 때 어느 한 곳에서 상태를 변경하면 그 즉시 다른 곳에서도 변화를 알수 있게 되었습니다. 

 

끝!

반응형

댓글