[{"data":1,"prerenderedAt":1195},["ShallowReactive",2],{"article-\u002Farticles\u002Fmqtt-iot-basics":3},{"id":4,"title":5,"body":6,"date":1182,"description":126,"excerpt":1183,"extension":1184,"meta":1185,"navigation":629,"path":1186,"seo":1187,"stem":1188,"tags":1189,"__hash__":1194},"articles\u002Farticles\u002Fmqtt-iot-basics.md","MQTT 在 IoT 的應用：Broker、Topic、QoS、Retained 與遺囑訊息一次搞懂",{"type":7,"value":8,"toc":1168},"minimark",[9,14,23,52,59,63,70,73,91,98,105,108,112,119,129,136,209,222,227,230,290,294,300,306,334,337,380,384,395,406,426,433,437,444,471,474,478,481,503,506,531,538,542,545,582,585,591,594,600,603,879,882,1002,1005,1087,1090,1158,1161,1164],[10,11,13],"h2",{"id":12},"為什麼-iot-不適合直接用-http-控制設備","為什麼 IoT 不適合直接用 HTTP 控制設備",[15,16,17,18,22],"p",{},"我之前的家電控制是這樣做的：每個 ESP8266 在內網開一個 HTTP 服務，數據機設定 Port Forwarding，手機從外網打 ",[19,20,21],"code",{},"http:\u002F\u002F家裡的IP:Port\u002Fon"," 就能開燈。做出來是能用，但問題很多：",[24,25,26,34,40,46],"ul",{},[27,28,29,33],"li",{},[30,31,32],"strong",{},"設備要對外開 Port","：只要知道 IP 和 Port，任何人都能控制，等於把家電直接暴露在網路上",[27,35,36,39],{},[30,37,38],{},"沒有固定 IP 就不能用","：換到沒有固定 IP 的環境，或是在電信業者的 NAT 後面，外面根本連不進來",[27,41,42,45],{},[30,43,44],{},"手機不知道設備現在的狀態","：要知道燈是開是關，只能一直輪詢每個設備",[27,47,48,51],{},[30,49,50],{},"一對多很麻煩","：想讓「手機」和「排程程式」都知道澆花系統啟動了，就要各自打一次 API",[15,53,54,55,58],{},"MQTT（Message Queuing Telemetry Transport）就是為這種場景設計的協定。它最早是 IBM 在 1999 年為了透過衛星監控油管而設計的，目標就是",[30,56,57],{},"在頻寬小、網路不穩、設備資源有限","的環境下可靠地傳訊息，現在是 OASIS 標準（3.1.1 版），也是 IoT 領域最常見的通訊協定之一。",[10,60,62],{"id":61},"mqtt-的基本模型所有人都連到-broker","MQTT 的基本模型：所有人都連到 Broker",[15,64,65],{},[66,67],"img",{"alt":68,"src":69},"MQTT 架構示意圖：客廳燈、房間電腦、澆花系統三個 ESP8266 設備位於家中 NAT 後方，全部主動往外連到雲端的 MQTT Broker（Mosquitto，TLS 8883 埠）；設備訂閱自己的 set 主題接收指令，並發布 state 主題回報狀態；手機 App 與排程程式也連到同一個 Broker，App 訂閱 home\u002F+\u002F+\u002Fstate 取得所有設備狀態，並發布 home\u002Flivingroom\u002Flight\u002Fset 送出開燈指令；數據機不需要設定任何 Port Forwarding","\u002Fimages\u002Farticles\u002Fmqtt-architecture.svg",[15,71,72],{},"MQTT 採用**發布／訂閱（Publish／Subscribe）**模型，角色只有兩種：",[24,74,75,81],{},[27,76,77,80],{},[30,78,79],{},"Broker","：訊息的中轉站，負責接收所有發布的訊息，再轉送給有訂閱的人。常見的實作有 Mosquitto、EMQ X、HiveMQ",[27,82,83,86,87,90],{},[30,84,85],{},"Client","：所有連到 Broker 的程式都是 Client，",[30,88,89],{},"同一個 Client 可以同時發布和訂閱","。ESP8266、手機 App、後端排程都是 Client",[15,92,93,94,97],{},"這個模型最大的好處是：",[30,95,96],{},"每個 Client 都是主動往外連到 Broker","。設備在 NAT 後面也沒關係，因為連線是它自己發起的，Broker 再透過這條已經建立的 TCP 連線把訊息推回去。數據機不用開任何 Port，前面說的問題一次解決大半。",[15,99,100,101,104],{},"另外，發布者和訂閱者",[30,102,103],{},"彼此不知道對方存在","，只知道 Topic：澆花系統只管發布「我開始澆水了」，有幾個人在聽、是誰在聽，它完全不需要知道。",[15,106,107],{},"MQTT 的封包也非常精簡，固定標頭最少只有 2 個 Byte，比 HTTP 動輒幾百 Byte 的 Header 輕量很多，對記憶體只有幾十 KB 的 ESP8266 很友善。",[10,109,111],{"id":110},"topic用斜線分層的主題","Topic：用斜線分層的主題",[15,113,114,115,118],{},"Topic 是一個用 ",[19,116,117],{},"\u002F"," 分層的字串，例如：",[120,121,127],"pre",{"className":122,"code":124,"language":125,"meta":126},[123],"language-text","home\u002Flivingroom\u002Flight\u002Fset\nhome\u002Flivingroom\u002Flight\u002Fstate\nhome\u002Fgarden\u002Fwatering\u002Fstate\n","text","",[19,128,124],{"__ignoreMap":126},[15,130,131,132,135],{},"Topic ",[30,133,134],{},"不需要事先建立","，發布時寫什麼就是什麼。訂閱時可以使用兩種萬用字元：",[137,138,139,158],"table",{},[140,141,142],"thead",{},[143,144,145,149,152,155],"tr",{},[146,147,148],"th",{},"萬用字元",[146,150,151],{},"意義",[146,153,154],{},"範例",[146,156,157],{},"符合",[159,160,161,186],"tbody",{},[143,162,163,169,172,177],{},[164,165,166],"td",{},[19,167,168],{},"+",[164,170,171],{},"剛好一層",[164,173,174],{},[19,175,176],{},"home\u002F+\u002Flight\u002Fstate",[164,178,179,182,183],{},[19,180,181],{},"home\u002Flivingroom\u002Flight\u002Fstate","、",[19,184,185],{},"home\u002Fbedroom\u002Flight\u002Fstate",[143,187,188,193,196,201],{},[164,189,190],{},[19,191,192],{},"#",[164,194,195],{},"零到多層，只能放在最後",[164,197,198],{},[19,199,200],{},"home\u002Fgarden\u002F#",[164,202,203,182,206],{},[19,204,205],{},"home\u002Fgarden",[19,207,208],{},"home\u002Fgarden\u002Fwatering\u002Fstate",[15,210,211,212,214,215,217,218,221],{},"注意 ",[19,213,168],{}," 和 ",[19,216,192],{}," ",[30,219,220],{},"只能用在訂閱","，發布時一定要寫完整的 Topic。",[223,224,226],"h3",{"id":225},"topic-怎麼設計","Topic 怎麼設計",[15,228,229],{},"Topic 設計好，後面的訂閱就會很省事。我的原則是：",[24,231,232,242,263,279],{},[27,233,234,237,238,241],{},[30,235,236],{},"由大到小","：",[19,239,240],{},"{位置}\u002F{區域}\u002F{設備}\u002F{用途}","，越後面越細，這樣萬用字元才好用",[27,243,244,237,247,250,251,254,255,258,259,262],{},[30,245,246],{},"指令與狀態分開",[19,248,249],{},"...\u002Fset"," 是「請你變成這樣」，",[19,252,253],{},"...\u002Fstate"," 是「我現在是這樣」。手機發 ",[19,256,257],{},"set","，設備收到、執行完之後再發 ",[19,260,261],{},"state","。如果兩者共用一個 Topic，設備會收到自己發出的狀態，分不清是指令還是回報",[27,264,265,237,271,274,275,278],{},[30,266,267,268,270],{},"不要用 ",[19,269,117],{}," 開頭",[19,272,273],{},"\u002Fhome\u002Flight"," 的第一層會是一個空字串，容易和 ",[19,276,277],{},"home\u002Flight"," 搞混",[27,280,281,237,286,289],{},[30,282,267,283,270],{},[19,284,285],{},"$",[19,287,288],{},"$SYS\u002F"," 開頭的 Topic 是 Broker 保留用來發布自身狀態的（例如連線數）",[10,291,293],{"id":292},"qos三種投遞保證","QoS：三種投遞保證",[15,295,296],{},[66,297],{"alt":298,"src":299},"MQTT 三種 QoS 的封包往返：QoS 0 只送一次 PUBLISH，不確認，最多一次，可能遺失；QoS 1 送 PUBLISH 後等待 PUBACK，沒收到就帶 DUP 旗標重送，至少一次，可能重複；QoS 2 經過 PUBLISH、PUBREC、PUBREL、PUBCOMP 四次往返，剛好一次，但成本最高","\u002Fimages\u002Farticles\u002Fmqtt-qos.svg",[15,301,302,303,237],{},"MQTT 在每一則訊息上都可以指定 ",[30,304,305],{},"QoS（Quality of Service）",[24,307,308,314,328],{},[27,309,310,313],{},[30,311,312],{},"QoS 0：最多一次（At most once）","。送出去就不管了，沒有確認，網路一斷就遺失。適合高頻率、掉一筆也無所謂的資料，例如每秒回報一次的溫度",[27,315,316,319,320,323,324,327],{},[30,317,318],{},"QoS 1：至少一次（At least once）","。接收端要回 ",[19,321,322],{},"PUBACK","，沒收到確認就重送，所以",[30,325,326],{},"可能重複","。大部分的指令都用這個等級",[27,329,330,333],{},[30,331,332],{},"QoS 2：剛好一次（Exactly once）","。透過四次往返確保不重複也不遺失，但延遲與負擔最大，在 IoT 設備上很少真的需要",[15,335,336],{},"有兩個容易誤會的地方：",[338,339,340,350],"ol",{},[27,341,342,345,346,349],{},[30,343,344],{},"QoS 是「每一段」各自的","：發布者到 Broker 是一段，Broker 到訂閱者是另一段。發布者用 QoS 1 發布，但訂閱者用 QoS 0 訂閱，Broker 轉送給它時就會",[30,347,348],{},"降級成 QoS 0","。實際的保證是兩者中比較低的那個",[27,351,352,355,356,359,360,182,363,366,367,359,370,373,374,376,377,379],{},[30,353,354],{},"QoS 1 會重複","：設備要能承受同一個指令收到兩次。所以指令的內容要用「",[30,357,358],{},"變成什麼狀態","」（",[19,361,362],{},"ON",[19,364,365],{},"OFF","），不要用「",[30,368,369],{},"切換",[19,371,372],{},"TOGGLE","）——收到兩次 ",[19,375,362],{}," 還是開，收到兩次 ",[19,378,372],{}," 就變回關了",[10,381,383],{"id":382},"retained-message新訂閱者馬上知道目前狀態","Retained Message：新訂閱者馬上知道目前狀態",[15,385,386,387,390,391,394],{},"一般的訊息，Broker 轉送完就丟了。手機 App 打開時才訂閱 ",[19,388,389],{},"home\u002F+\u002F+\u002Fstate","，要等到每個設備",[30,392,393],{},"下一次","發布狀態才知道燈是開是關。",[15,396,397,398,401,402,405],{},"發布時加上 ",[30,399,400],{},"Retain 旗標","，Broker 會把這個 Topic 的",[30,403,404],{},"最後一則訊息","保留下來，之後只要有人訂閱，就馬上收到這一則。所以：",[24,407,408,417],{},[27,409,410,416],{},[30,411,412,413,415],{},"狀態（",[19,414,261],{},"）用 Retained","：App 一打開就能拿到每個設備的最新狀態",[27,418,419,425],{},[30,420,421,422,424],{},"指令（",[19,423,257],{},"）不要用 Retained","：否則設備每次重新連線都會再收到一次舊的指令，例如「三天前的開燈」",[15,427,428,429,432],{},"要清除一個 Topic 的 Retained Message，就發布一則",[30,430,431],{},"內容為空","的 Retained 訊息。",[10,434,436],{"id":435},"clean-session離線期間的訊息要不要保留","Clean Session：離線期間的訊息要不要保留",[15,438,439,440,443],{},"Client 連線時會帶一個 ",[30,441,442],{},"Clean Session"," 旗標：",[24,445,446,454],{},[27,447,448,453],{},[30,449,450],{},[19,451,452],{},"true","：每次連線都是全新的，斷線期間的訊息全部不保留，重新連線後也要重新訂閱",[27,455,456,462,463,466,467,470],{},[30,457,458,461],{},[19,459,460],{},"false","（Persistent Session）","：Broker 會依 ",[30,464,465],{},"Client ID"," 記住這個 Client 的訂閱，斷線期間發給它的 ",[30,468,469],{},"QoS 1、QoS 2 訊息會先排隊","，重新連線後補送",[15,472,473],{},"對控制指令來說，要不要補送其實要想清楚：設備斷線 10 分鐘，回來後補收到「10 分鐘前的開燈指令」是合理的；但如果是「澆水 5 分鐘」這種有時效的指令，晚了 2 小時才執行反而有問題。我的做法是設備用 Persistent Session，但在指令裡帶上時間戳，設備發現指令太舊就丟掉。",[10,475,477],{"id":476},"keep-alive-與遺囑訊息知道設備斷線了","Keep Alive 與遺囑訊息：知道設備斷線了",[15,479,480],{},"Wi-Fi 斷線、設備斷電時，TCP 連線不一定會馬上被發現是斷的。MQTT 用兩個機制處理：",[24,482,483,493],{},[27,484,485,488,489,492],{},[30,486,487],{},"Keep Alive","：Client 連線時宣告一個秒數（例如 30 秒），在這段時間內如果沒有其他封包，就送一個 ",[19,490,491],{},"PINGREQ","。Broker 超過 1.5 倍的時間都沒收到任何東西，就判定這個 Client 已經斷線",[27,494,495,498,499,502],{},[30,496,497],{},"遺囑訊息（Last Will and Testament，LWT）","：Client 連線時先告訴 Broker：「",[30,500,501],{},"如果我不正常斷線，請幫我發布這則訊息","」",[15,504,505],{},"把兩者結合，就能做出設備的上下線狀態：",[338,507,508,519,526],{},[27,509,510,511,514,515,518],{},"設備連線時設定遺囑：Topic ",[19,512,513],{},"home\u002Flivingroom\u002Flight\u002Fstatus","、內容 ",[19,516,517],{},"offline","、Retained",[27,520,521,522,525],{},"連線成功後，自己發布一則 ",[19,523,524],{},"online"," 到同一個 Topic，也是 Retained",[27,527,528,529],{},"設備斷電，Broker 在 Keep Alive 逾時後代為發布 ",[19,530,517],{},[15,532,533,534,537],{},"App 訂閱 ",[19,535,536],{},"home\u002F+\u002F+\u002Fstatus","，就能即時知道哪些設備離線了，而且因為是 Retained，打開 App 的當下就看得到。",[10,539,541],{"id":540},"使用情境用-mqtt-改寫家電控制","使用情境：用 MQTT 改寫家電控制",[15,543,544],{},"架構改成這樣：",[24,546,547,554,566,576],{},[27,548,549,550,553],{},"一台雲端主機跑 ",[30,551,552],{},"Mosquitto","，只開放 TLS 的 8883 埠，關閉匿名登入",[27,555,556,557,559,560,562,563],{},"每個 ESP8266 用自己的帳號連到 Broker，訂閱自己的 ",[19,558,257],{}," Topic，發布自己的 ",[19,561,261],{}," 與 ",[19,564,565],{},"status",[27,567,568,569,562,571,573,574],{},"手機 App 訂閱 ",[19,570,389],{},[19,572,536],{}," 顯示所有設備，按下按鈕時發布到對應的 ",[19,575,257],{},[27,577,578,579],{},"自動澆花的排程程式也是一個 Client，時間到了就發布 ",[19,580,581],{},"home\u002Fgarden\u002Fwatering\u002Fset",[15,583,584],{},"Mosquitto 的設定：",[120,586,589],{"className":587,"code":588,"language":125,"meta":126},[123],"# mosquitto.conf\nlistener 8883\ncafile   \u002Fetc\u002Fmosquitto\u002Fcerts\u002Fca.crt\ncertfile \u002Fetc\u002Fmosquitto\u002Fcerts\u002Fserver.crt\nkeyfile  \u002Fetc\u002Fmosquitto\u002Fcerts\u002Fserver.key\n\nallow_anonymous false\npassword_file \u002Fetc\u002Fmosquitto\u002Fpasswd\nacl_file      \u002Fetc\u002Fmosquitto\u002Facl\n",[19,590,588],{"__ignoreMap":126},[15,592,593],{},"ACL 限制每個帳號只能碰自己的 Topic，避免其中一個設備的帳號被拿走後能控制全部：",[120,595,598],{"className":596,"code":597,"language":125,"meta":126},[123],"# acl：客廳燈只能讀自己的指令、寫自己的狀態\nuser livingroom-light\ntopic read  home\u002Flivingroom\u002Flight\u002Fset\ntopic write home\u002Flivingroom\u002Flight\u002Fstate\ntopic write home\u002Flivingroom\u002Flight\u002Fstatus\n\n# 手機 App 可以讀所有狀態、送所有指令\nuser mobile-app\ntopic read  home\u002F+\u002F+\u002Fstate\ntopic read  home\u002F+\u002F+\u002Fstatus\ntopic write home\u002F+\u002F+\u002Fset\n",[19,599,597],{"__ignoreMap":126},[15,601,602],{},"ESP8266 端使用 Arduino 的 PubSubClient 函式庫：",[120,604,608],{"className":605,"code":606,"language":607,"meta":126,"style":126},"language-cpp shiki shiki-themes github-dark","#include \u003CESP8266WiFi.h>\n#include \u003CPubSubClient.h>\n\nWiFiClientSecure net;\nPubSubClient mqtt(net);\n\nconst char* SET_TOPIC    = \"home\u002Flivingroom\u002Flight\u002Fset\";\nconst char* STATE_TOPIC  = \"home\u002Flivingroom\u002Flight\u002Fstate\";\nconst char* STATUS_TOPIC = \"home\u002Flivingroom\u002Flight\u002Fstatus\";\n\nvoid onMessage(char* topic, byte* payload, unsigned int length) {\n  String cmd;\n  for (unsigned int i = 0; i \u003C length; i++) cmd += (char)payload[i];\n\n  \u002F\u002F 用「目標狀態」而不是「切換」，重複收到也不會出錯\n  if (cmd == \"ON\")  digitalWrite(RELAY_PIN, HIGH);\n  if (cmd == \"OFF\") digitalWrite(RELAY_PIN, LOW);\n\n  mqtt.publish(STATE_TOPIC, cmd.c_str(), true);  \u002F\u002F 回報狀態，Retained\n}\n\nvoid connectMqtt() {\n  while (!mqtt.connected()) {\n    \u002F\u002F Client ID 要唯一；最後四個參數是遺囑：Topic、QoS、Retain、內容\n    if (mqtt.connect(\"livingroom-light\", MQTT_USER, MQTT_PASS,\n                     STATUS_TOPIC, 1, true, \"offline\")) {\n      mqtt.publish(STATUS_TOPIC, \"online\", true);\n      mqtt.subscribe(SET_TOPIC, 1);\n    } else {\n      delay(5000);  \u002F\u002F 連不上就等一下再試，不要瘋狂重連\n    }\n  }\n}\n\nvoid setup() {\n  pinMode(RELAY_PIN, OUTPUT);\n  WiFi.begin(WIFI_SSID, WIFI_PASS);\n  net.setFingerprint(BROKER_FINGERPRINT);  \u002F\u002F 驗證 Broker 憑證\n  mqtt.setServer(BROKER_HOST, 8883);\n  mqtt.setCallback(onMessage);\n}\n\nvoid loop() {\n  if (!mqtt.connected()) connectMqtt();\n  mqtt.loop();  \u002F\u002F 處理收到的訊息與 Keep Alive，一定要一直呼叫\n}\n","cpp",[19,609,610,618,624,631,637,643,648,654,660,666,671,677,683,689,694,700,706,712,717,723,729,734,740,746,752,758,764,770,776,782,788,794,800,805,810,816,822,828,834,840,846,851,856,862,868,874],{"__ignoreMap":126},[611,612,615],"span",{"class":613,"line":614},"line",1,[611,616,617],{},"#include \u003CESP8266WiFi.h>\n",[611,619,621],{"class":613,"line":620},2,[611,622,623],{},"#include \u003CPubSubClient.h>\n",[611,625,627],{"class":613,"line":626},3,[611,628,630],{"emptyLinePlaceholder":629},true,"\n",[611,632,634],{"class":613,"line":633},4,[611,635,636],{},"WiFiClientSecure net;\n",[611,638,640],{"class":613,"line":639},5,[611,641,642],{},"PubSubClient mqtt(net);\n",[611,644,646],{"class":613,"line":645},6,[611,647,630],{"emptyLinePlaceholder":629},[611,649,651],{"class":613,"line":650},7,[611,652,653],{},"const char* SET_TOPIC    = \"home\u002Flivingroom\u002Flight\u002Fset\";\n",[611,655,657],{"class":613,"line":656},8,[611,658,659],{},"const char* STATE_TOPIC  = \"home\u002Flivingroom\u002Flight\u002Fstate\";\n",[611,661,663],{"class":613,"line":662},9,[611,664,665],{},"const char* STATUS_TOPIC = \"home\u002Flivingroom\u002Flight\u002Fstatus\";\n",[611,667,669],{"class":613,"line":668},10,[611,670,630],{"emptyLinePlaceholder":629},[611,672,674],{"class":613,"line":673},11,[611,675,676],{},"void onMessage(char* topic, byte* payload, unsigned int length) {\n",[611,678,680],{"class":613,"line":679},12,[611,681,682],{},"  String cmd;\n",[611,684,686],{"class":613,"line":685},13,[611,687,688],{},"  for (unsigned int i = 0; i \u003C length; i++) cmd += (char)payload[i];\n",[611,690,692],{"class":613,"line":691},14,[611,693,630],{"emptyLinePlaceholder":629},[611,695,697],{"class":613,"line":696},15,[611,698,699],{},"  \u002F\u002F 用「目標狀態」而不是「切換」，重複收到也不會出錯\n",[611,701,703],{"class":613,"line":702},16,[611,704,705],{},"  if (cmd == \"ON\")  digitalWrite(RELAY_PIN, HIGH);\n",[611,707,709],{"class":613,"line":708},17,[611,710,711],{},"  if (cmd == \"OFF\") digitalWrite(RELAY_PIN, LOW);\n",[611,713,715],{"class":613,"line":714},18,[611,716,630],{"emptyLinePlaceholder":629},[611,718,720],{"class":613,"line":719},19,[611,721,722],{},"  mqtt.publish(STATE_TOPIC, cmd.c_str(), true);  \u002F\u002F 回報狀態，Retained\n",[611,724,726],{"class":613,"line":725},20,[611,727,728],{},"}\n",[611,730,732],{"class":613,"line":731},21,[611,733,630],{"emptyLinePlaceholder":629},[611,735,737],{"class":613,"line":736},22,[611,738,739],{},"void connectMqtt() {\n",[611,741,743],{"class":613,"line":742},23,[611,744,745],{},"  while (!mqtt.connected()) {\n",[611,747,749],{"class":613,"line":748},24,[611,750,751],{},"    \u002F\u002F Client ID 要唯一；最後四個參數是遺囑：Topic、QoS、Retain、內容\n",[611,753,755],{"class":613,"line":754},25,[611,756,757],{},"    if (mqtt.connect(\"livingroom-light\", MQTT_USER, MQTT_PASS,\n",[611,759,761],{"class":613,"line":760},26,[611,762,763],{},"                     STATUS_TOPIC, 1, true, \"offline\")) {\n",[611,765,767],{"class":613,"line":766},27,[611,768,769],{},"      mqtt.publish(STATUS_TOPIC, \"online\", true);\n",[611,771,773],{"class":613,"line":772},28,[611,774,775],{},"      mqtt.subscribe(SET_TOPIC, 1);\n",[611,777,779],{"class":613,"line":778},29,[611,780,781],{},"    } else {\n",[611,783,785],{"class":613,"line":784},30,[611,786,787],{},"      delay(5000);  \u002F\u002F 連不上就等一下再試，不要瘋狂重連\n",[611,789,791],{"class":613,"line":790},31,[611,792,793],{},"    }\n",[611,795,797],{"class":613,"line":796},32,[611,798,799],{},"  }\n",[611,801,803],{"class":613,"line":802},33,[611,804,728],{},[611,806,808],{"class":613,"line":807},34,[611,809,630],{"emptyLinePlaceholder":629},[611,811,813],{"class":613,"line":812},35,[611,814,815],{},"void setup() {\n",[611,817,819],{"class":613,"line":818},36,[611,820,821],{},"  pinMode(RELAY_PIN, OUTPUT);\n",[611,823,825],{"class":613,"line":824},37,[611,826,827],{},"  WiFi.begin(WIFI_SSID, WIFI_PASS);\n",[611,829,831],{"class":613,"line":830},38,[611,832,833],{},"  net.setFingerprint(BROKER_FINGERPRINT);  \u002F\u002F 驗證 Broker 憑證\n",[611,835,837],{"class":613,"line":836},39,[611,838,839],{},"  mqtt.setServer(BROKER_HOST, 8883);\n",[611,841,843],{"class":613,"line":842},40,[611,844,845],{},"  mqtt.setCallback(onMessage);\n",[611,847,849],{"class":613,"line":848},41,[611,850,728],{},[611,852,854],{"class":613,"line":853},42,[611,855,630],{"emptyLinePlaceholder":629},[611,857,859],{"class":613,"line":858},43,[611,860,861],{},"void loop() {\n",[611,863,865],{"class":613,"line":864},44,[611,866,867],{},"  if (!mqtt.connected()) connectMqtt();\n",[611,869,871],{"class":613,"line":870},45,[611,872,873],{},"  mqtt.loop();  \u002F\u002F 處理收到的訊息與 Keep Alive，一定要一直呼叫\n",[611,875,877],{"class":613,"line":876},46,[611,878,728],{},[15,880,881],{},"測試時用 Mosquitto 附的指令工具很方便：",[120,883,887],{"className":884,"code":885,"language":886,"meta":126,"style":126},"language-bash shiki shiki-themes github-dark","# 監看所有設備的狀態（-v 會一起印出 Topic）\nmosquitto_sub -h broker.example.com -p 8883 --cafile ca.crt \\\n  -u mobile-app -P '***' -t 'home\u002F+\u002F+\u002Fstate' -v\n\n# 送出開燈指令\nmosquitto_pub -h broker.example.com -p 8883 --cafile ca.crt \\\n  -u mobile-app -P '***' -t 'home\u002Flivingroom\u002Flight\u002Fset' -m 'ON' -q 1\n","bash",[19,888,889,895,924,947,951,956,975],{"__ignoreMap":126},[611,890,891],{"class":613,"line":614},[611,892,894],{"class":893},"sAwPA","# 監看所有設備的狀態（-v 會一起印出 Topic）\n",[611,896,897,901,905,909,912,915,918,921],{"class":613,"line":620},[611,898,900],{"class":899},"svObZ","mosquitto_sub",[611,902,904],{"class":903},"sDLfK"," -h",[611,906,908],{"class":907},"sU2Wk"," broker.example.com",[611,910,911],{"class":903}," -p",[611,913,914],{"class":903}," 8883",[611,916,917],{"class":903}," --cafile",[611,919,920],{"class":907}," ca.crt",[611,922,923],{"class":903}," \\\n",[611,925,926,929,932,935,938,941,944],{"class":613,"line":626},[611,927,928],{"class":903},"  -u",[611,930,931],{"class":907}," mobile-app",[611,933,934],{"class":903}," -P",[611,936,937],{"class":907}," '***'",[611,939,940],{"class":903}," -t",[611,942,943],{"class":907}," 'home\u002F+\u002F+\u002Fstate'",[611,945,946],{"class":903}," -v\n",[611,948,949],{"class":613,"line":633},[611,950,630],{"emptyLinePlaceholder":629},[611,952,953],{"class":613,"line":639},[611,954,955],{"class":893},"# 送出開燈指令\n",[611,957,958,961,963,965,967,969,971,973],{"class":613,"line":645},[611,959,960],{"class":899},"mosquitto_pub",[611,962,904],{"class":903},[611,964,908],{"class":907},[611,966,911],{"class":903},[611,968,914],{"class":903},[611,970,917],{"class":903},[611,972,920],{"class":907},[611,974,923],{"class":903},[611,976,977,979,981,983,985,987,990,993,996,999],{"class":613,"line":650},[611,978,928],{"class":903},[611,980,931],{"class":907},[611,982,934],{"class":903},[611,984,937],{"class":907},[611,986,940],{"class":903},[611,988,989],{"class":907}," 'home\u002Flivingroom\u002Flight\u002Fset'",[611,991,992],{"class":903}," -m",[611,994,995],{"class":907}," 'ON'",[611,997,998],{"class":903}," -q",[611,1000,1001],{"class":903}," 1\n",[15,1003,1004],{},"和原本的做法比較：",[137,1006,1007,1019],{},[140,1008,1009],{},[143,1010,1011,1013,1016],{},[146,1012],{},[146,1014,1015],{},"原本：HTTP + Port Forwarding",[146,1017,1018],{},"改成 MQTT",[159,1020,1021,1032,1043,1054,1065,1076],{},[143,1022,1023,1026,1029],{},[164,1024,1025],{},"連線方向",[164,1027,1028],{},"外面連進家裡",[164,1030,1031],{},"設備主動連出去",[143,1033,1034,1037,1040],{},[164,1035,1036],{},"需要固定 IP",[164,1038,1039],{},"需要",[164,1041,1042],{},"不需要（Broker 要有）",[143,1044,1045,1048,1051],{},[164,1046,1047],{},"驗證與加密",[164,1049,1050],{},"沒有",[164,1052,1053],{},"帳號密碼 + TLS + ACL",[143,1055,1056,1059,1062],{},[164,1057,1058],{},"得知設備狀態",[164,1060,1061],{},"輪詢每個設備",[164,1063,1064],{},"訂閱 Retained 的 state",[143,1066,1067,1070,1073],{},[164,1068,1069],{},"得知設備離線",[164,1071,1072],{},"打不通才知道",[164,1074,1075],{},"遺囑訊息即時通知",[143,1077,1078,1081,1084],{},[164,1079,1080],{},"多個程式要收到同一個事件",[164,1082,1083],{},"各自呼叫",[164,1085,1086],{},"各自訂閱同一個 Topic",[10,1088,1089],{"id":1089},"注意事項",[24,1091,1092,1098,1107,1126,1132,1138,1144,1152],{},[27,1093,1094,1097],{},[30,1095,1096],{},"Client ID 一定要唯一","：兩個 Client 用同一個 Client ID 連線，Broker 會把先連的那個踢掉。如果兩個設備都設定了自動重連，就會互相踢來踢去、一直斷線。燒錄程式時最容易複製貼上忘了改",[27,1099,1100,1103,1104],{},[30,1101,1102],{},"ESP8266 的 PubSubClient 有限制","：發布只支援 QoS 0，訂閱支援 QoS 0 和 1；預設的封包大小上限是 128 Byte，訊息太長會直接發不出去，要調整 ",[19,1105,1106],{},"MQTT_MAX_PACKET_SIZE",[27,1108,1109,237,1118,1121,1122,1125],{},[30,1110,1111,1114,1115],{},[19,1112,1113],{},"loop()"," 裡不能有長時間的 ",[19,1116,1117],{},"delay()",[19,1119,1120],{},"mqtt.loop()"," 沒被呼叫，Keep Alive 就送不出去，Broker 會以為設備斷線並發布遺囑。需要等待的邏輯要改用 ",[19,1123,1124],{},"millis()"," 計時",[27,1127,1128,1131],{},[30,1129,1130],{},"TLS 在 ESP8266 上很吃記憶體","：TLS 握手需要不少 RAM，憑證驗證用 Fingerprint 比較省資源，但伺服器換憑證時，所有設備都要重新燒錄",[27,1133,1134,1137],{},[30,1135,1136],{},"重連要有間隔","：Broker 重啟時，所有設備會同時重連。每個設備的重連間隔最好加一點隨機值，不要全部在同一秒湧進來",[27,1139,1140,1143],{},[30,1141,1142],{},"不要什麼都用 QoS 2","：每則訊息四次往返，在不穩定的 Wi-Fi 上延遲會很明顯。溫度這種持續回報的資料用 QoS 0，指令用 QoS 1，再讓設備的處理邏輯能承受重複",[27,1145,1146,1151],{},[30,1147,1148,1149],{},"小心訂閱 ",[19,1150,192],{},"：訂閱全部 Topic 在除錯時很方便，但在正式環境，Broker 會把所有訊息都推給這個 Client，設備一多流量就很可觀",[27,1153,1154,1157],{},[30,1155,1156],{},"Broker 是單點","：所有設備都依賴它，Broker 掛掉整個系統就停了。至少要讓 Mosquitto 開機自動啟動、異常自動重啟，並監控它的狀態",[10,1159,1160],{"id":1160},"一句話總結",[15,1162,1163],{},"MQTT 讓 IoT 設備主動連到 Broker，用 Topic 發布與訂閱訊息，解決了設備在 NAT 後面、要對外開 Port 的問題；設計時把指令與狀態分成不同 Topic，狀態用 Retained 讓新訂閱者馬上拿到，指令用 QoS 1 並設計成重複收到也沒關係，再用 Keep Alive 加遺囑訊息掌握設備上下線，最後別忘了 TLS、帳號與 ACL，才不會從「任何人都能開你家的燈」變成「任何人都能訂閱你家的燈」。",[1165,1166,1167],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":126,"searchDepth":620,"depth":620,"links":1169},[1170,1171,1172,1175,1176,1177,1178,1179,1180,1181],{"id":12,"depth":620,"text":13},{"id":61,"depth":620,"text":62},{"id":110,"depth":620,"text":111,"children":1173},[1174],{"id":225,"depth":626,"text":226},{"id":292,"depth":620,"text":293},{"id":382,"depth":620,"text":383},{"id":435,"depth":620,"text":436},{"id":476,"depth":620,"text":477},{"id":540,"depth":620,"text":541},{"id":1089,"depth":620,"text":1089},{"id":1160,"depth":620,"text":1160},"2018-12-04",null,"md",{},"\u002Farticles\u002Fmqtt-iot-basics",{"title":5,"description":126},"articles\u002Fmqtt-iot-basics",[1190,1191,1192,552,1193],"MQTT","IoT","ESP8266","發布訂閱","gLsA7H2eURzdS_Tlz941x7UGXpi6Nph7Nj8qTLfkivc",1792959150584]