PMS5003ST Dashboard
Arduino IDE, Node32Lite ,Plantower PMS5003ST, WebSocket, VueJs, และ SPIFFS
บันทึกฉบับนี้อาจจะดูใช้ของเยอะไปหน่อย การอธิบายของผมอาจจะไม่ละเอียดทั้งหมด เพราะช่วงนี้เวลาไม่ค่อยมี แถมยังเป็นมือใหม่ด้วย หลายเรื่องก็ยังไม่เข้าใจมากนัก มีอะไรแนะนำอย่าได้รั้งรอนะครับ สอนผมด้วย
ครั้งก่อนบันทึกการใช้ WebServer ในการทำ Dashboatd สำหรับ MCU ตระกูล ESP32 และระหว่างที่เขียนอยู่นั้นก็นึกสนุกอยากเอา tools ใหญ่ๆ ยัดเข้าไปในทรัพยากรที่จำกันของ Node32Lite และลองพยายามอยู่พักใหญ่ ตอนแรกว่าจะแก้ lib แต่พบว่ามันมีคนทำเรื่องพวกนี้อยู่เยอะมาก เลยลองเอาตัวที่เค้านิยมใช้คือ ESPAsyncWebServer มาใช้ ทำให้พบว่า lib ของ ESP32 เองนั้นทำงานได้ข้ามาก Dashbord ที่เตรียมไว้ทดสอบ เรียกไป timeout ตลอด ESPAsyncWebServer มีความสามารถหลากหลายมาก หนึ่งในนั้นก็มี WebSocket มาให้ด้วย เคยได้ยินชื่อมานานแต่ไม่เคยศึกษาสักที บทความนี้เลยได้ลองของใหม่(สำหรับผม)ไปด้วย
Tools
- ESPAsyncWebServer → Arduino/libraries
- ESPAsyncTCP → Arduino/libraries
- arduino-esp32fs-plugin → Arduino/tools
- VueJs → JavaScript Framwoker สำหรับเขียน UI
- SPIFFS → เก็บ Dashbord ไว้ในนี้แทนฝังไว้ใน code
Code ตัวอย่าง
https://github.com/mrchoke/ESP32_PMS5003ST_Dashboard
จะมี 3 dir
- APMODE → ตัวอย่างใช้แบบ Access Point เหมือนกับตอนที่แล้ว
- CLIENT → เชื่อมต่อกับ WiFi ผมใช้อยู่สองแบบคือแบบ WPA2 Enterprice กับ Wifi มือถือ
- DashboardVueJs → จะเป็น Source ของ VueJs ใช้ lib และตัวอย่างมาจากหลายที่อาจจะไม่ค่อยสวยเท่าไหร่ :P
มันจะมีวิธีการเพิ่มเข้ามานิดหน่อยคือการ upload file ไว้ใน SPIFFS ติดตั้งตามในเว็บได้เลยไม่มีอะไรยุ่งยาก
Plantower PMS5003ST
เจ้า PMS5003ST ของเพิ่งเข้ามาดูความสามารถแล้วน่าสนใจมากส่วนค่าวัดมาตรงหรือเปล่านี้ต้องรอคนเปรียบเทียบกันก่อน ความสามารถนี่โหดจริงๆ ตัวเดียววัดได้สารพัดดังนี้
- ค่า PM 1.0 2.5 และ 10 μg/m³ ซึ่งมีมาให้สองค่า CF1 และ ATO ซึ่งผมก็ยังงงๆ ว่าจะเอาค่าไหนไปใช้เพราะเวลาออกไปวัดข้างนอกสองค่านี้จะต่างกันพอสมควรแต่ถ้าวัดในห้องที่ฝุ่นน้อยๆ ก็ไม่ต่างกัน
- ค่าจำนวนฝุ่นในแต่ละขนาด 0.3 0.5 1.0 2.5 5 10 ซึ่งจะมีค่า pcs / dl
- ค่า FORMALDEHYDE มีหน่วยเป็น mg/m³ โดยปกติค่านี้จะไม่ค่อยมีในอากาศนอกจากจะมีพวกตัวทำละลายในห้องแลป น้ำยาทาเล็บ พวกปากาเขียนกระดาน ถ้ามีค่าพวกนี้สูงก็ระวังเพราะเป็นอันตรายได้
- ค่า อุณหูมิ ตัวที่ซื้อมาเหมือนจะต่ำกว่าค่าจริงอยู่ประมาณ 3 องศา
- ค่า ความซื้น เทียบกับเครื่องฟอกอากาศที่ห้องก็ต่างกันอยู่พอสมควรแต่ก็ไม่มากนัก
PMS5003ST Library
ผมใช้ของคนนี้
https://github.com/i3water/Blinker_PMSX003ST
ซึ่งมีการคำนวณ AQI มาให้เสร็จสรรพ แค่ git clone มาแล้วเอาไปใส่ใน library แล้วเอา code ผมไป run ได้เลย
ตัวอย่าง Sketch
APMODE
/**
* Test by MrChoke
* APMODE
**/
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <SPIFFS.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>#include "BLINKER_PMSX003ST.h"//pin (RX16,TX17)
HardwareSerial pmsSerial(2);BLINKER_PMSX003ST pms;const char * domain = "pms5003st";const char* ssid = domain;
const char* password = "1234567890";unsigned long Timer1;AsyncWebServer server(80);
AsyncWebSocket ws("/ws");// Read CPU Temp#ifdef __cplusplus
extern "C" {
#endif
uint8_t temprature_sens_read();
#ifdef __cplusplus
}
#endif
uint8_t temprature_sens_read();// WebSocketvoid onWsEvent(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->text(JsonPMS());
} else if(type == WS_EVT_DISCONNECT){
Serial.printf("ws[%s][%u] disconnect: %u\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){
client->text(JsonPMS());
Serial.printf("ws[%s][%u] pong[%u]: %s\n", server->url(), client->id(), len, (len)?(char*)data:"");
} else if(type == WS_EVT_DATA){
// not implement yet
}
}// Start Sensorvoid StartPMS(){
Serial.println(F("\nStart"));pmsSerial.begin(9600);
pms.begin(pmsSerial);
// pms.wakeUp();
pms.setMode(PASSIVE);
Serial.println(F("PMS5003ST Start.."));
}// Read into JsonString JsonPMS(){
pms.request();
if(!pms.read()){
return "{\"status\":\"error\"}";
}
String data = "{";
data += "\"system\": [";
data += "{\"name\": \"temp\",\"val\":" + String((temprature_sens_read() - 32) / 1.8) + "}";
data += ",{\"name\":\"mem\",\"val\":\"" + String(esp_get_free_heap_size()/1024) +" KB\"}";
data += "]";
data += ",\"aqi\": [";
data += "{\"name\":\"us\",\"val\":" + String(pms.getAQI(AQI_BASE_US)) + ",\"level\":" + String(pms.getAQILevel(AQI_BASE_US)) + ",\"base\":\"" + String(pms.getMainPollu(AQI_BASE_US)) +"\"}";
data += ",{\"name\":\"cn\",\"val\":" + String(pms.getAQI(AQI_BASE_CN)) + ",\"level\":" + String(pms.getAQILevel(AQI_BASE_CN)) + ",\"base\":\"" + String(pms.getMainPollu(AQI_BASE_CN)) +"\"}";
data += "]";
data += ",\"cf1\": [";
data += "{\"name\":\"pm1.0\",\"val\":" + String(pms.getPmCf1(1)) +"}";
data += ",{\"name\":\"pm2.5\",\"val\":" + String(pms.getPmCf1(2.5)) +"}";
data += ",{\"name\":\"pm10.0\",\"val\":" + String(pms.getPmCf1(10)) +"}";
data += "]";
data += ",\"ato\": [";
data += "{\"name\":\"pm1.0\",\"val\":" + String(pms.getPmAto(1)) +"}";
data += ",{\"name\":\"pm2.5\",\"val\":" + String(pms.getPmAto(2.5)) +"}";
data += ",{\"name\":\"pm10.0\",\"val\":" + String(pms.getPmAto(10)) +"}";
data += "]";
data += ",\"pcs\": [";
data += "{\"name\":\"pcs0.3\",\"val\":" + String(pms.getPcs(0.3)) +"}";
data += ",{\"name\":\"pcs0.5\",\"val\":" + String(pms.getPcs(0.5)) +"}";
data += ",{\"name\":\"pcs1.0\",\"val\":" + String(pms.getPcs(1)) +"}";
data += ",{\"name\":\"pcs2.5\",\"val\":" + String(pms.getPcs(2.5)) +"}";
data += ",{\"name\":\"pcs5.0\",\"val\":" + String(pms.getPcs(5)) +"}";
data += ",{\"name\":\"pcs10.0\",\"val\":" + String(pms.getPcs(10)) +"}";
data += "]";
data += ",\"env\": [";
data += "{\"name\":\"formaldehyde\",\"val\":\"" + String(pms.getForm())+"\"}";
data += ",{\"name\":\"temp\",\"val\":\"" + String(pms.getTemp())+"\"}";
data += ",{\"name\":\"humidity\",\"val\":\"" + String(pms.getHumi())+"\"}";
data += "]";
data += ",\"network\": [";
data += "{\"name\":\"mac\",\"val\":\"" + String(WiFi.macAddress())+"\"}";
data += ",{\"name\":\"ip\",\"val\":\"" + (WiFi.softAPIP()).toString() +"\"}";
data += "]";
data += "}";
return data;
data = String();
}// Start mDNSvoid StartDNS() {
if (!MDNS.begin(domain)) {
Serial.println("Error setting up MDNS responder!");
while(1) {
delay(1000);
}
}
Serial.println("mDNS responder started");
MDNS.addService("http", "tcp", 80);
}//Start WebServer and WebSocketvoid StartWeb(){
ws.onEvent(onWsEvent);
server.addHandler(&ws);// Test Scan WiFi
server.on("/scan", HTTP_GET, [](AsyncWebServerRequest *request){
String json = "[";
int n = WiFi.scanComplete();
if(n == -2){
WiFi.scanNetworks(true,true);
} else if(n){
for (int i = 0; i < n; ++i){
if(i) json += ",";
json += "{";
json += "\"rssi\":"+String(WiFi.RSSI(i));
json += ",\"ssid\":\""+WiFi.SSID(i)+"\"";
json += ",\"bssid\":\""+WiFi.BSSIDstr(i)+"\"";
json += ",\"channel\":"+String(WiFi.channel(i));
json += ",\"secure\":"+String(WiFi.encryptionType(i));
json += "}";
}
WiFi.scanDelete();
if(WiFi.scanComplete() == -2){
WiFi.scanNetworks(true);
}
}
json += "]";
request->send(200, "application/json", json);
json = String();
});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);
});// for dev
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*");
server.begin();
}// Start Access Pointvoid StartWiFi(){
Serial.println();
Serial.println();
Serial.print(F("Configuring access point... "));
Serial.println(ssid);
WiFi.softAP(ssid, password);
WiFi.softAPsetHostname(domain);
Serial.printf("AP IP address: %s\n",WiFi.softAPIP().toString().c_str());
}void setup() {
Serial.begin(115200);
// mount FS
if(!SPIFFS.begin()){
Serial.println("SPIFFS Mount Failed");
}
StartPMS();
delay(500);
StartWiFi();
StartWeb();
StartDNS();
}void loop() {// if has Client Sent them all every 3sec
if (millis() - Timer1 >= 3000) {
Timer1 = millis();
if(ws.count()) {
// Serial.println(ws.count());
ws.textAll(JsonPMS());
}
}}
เทคนิคที่ใช้
นอกจาก lib ของ sensor ที่ต้องใช้แล้วยังมีเทคนิคที่เอาเข้ามาใช้คือ lib ของ ESPAsyncWebServer ตัวนี้ความสามารถหลากหลายกว่า lib WebServer ของ ESP เอง หลักๆ คือทำงานเร็วกว่ามากสามารถมี connection เข้ามาพร้อมๆ กันได้เยอะกว่าอ่านจาก SPIFFS ได้สะดวกกว่ามากเช่น
server.serveStatic("/", SPIFFS, "/").setDefaultFile("index.html");
ผมเอาตัวเว็บ upload ขึ้น แล้วผมตั้งให้ไปอ่านจากตรงนี้ได้เลย และกำหนดว่าหน้าแรกให้เป็น index.html ได้เลย และ file static อื่นๆ สามารถ gzip ได้เพื่อลดขนาด ซึ่งจะเห็นว่าผมสามารถเอา VueTifyJs มาเป็นตัวอย่างได้สะบายๆ แต่ก็ต้องตัดความสามารถบางตัวออก เช่นไม่เอา fonts พวก icons ต่างๆเป็นต้น
นอกจากความสะดวกในเรื่อง SPIFFS แล้วยังสามารถทำเป็น WebSocket และ Event Source ได้ด้วยซึ่งจะสนุกมากยิ่งขึ้นถ้าจะเล่นเพิ่มก็ลองเขียนได้ใน
onWsEvent()
ว่าเราจะคุยอะไรกันบ้างในละเหตุการณ์ซึ่งผมก็เพิ่งจะหัดเขียนใช้วิธีง่ายๆ คือ เมื่อมีการติดต่อกันครั้งแรกปุ๊บผมก็ส่งค่าไปให้เลยดังนั้นฝั่ง Dashboard ก็จะได้ข้อมูลไปแสดงทันที และหลังจากนั้นผมก็ไปทำงานในส่วนของ loop() โดยการให้เฝ้าทุกๆ 3 วิ โดยการดูว่ามี WebSocket ติดต่อกันอยู่หรือไม่ถ้ายังมีอยู่ก็ให้ส่งข้อมูลกระจายไปทุกๆ connection
void loop() {// if has Client Sent them all every 3sec
if (millis() - Timer1 >= 3000) {
Timer1 = millis();
if(ws.count()) {
// Serial.println(ws.count());
ws.textAll(JsonPMS());
}
}
ถ้าไม่มี connection ก็จะไม่ส่งค่าออกไป
SPIFFS
หลังจากศึกษามาสักพักผมเลือกใช้วิธีเก็บ file html ไว้ใน SPIFFS แทนการประกาศตัวแปรแต่ต้องมีวิธีการเพิ่มเติมขึ้นมาอีกนิดหน่อยคือ ต้องติดตั้งเครื่องมือ(tool) สำหรับ upload file โดยเข้าไปยัง https://github.com/me-no-dev/arduino-esp32fs-plugin แล้วทำการติดตั้งตามคำแนะนำ
- ไปยังหน้า releases
- เลือก releases ล่าสุดในรูปแบบ zip
- ดูว่าใน directory arduino ของเรามี directory tools อยู่หรือไม่ถ้าไม่มีก็ให้สร้างเพิ่ม
- เข้าไปใน tools แล้วแตก zip ที่นั้น
- ปิด — เปิด arduino ide ใหม่
- ดูในเมนู tools จะเห็นเมนูเพิ่มขึ้นมา
การ upload file
วิธีเก็บ file ไว้ใน SPIFFS ให้เราสร้าง diretory ชื่อ data ไว้ใน project ของเรา แล้วนำ file html ที่ออกแบบไว้ไปเก็บไว้ เมื่อเราเรียก เมนูนี้มันก็จะก็จะทำการ compress แล้วทำ image เพื่อ upload ให้เรา
ซึ่งเมื่อออกแบบหน้าเว็บเสร็จแล้วและทำการ gzip เรียบร้อยแล้วให้เรา upload file ขึ้นไปโดย คลิ้กที่เมนู
Tools -> ESP32 Sketch Data Upload
ขั้นตอนนี้ถ้าเราเปิดพวก Serial Monitor หรือ Serial Plotter อาจจะ error ได้ก็ให้ปิดไปก่อนนะครับ
file ที่เก็บอยู่ใน SPIFFS จะยังคงอยู่แม้เราจะ upload Sketch ใหม่ขึ้นไปหลายรอบดังนั้นถ้าเราไม่เปลี่ยนหน้าเว็บก็ไม่จำเป็นต้อง upload บ่อยๆ
Dashboard
ในตัวอย่างที่ผมใช้ครั้งนี้จะเป็น VueJs + VueTifyJs และ WebSocket ซึ่งสามารถศึกษาได้จากตัวอย่าง Source Code ได้เลย อาจจะไม่สมบูรณ์มากสำหรับคนที่ต้องการต่อยอดก็สามารถศึกษาเพิ่มเติมได้ เพราะยังมีอะไรให้เพิ่มเติมอีกเยอะ
วิธีการ Dev Dashboard
เข้าไปยัง directory ของ Dashboard ทำการติดตั้ง package ต่างๆสำหรับ dev
yarn install
ทั้งนี้ให้ทำการ upload Sketch ของผมให้เรียบร้อยก่อนเพื่อให้ WebSocket เริ่มทำงานภายใน MCU แล้วเครื่องพัฒนาสามารถติดต่อกับ MCU ได้โดยอาาจะอยู่ในวง WiFi เดียวกันหรือ ให้ MCU เป็น Access Point แล้วเครื่อง dev connect เข้าไปแล้วให้ดูค่า WebSocket Host ใน file main.js
let hname = process.env.NODE_ENV === 'production' ? location.host : 'pms5003st.local';
ถ้าไม่สามารถหาชื่อ host ผ่าน mDNS ได้ให้เปลี่ยน pms5003st.local เป็น IP ของ MCU แทนนะครับ
เมื่อ ตั้งค่าเสร็จให้ลอง
yarn dev
เพื่อ run dashboard ใน mode dev ถ้าไม่มี port ชนก็สามารถเข้าจาก web browser ได้ทาง
http://localhost:8080
ถ้า Dashboard สามารถติดต่อกับ MCU ได้เราควรจะมีค่ามาแสดงผลได้ ถ้ายังไม่สามารถเอาข้อมูลมาได้ลองเช็คเรื่อง IP กับดูว่า MCU ทำงานได้หรือไม่
เมื่อเราปรับแต่งจดเสร็จแล้วก็ให้ build production
yarn build
ตรงนี้ให้สังเกตขนาดด้วยนะครับว่าไม่ควรใหญ่มากซึ่งหลัง gzip ไม่ควรเกิน 2M เมื่อ build เสร็จเว็บจะเก็บไว้ใน dist ให้ copy ข้อมูลในนี้ทั้งหมดไปเก็บไว้ใน directory data ของ Sketch แล้วทำการ gzip static บน MAC กับ Linux สามารถสั่งได้เลยดังนี้
cd data
gzip -9 -r static
เมื่อเสร็จแล้วให้ปิด Serial Monitor แล้วทำการ upload ขึ้น SPIFFS แล้วทำการเรียกทดสอบได้เลย
http://mcu_ip
or
http://pms5003st.local
การเรียกผ่านชื่ออาจจะมีข้อจำกัดในบางเครือข่ายเช่นถ้าเครือข่ายจำกัดการเข้าถึงอาจจะเรียกไม่ได้ แต่เท่าที่ทดสอบถ้าปล่อย APMODE เรียกชื่อได้ และ แชร์มือถือกับ router ส่วนตัวได้แน่นอน แต่ที่ทำงานผมจะไม่ได้เพราะเข้มงวดมากกว่าเป็นต้น
ถ้าอยากทดสอบการวัดสามารถใช้ไม้ขีดไฟจุดไฟ หรือใช้แป้งฝุ่นก็ได้