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

ESP8266 웹서버+웹소켓+파일시스템(SPIFFS) 예제

by lovey25 2020. 10. 24.
반응형

ESP8266 모듈을 사용해서 전등을 컨트롤하는 프로젝트를 진행했었습니다.  컨트롤 인터페이스를 위해서 ESP8266 모듈을 웹서버로 활용할 수 있는 기능을 사용했었는데요. HTML 코드를 문자열로 저장하고 있다가 요청이 오면 그 문자열을 반환해서 웹페이지를 보여주는 방식이었습니다.

하지만 이런 방법으로는 웹페이지를 디자인하는데 제한사항이 너무 많습니다. 일단 웹페이지 디자인 수정이 필요한 경우 펌웨어 자체를 다시 올려야 하기 문제가 있습니다. 물론 OTA를 이용해서 나름 편리하게 수정을 할 수 있지만 업데이트 때마다 서버가 중단되어야 하는 문제는 해결하지 못합니다. 그리고 웹페이지에서 사용할 수 있는 소스에도 한계가 있습니다. 물론 ESP8266 모듈의 용량이 코딱지 만하기 때문에 물리적인 한계는 있지만 그림파일 같은 것도 활용하기 어렵죠.

그래서 이런 불편함을 해결하기 위해서 ESP8266 모듈에 있는 Flash 메모리 공간 일부를 일반적인 파일 시스템처럼 사용할 수 있는 기술이 있습니다. 아래 그림은 "ESP8266 Arduino Core’s documentation"에서 가져온 건데 플래시 메모리의 내부 구조를 보여주고 있습니다. 플래시 메모리 안에서 구역을 목적에 따라 임의로 구분합니다. 메모리 카드의 젤 앞쪽은 펌웨어가 되는 스케치 코드가 위치하고 그다음에 OTA를 사용할 때 사용하는 공간이 일부 차지하고 그 뒤에 File system으로 사용할 공간이 위치하고 있습니다. 그래서 플래시 메모리를 외부에서 접속하게 되면 우리가 일반적으로 사용하는 하드 드라이브나 메모리카드처럼 사용할 수 있게 되는 거죠. 물론 파일 시스템에 접속하기 위한 인터페이스는 별도로 마련해주어야 합니다.

그래서 본문에서 소개하고자 하는 게 바로 그 파일 시스템에 접속할 수 있는 예제입니다. 결과물 먼저 확인해 보시겠습니다. 

펌웨어와 필요한 웹페이지 파일을 업로드하고 나면 아래와 같은 화면을 볼 수 있습니다. index.htm을 열면 웹소켓으로 ESP8266 모듈과 데이터를 주고받을 수 있는 터미널 화면 같은 페이지도 볼 수 있고,

그리고 edit.htm을 열면 아래와 같이 로그인 과정을 거친 다음,

ESP8266 모듈 내부 파일 시스템에 저장된 파일을 확인하고 편집할 수 있는 인터페이스를 볼 수 있습니다. 여기서는 새로운 파일을 생성하거나 기존에 있는 다른 파일을 업로드할 수 있고 업로드된 파일을 삭제하거나 다운로드할 수도 있습니다.

그리고 여기서 또 하나 확인할 부분이 파로 favicon.ico파일입니다. 파비콘이 ico파일로 파일 시스템에 올라가 있고 웹브라우저에서 자동으로 읽어서 파비콘을 보여주고 있습니다. ^^ 정말 유용하게 사용할 수 있을 것 같죠?!

그럼 펌웨어 살펴보겠습니다. 참고로 이 예제 코드는 ESPAsyncWebserver 라이브러리에 기본으로 포함된 예제 중 하나입니다. 그리고 본 블로그에서 사용하고 있는 파비콘과 더불어 다른 라이브러리에서 가져온 에디터 페이지 파일을 사용했는데 하도 여러 가지 예제를 뒤지다 보니 어디서 가져온 건지 생각이 안 나네요.

소스코드는 원래 예제를 제 입맛에 맞게 구성만 조금 수정했습니다. 

#include <ArduinoOTA.h>
#ifdef ESP32
#include <FS.h>
#include <SPIFFS.h>
#include <ESPmDNS.h>
#include <WiFi.h>
#include <AsyncTCP.h>
#elif defined(ESP8266)
#include <ESP8266WiFi.h>
#include <ESPAsyncTCP.h>
#include <ESP8266mDNS.h>
#endif
#include <ESPAsyncWebServer.h>
#include <SPIFFSEditor.h>

AsyncWebServer server(80);
AsyncWebSocket ws("/ws");
AsyncEventSource events("/events");

const char* ssid = "[SSID]";
const char* password = "[PASSWORD]";
const char * hostName = "esp-async";
const char* http_username = "admin";
const char* http_password = "admin";

void setup(){
  Serial.begin(115200);
  //Serial.setDebugOutput(true);

  setupCONNECTION();
  setupOTA();
  setupWEBSOCKET();
  setupWEBPAGE();   

  MDNS.addService("http","tcp",80);
}

void loop(){
  ArduinoOTA.handle();
  ws.cleanupClients();
}

void setupCONNECTION() {
  WiFi.mode(WIFI_AP_STA);
  WiFi.softAP(hostName);
  WiFi.begin(ssid, password);
  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주소 표시

  if (WiFi.waitForConnectResult() != WL_CONNECTED) {
    Serial.printf("STA: Failed!\n");
    WiFi.disconnect(false);
    delay(1000);
    WiFi.begin(ssid, password);
  }
}

void setupOTA() {
  //ArduinoOTA.setPort(8266);           // Port defaults to 8266
  //ArduinoOTA.setHostname(hostName);   // Hostname defaults to esp8266-[ChipID]
  ArduinoOTA.setPassword("admin");      // No authentication by default

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

  ArduinoOTA.onStart([]() { events.send("Update Start", "ota"); });
  ArduinoOTA.onEnd([]() { events.send("Update End", "ota"); });
  ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
    char p[32];
    sprintf(p, "Progress: %u%%\n", (progress/(total/100)));
    events.send(p, "ota");
  });
  ArduinoOTA.onError([](ota_error_t error) {
    if(error == OTA_AUTH_ERROR) events.send("Auth Failed", "ota");
    else if(error == OTA_BEGIN_ERROR) events.send("Begin Failed", "ota");
    else if(error == OTA_CONNECT_ERROR) events.send("Connect Failed", "ota");
    else if(error == OTA_RECEIVE_ERROR) events.send("Recieve Failed", "ota");
    else if(error == OTA_END_ERROR) events.send("End Failed", "ota");
  });
  
  ArduinoOTA.begin();
  Serial.println("OTA Ready");
}

void setupWEBSOCKET() {
  ws.onEvent(webSocketEvent);
  server.addHandler(&ws);
  Serial.println("WebSocket server started.");
}

void webSocketEvent(AsyncWebSocket * server, AsyncWebSocketClient * client, AwsEventType type, void * arg, uint8_t *data, size_t len){
  if(type == WS_EVT_CONNECT){
    Serial.printf("ws[%s][%u] connect\n", server->url(), client->id());
    client->printf("Hello Client %u :)", client->id());
    client->ping();
  } else if(type == WS_EVT_DISCONNECT){
    Serial.printf("ws[%s][%u] disconnect\n", server->url(), client->id());
  } else if(type == WS_EVT_ERROR){
    Serial.printf("ws[%s][%u] error(%u): %s\n", server->url(), client->id(), *((uint16_t*)arg), (char*)data);
  } else if(type == WS_EVT_PONG){
    Serial.printf("ws[%s][%u] pong[%u]: %s\n", server->url(), client->id(), len, (len)?(char*)data:"");
  } else if(type == WS_EVT_DATA){
    AwsFrameInfo * info = (AwsFrameInfo*)arg;
    String msg = "";
    if(info->final && info->index == 0 && info->len == len){
      //the whole message is in a single frame and we got all of it's data
      Serial.printf("ws[%s][%u] %s-message[%llu]: ", server->url(), client->id(), (info->opcode == WS_TEXT)?"text":"binary", info->len);

      if(info->opcode == WS_TEXT){
        for(size_t i=0; i < info->len; i++) {
          msg += (char) data[i];
        }
      } else {
        char buff[3];
        for(size_t i=0; i < info->len; i++) {
          sprintf(buff, "%02x ", (uint8_t) data[i]);
          msg += buff ;
        }
      }
      Serial.printf("%s\n",msg.c_str());

      if(info->opcode == WS_TEXT)
        client->text("I got your text message");
      else
        client->binary("I got your binary message");
    } else {
      //message is comprised of multiple frames or the frame is split into multiple packets
      if(info->index == 0){
        if(info->num == 0)
          Serial.printf("ws[%s][%u] %s-message start\n", server->url(), client->id(), (info->message_opcode == WS_TEXT)?"text":"binary");
        Serial.printf("ws[%s][%u] frame[%u] start[%llu]\n", server->url(), client->id(), info->num, info->len);
      }

      Serial.printf("ws[%s][%u] frame[%u] %s[%llu - %llu]: ", server->url(), client->id(), info->num, (info->message_opcode == WS_TEXT)?"text":"binary", info->index, info->index + len);

      if(info->opcode == WS_TEXT){
        for(size_t i=0; i < len; i++) {
          msg += (char) data[i];
        }
      } else {
        char buff[3];
        for(size_t i=0; i < len; i++) {
          sprintf(buff, "%02x ", (uint8_t) data[i]);
          msg += buff ;
        }
      }
      Serial.printf("%s\n",msg.c_str());

      if((info->index + len) == info->len){
        Serial.printf("ws[%s][%u] frame[%u] end[%llu]\n", server->url(), client->id(), info->num, info->len);
        if(info->final){
          Serial.printf("ws[%s][%u] %s-message end\n", server->url(), client->id(), (info->message_opcode == WS_TEXT)?"text":"binary");
          if(info->message_opcode == WS_TEXT)
            client->text("I got your text message");
          else
            client->binary("I got your binary message");
        }
      }
    }
  }
}

void setupWEBPAGE() {
  SPIFFS.begin();
  
  events.onConnect([](AsyncEventSourceClient *client){
    client->send("hello!",NULL,millis(),1000);
  });
  server.addHandler(&events);

#ifdef ESP32
  server.addHandler(new SPIFFSEditor(SPIFFS, http_username,http_password));
#elif defined(ESP8266)
  server.addHandler(new SPIFFSEditor(http_username,http_password));
#endif
  
  server.on("/heap", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(200, "text/plain", String(ESP.getFreeHeap()));
  });

  server.serveStatic("/", SPIFFS, "/").setDefaultFile("index.htm");

  server.onNotFound([](AsyncWebServerRequest *request){
    Serial.printf("NOT_FOUND: ");
    if(request->method() == HTTP_GET)
      Serial.printf("GET");
    else if(request->method() == HTTP_POST)
      Serial.printf("POST");
    else if(request->method() == HTTP_DELETE)
      Serial.printf("DELETE");
    else if(request->method() == HTTP_PUT)
      Serial.printf("PUT");
    else if(request->method() == HTTP_PATCH)
      Serial.printf("PATCH");
    else if(request->method() == HTTP_HEAD)
      Serial.printf("HEAD");
    else if(request->method() == HTTP_OPTIONS)
      Serial.printf("OPTIONS");
    else
      Serial.printf("UNKNOWN");
    Serial.printf(" http://%s%s\n", request->host().c_str(), request->url().c_str());

    if(request->contentLength()){
      Serial.printf("_CONTENT_TYPE: %s\n", request->contentType().c_str());
      Serial.printf("_CONTENT_LENGTH: %u\n", request->contentLength());
    }

    int headers = request->headers();
    int i;
    for(i=0;i<headers;i++){
      AsyncWebHeader* h = request->getHeader(i);
      Serial.printf("_HEADER[%s]: %s\n", h->name().c_str(), h->value().c_str());
    }

    int params = request->params();
    for(i=0;i<params;i++){
      AsyncWebParameter* p = request->getParam(i);
      if(p->isFile()){
        Serial.printf("_FILE[%s]: %s, size: %u\n", p->name().c_str(), p->value().c_str(), p->size());
      } else if(p->isPost()){
        Serial.printf("_POST[%s]: %s\n", p->name().c_str(), p->value().c_str());
      } else {
        Serial.printf("_GET[%s]: %s\n", p->name().c_str(), p->value().c_str());
      }
    }

    request->send(404);
  });
  server.onFileUpload([](AsyncWebServerRequest *request, const String& filename, size_t index, uint8_t *data, size_t len, bool final){
    if(!index)
      Serial.printf("UploadStart: %s\n", filename.c_str());
    Serial.printf("%s", (const char*)data);
    if(final)
      Serial.printf("UploadEnd: %s (%u)\n", filename.c_str(), index+len);
  });
  server.onRequestBody([](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total){
    if(!index)
      Serial.printf("BodyStart: %u\n", total);
    Serial.printf("%s", (const char*)data);
    if(index + len == total)
      Serial.printf("BodyEnd: %u\n", total);
  });
  server.begin();
}

그리고 아래는 파일 시스템에 올라갈 파일들입니다.

data.zip
0.01MB

파일 시스템의 업로드 방법은 스케치를 업로드하는 방법과 약간 차이가 있으며 그리고 사용하는 IDE에 따라서도 차이가 있습니다. 저는 PlatformIO IDE를 사용하고 있는데요. 위의 소스코드와 추가파일을 아래 그림과 같은 구조로 준비를 합니다. 그러니까 소스코드와 별도로 파일 시스템에 올라갈 파일은 구분해서 "data"라는 폴더에 저장되어 있습니다.

그리고 platformio.ini는 다음과 같이 설정하였습니다.

[platformio]
lib_dir = libraries
data_dir = data
default_envs = esp12e

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

이제 PlatformIO메뉴에서 "Upload"와 "Upload Filesystem Image"를 각각 눌러서 준비한 데이터를 모두 업로드시켜줍니다.

이렇게 하면 모든 과정이 끝났습니다. 시리얼 모니터에 표시되는 IP주소를 확인해서 웹브라우저로 접속해 보면 앞에서 봤던 결과 페이지들을 확인할 수 있습니다. 참고로 에디터 페이지는 "[IP주소]/edit.htm"으로 파일명까지 입력하시면 확인할 수 있습니다.

마지막으로 컴파일 중에 다음과 같은 경고 메시지를 확인할 수 있는데요. 본 예제에서는 파일 시스템을 사용하는 하기 위해서 SPIFFS라는 라이브러리를 사용합니다. 하지만 SPIFFS는 개발이 더 이상 이루어지지 않는 라이브러리라서 앞으로는 없어질 기능이라고 합니다. 대신에 LittleFS를 사용하라고 권고하고 있네요.

공부를 좀 더 해서 앞으로는 LittleFS를 프로젝트에 적용해야겠네요.

 

끝!

반응형

댓글