Extra — จอยรีโมต (Wii-style) ด้วยการเอียง

เอียงบอร์ดใบหนึ่ง คุมยานบนจออีกใบ ผ่าน WiFi + MQTT

มินิโปรเจกต์เสริม — ต่อยอดจากคาบ WiFi + MQTT
ใช้ 2 บอร์ด · เซนเซอร์ + เน็ต + เกม รวมกันในงานเดียว

จำจอย Wii ที่เอียงมือแล้วของบนจอขยับตามได้ไหม วันนี้เราจะทำเองด้วยบอร์ดสองใบ ใบหนึ่งเป็นรีโมต อีกใบเป็นจอเกม

ดูของจริงก่อน — การควบคุมด้วยจอย/รีโมท บนบอร์ด BENTO

เดโมรันบนบอร์ด BENTO จริง — โดย อ.วิรุฬห์ ศรีบริรักษ์ (YouTube)

เป้าหมาย + use case จริง

เราจะแยกหน้าที่ออกเป็นสองบอร์ดที่คุยกันผ่านอากาศ:

บอร์ด TX = รีโมต
อ่าน sensors.bmi270.acceleration() ความเอียงมือ
แล้ว publish ค่า x,y ออกไปทุก ~100ms
บอร์ด RX = จอเกม
subscribe + get_message รับค่ามา
แล้วขยับ game.Box (ยาน) ตามที่เอียง

ทั้งสองใบไม่มีสายต่อกันเลย คุยกันผ่านอากาศล้วน ๆ:

บอร์ด TX (รีโมต) อ่านเซนเซอร์เอียง BMI270 -> publish บอร์ด RX (จอเกม) รับค่า -> ขยับยาน subscribe -> Box ผ่านอากาศ WiFi + MQTT broker "x:-3,y:2"

ทำไมเรื่องนี้สำคัญ: นี่คือหัวใจของ IoT — เซนเซอร์ที่ "หนึ่งที่" ส่งค่าให้ "อีกที่" ตัดสินใจ เหมือนรีโมตโรงงาน, จอย VR (เอียงมือคุมของในเกม), หรือเซนเซอร์สั่นในเครื่องจักร Industry 4.0 ที่ส่งสถานะขึ้นห้องควบคุม

จอย VR ก็คือ "เซนเซอร์ในมือ" ที่ส่งการเคลื่อนไหวไปขยับของบนจอ — หลักการเดียวกับงานวันนี้ · ที่มา: "HTC Vive Controllers" — CULLEN STEBER, CC BY-SA 4.0, Wikimedia Commons

ภาพรวมงาน: เอียง -> ส่ง -> ขยับยาน

บอร์ด TX อ่านความเอียงแล้ว publish ผ่าน broker · บอร์ด RX subscribe แล้วขยับยานตาม — ทั้งระบบทำงานผ่านอากาศ ไม่มีสายต่อระหว่างสองบอร์ดเลย

เกร็ด: ของจริงที่เรากำลังจำลอง — Wii Remote

ปี 2006 Nintendo เปลี่ยนวิธีเล่นเกมไปทั้งวงการด้วยจอยที่มี accelerometer ข้างใน — เอียงมือ ฟันดาบ ตีเทนนิส ของบนจอขยับตาม

  • ข้างในคือเซนเซอร์ตระกูลเดียวกับ BMI270 บนบอร์ดเรา (วัดแรงโน้มถ่วงที่ถูกแตกตามมุมเอียง)
  • จอยส่งค่าให้เครื่องเกมแบบไร้สาย (ที่นั่นใช้ Bluetooth) — งานเราใช้ WiFi + MQTT แทน หลักการ "เอียงที่หนึ่ง ขยับอีกที่" เหมือนกันเป๊ะ

ที่มา: "Wii Remote Image" — Greyson Orlando, Public domain, Wikimedia Commons · จอยที่จุดกระแสเกมควบคุมด้วยการเคลื่อนไหว

เราไม่ได้สร้างของเล่นใหม่จากศูนย์ เรากำลัง ถอดหลักการ ของอุปกรณ์ระดับตำนานออกมาทำเองด้วยบอร์ดสองใบ

ดูของจริงสักครู่: สังเกตว่าผู้เล่นแค่ "เหวี่ยงมือ" แล้วไม้เทนนิสบนจอขยับตาม — นั่นคือ accelerometer ในจอยอ่านความเอียง/ความเร่ง แล้วส่งไปให้เครื่องเกม เหมือนที่เราจะทำเป๊ะ

Wii Sports Nintendo Wii Gameplay Walkthrough Part 1 - Tennis! — Zebra Gamer · เอียง/เหวี่ยงมือ -> ของบนจอขยับตาม คือสิ่งที่โปรเจกต์นี้จำลอง

เอียงแล้วได้ "เลข" มาได้ยังไง

ก่อนส่ง เราต้องเข้าใจว่าค่า ax, ay มาจากไหน เวลาเราเอียงบอร์ดเป็นมุม θ แรงโน้มถ่วง g ที่ดึงลงตรง ๆ ถูกแตกออกตามมุมที่เอียง

  • ส่วนที่ดันแนวพื้น = gsinθg\sin\theta — เอียงมากยิ่งดันแรง · เซนเซอร์อ่านแกนนั้นได้ axsinθa_x \approx \sin\theta (หน่วย g)
  • วางราบ = 0, เอียง 30° = 0.5, ตั้งฉาก = 1.0 — นี่คือ "เลข" ที่เราเอาไปคูณ GAIN แล้วส่งออกไป

ค่า ax สูงสุดแค่ราว ๆ 1.0 เพราะมันคือ ค่า sin ของมุมเอียง ไม่ใช่ความเร็วของบอร์ด

จากองศา สู่ "เลขที่ส่งได้จริง"

ค่า axa_x เป็นทศนิยมต่อเนื่อง (เช่น 0.4731...) ส่งทั้งดุ้นก็เปลือง เราจึงบีบให้เป็น จำนวนเต็มเล็ก ๆ ก่อน ด้วยสองขั้น: ตัด deadzone แล้วคูณ GAIN ปัดเป็น int

x={0ax<DEADZONEint ⁣(ax×GAIN)อื่น ๆGAIN=10x=\begin{cases}0 & |a_x|<\text{DEADZONE}\\[2pt]\operatorname{int}\!\big(a_x\times \text{GAIN}\big) & \text{อื่น ๆ}\end{cases}\qquad \text{GAIN}=10

  • เส้นประม่วง = ค่าต่อเนื่อง ax×10a_x\times 10 · ขั้นบันไดเขียว = หลัง int() ปัดลงเป็นจำนวนเต็ม
  • แถบแดงกลาง = deadzone: มือสั่นนิด ๆ (ax<0.10|a_x|<0.10) บีบให้ส่ง 0 ยานเลยนิ่งเมื่อวางราบ
  • เอียง 30° ได้ ax0.5a_x\approx 0.5 คูณ 10 ปัดได้ 5 — นี่คือเลขที่ใส่ลง payload แล้ว publish ออกไป

ทำไมต้องปัดเป็น int ก่อนส่ง เพราะ payload เราเป็น ข้อความสั้น ๆ ("x:5,y:-3") เลขเต็มอ่านง่าย ส่งน้อยไบต์ และฝั่ง RX int() กลับมาตรง ๆ ได้เลย

ไดอะแกรม: pub / sub (topic + payload)

บอร์ด TX ส่ง "โปสการ์ดความเอียง" ผ่าน broker ไปให้บอร์ด RX ที่ฟัง topic เดียวกัน

บอร์ด TX เอียง -> publish "x:-3,y:2" Broker test.mosquitto.org topic: ctrl/ship บอร์ด RX subscribe -> ขยับยาน publish deliver topic เดียวกัน = อยู่ห้องเดียวกัน · payload = ข้อความสั้น ๆ ที่ทั้งคู่ตกลงรูปแบบกัน

สัญญาของเรา: topic = ctrl/ship, payload = "x:<int>,y:<int>" เช่น "x:-3,y:2" ทั้งสองฝั่งต้องเป๊ะตัวเดียวกัน

ดูแบบเคลื่อนไหว: โปสการ์ดความเอียงเดินทาง

  • TX ไม่ได้ต่อสายตรงไปหา RX มัน publish โปสการ์ดเข้า broker แล้ว จบหน้าที่ ไปอ่านเอียงรอบใหม่ทันที
  • broker รับไว้แล้ว ส่งต่อ ให้ทุกคนที่ subscribe topic นั้น — นี่คือ decoupling: คนส่งกับคนรับไม่ต้องรู้จักกัน รู้แค่ชื่อ topic
  • ทำซ้ำทุก ~100 ms โปสการ์ดเลยไหลถี่ ๆ ยานฝั่ง RX ขยับลื่นเหมือนต่อสายตรง

สังเกตว่า TX ไม่เคย "รอ" RX ตอบ — ยิงแล้วไปต่อเลย (fire-and-forget) นี่คือเหตุผลที่ระบบ IoT ส่งจากเซนเซอร์เป็นพัน ๆ ตัวพร้อมกันได้โดยไม่ติดขัด

เดินโค้ดฝั่ง TX — เอียงแล้วส่ง

หัวใจฝั่งส่ง: อ่านความเอียง ตัด deadzone แปลงเป็นเลขเต็ม แล้ว publish ทุก ~100ms

    sensors.init()                       # ปลุกเซนเซอร์ครั้งเดียวพอ

    while not btn.is_pressed():           # กด SW1 ค้างเพื่อหยุด
        ax, ay, az = sensors.bmi270.acceleration()  # เซนเซอร์คืน m/s²
        ax, ay = ax/9.81, ay/9.81        # แปลงเป็น g (วางราบ ≈ 0, เอียงราว -1..1)

        if abs(ax) < DEADZONE:            # ตัด noise -> ยานนิ่งเมื่อวางราบ
            ax = 0.0
        if abs(ay) < DEADZONE:
            ay = 0.0

        x = int(ax * GAIN)                # แปลง g (~ -1..1) เป็นเลขเต็ม -10..10
        y = int(ay * GAIN)

        payload = "x:%d,y:%d" % (x, y)    # รูปแบบที่ตกลงกับ RX: "x:-3,y:2"
        mqtt.publish(TOPIC, payload)      # ส่งออกไปให้บอร์ด RX
        time.sleep_ms(100)               # ~10 ครั้ง/วินาที พอลื่น ไม่ถี่เกิน

deadzone สำคัญมากนะ ถ้าไม่ตัด ยานจะสั่นตลอดเพราะมือเราไม่เคยนิ่งจริง

สายพานแปลงค่าฝั่ง TX — จากแรงดิบ สู่ข้อความ

โค้ดข้างบนคือ "สายพาน" สั้น ๆ ที่ค่อย ๆ แปลงค่าเซนเซอร์ดิบให้กลายเป็นข้อความพร้อมส่ง:

แต่ละกล่อง = หนึ่งบรรทัดในลูป TX · ขั้นตอนนี้ (อ่านดิบ -> ทำให้เป็นหน่วยมาตรฐาน -> กรอง -> ย่อ -> ห่อเป็นข้อความ) คือลำดับเดียวกับที่ระบบ telemetry จริงทำก่อนยิงค่าขึ้น cloud ทุกครั้ง

จำลำดับนี้ไว้ มันใช้ซ้ำได้กับเซนเซอร์ทุกตัว: อ่านค่าดิบ → แปลงหน่วย → ตัด noise → บีบขนาด → ห่อเป็น payload → ส่ง

เดินโค้ดฝั่ง RX — รับแล้วขยับยาน

ฝั่งรับใช้เกมลูป: ทุกเฟรมดึงข้อความล่าสุดมา parse แล้วขยับยาน

    def parse(payload):
        text = payload.decode()          # bytes ต้อง decode ก่อนเสมอ
        x = y = 0
        for part in text.split(","):     # ["x:-3", "y:2"]
            key, value = part.split(":")  # ["x", "-3"]
            if key == "x":
                x = int(value)
            elif key == "y":
                y = int(value)
        return x, y

    def update():                         # เกมลูปเรียกทุกเฟรม
        msg = mqtt.get_message()          # (topic, bytes) หรือ None (ไม่บล็อก)
        if msg is not None:
            topic, data = msg
            x, y = parse(data)
            ship.move(x, y)               # move คุมขอบจอให้แล้ว

    game.run(update, fps=20)              # BACK = ออกจากเกม

ฟังก์ชัน parse() คือหัวใจ — มันแกะข้อความดิบทีละชั้นจนได้ตัวเลขสองตัว:

get_message() คืน None เมื่อยังไม่มีของ เกมจึงไม่ค้าง วนเช็กไปเรื่อย ๆ ได้สบาย

วิธีรัน — ต้องใช้ 2 บอร์ด

ตั้ง SSID/PASSWORD ให้ตรงห้องเรา (หรือต่อ WiFi ผ่านเมนูบนจอไว้ก่อน) แล้วแยกบอร์ด — ผลที่ควรเห็น: เอียงบอร์ดซ้าย ยานวิ่งซ้าย เอียงขวา ยานวิ่งขวา แบบนี้

บอร์ด TX — เอียงมือ "x:..,y:.." (~10/วินาที) WiFi + MQTT บอร์ด RX — ยานวิ่งตาม
บอร์ดที่ 1 (รีโมต)
เปิด codes/ex_a3_remote_tx.py
กด Program to Device
ถือบอร์ดนี้ไว้ เอียงซ้าย-ขวา-หน้า-หลัง
บอร์ดที่ 2 (จอเกม)
เปิด codes/ex_a3_remote_rx.py
กด Program to Device
มองยานบนจอ มันจะวิ่งตามมือที่ถือบอร์ดแรก

เคล็ดลับลำดับ: เปิด RX ก่อนให้พร้อมรับ แล้วค่อยเปิด TX จะได้เห็นยานขยับทันทีที่เริ่มเอียง · ทั้งสองฝั่งต้องใช้ TOPIC = "ctrl/ship" ตัวเดียวกัน และตั้ง CLIENT คนละชื่อ

เชื่อมโยงรากฐาน · จอยใบเดียว ซ่อนวิศวกรรมไว้ 4 ชั้น

จอยรีโมตวันนี้ดูเหมือนของเล่น แต่ข้างใต้คือรากฐานที่ระบบ IoT จริงใช้ทุกวัน:

ฝั่ง Embedded / ฮาร์ดแวร์

  • digital sensor + I2C — BMI270 ทำ ADC ในชิป ส่งค่า g มาทาง 2 สาย (SDA/SCL) เรียกด้วย address 0x68
  • WiFi radio + โปรโตคอล — บอร์ดคุยกันผ่านคลื่น 2.4GHz บน MQTT ที่ออกแบบมาให้เบา เหมาะกับอุปกรณ์เล็ก

ฝั่ง Python

  • parameter passing — เราส่งฟังก์ชัน update ให้ game.run(update, fps=20) เรียกแทนเราทุกเฟรม (callback)
  • string parsingpayload.decode() แล้ว split(",") / split(":") แปลง bytes กลับเป็นตัวเลข

ฝั่ง Algorithms

  • sensor value conversion — สายพาน m/s² → ÷9.81 → deadzone → ×GAIN → int() คือการแปลงค่าเซนเซอร์มาตรฐาน
  • fixed-rate samplingsleep_ms(100) คุมอัตราส่ง ~10 Hz เหมือน control loop ในงานจริง (ถี่ไปท่วม broker, ห่างไปยานกระตุก)

ฝั่ง Graphics

  • ระบบพิกัด + clampbox.move(dx,dy) ขยับยานแล้วคุมไม่ให้หลุดขอบจอ รากฐานของการวาดวัตถุบนจอ 2D

ดูคลิป (เสริม): กลไก publish/subscribe ที่ทั้งโปรเจกต์นี้ตั้งอยู่ จากทีมที่เขียนสเปก MQTT เอง

MQTT Publish / Subscribe / Unsubscribe | MQTT Essentials Part 5 — HiveMQ

ต่อยอดสู่โลกจริง

สิ่งที่เราทำวันนี้คือต้นแบบจริงของหลายระบบรอบตัว ลองเลือกต่อยอดสักอย่าง:

  • multiplayer: ให้ TX ส่งปุ่มยิงไปด้วย เช่น "x:-3,y:2,fire:1" แล้ว RX ยิงกระสุนตามคำสั่ง — ได้เกมยิงแบบคุมจากมืออีกบอร์ด
  • Industry 4.0: เปลี่ยนค่าเอียงเป็นค่าสั่นของมอเตอร์ ส่งขึ้น broker ให้ห้องควบคุมเฝ้าดู (predictive maintenance) — โครงโค้ดเดิมเป๊ะ ๆ
  • IoT remote: ใช้บอร์ด TX เป็นรีโมตคุมหุ่น/รถของเล่นจริง โดย RX ขับมอเตอร์แทนการขยับ Box

ของจริง: รถ RC ต่อเข้าไมโครคอนโทรลเลอร์ (Arduino) — เปลี่ยน "ขยับยานบนจอ" เป็น "สั่งล้อหมุนจริง" ก็คือ IoT remote · ที่มา: "RC car hooked up to the arduino" — Mbroemme5783, CC BY-SA 3.0, Wikimedia Commons

หลักการเดียวกันนี้ scale ได้ถึงระดับโรงงาน เซนเซอร์หนึ่งที่ ส่งค่าให้อีกที่ตัดสินใจ คือหัวใจของ IoT ทั้งหมด น้อง ๆ ทำเป็นแล้ววันนี้

fit-css

← Extra Index