← 回文章列表

2018年12月4日

MQTT 在 IoT 的應用:Broker、Topic、QoS、Retained 與遺囑訊息一次搞懂

MQTTIoTESP8266Mosquitto發布訂閱

為什麼 IoT 不適合直接用 HTTP 控制設備

我之前的家電控制是這樣做的:每個 ESP8266 在內網開一個 HTTP 服務,數據機設定 Port Forwarding,手機從外網打 http://家裡的IP:Port/on 就能開燈。做出來是能用,但問題很多:

  • 設備要對外開 Port:只要知道 IP 和 Port,任何人都能控制,等於把家電直接暴露在網路上
  • 沒有固定 IP 就不能用:換到沒有固定 IP 的環境,或是在電信業者的 NAT 後面,外面根本連不進來
  • 手機不知道設備現在的狀態:要知道燈是開是關,只能一直輪詢每個設備
  • 一對多很麻煩:想讓「手機」和「排程程式」都知道澆花系統啟動了,就要各自打一次 API

MQTT(Message Queuing Telemetry Transport)就是為這種場景設計的協定。它最早是 IBM 在 1999 年為了透過衛星監控油管而設計的,目標就是在頻寬小、網路不穩、設備資源有限的環境下可靠地傳訊息,現在是 OASIS 標準(3.1.1 版),也是 IoT 領域最常見的通訊協定之一。

MQTT 的基本模型:所有人都連到 Broker

MQTT 架構示意圖:客廳燈、房間電腦、澆花系統三個 ESP8266 設備位於家中 NAT 後方,全部主動往外連到雲端的 MQTT Broker(Mosquitto,TLS 8883 埠);設備訂閱自己的 set 主題接收指令,並發布 state 主題回報狀態;手機 App 與排程程式也連到同一個 Broker,App 訂閱 home/+/+/state 取得所有設備狀態,並發布 home/livingroom/light/set 送出開燈指令;數據機不需要設定任何 Port Forwarding

MQTT 採用**發布/訂閱(Publish/Subscribe)**模型,角色只有兩種:

  • Broker:訊息的中轉站,負責接收所有發布的訊息,再轉送給有訂閱的人。常見的實作有 Mosquitto、EMQ X、HiveMQ
  • Client:所有連到 Broker 的程式都是 Client,同一個 Client 可以同時發布和訂閱。ESP8266、手機 App、後端排程都是 Client

這個模型最大的好處是:每個 Client 都是主動往外連到 Broker。設備在 NAT 後面也沒關係,因為連線是它自己發起的,Broker 再透過這條已經建立的 TCP 連線把訊息推回去。數據機不用開任何 Port,前面說的問題一次解決大半。

另外,發布者和訂閱者彼此不知道對方存在,只知道 Topic:澆花系統只管發布「我開始澆水了」,有幾個人在聽、是誰在聽,它完全不需要知道。

MQTT 的封包也非常精簡,固定標頭最少只有 2 個 Byte,比 HTTP 動輒幾百 Byte 的 Header 輕量很多,對記憶體只有幾十 KB 的 ESP8266 很友善。

Topic:用斜線分層的主題

Topic 是一個用 / 分層的字串,例如:

text
home/livingroom/light/set
home/livingroom/light/state
home/garden/watering/state

Topic 不需要事先建立,發布時寫什麼就是什麼。訂閱時可以使用兩種萬用字元:

萬用字元意義範例符合
+剛好一層home/+/light/statehome/livingroom/light/state、home/bedroom/light/state
#零到多層,只能放在最後home/garden/#home/garden、home/garden/watering/state

注意 + 和 # 只能用在訂閱,發布時一定要寫完整的 Topic。

Topic 怎麼設計

Topic 設計好,後面的訂閱就會很省事。我的原則是:

  • 由大到小:{位置}/{區域}/{設備}/{用途},越後面越細,這樣萬用字元才好用
  • 指令與狀態分開:.../set 是「請你變成這樣」,.../state 是「我現在是這樣」。手機發 set,設備收到、執行完之後再發 state。如果兩者共用一個 Topic,設備會收到自己發出的狀態,分不清是指令還是回報
  • 不要用 / 開頭:/home/light 的第一層會是一個空字串,容易和 home/light 搞混
  • 不要用 $ 開頭:$SYS/ 開頭的 Topic 是 Broker 保留用來發布自身狀態的(例如連線數)

QoS:三種投遞保證

MQTT 三種 QoS 的封包往返:QoS 0 只送一次 PUBLISH,不確認,最多一次,可能遺失;QoS 1 送 PUBLISH 後等待 PUBACK,沒收到就帶 DUP 旗標重送,至少一次,可能重複;QoS 2 經過 PUBLISH、PUBREC、PUBREL、PUBCOMP 四次往返,剛好一次,但成本最高

MQTT 在每一則訊息上都可以指定 QoS(Quality of Service):

  • QoS 0:最多一次(At most once)。送出去就不管了,沒有確認,網路一斷就遺失。適合高頻率、掉一筆也無所謂的資料,例如每秒回報一次的溫度
  • QoS 1:至少一次(At least once)。接收端要回 PUBACK,沒收到確認就重送,所以可能重複。大部分的指令都用這個等級
  • QoS 2:剛好一次(Exactly once)。透過四次往返確保不重複也不遺失,但延遲與負擔最大,在 IoT 設備上很少真的需要

有兩個容易誤會的地方:

  1. QoS 是「每一段」各自的:發布者到 Broker 是一段,Broker 到訂閱者是另一段。發布者用 QoS 1 發布,但訂閱者用 QoS 0 訂閱,Broker 轉送給它時就會降級成 QoS 0。實際的保證是兩者中比較低的那個
  2. QoS 1 會重複:設備要能承受同一個指令收到兩次。所以指令的內容要用「變成什麼狀態」(ON、OFF),不要用「切換」(TOGGLE)——收到兩次 ON 還是開,收到兩次 TOGGLE 就變回關了

Retained Message:新訂閱者馬上知道目前狀態

一般的訊息,Broker 轉送完就丟了。手機 App 打開時才訂閱 home/+/+/state,要等到每個設備下一次發布狀態才知道燈是開是關。

發布時加上 Retain 旗標,Broker 會把這個 Topic 的最後一則訊息保留下來,之後只要有人訂閱,就馬上收到這一則。所以:

  • 狀態(state)用 Retained:App 一打開就能拿到每個設備的最新狀態
  • 指令(set)不要用 Retained:否則設備每次重新連線都會再收到一次舊的指令,例如「三天前的開燈」

要清除一個 Topic 的 Retained Message,就發布一則內容為空的 Retained 訊息。

Clean Session:離線期間的訊息要不要保留

Client 連線時會帶一個 Clean Session 旗標:

  • true:每次連線都是全新的,斷線期間的訊息全部不保留,重新連線後也要重新訂閱
  • false(Persistent Session):Broker 會依 Client ID 記住這個 Client 的訂閱,斷線期間發給它的 QoS 1、QoS 2 訊息會先排隊,重新連線後補送

對控制指令來說,要不要補送其實要想清楚:設備斷線 10 分鐘,回來後補收到「10 分鐘前的開燈指令」是合理的;但如果是「澆水 5 分鐘」這種有時效的指令,晚了 2 小時才執行反而有問題。我的做法是設備用 Persistent Session,但在指令裡帶上時間戳,設備發現指令太舊就丟掉。

Keep Alive 與遺囑訊息:知道設備斷線了

Wi-Fi 斷線、設備斷電時,TCP 連線不一定會馬上被發現是斷的。MQTT 用兩個機制處理:

  • Keep Alive:Client 連線時宣告一個秒數(例如 30 秒),在這段時間內如果沒有其他封包,就送一個 PINGREQ。Broker 超過 1.5 倍的時間都沒收到任何東西,就判定這個 Client 已經斷線
  • 遺囑訊息(Last Will and Testament,LWT):Client 連線時先告訴 Broker:「如果我不正常斷線,請幫我發布這則訊息」

把兩者結合,就能做出設備的上下線狀態:

  1. 設備連線時設定遺囑:Topic home/livingroom/light/status、內容 offline、Retained
  2. 連線成功後,自己發布一則 online 到同一個 Topic,也是 Retained
  3. 設備斷電,Broker 在 Keep Alive 逾時後代為發布 offline

App 訂閱 home/+/+/status,就能即時知道哪些設備離線了,而且因為是 Retained,打開 App 的當下就看得到。

使用情境:用 MQTT 改寫家電控制

架構改成這樣:

  • 一台雲端主機跑 Mosquitto,只開放 TLS 的 8883 埠,關閉匿名登入
  • 每個 ESP8266 用自己的帳號連到 Broker,訂閱自己的 set Topic,發布自己的 state 與 status
  • 手機 App 訂閱 home/+/+/state 與 home/+/+/status 顯示所有設備,按下按鈕時發布到對應的 set
  • 自動澆花的排程程式也是一個 Client,時間到了就發布 home/garden/watering/set

Mosquitto 的設定:

text
# mosquitto.conf
listener 8883
cafile   /etc/mosquitto/certs/ca.crt
certfile /etc/mosquitto/certs/server.crt
keyfile  /etc/mosquitto/certs/server.key

allow_anonymous false
password_file /etc/mosquitto/passwd
acl_file      /etc/mosquitto/acl

ACL 限制每個帳號只能碰自己的 Topic,避免其中一個設備的帳號被拿走後能控制全部:

text
# acl:客廳燈只能讀自己的指令、寫自己的狀態
user livingroom-light
topic read  home/livingroom/light/set
topic write home/livingroom/light/state
topic write home/livingroom/light/status

# 手機 App 可以讀所有狀態、送所有指令
user mobile-app
topic read  home/+/+/state
topic read  home/+/+/status
topic write home/+/+/set

ESP8266 端使用 Arduino 的 PubSubClient 函式庫:

cpp
#include <ESP8266WiFi.h>
#include <PubSubClient.h>

WiFiClientSecure net;
PubSubClient mqtt(net);

const char* SET_TOPIC    = "home/livingroom/light/set";
const char* STATE_TOPIC  = "home/livingroom/light/state";
const char* STATUS_TOPIC = "home/livingroom/light/status";

void onMessage(char* topic, byte* payload, unsigned int length) {
  String cmd;
  for (unsigned int i = 0; i < length; i++) cmd += (char)payload[i];

  // 用「目標狀態」而不是「切換」,重複收到也不會出錯
  if (cmd == "ON")  digitalWrite(RELAY_PIN, HIGH);
  if (cmd == "OFF") digitalWrite(RELAY_PIN, LOW);

  mqtt.publish(STATE_TOPIC, cmd.c_str(), true);  // 回報狀態,Retained
}

void connectMqtt() {
  while (!mqtt.connected()) {
    // Client ID 要唯一;最後四個參數是遺囑:Topic、QoS、Retain、內容
    if (mqtt.connect("livingroom-light", MQTT_USER, MQTT_PASS,
                     STATUS_TOPIC, 1, true, "offline")) {
      mqtt.publish(STATUS_TOPIC, "online", true);
      mqtt.subscribe(SET_TOPIC, 1);
    } else {
      delay(5000);  // 連不上就等一下再試,不要瘋狂重連
    }
  }
}

void setup() {
  pinMode(RELAY_PIN, OUTPUT);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  net.setFingerprint(BROKER_FINGERPRINT);  // 驗證 Broker 憑證
  mqtt.setServer(BROKER_HOST, 8883);
  mqtt.setCallback(onMessage);
}

void loop() {
  if (!mqtt.connected()) connectMqtt();
  mqtt.loop();  // 處理收到的訊息與 Keep Alive,一定要一直呼叫
}

測試時用 Mosquitto 附的指令工具很方便:

Shell
# 監看所有設備的狀態(-v 會一起印出 Topic)
mosquitto_sub -h broker.example.com -p 8883 --cafile ca.crt \
  -u mobile-app -P '***' -t 'home/+/+/state' -v

# 送出開燈指令
mosquitto_pub -h broker.example.com -p 8883 --cafile ca.crt \
  -u mobile-app -P '***' -t 'home/livingroom/light/set' -m 'ON' -q 1

和原本的做法比較:

原本:HTTP + Port Forwarding改成 MQTT
連線方向外面連進家裡設備主動連出去
需要固定 IP需要不需要(Broker 要有)
驗證與加密沒有帳號密碼 + TLS + ACL
得知設備狀態輪詢每個設備訂閱 Retained 的 state
得知設備離線打不通才知道遺囑訊息即時通知
多個程式要收到同一個事件各自呼叫各自訂閱同一個 Topic

注意事項

  • Client ID 一定要唯一:兩個 Client 用同一個 Client ID 連線,Broker 會把先連的那個踢掉。如果兩個設備都設定了自動重連,就會互相踢來踢去、一直斷線。燒錄程式時最容易複製貼上忘了改
  • ESP8266 的 PubSubClient 有限制:發布只支援 QoS 0,訂閱支援 QoS 0 和 1;預設的封包大小上限是 128 Byte,訊息太長會直接發不出去,要調整 MQTT_MAX_PACKET_SIZE
  • loop() 裡不能有長時間的 delay():mqtt.loop() 沒被呼叫,Keep Alive 就送不出去,Broker 會以為設備斷線並發布遺囑。需要等待的邏輯要改用 millis() 計時
  • TLS 在 ESP8266 上很吃記憶體:TLS 握手需要不少 RAM,憑證驗證用 Fingerprint 比較省資源,但伺服器換憑證時,所有設備都要重新燒錄
  • 重連要有間隔:Broker 重啟時,所有設備會同時重連。每個設備的重連間隔最好加一點隨機值,不要全部在同一秒湧進來
  • 不要什麼都用 QoS 2:每則訊息四次往返,在不穩定的 Wi-Fi 上延遲會很明顯。溫度這種持續回報的資料用 QoS 0,指令用 QoS 1,再讓設備的處理邏輯能承受重複
  • 小心訂閱 #:訂閱全部 Topic 在除錯時很方便,但在正式環境,Broker 會把所有訊息都推給這個 Client,設備一多流量就很可觀
  • Broker 是單點:所有設備都依賴它,Broker 掛掉整個系統就停了。至少要讓 Mosquitto 開機自動啟動、異常自動重啟,並監控它的狀態

一句話總結

MQTT 讓 IoT 設備主動連到 Broker,用 Topic 發布與訂閱訊息,解決了設備在 NAT 後面、要對外開 Port 的問題;設計時把指令與狀態分成不同 Topic,狀態用 Retained 讓新訂閱者馬上拿到,指令用 QoS 1 並設計成重複收到也沒關係,再用 Keep Alive 加遺囑訊息掌握設備上下線,最後別忘了 TLS、帳號與 ACL,才不會從「任何人都能開你家的燈」變成「任何人都能訂閱你家的燈」。