前端程式設計師是怎麼做物聯網開發的

2023-02-23 15:03:53

前端程式設計師是怎麼做物聯網開發的

上圖是我歷時一週做的線上的溫溼度視覺化專案,可以檢視截至目前往前一天的溫度、溼度變化趨勢,並且實時更新當前溫溼度

本文可能含有知識詛咒

概述和基礎講解

該專案用到的技術有:

  • 前端:jq、less、echarts、mqtt.js
  • 後端:eggjs、egg-emqtt
  • 資料庫:mysql
  • 伺服器:emqx(mqtt broker)
  • 硬體:
    • 板子:wemos D1 R2(設計基於 Arduino Uno R3 , 搭載esp8266 wifi模組)
  • 偵錯工具:mqttx、Arduino IDE v2.0.3 使用Arduino C開發

必備知識:

  • nodejs(eggjs框架)能面向業務即可
  • mysql 能寫基本插入查詢語句即可
  • C語言的基本語法瞭解即可
  • 知道mqtt協定的運作方式即可
  • arduino 開發板或任何其他電路板的初步瞭解即可

簡單介紹一下上面幾個的知識點:

  1. 從來沒有後端學習經驗的同學,推薦一個全棧專案供你參考:vue-xmw-admin-pro ,該專案用到了 前端VUE、後端eggjs、mysql、redis,對全棧學習很有幫助。

  2. mysql 只需要知道最簡單的插入和查詢語句即可,在本專案中,其實使用mongodb是更合適的,但是我為了方便,直接用了現成的mysql

  3. 即使你不知道C語言的基本語法,也可以在一小時內快速瞭解一下,知道簡單的定義變數、函數、返回值即可

  4. MQTT(訊息佇列遙測傳輸)是一種網路協定(長連線,意思就是除了使用者端可以主動向伺服器通訊外,伺服器也可以主動向使用者端發起),也是基於TCP/IP的,適用於算力低下的硬體裝置使用,基於釋出\訂閱正規化的訊息協定,具體範例如下:

    當某使用者端想釋出訊息時,圖大概長這樣:

    由上圖可知,當用戶端通過驗證上線後,還需要訂閱主題,當某使用者端向某主題釋出訊息時,只有訂閱了該主題的使用者端會收到broker的轉發。

    舉一個簡單的例子:你和我,還有他,我們把自己的名字、學號報告給門衛大爺(broker),門衛大爺就同意我們在警衛室玩一會,警衛室有無數塊黑板(topic),我們每個人都可以向門衛請求:如果某黑板上被人寫了字,請轉告給我。門衛會記住每個人的要求,比如當你向一塊黑板寫了字(你向某topic傳送了訊息),所有要求門衛告訴的人都會被門衛告知你寫了什麼(如果你也要求被告知,那麼也包括你自己)。

  5. 開發板可以被寫入程式,程式可以使用簡單的程式碼控制某個針腳的高低電平,或者讀取某針腳的資料。

開始

  1. 購買 wemos d1開發板、DHT11溫溼度感測器,共計19.3元。
  2. 使用arduino ide(以下簡稱ide) 對wemos d1程式設計需要下載esp8266依賴 參見:Arduino IDE安裝esp8266 SDK
  3. 在ide的選單欄選擇:檔案>偏好設定>其他開發板管理器地址填入:http://arduino.esp8266.com/stable/package_esp8266com_index.json,可以順便改個中文
  4. 安裝ch340驅動參見: win10 安裝 CH340驅動 實測win11同樣可用
  5. 使用 micro-usb 線,連線電腦和開發板,在ide選單中選擇:工具>開發板>esp8266>LOLIN(WEMOS) D1 R2 & mini
  6. 選擇埠,按win+x,開啟裝置管理器,檢視你的ch340在哪個埠,在ide中選擇對應的埠
  7. 當ide右下角顯示LOLIN(WEMOS) D1 R2 & mini 在comXX上時,連線就成功了
  8. 開啟ide選單欄 :檔案>範例>esp8266>blink,此時ide會開啟新視窗,在新視窗點選左上角的上傳按鈕,等待上傳完成,當板子上的燈一閃一閃,就表明:環境、設定、板子都沒問題,可以開始程式設計了,如果報錯,那麼一定是哪一步出問題了,我相信你能夠根據錯誤提示找出到底是什麼問題,如果實在找不出問題,那麼可能買到了壞的板子(故障率還是蠻高的)

wemos d1 針腳中有一個 3.3v電源輸出,三個或更多的GND接地口,當安裝DHT11感測器元件時,需要將正極插入3.3v口,負極插入GND口,中間的傳輸線插入隨便的數位輸入口,比如D5口(D5口的PIN值是14,後面會用到)。

使用DHT11感測器,需要安裝庫:DHT sensor library by Adafruit , 在ide的左側欄中的庫管理中直接搜尋安裝即可

下面是一個獲取DHT11資料的簡單範例,如果正常的話,在串列埠監視器中,會每秒輸出溫溼度資料

#include "DHT.h"  //這是依賴或者叫庫,或者叫驅動也行
#include "string.h"
#define DHTPIN 14      // DHT11資料引腳連線到D5引腳 D5引腳的PIN值是14
#define DHTTYPE DHT11  // 定義DHT11感測器
DHT dht(DHTPIN, DHTTYPE);  //初始化感測器

void setup() {
  Serial.begin(115200);
  //wemos d1 的波特率是 115200
  pinMode(BUILTIN_LED, OUTPUT); //設定一個輸出的LED
  dht.begin();  //啟動感測器
}

char* getDHT11Data() {
  float h = dht.readHumidity();  //獲取溼度值
  float t = dht.readTemperature(); //獲取溫度值
  static char data[100];
  if (isnan(h) || isnan(t)) {
    Serial.println("Failed to read from DHT sensor!");
    sprintf(data, "Temperature: %.1f, Humidity: %.1f", 0.0, 0.0); //如果任何一個值沒有值,直接返回兩個0.0,這樣我們就知道感測器可能出問題了
    return data;
  }
  sprintf(data, "Temperature: %.1f, Humidity: %.1f", t, h); //正常就取到值,我這裡拼成了一句話
  return data;
}

void loop() {
  char* data = getDHT11Data(); //此處去取感測器值
  Serial.println("got: " + String(data));  // 列印主題內容
  delay(1000); //每次迴圈延遲一秒
}

繼續

到了這一步,如果你用的是普通的arduino uno r3板子,就可以結束了。

取到資料之後,你就可以根據資料做一些其他的事情了,比如開啟接在d6引腳上的繼電器,而這個繼電器控制著一個加溼器。

如果你跟我一樣,使用了帶wifi網路的板子,就可以繼續跟我做。

我們繼續分步操作:

裝置端:

  1. 引入esp8266庫(上面已經提到安裝過程)

    1. #include "ESP8266WiFi.h"
      
  2. 安裝mqtt使用者端庫 ,直接在庫商店搜尋 PubSubClient ,下載 PubSubClient by Nick O'Leary 那一項,下載完成後:

    1. #include "PubSubClient.h"
      
  3. 至此,庫檔案已全部安裝引入完畢

  4. 設定 wifi ssid(即名字) 和 密碼,如:

    1. char* ssid = "2104";
      char* passwd = "13912428897";
      
  5. 嘗試連線 wifi

    1. WiFiClient espClient;
      int isConnect = 0;
      void connectWIFI() {
        isConnect = 0; 
        WiFi.mode(WIFI_STA);  //不知道什麼意思,照著寫就完了
        WiFi.begin(ssid, passwd); //嘗試連線
        int timeCount = 0;  //嘗試次數
        while (WiFi.status() != WL_CONNECTED) { //如果沒有連上,繼續迴圈
          for (int i = 200; i <= 255; i++) {
            analogWrite(BUILTIN_LED, i);
            delay(2);
          }
          for (int i = 255; i >= 200; i--) {
            analogWrite(BUILTIN_LED, i);
            delay(2);
          }
          // 上兩個迴圈共計200ms左右,在控制LED閃爍而已,你也可以不寫
          Serial.println("wifi connecting......" + String(timeCount));
          timeCount++;
          isConnect = 1; //每次都需要把連線狀態碼設定一下,只有連不上時設定為0
          // digitalWrite(BUILTIN_LED, LOW);
          if (timeCount >= 200) {
            // 當40000毫秒時還沒連上,就不連了
            isConnect = 0; //設定狀態碼為 0
            break;
          }
        }
        if (isConnect == 1) {
          Serial.println("Connect to wifi successfully!" + String("SSID is ") + WiFi.SSID());
          Serial.println(String("mac address is ") + WiFi.macAddress());
          // digitalWrite(BUILTIN_LED, LOW);
          analogWrite(BUILTIN_LED, 250); //設定LED常亮,250的亮度對我來說已經很合適了
          settMqttConfig();  //嘗試連線mqtt伺服器,在下一步有詳細程式碼
        } else {
          analogWrite(BUILTIN_LED, 255); //設定LED常滅,不要問我為什麼255是常滅,因為我的燈是高電平熄滅的
          //連線wifi失敗,等待一分鐘重連
          delay(60000);
        }
      }
      
  6. 嘗試連線 mqtt

    1. const char* mqtt_server = "larryblog.top";  //這裡是我的伺服器,當你看到這篇文章的時候,很可能已經沒了,因為我的伺服器還剩11天到期
      const char* TOPIC = "testtopic";            // 設定資訊主題
      const char* client_id = "mqttx_3b2687d2";   //client_id不可重複,可以隨便取,相當於你的網名
      PubSubClient client(espClient);
      void settMqttConfig() {
        client.setServer(mqtt_server, 1883);  //設定MQTT伺服器與使用的埠,1883是預設的MQTT埠
        client.setCallback(onMessage);  //設定收信函數,當訂閱的主題有訊息進來時,會進這個函數
        Serial.println("try connect mqtt broker");
        client.connect(client_id, "wemos", "aa995231030");  //後兩個引數是使用者名稱密碼
        client.subscribe(TOPIC); //訂閱主題
        Serial.println("mqtt connected");  //一切正常的話,就連上了
      }
      //收信函數
      void onMessage(char* topic, byte* payload, unsigned int length) {
        Serial.print("Message arrived [");
        Serial.print(topic);  // 列印主題資訊
        Serial.print("]:");
        char* payloadStr = (char*)malloc(length + 1);
        memcpy(payloadStr, payload, length);
        payloadStr[length] = '\0';
        Serial.println(payloadStr);  // 列印主題內容
        if (strcmp(payloadStr, (char*)"getDHTData") == 0) {
          char* data = getDHT11Data();
          Serial.println("got: " + String(data));  // 列印主題內容
          client.publish("wemos/dht11", data);
        }
        free(payloadStr);  // 釋放記憶體
      }
      
  7. 傳送訊息

    1. client.publish("home/status/", "{device:client_id,'status':'on'}");
      //注意,這裡向另外一個主題傳送的訊息,訊息內容就是裝置線上,當有其他的使用者端(比如web端)訂閱了此主題,便能收到此訊息
      

至此,板子上的程式碼基本上就寫完了,完整程式碼如下:

#include "ESP8266WiFi.h"
#include "PubSubClient.h"
#include "DHT.h"
#include "string.h"
#define DHTPIN 14      // DHT11資料引腳連線到D5引腳
#define DHTTYPE DHT11  // DHT11感測器
DHT dht(DHTPIN, DHTTYPE);

char* ssid = "2104";
char* passwd = "13912428897";
const char* mqtt_server = "larryblog.top";
const char* TOPIC = "testtopic";            // 訂閱資訊主題
const char* client_id = "mqttx_3b2687d2";
int isConnect = 0;
WiFiClient espClient;
PubSubClient client(espClient);
long lastMsg = 0;
void setup() {
  Serial.begin(115200);
  // Set WiFi to station mode
  connectWIFI();
  pinMode(BUILTIN_LED, OUTPUT);
  dht.begin();
}
char* getDHT11Data() {
  float h = dht.readHumidity();
  float t = dht.readTemperature();
  static char data[100];
  if (isnan(h) || isnan(t)) {
    Serial.println("Failed to read from DHT sensor!");
    sprintf(data, "Temperature: %.1f, Humidity: %.1f", 0.0, 0.0);
    return data;
  }
  sprintf(data, "Temperature: %.1f, Humidity: %.1f", t, h);
  return data;
}
void connectWIFI() {
  isConnect = 0;
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, passwd);
  int timeCount = 0;
  while (WiFi.status() != WL_CONNECTED) {
    for (int i = 200; i <= 255; i++) {
      analogWrite(BUILTIN_LED, i);
      delay(2);
    }
    for (int i = 255; i >= 200; i--) {
      analogWrite(BUILTIN_LED, i);
      delay(2);
    }
    // 上兩個迴圈共計200ms左右
    Serial.println("wifi connecting......" + String(timeCount));
    timeCount++;
    isConnect = 1;
    // digitalWrite(BUILTIN_LED, LOW);
    if (timeCount >= 200) {
      // 當40000毫秒時還沒連上,就不連了
      isConnect = 0;
      break;
    }
  }
  if (isConnect == 1) {
    Serial.println("Connect to wifi successfully!" + String("SSID is ") + WiFi.SSID());
    Serial.println(String("mac address is ") + WiFi.macAddress());
    // digitalWrite(BUILTIN_LED, LOW);
    analogWrite(BUILTIN_LED, 250);
    settMqttConfig();
  } else {
    analogWrite(BUILTIN_LED, 255);
    //連線wifi失敗,等待一分鐘重連
    delay(60000);
  }
}
void settMqttConfig() {
  client.setServer(mqtt_server, 1883);  //設定MQTT伺服器與使用的埠,1883是預設的MQTT埠
  client.setCallback(onMessage);
  Serial.println("try connect mqtt broker");
  client.connect(client_id, "wemos", "aa995231030");
  client.subscribe(TOPIC);
  Serial.println("mqtt connected");
}
void onMessage(char* topic, byte* payload, unsigned int length) {
  Serial.print("Message arrived [");
  Serial.print(topic);  // 列印主題資訊
  Serial.print("]:");
  char* payloadStr = (char*)malloc(length + 1);
  memcpy(payloadStr, payload, length);
  payloadStr[length] = '\0';
  Serial.println(payloadStr);  // 列印主題內容
  if (strcmp(payloadStr, (char*)"getDHTData") == 0) {
    char* data = getDHT11Data();
    Serial.println("got: " + String(data));  // 列印主題內容
    client.publish("wemos/dht11", data);
  }
  free(payloadStr);  // 釋放記憶體
}
void publishDhtData() {
  char* data = getDHT11Data();
  Serial.println("got: " + String(data));  // 列印主題內容
  client.publish("wemos/dht11", data);
  delay(2000);
}
void reconnect() {
  Serial.print("Attempting MQTT connection...");
  // Attempt to connect
  if (client.connect(client_id, "wemos", "aa995231030")) {
    Serial.println("reconnected successfully");
    // 連線成功時訂閱主題
    client.subscribe(TOPIC);
  } else {
    Serial.print("failed, rc=");
    Serial.print(client.state());
    Serial.println(" try again in 5 seconds");
    // Wait 5 seconds before retrying
    delay(5000);
  }
}
void loop() {
  if (!client.connected() && isConnect == 1) {
    reconnect();
  }
  if (WiFi.status() != WL_CONNECTED) {
    connectWIFI();
  }
  client.loop();
  publishDhtData();
  long now = millis();
  if (now - lastMsg > 2000) {
    lastMsg = now;
    client.publish("home/status/", "{device:client_id,'status':'on'}");
  }
  // Wait a bit before scanning again
  delay(1000);
}

伺服器

剛才的一同操作很可能讓人一頭霧水,相信大家對上面mqtt的操作還是一知半解的,不過沒有關係,通過對伺服器端的設定,你會對mqtt的機制瞭解的更加透徹

我們需要在伺服器端部署 mqtt broker,也就是mqtt的訊息中心伺服器

在網路上搜尋 emqx , 點選 EMQX: 大規模分散式物聯網 MQTT 訊息伺服器 ,這是一個帶有視覺化介面的軟體,而且畫面特別精美,操作特別絲滑,功能相當強大,使用起來基本上沒有心智負擔。點選立即下載,並選擇適合你的伺服器系統的版本:

這裡拿 ubuntu和windows說明舉例,相信其他系統也都大差不差

在ubuntu上,推薦使用apt下載,按上圖步驟操作即可,如中途遇到其他問題,請自行解決

  1. sudo ufw status 檢視開放埠,一般情況下,你只會看到幾個你手動開放過的埠,或者只有80、443埠
  2. udo ufw allow 18083 此埠是 emqx dashboard 使用的埠,開啟此埠後,可以在外網存取 emqx看板控制檯

當你看到如圖所示的畫面,說明已經開啟成功了

windows下直接下載安裝包,上傳到伺服器,雙擊安裝即可

  1. 開啟 「高階安全Windows Defender 防火牆」,點選入站規則>新建規則
  2. 點選埠 > 下一步
  3. 點選TCP、特定本地埠 、輸入18083,點選下一步
  4. 一直下一步到最後一步,輸入名稱,推薦輸入 emqx 即可

當你看到如圖所示畫面,說明你已經設定成功了。

完成伺服器端程式安裝和防火牆埠設定後,我們需要設定伺服器後臺的安全策略,這裡拿阿里雲舉例:

如果你是 ESC 雲主機,點選範例>點選你的伺服器名>安全組>設定規則>手動新增

新增這麼一條即可:

如果你是輕量伺服器,點選安全>防火牆>新增規則 即可,跟esc設定大差不差。

完成後,可以在本地瀏覽器嘗試存取你的emqx控制檯

直接輸入域名:18083即可,初始使用者名稱為admin,初始密碼為public,登入完成後,你便會看到如下畫面

接下來需要設定 使用者端登入名和密碼,比如剛剛在裝置中寫的使用者名稱密碼,就是在這個系統中設定的

點選 存取控制>認證 > 建立,然後無腦下一步即可,完成後你會看到如下畫面

點選使用者管理,新增使用者即可,使用者名稱和密碼都是自定義的,這些使用者名稱密碼可以分配給裝置端、使用者端、伺服器端、測試端使用,可以參考我的設定

userClient是準備給前端頁面用的 ,server是給後端用的,995231030是我個人自留的超級使用者,wemos是裝置用的,即上面裝置連線時輸入的使用者名稱密碼。

至此,emqx 控制檯設定完成。

下載 mqttx,作為測試端嘗試連線一下

點選連線,你會發現,根本連線不上......

因為,1883(mqtt預設埠)也是沒有開啟的,當然,和開啟18083的方法一樣。

同時,還建議你開啟:

  • 1803 websocket 預設埠
  • 1804 websockets 預設埠
  • 3306 mysql預設埠

後面這四個埠都會用到。

當你開啟完成後,再次嘗試使用mqttx連線broker,會發現可以連線了

這個頁面的功能也是很易懂的,我們在左側新增訂閱,右側的聊天框裡會出現該topic的訊息

你是否還記得,在上面的裝置程式碼中,我們在loop中每一秒向 home/status/ 傳送一條裝置線上的提示,我們現在在這裡就收到了。

當你看到這些訊息的時候,就說明,你的裝置、伺服器、emqx控制檯已經跑通了。

前後端以及資料庫

前端

前端不必多說,我們使用echarts承載展示資料,由於體量較小,我們不使用任何框架,直接使用jq和echarts實現,這裡主要講前端怎麼連線mqtt

首先引入mqtt庫

<script src="https://cdn.bootcdn.net/ajax/libs/mqtt/4.1.0/mqtt.min.js"></script>

然後設定連線引數

  const options = {
    clean: true, // true: 清除對談, false: 保留對談
    connectTimeout: 4000, // 超時時間
    clientId: 'userClient_' + generateRandomString(),
    //前端使用者端很可能比較多,所以這裡我們生成一個隨機的6位字母加數位作為clientId,以保證不會重複
    username: 'userClient',
    password: 'aa995231030',
  }
   function generateRandomString() {
    let result = '';
    let characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let charactersLength = characters.length;
    for (let i = 0; i < 6; i++) {
      result += characters.charAt(Math.floor(Math.random() * charactersLength));
    }
    return result;
  }
 

連線

  // const connectUrl = 'mqtt://larryblog.top/mqtt' 當然你可以使用mqtt協定,但是有可能會遇到 ssl 跨域的問題,如果你不使用 https 可以忽略這一項,直接使用mqtt即可
  const connectUrl = 'wss://larryblog.top/mqtt' //注意,這裡使用了nginx進行轉發,後面會講
  const client = mqtt.connect(connectUrl, options)

因為前端程式碼不多,我這裡直接貼了

html:

index.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="stylesheet/less" href="./style.less">
  <link rel="stylesheet" href="//at.alicdn.com/t/c/font_3712319_bzaequy11dn.css">
  <script src="https://cdn.bootcdn.net/ajax/libs/less.js/4.1.3/less.js"></script>
  <title>wemos d1 test</title>
</head>

<body>
  <div class="app" id="app">
    <div id="deviceStatus">
      <span class="statusLight"></span>
      <span id="statusText">Loading device status</span>
      <!-- <span class="iconfont icon-xinxi"></span> -->
    </div>
    <div class="container">
      <div class="Temperature">
        <div id="echartsViewTemperature"></div>
        <span>Current temperature:</span>
        <span id="Temperature">loading...</span>
      </div>
      <div class="Humidity">
        <div id="echartsViewHumidity"></div>
        <span>Current humidity:</span>
        <span id="Humidity">loading...</span>
      </div>
    </div>
  </div>
</body>
<script src="./showTip.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.4/moment.min.js"></script>
<script src="https://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/mqtt/4.1.0/mqtt.min.js"></script>
<script src="https://cdn.staticfile.org/echarts/4.7.0/echarts.js"></script>
<script src="./echarts.js?v=1.0.0"></script>
<script src="./mqttController.js"></script>

</html>

mqttController.js

// const mqtt = require('mqtt')
$(document).ready(() => {
  // Welcome to request my open interface. When the device is not online, the latest 2000 pieces of data will be returned
  $.post("https://larryblog.top/api", {
    topic: "getWemosDhtData",
    skip: 0
  },
    (data, textStatus, jqXHR) => {
      setData(data.res)
      // console.log("line:77 data==> ", data)
    },
  );
  // for (let i = 0; i <= 10; i++) {
  //   toast.showToast(1, "test")
  // }
  const options = {
    clean: true, // true: 清除對談, false: 保留對談
    connectTimeout: 4000, // 超時時間
    // Authentication information
    clientId: 'userClient_' + generateRandomString(),
    username: 'userClient',
    password: 'aa995231030',
    // You are welcome to use my open mqtt broker(My server is weak but come on you). When connecting, remember to give yourself a personalized clientId to prevent being squeezed out
    // Topic rule:
    // baseName/deviceId/events
  }
  // 連線字串, 通過協定指定使用的連線方式
  // ws 未加密 WebSocket 連線
  // wss 加密 WebSocket 連線
  // mqtt 未加密 TCP 連線
  // mqtts 加密 TCP 連線
  // wxs 微信小程式連線
  // alis 支付寶小程式連線
  let timer;
  let isShowTip = 1
  const connectUrl = 'wss://larryblog.top/mqtt'
  const client = mqtt.connect(connectUrl, options)
  client.on('connect', (error) => {
    console.log('已連線:', error)
    toast.showToast("Broker Connected")
    timer = setTimeout(onTimeout, 3500);
    // 訂閱主題
    client.subscribe('wemos/dht11', function (err) {
      if (!err) {
        // 釋出訊息
        client.publish('testtopic', 'getDHTData')
      }
    })
    client.subscribe('home/status/')
    client.publish('testtopic', 'Hello mqtt')

  })
  client.on('reconnect', (error) => {
    console.log('正在重連:', error)
    toast.showToast(3, "reconnecting...")
  })

  client.on('error', (error) => {
    console.log('連線失敗:', error)
    toast.showToast(2, "connection failed")
  })
  client.on('message', (topic, message) => {
    // console.log('收到訊息:', topic, message.toString())
    switch (topic) {
      case "wemos/dht11":
        const str = message.toString()
        const arr = str.split(", "); // 分割字串
        const obj = Object.fromEntries(arr.map(s => s.split(": "))); // 轉化為物件

        document.getElementById("Temperature").innerHTML = obj.Temperature + " ℃"
        optionTemperature.xAxis.data.push(moment().format("MM-DD/HH:mm:ss"))
        optionTemperature.xAxis.data.length >= 100 && optionTemperature.xAxis.data.shift()
        optionTemperature.series[0].data.length >= 100 && optionTemperature.series[0].data.shift()
        optionTemperature.series[0].data.push(parseFloat(obj.Temperature))
        ChartTemperature.setOption(optionTemperature, true);

        document.getElementById("Humidity").innerHTML = obj.Humidity + " %RH"
        optionHumidity.xAxis.data.push(moment().format("MM-DD/HH:mm:ss"))
        optionHumidity.xAxis.data.length >= 100 && optionHumidity.xAxis.data.shift()
        optionHumidity.series[0].data.length >= 100 && optionHumidity.series[0].data.shift()
        optionHumidity.series[0].data.push(parseFloat(obj.Humidity))
        ChartHumidity.setOption(optionHumidity, true);
        break
      case "home/status/":
        $("#statusText").text("device online")
        deviceOnline()
        $(".statusLight").removeClass("off")
        $(".statusLight").addClass("on")
        clearTimeout(timer);
        timer = setTimeout(onTimeout, 3500);
        break

    }

  })

  function deviceOnline() {
    if (isShowTip) {
      toast.showToast(1, "device online")
    }
    isShowTip = 0
  }

  function setData(data) {
    // console.log("line:136 data==> ", data)
    for (let i = data.length - 1; i >= 0; i--) {
      let item = data[i]
      // console.log("line:138 item==> ", item)
      optionTemperature.series[0].data.push(item.temperature)
      optionHumidity.series[0].data.push(item.humidity)
      optionHumidity.xAxis.data.push(moment(item.updateDatetime).format("MM-DD/HH:mm:ss"))
      optionTemperature.xAxis.data.push(moment(item.updateDatetime).format("MM-DD/HH:mm:ss"))
    }
    ChartTemperature.setOption(optionTemperature);
    ChartHumidity.setOption(optionHumidity);
  }

  function onTimeout() {
    $("#statusText").text("device offline")
    toast.showToast(3, "device offline")
    isShowTip = 1
    document.getElementById("Temperature").innerHTML = "No data"
    document.getElementById("Humidity").innerHTML = "No data"
    $(".statusLight").removeClass("on")
    $(".statusLight").addClass("off")
  }

  function generateRandomString() {
    let result = '';
    let characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let charactersLength = characters.length;
    for (let i = 0; i < 6; i++) {
      result += characters.charAt(Math.floor(Math.random() * charactersLength));
    }
    return result;
  }
});

showTip.js 是我釋出在npm上的一個包,如果有需要可以自行npm下載

style.less

* {
  padding: 0;
  margin: 0;
  color: #fff;
}

.app {
  background: #1b2028;
  width: 100vw;
  height: 100vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;

  #deviceStatus {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 20px;

    .statusLight {
      display: block;
      height: 10px;
      width: 10px;
      border-radius: 100px;
      background: #b8b8b8;

      &.on {
        background: #00a890;
      }

      &.off {
        background: #b8b8b8;
      }
    }
  }

  .container {
    width: 100%;
    height: 0;
    flex: 1;
    display: flex;

    @media screen and (max-width: 768px) {
      flex-direction: column;
    }

    >div {
      flex: 1;
      height: 100%;
      text-align: center;

      #echartsViewTemperature,
      #echartsViewHumidity {
        width: 80%;
        height: 50%;
        margin: 10px auto;
        // background: #eee;
      }
    }
  }
}

echarts.js 這個檔案是我自己寫的,別學我這種命名方式,這是反例

let optionTemperature = null
let ChartTemperature = null
$(document).ready(() => {
  setTimeout(() => {
    // waiting
    ChartTemperature = echarts.init(document.getElementById('echartsViewTemperature'));
    ChartHumidity = echarts.init(document.getElementById('echartsViewHumidity'));
    // 指定圖表的設定項和資料
    optionTemperature = {
      textStyle: {
        color: '#fff'
      },
      tooltip: {
        trigger: 'axis',
        // transitionDuration: 0,
        backgroundColor: '#fff',
        textStyle: {
          color: "#333",
          align: "left"
        },
      },
      xAxis: {
        min: 0,
        data: [],
        boundaryGap: false,
        splitLine: {
          show: false
        },
        axisLine: {
          lineStyle: {
            color: '#fff'
          }
        }
      },
      yAxis: {
        splitLine: {
          show: false
        },
        axisTick: {
          show: false // 隱藏 y 軸的刻度線
        },
        axisLine: {
          show: false,
          lineStyle: {
            color: '#fff'
          }
        }
      },
      grid: {
        // 為了讓標尺和提示框在圖表外面,需要將圖表向外擴充套件一點
        left: '10%',
        right: '5%',
        bottom: '5%',
        top: '5%',
        containLabel: true,
      },
      series: [{
        // clipOverflow: false,
        name: '溫度',
        type: 'line',
        smooth: true,
        symbol: 'none',
        data: [],
        itemStyle: {
          color: '#00a890'
        },
        areaStyle: {
          color: {
            type: 'linear',
            x: 0,
            y: 0,
            x2: 0,
            y2: 1,
            colorStops: [{
              offset: 0,
              color: '#00a89066' // 0% 處的顏色
            }, {
              offset: 1,
              color: '#00a89000' // 100% 處的顏色
            }],
            global: false // 預設為 false
          }
        },
        hoverAnimation: true,
        label: {
          show: false,
        },
        markLine: {
          symbol: ['none', 'none'],
          data: [
            {
              type: 'average',
              name: '平均值',
            },
          ],
        },
      }]
    };
    optionHumidity = {
      textStyle: {
        color: '#fff'
      },
      tooltip: {
        trigger: 'axis',
        backgroundColor: '#fff',
        textStyle: {
          color: "#333",
          align: "left"
        },
      },
      xAxis: {
        min: 0,
        data: [],
        boundaryGap: false,
        splitLine: {
          show: false
        },
        axisTick: {
          //x軸刻度相關設定
          alignWithLabel: true,
        },
        axisLine: {
          lineStyle: {
            color: '#fff'
          }
        }
      },
      yAxis: {
        splitLine: {
          show: false
        },
        axisTick: {
          show: false // 隱藏 y 軸的刻度線
        },
        axisLine: {
          show: false,
          lineStyle: {
            color: '#fff'
          }
        }
      },
      grid: {
        // 為了讓標尺和提示框在圖表外面,需要將圖表向外擴充套件一點
        left: '5%',
        right: '5%',
        bottom: '5%',
        top: '5%',
        containLabel: true,
      },
      // toolbox: {
      //   feature: {
      //     dataZoom: {},
      //     brush: {
      //       type: ['lineX', 'clear'],
      //     },
      //   },
      // },
      series: [{
        clipOverflow: false,
        name: '溼度',
        type: 'line',
        smooth: true,
        symbol: 'none',
        data: [],
        itemStyle: {
          color: '#ffa74b'
        },
        areaStyle: {
          color: {
            type: 'linear',
            x: 0,
            y: 0,
            x2: 0,
            y2: 1,
            colorStops: [{
              offset: 0,
              color: '#ffa74b66' // 0% 處的顏色
            }, {
              offset: 1,
              color: '#ffa74b00' // 100% 處的顏色
            }],
            global: false // 預設為 false
          }
        },
        hoverAnimation: true,
        label: {
          show: false,
        },
        markLine: {
          symbol: ['none', 'none'],
          data: [
            {
              type: 'average',
              name: '平均值',
            },
          ],
        },
      }]
    };

    // 使用剛指定的設定項和資料顯示圖表。
    ChartTemperature.setOption(optionTemperature);
    ChartHumidity.setOption(optionHumidity);
  }, 100)
});

當你看到這裡,你應該可以在你的前端頁面上展示你的板子發來的每一條訊息了,但是還遠遠做不到首圖上那種密密麻麻的資料,我並不是把頁面開了一天,而是使用了後端和資料庫儲存了一部分資料。

後端

後端我們分為了兩個部分,一個是nodejs的後端程式,一個是nginx代理,這裡先講代理,因為上一步前端的連線需要走這個代理

nginx

如果你沒有使用https連線,那麼可以不看本節,直接使用未加密的mqtt協定,如果你有自己的域名,且申請了ssl證書,那麼可以參考我的nginx設定,設定如下

http {
	sendfile on;
	tcp_nopush on;
	tcp_nodelay on;
	keepalive_timeout 65;
	types_hash_max_size 2048;
	include /etc/nginx/mime.types;
	default_type application/octet-stream;

	##
	# SSL Settings
	##
	server {
    listen 80;
    server_name jshub.cn;
    #將請求轉成https
    rewrite ^(.*)$ https://$host$1 permanent;
	}
	server {
        listen 443 ssl;
				server_name jshub.cn;
				location / {
					root /larryzhu/web/release/toolbox;
					index index.html index.htm;
					try_files $uri $uri/ /index.html;
				}
	 location /mqtt {
           proxy_pass http://localhost:8083;
           proxy_http_version 1.1;
           proxy_set_header Upgrade $http_upgrade;
           proxy_set_header Connection "upgrade";
      	 }
        # SSL 協定版本
        ssl_protocols TLSv1.2;
        # 證書
        ssl_certificate /larryzhu/web/keys/9263126_jshub.cn.pem;
        # 私鑰
        ssl_certificate_key /larryzhu/web/keys/9263126_jshub.cn.key;
        # ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
        # ssl_ciphers AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256;

        # 與False Start沒關係,預設此項開啟,此處減少抓包的干擾而關閉
        # ssl_session_tickets off;

        # return 200 "https ok \n";
  }

注意這只是部分設定,切不可全部覆蓋你的設定。

如果你不會使用nginx,說明你無需設定 ssl ,直接使用 mqtt協定即可。

後端程式部分

這裡以egg.js框架為例

首先需要下載egg.js的外掛 egg-emqtt ,直接使用npm下載即可,詳細設定和啟用方法 參見 MQTT系列實踐二 在EGG中使用mqtt

上面教學的方法並不全面,可以下載我的範例,仿照著寫一下,因為內容相對複雜,地址:https://gitee.com/zhu_yongbo/mqttineggjs

其中還包含了 mysql 資料庫的連線方法,內有我伺服器的地址、mysql開放埠,使用者名稱以及密碼,我伺服器還剩不到十天到期,有緣人看到我的文章可以對我的伺服器為所欲為,沒有什麼重要資料。

mysql

mysql方面,只需要一個庫,一個表即可完成全部工作

如圖所示,不復雜,仿照我的建庫即可

有一點,比較重要,因為mysql本身不適用於儲存量級太大的資料,我們的資料重複的又比較多,可以考慮一下壓縮演演算法,或者新增一個事件(每次插入時檢查資料量是否超過一定值)。像我的板子大概正常累計執行了幾天的時間(每兩秒一條資料),到目前可以看到已經累計了七十萬條資料了,如果不是因為我設定了插入事件,這個資料量已經可以明顯影響查詢速度了。

可以仿照我的事件,語句如下:

DELIMITER $$
CREATE TRIGGER delete_oldest_data
AFTER INSERT ON wemosd1_dht11
FOR EACH ROW
BEGIN
    -- 如果資料量超過43200(每兩秒插入一條,這是一天的量)條,呼叫儲存過程刪除最早的一條資料
    IF (SELECT COUNT(*) FROM wemosd1_dht11) > 43200 THEN
        CALL delete_oldest();
    END IF;
END$$
DELIMITER ;

-- 建立儲存過程
CREATE PROCEDURE delete_oldest()
BEGIN
    -- 刪除最早的一條資料
    delete from wemosd1_dht11 order by id asc limit 1
    
END$$
DELIMITER ;

BTW:這是chatGPT教我的,我只進行了一點小小的修改。

這樣做會刪除id比較小的資料,然後就會導致,id會增長的越來越大,好處是可以看到一共累計了多少條資料。但是如果你不想讓id累計,那麼可以選擇重建id,具體做法,建議你諮詢一下chatGPT

結語

至此,我們已經完成了前端、後端、裝置端三端連通。

我們整體梳理一下資料是怎麼一步一步來到我們眼前的:

首先wemos d1開發板會在DHT11溫溼度感測器上讀取溫溼度值,然後開發板把資料通過mqtt廣播給某topic,我們的前後端都訂閱了此topic,後端收到後,把處理過的資料存入mysql,前端直接使用echarts進行展示,當前端啟動時,還可以向後端程式查詢歷史資料,比如前8000條資料,之後的變化由線上的開發板提供,我們就得到了一個實時的,並且能看到歷史資料的溫溼度線上大屏。

如果你覺得牛逼,就給我點個贊吧。