ต่อเครื่องพิมพ์ 3D ด้วย MQTT: snapshot + delta ต่อใหม่เอง และเฝ้าว่ายังทำงาน
เรียนรู้ MQTT จากงานจริง ดึงสถานะเครื่องพิมพ์ 3D หลายยี่ห้อมาแสดงบนไลฟ์ ขอ snapshot แล้วรวม delta ต่อใหม่เมื่อหลุด แยก process และเฝ้า token ที่หมดอายุเงียบ ๆ
- MQTT คือการส่งข้อความผ่านตัวกลางตาม topic อุปกรณ์ส่งเมื่อมีอะไรเปลี่ยน ไม่ต้องให้เราถามซ้ำ ๆ
- ถ้าอุปกรณ์ส่งแค่ส่วนที่เปลี่ยน (delta) ต้องขอภาพรวม (snapshot) ตอนเริ่ม และขอซ้ำเป็นระยะกันข้อมูลตกหล่น
- อุปกรณ์ไม่บอกเราเมื่อมันพัง ต้องมีตัวเฝ้าที่ตรวจเองเป็นระยะ และแยก process ที่คุยกับอุปกรณ์ออกจากเซิร์ฟเวอร์หลัก
เราทำระบบไลฟ์สดงานพิมพ์ 3D ที่รวมกล้องหลายตัวเป็นภาพเดียว และมีแถบด้านล่างบอกสถานะของแต่ละเครื่อง พิมพ์ไปกี่เปอร์เซ็นต์ เหลือกี่นาที อุณหภูมิหัวฉีดเท่าไหร่ เครื่องพิมพ์หลายยี่ห้อในงานนี้ส่งสถานะผ่าน MQTT บทความนี้ใช้งานจริงนี้อธิบาย MQTT ตั้งแต่พื้นฐาน จนถึงรูปแบบที่ทำให้ระบบอยู่ได้ทั้งวันโดยไม่ต้องมีคนคอยรีสตาร์ต
ตัวอย่างใช้ Python 3.11 กับ library paho-mqtt บน Windows และเครือข่ายภายในบ้าน
MQTT ในห้านาที
MQTT คือการส่งข้อความผ่าน ตัวกลาง (broker) ผู้ส่งไม่รู้จักผู้รับ ทั้งสองฝ่ายรู้จักแค่ topic ซึ่งเป็นชื่อช่อง เช่น device/01S00A123/report
- publish ส่งข้อความเข้า topic
- subscribe บอก broker ว่าอยากได้ข้อความจาก topic ไหน broker จะส่งมาให้เองทุกครั้งที่มี
ต่างจากการเรียก API ตรงที่เราไม่ต้องถามซ้ำ ๆ ว่ามีอะไรเปลี่ยนไหม อุปกรณ์ส่งมาเองเมื่อมีอะไรเปลี่ยน เหมาะกับอุปกรณ์ที่เน็ตไม่เสถียรและแบตเตอรี่จำกัด
| คำ | ความหมาย |
|---|---|
| QoS 0 | ส่งครั้งเดียว อาจหาย ใช้กับค่าที่ส่งมาบ่อยอยู่แล้ว |
| QoS 1 | รับประกันว่าถึง แต่อาจถึงซ้ำ |
| QoS 2 | ถึงครั้งเดียวพอดี ช้าที่สุด |
| retained | broker จำข้อความล่าสุดของ topic ไว้ ใครมา subscribe ทีหลังได้ทันที |
| last will | ข้อความที่ broker ประกาศแทนเรา ถ้าเราหลุดแบบไม่บอกลา ใช้แจ้งว่า offline |
| keepalive | ระยะที่ต้องส่งสัญญาณว่ายังอยู่ ถ้าเงียบนานเกิน broker ถือว่าหลุด |
รูปแบบที่ 1: ขอ snapshot แล้วรวม delta
เครื่องพิมพ์ Bambu Lab เป็น broker ในตัว ต่อผ่าน TLS ที่พอร์ต 8883 บนเครือข่ายภายใน เมื่อต่อได้ เรา subscribe topic สถานะของเครื่อง
ปัญหาคือเครื่องไม่ได้ส่งสถานะทั้งหมดทุกครั้ง ส่งแค่ ส่วนที่เปลี่ยน (delta) เช่น {"print": {"mc_percent": 42}} ถ้าเราต่อเข้าไปตอนกลางงานพิมพ์ จะไม่รู้ชื่องาน อุณหภูมิ หรือเวลาที่เหลือ จนกว่าค่าพวกนั้นจะเปลี่ยน
วิธีแก้คือ ขอภาพรวมทันทีที่ต่อได้ แล้วรวม delta ที่ตามมาลงไปเรื่อย ๆ
def on_connect(client, userdata, flags, rc, properties=None):
client.subscribe(f"device/{serial}/report")
client.publish(f"device/{serial}/request",
json.dumps({"pushing": {"sequence_id": "0", "command": "pushall"}}))
def on_message(client, userdata, msg):
payload = json.loads(msg.payload)
state.update(payload.get("print", {})) # รวม delta ลงในภาพรวมที่มีอยู่
และ ขอภาพรวมซ้ำทุก 15 วินาที แม้ไม่มีอะไรผิดปกติ เพราะถ้า delta ตัวไหนหายระหว่างทาง ภาพรวมที่เราถืออยู่จะผิดไปตลอด การขอซ้ำเป็นระยะทำให้ความผิดพลาดอยู่ได้ไม่เกิน 15 วินาที
หลักการ: ระบบที่ส่งแค่ส่วนที่เปลี่ยน ต้องมีทางขอภาพรวมทั้งหมดเสมอ ทั้งตอนเริ่มและเป็นระยะ
รูปแบบที่ 2: หลุดแล้วต่อใหม่เอง
เครือข่าย WiFi หลุดเป็นเรื่องปกติ เครื่องพิมพ์ก็รีสตาร์ตได้ ระบบที่ต้องอยู่ทั้งวันจึงต้องต่อใหม่เองโดยไม่มีคนสั่ง
client.reconnect_delay_set(min_delay=1, max_delay=30)
client.connect(host, 8883, keepalive=30)
client.loop_start()
reconnect_delay_set ให้ลองต่อใหม่ห่างขึ้นเรื่อย ๆ เริ่มที่ 1 วินาที ไม่เกิน 30 วินาที ไม่ถี่จนท่วมเครื่องพิมพ์ที่เพิ่งเปิด และไม่ห่างจนข้อมูลหายนาน
อีกจุดคือ ตอนหลุด ให้ประกาศว่า offline ทันที อย่าปล่อยให้หน้าจอแสดงค่าสุดท้ายค้างไว้ราวกับยังทำงาน
def on_disconnect(client, userdata, *args):
state_online = False
write_status() # เขียนสถานะ offline ทันที ไม่รอรอบถัดไป
รูปแบบที่ 3: แยก process ที่คุยกับอุปกรณ์
ตอนแรกเราให้เซิร์ฟเวอร์หลักต่อ MQTT เอง แต่การเชื่อมต่อหมดเวลาทุกครั้ง ทั้งที่รหัสและเครือข่ายถูก สาเหตุคือบน Windows เซิร์ฟเวอร์ต้องใช้ event loop แบบที่รันโปรแกรมย่อยได้ (สำหรับ ffmpeg) แต่ library MQTT ต้องการ event loop อีกแบบ สองแบบอยู่ในโปรแกรมเดียวกันไม่ได้
ทางแก้คือแยก process เล็ก ๆ ข้างเคียง (side-car) ที่ทำหน้าที่เดียว คุยกับเครื่องพิมพ์ แล้วเขียนสถานะลงไฟล์ JSON ทุก 2 วินาทีเป็นอย่างถี่ เซิร์ฟเวอร์หลักแค่อ่านไฟล์
{"online": true, "progress": 42, "remaining_min": 87, "nozzle": 219.5, "ts": 1760598000}
ได้มากกว่าที่ตั้งใจ
- เครื่องพิมพ์มีปัญหา เซิร์ฟเวอร์ไม่ล่มตาม process ข้างเคียงพังก็รีสตาร์ตแยกได้
- มี
tsบอกเวลา ถ้าไฟล์ไม่ถูกอัปเดตนานเกินไป ฝั่งที่อ่านรู้ว่าข้อมูลเก่า แสดงว่าไม่ทราบสถานะ แทนที่จะแสดงค่าผิด - ทดสอบง่าย ปลอมไฟล์สถานะได้เลย ไม่ต้องมีเครื่องพิมพ์จริง
รูปแบบที่ 4: เฝ้าสิ่งที่หมดอายุเงียบ ๆ
เครื่องพิมพ์อีกยี่ห้อส่งลิงก์วิดีโอผ่าน MQTT ลิงก์มี token ที่ หมดอายุเอง เมื่อหมดแล้ว ลิงก์ตอบ 404 ตัวดึงวิดีโอก็พยายามต่อลิงก์ตายซ้ำไปเรื่อย ๆ ภาพบนไลฟ์ค้างที่ 0 เฟรม โดยไม่มีอะไรแจ้งเตือนเลย
เราจึงมีตัวเฝ้าที่รันทุก 30 วินาที
- ลองเปิดลิงก์วิดีโอปัจจุบัน
- ถ้าตอบ 404 หรือไม่มีข้อมูล สั่งเครื่องพิมพ์เริ่มจับภาพใหม่ผ่าน MQTT แล้วขอสถานะใหม่เพื่อเอา token ใหม่
- ถ้า token เปลี่ยน ตั้งค่าตัวดึงวิดีโอใหม่ แล้วรอจนเห็นข้อมูลเข้าจริง
อุปกรณ์ไม่บอกเราเมื่อมันพัง สิ่งที่หมดอายุได้ ต้องมีคนตรวจเป็นระยะ และคนนั้นควรเป็นโปรแกรม
ความปลอดภัย
- อย่าเปิด MQTT ของอุปกรณ์ออกอินเทอร์เน็ต ใช้ในเครือข่ายภายในเท่านั้น ถ้าต้องดูจากข้างนอก ให้ผ่าน VPN หรือผ่านเซิร์ฟเวอร์ของเราที่ส่งต่อเฉพาะข้อมูลที่จำเป็น
- ใบรับรองที่เซ็นเอง เครื่องพิมพ์หลายรุ่นใช้ใบรับรองที่ตรวจด้วยวิธีปกติไม่ผ่าน โค้ดตัวอย่างทั่วไปจึงปิดการตรวจทิ้ง ซึ่งยอมรับได้แค่ในเครือข่ายภายในที่ควบคุมได้ ทางที่ดีกว่าคือเก็บใบรับรองของเครื่องนั้นไว้ แล้วตรวจว่าตรงกันทุกครั้ง
- รหัสเข้าเครื่องคือความลับ เก็บในไฟล์ตั้งค่าที่ไม่ขึ้น git
ข้อผิดพลาดที่เจอบ่อย
- subscribe แล้วรอ ไม่ขอภาพรวม หน้าจอว่างจนกว่าค่าจะเปลี่ยน
- รวม delta แต่ไม่ขอภาพรวมซ้ำ ค่าที่ตกหล่นค้างผิดไปตลอด
- หลุดแล้วไม่ประกาศ offline หน้าจอโชว์ค่าเก่าเหมือนยังทำงาน
- ใส่โค้ดคุยกับอุปกรณ์ไว้ในเซิร์ฟเวอร์หลัก อุปกรณ์ค้าง เซิร์ฟเวอร์ค้างตาม
- เขียนไฟล์สถานะทุกข้อความที่เข้ามา ทั้งที่ส่งมาวินาทีละหลายครั้ง
คำถามที่พบบ่อย
ต้องตั้ง broker เองไหม
แล้วแต่อุปกรณ์ เครื่องพิมพ์บางยี่ห้อเป็น broker ในตัวบนเครือข่ายภายใน เราต่อเข้าไปได้เลย ถ้าเป็นเซนเซอร์ที่ทำเองด้วย ESP32 ส่วนใหญ่ต้องมี broker กลาง เช่น Mosquitto รันบนเครื่องในบ้านหรือเซิร์ฟเวอร์
QoS ควรใช้ระดับไหน
ข้อมูลสถานะที่ส่งมาบ่อย ๆ ใช้ QoS 0 ก็พอ เพราะเดี๋ยวก็มีค่าใหม่ตามมา คำสั่งที่ต้องไปถึงแน่ เช่น สั่งหยุดพิมพ์ ใช้ QoS 1 และทำให้คำสั่งนั้นส่งซ้ำได้โดยไม่เกิดผลซ้ำ
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ