배경
ESP8266 모듈을 사용하는 데 있어서, 제 수준에서는 나름 끝판왕 프로젝트라고 할 수 있을 것 같습니다.
얼마 전에 캠핑장에서 사용하는 온풍기를 온도에 따라 자동으로 끄고 켜주기 위해서 릴레이를 ESP8266 모듈 중 하나인 wemos d1 mini에 연결하고 온습도 센서도 연결해서 온도 값에 따라서 온풍기가 연결될 릴레이를 컨트롤하는 프로젝트를 했던 적이 있었습니다. 프로젝트에 대한 상세는 아래 링크를 참고해 주세요.
나름 스마트 플러그처럼 만들어서 완성도도 신경을 썼고 핸드폰으로 연결해서 무선으로 조작할 수 있도록 사용성 면에서도 고민을 많이 했었던 프로젝트였습니다. 실제로 캠핑장에서 3번 정도 사용을 해 본 것 같은데 이 정도로도 아주 만족스러운 결과를 얻긴 했지만 역시나 몇 가지 아쉬운 점이 보였고 그리고 ESP8266 활용 예제를 공부하면서 조금씩 견문도 넓어져서 이것저것 해볼 만한 잔기술도 생기기도 해서 약간 업그레이드를 시도해 봤습니다.
업그레이드를 통해서 구현하고자 하는 목표 기능은 2가지입니다. 첫 번째는 양방향의 동적 컨트롤을 가동하도록 하는 겁니다. 이전 예제는 POST 혹은 GET 메시지를 통해서 클라이언트의 요청사항이 있어야지만 서버 측인 ESP8266 모듈이 그에 상응하는 명령을 수행하는 방식이었습니다. 그러다 보니 센서 값을 실시간으로 모니터링하기 위해서는 클라이언트에서 지속적으로 웹페이지 새로고침 등 센서 값 요청을 지속적으로 보내줘야 하는 제약이 있었고 반대로 서버 측에서는 어떤 변동사항이 생겨도 클라이언트에게 먼저 알려줄 수 없는 한계가 있었습니다.
HTTP 통신의 한계를 극복하기 위한 웹소켓이라는 통신방법이 있는데 감사하게도 ESP8266 모듈에서도 웹소켓을 사용할 수 있도록 해주는 라이브러리들이 있습니다. 이전에 ESP8266용 웹소켓 라이브러리가 포함된 예제 프로젝트를 소개한 적이 있었는데요. 웹소켓을 사용하면 HTTP의 단방향 통신의 한계를 해결할 수 있습니다.
저 글을 쓸 당시에는 기능을 한번 구현해 보는데 급급해서 응용을 생각할 여유가 없었지만 코드가 익숙해지고 나니 생각보다 간단한 수정만으로도 웹소켓 통신의 응용이 가능했습니다. 통신에 대한 모든 구현이 되어있는 예제이기 때문에 응용도 아니고 그냥 활용이라고 할 수 있겠습니다.
그리고 구현하고 싶은 두 번째 기능도 위의 예제에 포함된 것인데요. 측정한 센서 데이터를 기록해서 나중에 컨트롤러가 잘 동작했는지 그 결과를 확인해 보는 것이었습니다. 위 예제에는 웹소켓뿐만 아니라 SPIFFS에 대한 부분도 같이 포함되어 있습니다. ESP8266메모리의 일부를 파일 시스템으로 사용할 수 있도록 하는 기술로써 원래 웹페이지를 표현하기 위한 HTML 소스를 ESP8266 펌웨어 코드에 포함시켜서 코딩을 해야 했던 것을 별도의 파일로 저장시켜서 사용할 수 있게 되었습니다. 그래서 웹페이지 UI를 수정하는 등 소프트 작업 정도는 컴파일 없이 해결할 수 있게 되고 동시에 인터넷에 연결되어 있지 않더라도 센서 로그 등을 파일로 저장하는 것도 가능해지는 거죠.
먼저 업그레이드 결과를 먼저 보고 상세한 내용 이어가도록 하겠습니다.
짜란~ 이제 먼가 그럴듯해 보이죠?! ㅎ 개인적으로는 만족입니다.
인터페이스는 기존 스마트 플러그와 거의 동일합니다. 대신 현재 온도와 습도 값이 웹소켓으로 전달되고 Java Script로 웹소켓 메시지를 파싱 해서 화면에 뿌려주는 방식으로 동작하도록 하였습니다. 그래서 웹페이지에서 별도의 온도 값 갱신 요청이 없더라도 변화되는 온도를 즉시 확인할 수 있습니다. 그리고 각 버튼들도 눌려질 때마다 별도로 지정된 메시지가 웹소켓으로 송신될 수 있도록해서 스마트 플러그와 핸드폰의 연결이 좀 더 다이내믹하게 되었습니다.
그리고 화면 하단에는 구글 API에서 제공하는 그래프를 추가해서 파일로 저장된 센서 값을 확인할 수 있도록 하였습니다. 그래프는 줌인/아웃도 가능하고 시간축을 이동하는 것도 가능합니다. 구글 API도 유용한 것들이 많아서 따로 공부해보고 싶은 욕심도 생기네요.
화면 중간에 토글 버튼은 "이웃집몽씨"라는 블로그를 참고하였습니다.(출처: https://imivory.tistory.com/15)
자 그럼 이제부터 프로젝트 구성에 대해서 살펴보겠습니다. 하드웨어 부분은 이전 포스팅 내용에서 변경되는 부분이 없으니 생략하고 바로 소프트웨어 부분으로 들어가겠습니다.
펌웨어
#include<Arduino.h>
#include <ArduinoOTA.h>
#include <ESP8266WiFiMulti.h>
#include <ESP8266mDNS.h> // 현재 WIN10, Android에서는 사용이 불가함
#include <ESPAsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <SPIFFSEditor.h>
#include <WiFiUdp.h>
#include <chrono>
#include "DHT.h"
#define DHTPIN 5 // GPIO5 = D1, DHT센서 연결
#define HEATPIN 4 // GPIO4 = D2, 히터 컨트롤용 릴레이 연결
#define DHTTYPE DHT22
DHT dht(DHTPIN, DHTTYPE);
#define numofdata 30 // 평균값을 구할 데이터 크기
float TEMP[numofdata] = {0.0}; // 상대온도 저장 변수
float HUMI[numofdata] = {0.0}; // 섭씨습도 저장 변수
__int8_t nextindex = numofdata - 1; // 온습도를 저장 할 다음 순서 마지막 인덱스로 초기화
float setTemp = 27.0; // 설정온도 저장 변수
bool setLog = false; // 로그 설정값 저장 변수 (false=기록안함)
WiFiUDP UDP;
const char* NTPServerName = "kr.pool.ntp.org";
const int NTP_PACKET_SIZE = 48; // 패킷 메시지의 첫 48 bytes가 timestamp
byte NTPBuffer[NTP_PACKET_SIZE]; // 주고받는 패킷 메시지를 저장할 버퍼
unsigned long intervalNTP = 3600000; // NTP 시간을 반복 요청할 간격 (1시간)
unsigned long prevNTP = 0;
unsigned long lastNTPResponse = millis();
unsigned long prevActualTime = 0;
uint32_t timeUNIX = 0; // Unixtime 저장 변수
IPAddress timeServerIP;
const char *ssid = "GoCamp_AP";
const char *password = "passpass";
unsigned long preMillsForDHT = 0;
unsigned long preMillsForHeat = 0;
unsigned long preMillsForWS = 0;
ESP8266WiFiMulti wifiMulti;
String strTemp, strHumi, strsetTemp, myLocalIP;
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");
AsyncEventSource events("/events");
const char * hostName = "gocamp";
const char* http_username = "admin";
const char* http_password = "admin";
File fsUploadFile; // a File variable to temporarily store the received file
// WIFI Connection 부분
void setupCONNECTION() {
WiFi.mode(WIFI_AP_STA);
WiFi.softAP(hostName);
Serial.println('\n');
Serial.print("Access Point \"");
Serial.print(ssid);
Serial.println("\" started");
wifiMulti.addAP("SSID1","PASSWORD1");
wifiMulti.addAP("SSID2","PASSWORD2");
Serial.println('\n');
Serial.println("Connecting...");
while (wifiMulti.run() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println('\n');
Serial.print("Connected to: ");
Serial.println(WiFi.SSID()); // 접속된 AP 표시
Serial.print("IP address:\t");
Serial.println(WiFi.localIP()); // 할당받은 IP주소 표시
}
// OTA 서비스 부분
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 sendNTPpacket(IPAddress &address)
{
memset(NTPBuffer, 0, NTP_PACKET_SIZE); // 버퍼 리셋
NTPBuffer[0] = 0b11100011; // NTP패킷 초기화 (LI=11, Version=100, Mode=011)
UDP.beginPacket(address, 123); // 123번 포트로 패킷 전송 시작
UDP.write(NTPBuffer, NTP_PACKET_SIZE); // NTP메시지를 패킷에 포함
UDP.endPacket(); // 전송 완료
}
void setupUDP()
{
Serial.println("Starting UDP");
UDP.begin(123); // UDP 메시지 모니터링 시작 (포트번호:123)
Serial.print("Local port:\t");
Serial.println(UDP.localPort());
Serial.println();
if (!WiFi.hostByName(NTPServerName, timeServerIP))
{ // NTP 서버의 IP확인
Serial.println("DNS lookup failed. Rebooting."); // NTP 서버를 못찾은 경우 다시 시도
Serial.flush();
ESP.reset();
}
Serial.print("Time server IP:\t"); // NTP 접속정보 표시
Serial.println(timeServerIP);
Serial.println("\r\nSending NTP request ...");
sendNTPpacket(timeServerIP); // NTP 시간 요청
}
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 (msg[0] == '@')
{
msg = msg.substring(1); // 맨앞 @ 문자를 제거하고
setTemp = msg.toFloat(); // 설정온도값을 실수로 변환
String str_msg;
str_msg = String("@,"+ (String)setTemp);
Serial.printf("%s\n",msg.c_str());
ws.textAll(str_msg); // 수신한 명령을 다시 회신
}
// '#'로 시작하는 메시지(기록 설정값)를 수신했을 때
if (msg[0] == '#')
{
msg = msg.substring(1); // 맨앞 # 문자를 제거하고
setLog = msg.toInt(); // 설정값을 숫자로 변환 (0 or 1)
String str_msg;
str_msg = String("#,"+ (String)setLog);
Serial.printf("%s\n",msg.c_str());
ws.textAll(str_msg); // 수신한 명령을 다시 회신
}
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 setupWEBSOCKET() {
ws.onEvent(webSocketEvent);
server.addHandler(&ws);
Serial.println("WebSocket server started.");
}
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.html");
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();
}
void setupMDNS() {
if (MDNS.begin(hostName)) { // Start the mDNS responder for esp8266.local
Serial.println("mDNS responder started");
} else {
Serial.println("Error setting up MDNS responder!");
}
}
uint32_t getTime()
{
if (UDP.parsePacket() == 0)
{ // NTP의 응답이 없으면
return 0; // 0을 반환
}
UDP.read(NTPBuffer, NTP_PACKET_SIZE); // 패킷 메시지 읽어오기
// Combine the 4 timestamp bytes into one 32-bit number
uint32_t NTPTime = (NTPBuffer[40] << 24) | (NTPBuffer[41] << 16) | (NTPBuffer[42] << 8) | NTPBuffer[43];
// @ NTP time -> UNIX timestamp로 변환
/// - Unix time starts on Jan 1 1970.
/// - NTP time starts on Jan 1 1990.
const uint32_t seventyYears = 2208988800UL; //2208988800 sec = 70 years
uint32_t UNIXTime = NTPTime - seventyYears; // (변환계산)
return UNIXTime; // UNIX time값 반환
}
uint32_t stamptime()
{
unsigned long currentMillis = millis();
if (currentMillis - prevNTP > intervalNTP)
{ // intervalNTP 간격으로
prevNTP = currentMillis;
Serial.println("\r\nSending NTP request ...");
sendNTPpacket(timeServerIP); // NTP 시간 요청
}
uint32_t time = getTime(); // NTP서버가 회신한 시간이 있으면 시간값 가져오기 (UNIXtime)
if (time)
{ // NTP의 응답이 없을경우 time은 "0"
timeUNIX = time; // 응답이 있으면 반환된 시간을 전역변수에 저장
Serial.print("NTP response:\t");
Serial.println(timeUNIX); // UNIXtime값 시리얼 출력
lastNTPResponse = currentMillis; // NTP 시간 동기화를 시도한 시점의 ESP 내부 시간 기록 (현재시간 계산용)
}
else if ((currentMillis - lastNTPResponse) > 36000000)
{
Serial.println("마지막 NTP 동기 후 10시간 경과하여 리셋합니다.");
Serial.flush();
ESP.reset();
}
uint32_t actualTime = timeUNIX + (currentMillis - lastNTPResponse) / 1000; // NTP 응답을 기반으로 현재시간 계산 (한국은 UTC +9*3600)
if (actualTime != prevActualTime && timeUNIX != 0)
{ // 현재시간이 변했다면 (1초 경과)
prevActualTime = actualTime;
return actualTime;
}
}
// HEATPIN 상태를 확인해서 0 OR 1 반환
bool isHeaterOn()
{
if (digitalRead(HEATPIN)) {
return 1;
}
else {
return 0;
}
}
// 접속된 클라이언트에게 컬트롤러 상태를 브로드케스팅하는 함수
// 송출 포멧 : "온풍기상태,UNIXtime,온도,습도,설정온도,기록여부"
// ex) 0,1631627692,30.94,33.74,27.00,0
void noti2Client() {
String str_msg;
str_msg = String(isHeaterOn())+","+
stamptime()+","+
TEMP[nextindex-1]+","+
HUMI[nextindex-1]+","+
setTemp+","+
setLog;
ws.textAll(str_msg);
ws.textAll((String)(nextindex-1));
// nextindex가 numofdata에 도달하여 배열이 꽉 차면
// 배열안의 모든데이터를 평균하여 로그에 기록
if (setLog && nextindex >= numofdata)
{
File tempLog = SPIFFS.open("/temp.txt", "a"); // Write the time and the temperature to the csv file
float avr_T = 0.0;
float avr_H = 0.0;
for (int i = 0; i < numofdata; ++i){
avr_T += TEMP[i];
avr_H += HUMI[i];
}
avr_T /= numofdata;
avr_H /= numofdata;
str_msg = String(isHeaterOn())+","+
(String)stamptime()+","+
(String)avr_T+","+
(String)avr_H+","+
(String)setTemp;
tempLog.println(str_msg);
tempLog.close();
}
}
// 설정된 interval(5초) 간격마다 온습도 측정해서
// 크기가 numofdata인 배열에 차례대로 저장
bool getData() {
unsigned long curMills = millis();
if (curMills - preMillsForDHT >= 5000)
{
if (nextindex >= numofdata) {
nextindex = 0;
}
preMillsForDHT = curMills;
HUMI[nextindex] = dht.readHumidity() - 30; // 상대습도 읽기
TEMP[nextindex] = dht.readTemperature(); // 섭씨온도 읽기
// Check if any reads failed and exit early (to try again).
if (isnan(HUMI[nextindex]) || isnan(TEMP[nextindex])) {
Serial.println(F("Failed to read from DHT sensor!"));
return false;
}
Serial.print((String)TEMP[nextindex]);
nextindex++;
noti2Client(); // 현재상태 발송
}
return true;
}
// 설정온도를 입력하면 현재온도와 비교해서
// 높으면 "true" 낮으면 "false" 을 반환하는 함수
void isNeedHeat(float setT) {
unsigned long curMills = millis();
if (curMills - preMillsForHeat >= 2000) {
preMillsForHeat = curMills;
if (setT > TEMP[nextindex-1]) {
Serial.println("Heater ON!");
digitalWrite(HEATPIN, HIGH);
}
else
{
Serial.println("Heater OFF!");
digitalWrite(HEATPIN, LOW);
}
}
}
void setup() {
Serial.begin(115200);
dht.begin();
pinMode(HEATPIN, OUTPUT);
digitalWrite(HEATPIN, LOW);
setupCONNECTION(); // WIFI 연결
setupOTA(); // OTA 활성화
setupUDP(); // UDP
setupWEBSOCKET(); // 웹소켓
setupWEBPAGE(); // 웹서버 초기화
setupMDNS(); // mDNS 활성화
}
void loop() {
ArduinoOTA.handle();
ws.cleanupClients();
// 함수 호출 순서
/// getData() => noti2Client() => isHeaterOn(), stamptime() ...
getData(); // 온습도를 저장하는 전역변수 갱신
isNeedHeat(setTemp); // 설정온도에 따라 릴레이 컨트롤
}
코드가 길어서 첨부터 끝까지 얘기하기엔 무리가 있고 본 포스팅의 목적인 업그레이드 부분만 간단히 언급하도록 하겠습니다.
webSocketEvent() 함수는 웹소켓을 통해서 어떤 이벤트가 발생이 되면 호출됩니다. 그리고 함수 내부에서는 발생되는 이벤트 중에서 메시지가 들어오면 그 텍스트를 파싱 해서 ESP8266 모듈이 수행해야 하는 명령을 인식하고 수행하게 됩니다.
23행부터 42행이 추가된 부분인데요.
void webSocketEvent(AsyncWebSocket * server, AsyncWebSocketClient * client, AwsEventType type, void * arg, uint8_t *data, size_t len){
// -- 중략 --
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 (msg[0] == '@')
{
msg = msg.substring(1); // 맨앞 @ 문자를 제거하고
setTemp = msg.toFloat(); // 설정온도값을 실수로 변환
String str_msg;
str_msg = String("@,"+ (String)setTemp);
Serial.printf("%s\n",msg.c_str());
ws.textAll(str_msg); // 수신한 명령을 다시 회신
}
// '#'로 시작하는 메시지(기록 설정값)를 수신했을 때
if (msg[0] == '#')
{
msg = msg.substring(1); // 맨앞 # 문자를 제거하고
setLog = msg.toInt(); // 설정값을 숫자로 변환 (0 or 1)
String str_msg;
str_msg = String("#,"+ (String)setLog);
Serial.printf("%s\n",msg.c_str());
ws.textAll(str_msg); // 수신한 명령을 다시 회신
}
// -- 후략 --
수신된 메시지는 'msg'라는 String 변수에 저장이 됩니다. 그리고 24행에서 'msg' 문자열의 맨 앞을 확인해서 "@"문자로 시작되면 설정온도 값을 변경하라는 명령으로 인식하고 그다음으로 따라오는 문자열을 실수로 변환하여 설정온도 값을 저장하는 변수인 'setTemp'에 저장합니다. 그리고 변경된 'setTemp'값은 다시 웹소켓을 통해 클라이언트로 피드백합니다. 만약 시작 문자가 "#"인 경우에는 기록 여부를 변경하라는 명령으로 인식하는데 "#"문자 다음에 오는 숫자를 'setLog' 변수에 저장하고 이 숫자가 '1'이면 온도 값을 기록하는 루틴이 활성화되고 '0'이면 비활성화됩니다. 역시 마찬가지로 설정된 변경에 대해서 클라이언트로 피드백을 줍니다. 피드백을 할 때도 마찬가지로 클라이언트가 어떤 피드백인지 파싱이 쉽게 문자열 앞에 원래 있던 마커인 '@, #'을 다시 붙여서 송신합니다.
다음으로 서버(ESP8266)에서 클라이언트로 센서 값 및 상태 값을 보내주는 "noti2Client()" 함수입니다. 이 함수는 스마트 플러그의 전원 상태 그러니까 온풍기를 연결해서 사용하니까 온풍기의 전원 상태, 현재시간, 온도 값, 습도 값, 설정온도 값, 온도의 기록 여부를 순서대로 쉼표(,)로 구분한 포맷의 문자열(str_msg)로 만들어서 웹소켓으로 전송하는 역할을 합니다.
// 접속된 클라이언트에게 컬트롤러 상태를 브로드케스팅하는 함수
// 송출 포멧 : "온풍기상태,UNIXtime,온도,습도,설정온도,기록여부"
// ex) 0,1631627692,30.94,33.74,27.00,0
void noti2Client() {
String str_msg;
str_msg = String(isHeaterOn())+","+
stamptime()+","+
TEMP[nextindex-1]+","+
HUMI[nextindex-1]+","+
setTemp+","+
setLog;
ws.textAll(str_msg);
ws.textAll((String)(nextindex-1));
// nextindex가 numofdata에 도달하여 배열이 꽉 차면
// 배열안의 모든데이터를 평균하여 로그에 기록
if (setLog && nextindex >= numofdata)
{
File tempLog = SPIFFS.open("/temp.txt", "a"); // Write the time and the temperature to the csv file
float avr_T = 0.0;
float avr_H = 0.0;
for (int i = 0; i < numofdata; ++i){
avr_T += TEMP[i];
avr_H += HUMI[i];
}
avr_T /= numofdata;
avr_H /= numofdata;
str_msg = String(isHeaterOn())+","+
(String)stamptime()+","+
(String)avr_T+","+
(String)avr_H+","+
(String)setTemp;
tempLog.println(str_msg);
tempLog.close();
}
}
17행부터는 파일에 기록을 하기 위한 부분인데요. 측정된 데이터 개수가 정해진 숫자에 도달하게 되면 그동안의 데이터 평균값을 파일로 저장하는 역할을 합니다. "noti2Client()" 함수는 5초마다 한 번씩 호출되기 때문에 매 호출시마다 모든 데이터를 저장하게 되면 파일 시스템에 접근도 잦아지고 파일도 필요 이상으로 커지며 나중에 그래프를 그렸을 때 지저분해 보이기도 해서 저는 약 30개 데이터를 모아서 평균값만 저장하도록 하였습니다.
여기서 데이터를 저장할 때 언제 측정한 값인지 알아야 되기 때문에 인터넷 시간을 가져와서 같이 기록이 되도록 하는 부분도 추가를 하였는데 그 부분은 별도 포스팅으로 다룬 적이 있으니 그 글을 참고해주세요.
UI
이제 펌웨어 설명은 줄이고 웹페이지 UI 부분 살펴보겠습니다. 펌웨어를 backend라고 한다면 frontend라고 할 수 있겠죠. frontend의 구성을 먼저 살펴보겠습니다. 메인이 되는 파일은 'index.html'이고 'temperatureGraph.js'는 그래프를 그리는데 필요한 JS코드입니다. 나머지는 부가적인 것들로 없어도 되지만 디버깅에 요긴하게 사용할 수 있는 아주 유용한 파일들입니다.
전체 파일은 위 첨부를 참고하시고 여기서는 'index.html'와 temperarueGraph.js만 보도록 하겠습니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<TITLE>GoCamp</TITLE>
<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="stylesheet" href="https://use.fontawesome.com/releases/v5.7.2/css/all.css"
integrity="sha384-fnmOCqbTlWIlj8LyTjo7mOUStjsKC4pOpQbqyi7RrhN7udi9RwhKkMHpvLbHG9Sr" crossorigin="anonymous">
<style>
body {
text-align: center;
font-family: verdana;
}
h2 {
font-size: 1.6rem
}
button {
border: 1;
border-radius: 1.5rem;
background-color: #0066ff;
color: rgb(255, 255, 255);
line-height: 2.4rem;
font-size: 1.2rem;
margin: 5px;
}
p {
font-size: 1.5rem;
}
.units {
font-size: 1.2rem;
}
.heading {
vertical-align: middle;
padding-bottom: 5px;
padding-top: 15px;
}
/* The switch - JS 토글스위치 출처: https://imivory.tistory.com/15 */
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
vertical-align: middle;
}
/* Hide default HTML checkbox */
.switch input {
display: none;
}
/* The slider */
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: .4s;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
-webkit-transition: .4s;
transition: .4s;
}
input:checked+.slider {
background-color: #2196F3;
}
input:focus+.slider {
box-shadow: 0 0 1px #2196F3;
}
input:checked+.slider:before {
-webkit-transform: translateX(26px);
-ms-transform: translateX(26px);
transform: translateX(26px);
}
/* Rounded sliders */
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
</style>
<!-- 그래프 표시용 -->
<script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
<!-- 토글 스위치 표시용 -->
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.3/jquery.min.js"></script>
</head>
<BODY>
<div style="text-align:center;display:inline-block;min-width:280px;">
<h2>GoCamp 온도조절기</h2>
<p>
<span class="heading">현재상태</span>
<span>
<i class="fas fa-thermometer-half" style="color:#059e8a;"></i>
<span id="temperature">0.0</span>
<span class="units">°C / </span>
</span>
<span>
<i class="fas fa-tint" style="color:#00add6;"></i>
<span id="humidity">0.0</span>
<span class="units">%</span>
</span>
</p>
<p><span class="heading">설정온도</span>
<input type="number" id="Temp" min="20" max="30" step="0.1" value="0.0"
style="min-width:100px; height:40px; font-size: 1.5rem;">ºC
<button id="setTemp" VALUE="set" style="width:70px; background-color:#2b499b;">set</button>
<br>
<button id="tuneUP" VALUE="tuneUP" style="width:40%; background-color:#e91f1f;">+</button>
<button id="tuneDW" VALUE="tuneDW" style="width:40%;">-</button>
</p>
<p>온풍기 상태: <b id="heater">확인중...</b> </p>
</div>
<div>
<div id="switch">
<p>
<span class="heading">온도 기록: </span>
<label class="switch">
<input type="checkbox">
<span class="slider round"></span>
</label>
</p>
</div>
<div id="chart_div"></div>
<div id="loading">Loading ...</div>
<div id="dateselect" style="visibility: hidden;">
<div id="date"></div>
<button id="prev" style="width:15%;"><</button>
<button id="next" style="width:15%;">></button>
<button id="zoomout" style="width:15%;">-</button>
<button id="zoomin" style="width:15%;">+</button><br>
<button id="reset" style="width: 4.4em;">Reset</button>
<button id="refresh" style="width: 4.4em;">Refresh</button>
</div>
<script src="temperatureGraph.js"></script>
</div>
<script>
var state, settemp, temp, humi, log;
var gateway = 'ws://' + document.location.host + '/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);
}
// msg format
// 0,202109111200,25.0,50.0,27.0,0 (온풍기상태,UNIXtime,온도,습도,설정온도,로그설정)
function onMessage(event) {
var msg = event.data.split(',');
// 현재상태 메시지는 0 OR 1로 시작됨
// 0이면 온풍기 꺼짐상태, 1이면 켜짐
if (msg[0] == "0") {
state = "OFF";
}
else if (msg[0] == "1") {
state = "ON";
}
// '@'로 시작하는 메시지는 설정 온도에 대한 서버 피드백
else if (msg[0] == "@") {
settemp = Number(msg[1]);
document.getElementById('Temp').value = settemp;
return 0;
}
// '#'로 시작하는 메시지는 로그 설정에 대한 서버 피드백
else if (msg[0] == "#") {
if (msg[1] == 1) {
check[0].checked = true;
}
if (msg[1] == 0) {
check[0].checked = false;
}
return 0;
}
// 서버에서 받은 메시지가 없는경우에는 건너띄기
if (msg[2] !== undefined) {
settemp = Number(msg[4]);
temp = msg[2];
humi = msg[3];
if (msg[5] == 0) {
check[0].checked = false;
}
if (msg[5] == 1) {
check[0].checked = true;
}
}
// 읽어온 컨트롤러 상태에 따라 페이지에 반영
document.getElementById('heater').innerHTML = state;
document.getElementById('temperature').innerHTML = temp;
document.getElementById('humidity').innerHTML = humi;
document.getElementById('Temp').value = settemp;
}
function onLoad(event) {
initWebSocket();
initButton();
}
function initButton() {
document.getElementById('setTemp').addEventListener('click', setTemp);
document.getElementById('tuneUP').addEventListener('click', tuneUP);
document.getElementById('tuneDW').addEventListener('click', tuneDW);
}
// 버튼 동작에 따라 해당되는 명령 메시지를 서버로 전달
function setTemp() {
var msg = "@" + document.getElementById("Temp").value;
websocket.send(msg);
}
function tuneUP() {
settemp = settemp + 0.5;
var msg = "@" + settemp;
websocket.send(msg);
document.getElementById('Temp').value = settemp;
}
function tuneDW() {
settemp = settemp - 0.5;
var msg = "@" + settemp;
websocket.send(msg);
document.getElementById('Temp').value = settemp;
}
var check = $("input[type='checkbox']");
check.click(function () {
var msg = "#";
if (check[0].checked) {
msg += "1";
}
else {
msg += "0";
}
websocket.send(msg);
});
</script>
</BODY>
</HTML>
HTML 코드에서 중요한 부분은 실제로 기능을 구현하는 JS코드라고 할 수 있는데요. 그중에서도 187행 "onMessage()"함수가 이 코드의 핵심이라고 할 수 있겠습니다. 웹페이지가 로딩이 되고 나면 서버와 웹소켓이 연결되는데요. 웹소켓을 연결하고 나서 웹소켓에서 발생하는 이벤트 콜백을 174행에서 'onMessage'로 연결을 하였습니다. 따라서 웹소켓에서 어떤 이벤트가 발생하면 이 함수가 호출이 됩니다. 역할은 웹소켓으로 전송되는 문자열을 파싱 하는 것입니다. 문자열은 정보의 단위가 쉼표로 포맷으로 함수는 문자열을 쉼표를 기준으로 분해하는 것부터 시작을 합니다. 그리고 문자열의 첫 번째 단위는 "msg[0]" 배열에 저장이 되는데 이 문자에 따라 받은 메시지가 어떤 것인지 파악을 하고 거기에 맞는 대응을 합니다.
앞서 펌웨어에서 우리는 서버에서 클라이언트로 보내는 웹소켓 메시지 총 3가지를 정의해 두었습니다. 3가지 메시지 포맷은 다음과 같습니다.
1: 0,1631734567,25.3,30.7,27.0,0
2: @,27.5
3: #,1
1번은 현재 상태를 지속적으로 알려주는 메시지로 "온풍기 현재 동작상태, 현재시간, 온도, 습도, 설정온도, 기록 여부"를 의미합니다. 메시지의 시작은 온풍기의 동작상태에 따라 "0"또는 "1"로 시작되기 때문에 0으로 시작되면 온풍기의 상태 값을 나타내는 html 코드를 "OFF"로 변경하고 그 반대이면 "ON"을 반영합니다.
만약 첫 번째 문자가 "@"라면 설정온도에 대한 피드백이기 때문에 웹페이지 상에서 설정온도를 나타내는 숫자를 그다음의 숫자로 변경시킵니다.
"#"로 시작하는 메시지를 받았다면 그다음 문자는 ESP8266이 측정된 온도 값의 기록 여부를 나타내는 "0"이나 "1"이며 이 값에 따라서 온도 기록 토글스위치 상태를 변경합니다.
다음으로 중요한 부분은 클라이언트에서 웹서버 방향으로 웹소켓 메시지를 전달하는 "setTemp(), tuneUP(), tuneDW()" 함수와 토글스위치 스크립트입니다. 각 버튼이 눌려졌을 때 호출이 되며 함수 별로 정해진 포맷의 문자열을 웹소켓으로 전송합니다. "setTemp()"함수는 설정온도를 변경하라는 명령이며 "@+숫자" 포맷이며 인풋 박스에 있는 숫자를 그대로 웹소켓 메시지로 전달합니다. "tuneUP(), tuneDW()"함수는 동일하게 "@+숫자"포맷이며 인풋 박스에 있는 숫자에 'tuenUP'은 0.5를 더하고 'tuneDW'은 0.5를 뺀 숫자를 전달합니다.
마지막으로 토글스위치는 동작시킬 때마다 "#1"과 "#2"메시지를 발생시킵니다.
var dataArray = [];
var defaultZoomTime = 24*60*60*1000; // 1 day
var minZoom = -6; // 22 minutes 30 seconds
var maxZoom = 8; // ~ 8.4 months
var zoomLevel = 0;
var viewportEndTime = new Date();
var viewportStartTime = new Date();
loadCSV(); // Download the CSV data, load Google Charts, parse the data, and draw the chart
/*
Structure:
loadCSV
callback:
parseCSV
load Google Charts (anonymous)
callback:
updateViewport
displayDate
drawChart
*/
/*
| CHART |
| VIEW PORT |
invisible | visible | invisible
---------------|---------------------------------------------|---------------> time
viewportStartTime viewportEndTime
|______________viewportWidthTime______________|
viewportWidthTime = 1 day * 2^zoomLevel = viewportEndTime - viewportStartTime
*/
function loadCSV() {
var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
dataArray = parseCSV(this.responseText);
google.charts.load('current', { 'packages': ['line', 'corechart'] });
google.charts.setOnLoadCallback(updateViewport);
}
};
xmlhttp.open("GET", "temp.txt", true);
xmlhttp.send();
var loadingdiv = document.getElementById("loading");
loadingdiv.style.visibility = "visible";
}
function parseCSV(string) {
var array = [];
var lines = string.split("\r\n");
for (var i = 0; i < lines.length; i++) {
var rawdata = lines[i].split(",", 5);
var data = [];
data[0] = new Date(parseInt(rawdata[1]) * 1000);
data[1] = parseFloat(rawdata[2]);
array.push(data);
}
return array;
}
function drawChart() {
var data = new google.visualization.DataTable();
data.addColumn('datetime', 'UNIX');
data.addColumn('number', 'temperature');
data.addRows(dataArray);
var options = {
curveType: 'function',
height: 300,
legend: { position: 'none' },
hAxis: {
viewWindow: {
min: viewportStartTime,
max: viewportEndTime
},
gridlines: {
count: -1,
units: {
days: { format: ['MMM dd'] },
hours: { format: ['HH:mm', 'ha'] },
}
},
minorGridlines: {
units: {
hours: { format: ['hh:mm:ss a', 'ha'] },
minutes: { format: ['HH:mm a Z', ':mm'] }
}
}
},
vAxis: {
title: "Temperature (Celsius)"
}
};
var chart = new google.visualization.LineChart(document.getElementById('chart_div'));
chart.draw(data, options);
var dateselectdiv = document.getElementById("dateselect");
dateselectdiv.style.visibility = "visible";
var loadingdiv = document.getElementById("loading");
loadingdiv.style.visibility = "hidden";
}
function displayDate() { // Display the start and end date on the page
var dateDiv = document.getElementById("date");
var endDay = viewportEndTime.getDate();
var endMonth = viewportEndTime.getMonth();
var startDay = viewportStartTime.getDate();
var startMonth = viewportStartTime.getMonth()
if (endDay == startDay && endMonth == startMonth) {
dateDiv.textContent = (endDay).toString() + "/" + (endMonth + 1).toString();
} else {
dateDiv.textContent = (startDay).toString() + "/" + (startMonth + 1).toString() + " - " + (endDay).toString() + "/" + (endMonth + 1).toString();
}
}
document.getElementById("prev").onclick = function() {
viewportEndTime = new Date(viewportEndTime.getTime() - getViewportWidthTime()/3); // move the viewport to the left for one third of its width (e.g. if the viewport width is 3 days, move one day back in time)
updateViewport();
}
document.getElementById("next").onclick = function() {
viewportEndTime = new Date(viewportEndTime.getTime() + getViewportWidthTime()/3); // move the viewport to the right for one third of its width (e.g. if the viewport width is 3 days, move one day into the future)
updateViewport();
}
document.getElementById("zoomout").onclick = function() {
zoomLevel += 1; // increment the zoom level (zoom out)
if(zoomLevel > maxZoom) zoomLevel = maxZoom;
else updateViewport();
}
document.getElementById("zoomin").onclick = function() {
zoomLevel -= 1; // decrement the zoom level (zoom in)
if(zoomLevel < minZoom) zoomLevel = minZoom;
else updateViewport();
}
document.getElementById("reset").onclick = function() {
viewportEndTime = new Date(); // the end time of the viewport is the current time
zoomLevel = 0; // reset the zoom level to the default (one day)
updateViewport();
}
document.getElementById("refresh").onclick = function() {
viewportEndTime = new Date(); // the end time of the viewport is the current time
loadCSV(); // download the latest data and re-draw the chart
}
document.body.onresize = drawChart;
function updateViewport() {
viewportStartTime = new Date(viewportEndTime.getTime() - getViewportWidthTime());
displayDate();
drawChart();
}
function getViewportWidthTime() {
return defaultZoomTime*(2**zoomLevel); // exponential relation between zoom level and zoom time span
// every time you zoom, you double or halve the time scale
}
다음으로 그래프를 그리는 JS코드입니다. 데이터를 저장하는 기능은 ESP8266 관련 시작과 끝을 공부할 수 있는 A Beginner's Guide to the ESP8266을 참고했고 위 코드에서 45행, 51-60행(parseCSV() 함수)만 약간 수정을 했습니다.
45행은 저장된 파일을 불러오는 코드인데 원래 데이터 로그 파일 확장자가 '.csv'였는데 편의상 '.txt'로 고쳐서 그 부분을 수정했습니다. 그리고 파일에서 불러온 결과를 그래프로 그리려면 "temperarueGraph.js"코드에서 원하는 데이터 프레임에 맞추어 줘야 하는데 저장된 여러 값 중에서 현재시간과 온도 값만 불러와서 데이터 프레임(배열)에 맞게 넣어주도록 parseCSV() 함수를 손봤습니다.
결과
나머지 자잘한 터치업이 여기저기 있지만 중요하지 않으니 생략하고 바로 결과물로 넘어갑니다.
기대한 대로 잘 동작하고 반응도 빠릅니다. 그래프도 아주 장시간의 데이터를 기록해보지는 않았지만 12시간 정도까지는 문제없었고 최적화만 한다면 며칠 동안의 데이터를 저장하는데도 아무런 무리가 없을 것 같습니다.
파일 시스템에 업로드되는 파일 중 "/console.htm"파일을 브라우저로 열어보면 다음과 같은 콘솔 창이 보이는데, 실시간으로 서버로부터 전송되고 있는 메시지를 확인할 수 있습니다. 여기에 직접 서버에서 파싱 할 수 있는 포맷으로 명령을 넣어줄 수도 있습니다.
다음으로 "/edit.htm"파일을 열어보면 파일 시스템에 저장된 파일들을 확인할 수도 있고, 여기서 바로 편집도 가능합니다. 목록 중 "/temp.txt"파일을 열어보면 기록된 결과를 텍스트로 확인할 수도 있습니다.
끝!
'Hardware > MCU(Arduino,ESP8266)' 카테고리의 다른 글
아두이노 + ESP8266으로 무선으로 확인하는 오실로스코프 (Arduino -Web Oscilloscope 수정) (0) | 2021.12.16 |
---|---|
벽돌이 된 wemos d1 mini 소생시키기 (0) | 2021.12.12 |
온풍기 자동 온도조절용 스마트 플러그 DIY (0) | 2021.05.28 |
ESP8266, Watchdog (wdt reset error) (4) | 2020.12.09 |
댓글