DEV Community

MrChoke
MrChoke

Posted on • Originally published at Medium on

PMS5003ST Dashboard

Arduino IDE, Node32Lite ,Plantower PMS5003ST, WebSocket, VueJs, และ SPIFFS

AQI ที่คนเขียน Lib คำณวณไว้ให้

บันทึกฉบับนี้อาจจะดูใช้ของเยอะไปหน่อย การอธิบายของผมอาจจะไม่ละเอียดทั้งหมด เพราะช่วงนี้เวลาไม่ค่อยมี แถมยังเป็นมือใหม่ด้วย หลายเรื่องก็ยังไม่เข้าใจมากนัก มีอะไรแนะนำอย่าได้รั้งรอนะครับ สอนผมด้วย

ครั้งก่อนบันทึกการใช้ WebServer ในการทำ Dashboatd สำหรับ MCU ตระกูล ESP32 และระหว่างที่เขียนอยู่นั้นก็นึกสนุกอยากเอา tools ใหญ่ๆ ยัดเข้าไปในทรัพยากรที่จำกันของ Node32Lite และลองพยายามอยู่พักใหญ่ ตอนแรกว่าจะแก้ lib แต่พบว่ามันมีคนทำเรื่องพวกนี้อยู่เยอะมาก เลยลองเอาตัวที่เค้านิยมใช้คือ ESPAsyncWebServer มาใช้ ทำให้พบว่า lib ของ ESP32 เองนั้นทำงานได้ข้ามาก Dashbord ที่เตรียมไว้ทดสอบ เรียกไป timeout ตลอด ESPAsyncWebServer มีความสามารถหลากหลายมาก หนึ่งในนั้นก็มี WebSocket มาให้ด้วย เคยได้ยินชื่อมานานแต่ไม่เคยศึกษาสักที บทความนี้เลยได้ลองของใหม่(สำหรับผม)ไปด้วย

Tools

  • ESPAsyncWebServerArduino/libraries
  • ESPAsyncTCPArduino/libraries
  • arduino-esp32fs-pluginArduino/tools
  • VueJs → JavaScript Framwoker สำหรับเขียน UI
  • SPIFFS → เก็บ Dashbord ไว้ในนี้แทนฝังไว้ใน code

Code ตัวอย่าง

[https://github.com/mrchoke/ESP32\_PMS5003ST\_Dashboard](https://github.com/mrchoke/ESP32_PMS5003ST_Dashboard)
Enter fullscreen mode Exit fullscreen mode

จะมี 3 dir

  • APMODE → ตัวอย่างใช้แบบ Access Point เหมือนกับตอนที่แล้ว
  • CLIENT → เชื่อมต่อกับ WiFi ผมใช้อยู่สองแบบคือแบบ WPA2 Enterprice กับ Wifi มือถือ
  • DashboardVueJs → จะเป็น Source ของ VueJs ใช้ lib และตัวอย่างมาจากหลายที่อาจจะไม่ค่อยสวยเท่าไหร่ :P

มันจะมีวิธีการเพิ่มเข้ามานิดหน่อยคือการ upload file ไว้ใน SPIFFS ติดตั้งตามในเว็บได้เลยไม่มีอะไรยุ่งยาก

Plantower PMS5003ST

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](https://github.com/i3water/Blinker_PMSX003ST)
Enter fullscreen mode Exit fullscreen mode

ซึ่งมีการคำนวณ 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();

// WebSocket

void 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 Sensor

void StartPMS(){
 Serial.println(F("\nStart"));

pmsSerial.begin(9600);
 pms.begin(pmsSerial);
 // pms.wakeUp();
 pms.setMode(PASSIVE);
 Serial.println(F("PMS5003ST Start.."));

}

// Read into Json

String 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 mDNS

void 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 WebSocket

void 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 Point

void 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());
 }

 }

}
Enter fullscreen mode Exit fullscreen mode

เทคนิคที่ใช้

นอกจาก lib ของ sensor ที่ต้องใช้แล้วยังมีเทคนิคที่เอาเข้ามาใช้คือ lib ของ ESPAsyncWebServer ตัวนี้ความสามารถหลากหลายกว่า lib WebServer ของ ESP เอง หลักๆ คือทำงานเร็วกว่ามากสามารถมี connection เข้ามาพร้อมๆ กันได้เยอะกว่าอ่านจาก SPIFFS ได้สะดวกกว่ามากเช่น

server.serveStatic("/", SPIFFS, "/").setDefaultFile("index.html");
Enter fullscreen mode Exit fullscreen mode

ผมเอาตัวเว็บ upload ขึ้น แล้วผมตั้งให้ไปอ่านจากตรงนี้ได้เลย และกำหนดว่าหน้าแรกให้เป็น index.html ได้เลย และ file static อื่นๆ สามารถ gzip ได้เพื่อลดขนาด ซึ่งจะเห็นว่าผมสามารถเอา VueTifyJs มาเป็นตัวอย่างได้สะบายๆ แต่ก็ต้องตัดความสามารถบางตัวออก เช่นไม่เอา fonts พวก icons ต่างๆเป็นต้น

นอกจากความสะดวกในเรื่อง SPIFFS แล้วยังสามารถทำเป็น WebSocket และ Event Source ได้ด้วยซึ่งจะสนุกมากยิ่งขึ้นถ้าจะเล่นเพิ่มก็ลองเขียนได้ใน

onWsEvent()
Enter fullscreen mode Exit fullscreen mode

ว่าเราจะคุยอะไรกันบ้างในละเหตุการณ์ซึ่งผมก็เพิ่งจะหัดเขียนใช้วิธีง่ายๆ คือ เมื่อมีการติดต่อกันครั้งแรกปุ๊บผมก็ส่งค่าไปให้เลยดังนั้นฝั่ง 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());**
 }

 }
Enter fullscreen mode Exit fullscreen mode

ถ้าไม่มี connection ก็จะไม่ส่งค่าออกไป

SPIFFS

หลังจากศึกษามาสักพักผมเลือกใช้วิธีเก็บ file html ไว้ใน SPIFFS แทนการประกาศตัวแปรแต่ต้องมีวิธีการเพิ่มเติมขึ้นมาอีกนิดหน่อยคือ ต้องติดตั้งเครื่องมือ(tool) สำหรับ upload file โดยเข้าไปยัง https://github.com/me-no-dev/arduino-esp32fs-plugin แล้วทำการติดตั้งตามคำแนะนำ

  1. ไปยังหน้า releases
  2. เลือก releases ล่าสุดในรูปแบบ zip
  3. ดูว่าใน directory arduino ของเรามี directory tools อยู่หรือไม่ถ้าไม่มีก็ให้สร้างเพิ่ม
  4. เข้าไปใน tools แล้วแตก zip ที่นั้น
  5. ปิด — เปิด arduino ide ใหม่
  6. ดูในเมนู tools จะเห็นเมนูเพิ่มขึ้นมา

เมนู ESP32 Sketch Data Upload เพิ่มขึ้นมา

การ upload file

วิธีเก็บ file ไว้ใน SPIFFS ให้เราสร้าง diretory ชื่อ data ไว้ใน project ของเรา แล้วนำ file html ที่ออกแบบไว้ไปเก็บไว้ เมื่อเราเรียก เมนูนี้มันก็จะก็จะทำการ compress แล้วทำ image เพื่อ upload ให้เรา

โครงสร้าง Project

ซึ่งเมื่อออกแบบหน้าเว็บเสร็จแล้วและทำการ gzip เรียบร้อยแล้วให้เรา upload file ขึ้นไปโดย คลิ้กที่เมนู

Tools -\> ESP32 Sketch Data Upload
Enter fullscreen mode Exit fullscreen mode

ขั้นตอนนี้ถ้าเราเปิดพวก Serial Monitor หรือ Serial Plotter อาจจะ error ได้ก็ให้ปิดไปก่อนนะครับ

file ที่เก็บอยู่ใน SPIFFS จะยังคงอยู่แม้เราจะ upload Sketch ใหม่ขึ้นไปหลายรอบดังนั้นถ้าเราไม่เปลี่ยนหน้าเว็บก็ไม่จำเป็นต้อง upload บ่อยๆ

Dashboard

ในตัวอย่างที่ผมใช้ครั้งนี้จะเป็น VueJs + VueTifyJs และ WebSocket ซึ่งสามารถศึกษาได้จากตัวอย่าง Source Code ได้เลย อาจจะไม่สมบูรณ์มากสำหรับคนที่ต้องการต่อยอดก็สามารถศึกษาเพิ่มเติมได้ เพราะยังมีอะไรให้เพิ่มเติมอีกเยอะ

วิธีการ Dev Dashboard

เข้าไปยัง directory ของ Dashboard ทำการติดตั้ง package ต่างๆสำหรับ dev

yarn install
Enter fullscreen mode Exit fullscreen mode

ทั้งนี้ให้ทำการ 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';
Enter fullscreen mode Exit fullscreen mode

ถ้าไม่สามารถหาชื่อ host ผ่าน mDNS ได้ให้เปลี่ยน pms5003st.local เป็น IP ของ MCU แทนนะครับ

เมื่อ ตั้งค่าเสร็จให้ลอง

yarn dev
Enter fullscreen mode Exit fullscreen mode

เพื่อ run dashboard ใน mode dev ถ้าไม่มี port ชนก็สามารถเข้าจาก web browser ได้ทาง

http://localhost:8080
Enter fullscreen mode Exit fullscreen mode

ถ้า Dashboard สามารถติดต่อกับ MCU ได้เราควรจะมีค่ามาแสดงผลได้ ถ้ายังไม่สามารถเอาข้อมูลมาได้ลองเช็คเรื่อง IP กับดูว่า MCU ทำงานได้หรือไม่

Landing page

หน้ารวมค่าต่างๆ ที่รับมา

เมื่อเราปรับแต่งจดเสร็จแล้วก็ให้ build production

yarn build
Enter fullscreen mode Exit fullscreen mode

ตรงนี้ให้สังเกตขนาดด้วยนะครับว่าไม่ควรใหญ่มากซึ่งหลัง gzip ไม่ควรเกิน 2M เมื่อ build เสร็จเว็บจะเก็บไว้ใน dist ให้ copy ข้อมูลในนี้ทั้งหมดไปเก็บไว้ใน directory data ของ Sketch แล้วทำการ gzip static บน MAC กับ Linux สามารถสั่งได้เลยดังนี้

cd data
gzip -9 -r static
Enter fullscreen mode Exit fullscreen mode

เมื่อเสร็จแล้วให้ปิด Serial Monitor แล้วทำการ upload ขึ้น SPIFFS แล้วทำการเรียกทดสอบได้เลย

เข้าผ่าน IP ที่แสดงใน Serial Monitor

APMODE IP Defualt จะเป็น 192.168.4.1

[http://mcu\_ip](http://mcu_ip)
Enter fullscreen mode Exit fullscreen mode

or

http://pms5003st.local
Enter fullscreen mode Exit fullscreen mode

การเรียกผ่านชื่ออาจจะมีข้อจำกัดในบางเครือข่ายเช่นถ้าเครือข่ายจำกัดการเข้าถึงอาจจะเรียกไม่ได้ แต่เท่าที่ทดสอบถ้าปล่อย APMODE เรียกชื่อได้ และ แชร์มือถือกับ router ส่วนตัวได้แน่นอน แต่ที่ทำงานผมจะไม่ได้เพราะเข้มงวดมากกว่าเป็นต้น

ตัวอย่าง UI

ถ้าอยากทดสอบการวัดสามารถใช้ไม้ขีดไฟจุดไฟ หรือใช้แป้งฝุ่นก็ได้


Top comments (0)