คาบ 9 — เซนเซอร์

เอียงบอร์ดเล่นเกม

เปิดฝากล่อง แตะฮาร์ดแวร์จริง — accelerometer บนบอร์ด PSoC Edge

คาถาประจำคาบ: "เอียงบอร์ด = ฮาร์ดแวร์ส่งตัวเลขให้เรา เราเอาตัวเลขนั้นไปขยับของบนจอ"

เป้าหมายของคาบนี้

ก่อนหน้านี้เราคุมเกมด้วย "ปุ่ม" มาตลอด คาบนี้อาจารย์อยากให้น้อง ๆ ลองคุมด้วย การเอียงบอร์ดจริง ๆ

สิ่งที่อยากเห็นตอนจบคาบ: วางบอร์ดราบ กล่องนิ่ง พอ เอียงซ้าย-ขวา-หน้า-หลัง กล่องวิ่งตามมือเราทันที

  • อ่านค่าความเร่ง 3 แกน (ax, ay, az) จากชิป BMI270 ผ่านบัส I2C
  • แปลง (map) ค่าความเอียง → ความเร็วการเลื่อนของกล่อง
  • ขยับ game.Box ด้วยการเอียงบอร์ด ไม่ต้องแตะปุ่มเลย

คาบนี้เป็น peripheral lab — เราเปิดฝาให้น้อง ๆ ได้แตะเซนเซอร์จริง ไม่ได้เล่นแค่บนจอแล้ว

accelerometer คืออะไร

ในบอร์ดมีชิปเล็ก ๆ ชื่อ BMI270 ข้างในมีก้อนเล็ก ๆ ที่ขยับได้ตามแรง พอเราเอียงบอร์ด แรงโน้มถ่วง จะกดก้อนนั้นไปคนละทิศ ชิปก็วัดออกมาเป็นตัวเลข — เทคโนโลยีนี้เรียก MEMS (กลไกจิ๋วฝังบนชิป)

ชิป MEMS accelerometer 3 แกนของจริง

ที่มา: "FreescaleSemiconductor 3-Axis-MEMS-Accelerometer MMA8452Q" — SparkFun, CC BY 2.0, Wikimedia Commons

  • เซนเซอร์คืนค่าหน่วย m/s² เราหารด้วย 9.81 เพื่อให้ได้หน่วย g (1g = แรงโน้มถ่วงโลก) ที่อ่านง่ายกว่า
  • หลังหารแล้ว วางราบ: แกน Z จะอ่านได้ราว ๆ 1.0 ส่วน X, Y อยู่ใกล้ 0
  • เอียงไปทางไหน แกนนั้นจะมีค่า ติดลบ/บวก ในช่วงประมาณ −1.0 ถึง +1.0
แกน X
เอียงซ้าย-ขวา
แกน Y
เอียงหน้า-หลัง
แกน Z
ตั้งฉากกับหน้าจอ

ก้อนตัวเลขจากเซนเซอร์เรียกว่า analog ของจริงในโลก — งานของเราคือเปลี่ยนมันให้เป็นการเคลื่อนไหวบนจอ

ฟิสิกส์เบื้องหลัง: แรงโน้มถ่วงถูก "แตกแรง"

ทำไมเอียงนิดเดียวได้ค่าน้อย เอียงเยอะได้ค่ามาก? เพราะ แรงโน้มถ่วง g ที่ดึงลงตรง ๆ ถูกแตกเป็น 2 ส่วนตามมุมที่เราเอียงบอร์ด

  • ส่วนที่ ดันกล่องให้ไถล = gsinθg\sin\theta — ยิ่งเอียงมาก (θ มาก) ยิ่งดันแรง
  • ส่วนที่ กดลงบอร์ด = gcosθg\cos\theta — ส่วนนี้ไม่ทำให้กล่องขยับ
  • เซนเซอร์อ่านแกนนั้นได้ axsinθa_x \approx \sin\theta (หน่วย g): ราบ = 0, เอียง 30° = 0.5, ตั้งฉาก = 1.0

นี่คือเหตุผลที่ค่า ax สูงสุดแค่ราว ๆ 1.0 — มันคือ ค่า sin ของมุมเอียง ไม่ใช่ "ความเร็ว" ของบอร์ด

ทฤษฎี → โค้ด (bridge): บรรทัด ax, ay, az = ax/9.81, ay/9.81, az/9.81 (s09_tilt.py:36) คือจุดที่แปลง m/s² → g พอดี ค่า ax ที่ได้ คือ sinθ\sin\theta ของมุมเอียงแกน X โดยตรง — เราจึงเอา ax ไปคูณ SPEED เป็นความเร็วเลื่อนได้เลย โดยไม่ต้องเรียก math.asin() หามุม θ\theta จริง ๆ (เกมสนใจ "ไถลแรงแค่ไหน" = sinθ\sin\theta ไม่ได้สนใจองศา)

เช็กความถูกต้อง: ขนาดรวมต้อง ≈ 1g

วางบอร์ดนิ่ง ๆ ไม่ว่าเอียงมุมไหน ผลรวมความเร่งทั้ง 3 แกนจะได้ ≈ 1g เสมอ เพราะมันคือแรงโน้มถ่วงก้อนเดียวที่ถูกกระจายไปตามแกน

a=ax2+ay2+az21.0 g|a| = \sqrt{a_x^2 + a_y^2 + a_z^2} \approx 1.0\ g

ยิ่งเอียง ax (เส้นแดง) โตขึ้น แต่ az (เส้นเขียว) เล็กลงพอดี ๆ — รวมกันแล้ว เส้นน้ำเงินยังราบที่ 1.0 g

  • วางราบ: az1a_z \approx 1, ที่เหลือ ≈ 0 → ขนาด = 1
  • เอียง 45° ด้านเดียว: ax0.71, az0.71a_x \approx 0.71,\ a_z \approx 0.710.712+0.7121\sqrt{0.71^2 + 0.71^2} \approx 1
  • ถ้าขนาดรวม เพี้ยนจาก 1 เยอะ แปลว่าบอร์ดกำลัง ขยับ/สะบัด (มีแรงอื่นนอกจากโน้มถ่วง) ไม่ใช่แค่เอียงนิ่ง ๆ

เคล็ดลับดีบั๊ก: print ค่า ax, ay, az แล้วบวกกำลังสองดู ถ้าไม่ใกล้ 1 แปลว่าอ่านผิดหรือบอร์ดสั่น

I2C — สายเส้นเดียวคุยกับชิป

ทำไมเราถึงเรียก sensors.bmi270.acceleration() แล้วได้ตัวเลขมาเลย ทั้งที่ชิปอยู่คนละก้อนกับ CPU

CPU (MicroPython) BMI270 (เซนเซอร์) SDA (ข้อมูล) SCL (สัญญาณ)
I2C คือบัสที่ใช้สายแค่ 2 เส้น ก็คุยกับชิปได้หลายตัว

CPU เป็น master ส่งคำสั่ง "ขอค่าความเร่งหน่อย" ชิปก็ส่งตัวเลขกลับมา

โมดูล bmi270 ห่อรายละเอียด I2C ทั้งหมดไว้ให้แล้ว เราแค่เรียกฟังก์ชันเดียว

เข้าใจฮาร์ดแวร์ · เซนเซอร์คุยผ่าน I2C

accelerometer (BMI270) เป็น digital sensor — มี ADC อยู่ในชิปแล้ว ส่งค่าออกมาเป็นตัวเลข (ถ้าเป็น analog sensor ดิบ เช่น potentiometer ต้องพึ่ง ADC ของ MCU เอง)

  • คุยกับชิปผ่าน I2C: สาย 2 เส้น SDA (data) + SCL (clock)
  • ชิปเรา (CPU) เป็น master, เซนเซอร์เป็น slave · เซนเซอร์แต่ละตัวมี address (BMI270 = 0x68)
  • หลายชิปแชร์ 2 เส้นเดียวกันได้ เรียกถูกตัวด้วย address
CPU master (เป็นคนถาม) BMI270 slave 0x68 SCL (clock) SDA (data) "ขอค่าจาก 0x68" ส่ง (ax, ay, az) กลับ
ภาพนี้คือ แนวคิด: CPU (master) ยิงคำถามไปที่ address 0x68 ผ่าน SCL+SDA เพียง 2 เส้น แล้ว BMI270 (slave) ตอบตัวเลขความเร่งกลับมา — นี่คือสิ่งที่ sensors.bmi270.acceleration() ทำให้เบื้องหลัง

ด้านล่างคือ schematic จริง ของบอร์ด — สังเกตตาราง SDO=0 → I2C address = 0x68 ที่มุมขวาบน

เวลาเราอ่านค่าเอียงจากเซนเซอร์ เบื้องหลังคือคุย I2C ไปถาม address 0x68 แล้วได้เลขกลับมา

เกร็ด: ทำไม embedded สำคัญ — Nintendo DS (2004)

DS เปิดยุค input แบบใหม่บนเครื่องพกพา — touchscreen + ไมโครโฟน กลายเป็นวิธีเล่นใหม่ ไม่ใช่แค่ปุ่ม

เชื่อมกับวันนี้: การ เอียงบอร์ด อ่านค่าจาก accelerometer ของน้อง คือ input modality ใหม่แบบเดียวกัน — เซนเซอร์กลายเป็นปุ่ม


Nintendo DS รุ่นแรก (2004) — สองจอ + จอสัมผัส

ที่มา: "Nintendo-DS-Fat-Blue" — Evan-Amos, Public domain, Wikimedia Commons · เนื้อหา: en.wikipedia.org/wiki/Nintendo_DS

API ที่ใช้คาบนี้

คำสั่ง ทำอะไร
sensors.init() ปลุกเซนเซอร์ทั้งชุด (รวม BMI270) — เรียก ครั้งเดียวพอ
sensors.bmi270.acceleration() คืนค่า 3 แกน (ax, ay, az) หน่วย m/s² — หารด้วย 9.81 เพื่อแปลงเป็น g
game.Box(x, y, w, h, color=...) สร้างกล่องบนจอ
box.move(dx, dy) เลื่อนกล่อง คุมขอบจอให้แล้ว ไม่หลุดออกนอกจอ
game.keys() อ่านปุ่มระบบ (.back = ออก)

จุดสำคัญ: ต้อง sensors.init() ก่อน เรียก sensors.bmi270.acceleration() เสมอ ไม่งั้นเซนเซอร์ยังไม่ตื่น acceleration() จะ โยน OSError (โปรแกรมหยุด) ไม่ใช่คืนค่าศูนย์เงียบ ๆ

จาก "ค่าเอียง" ไปเป็น "การขยับ"

หัวใจของคาบนี้คือสมการ map สั้น ๆ แค่บรรทัดเดียวต่อแกน:

dx=ax×SPEEDdy=ay×SPEEDdx = a_x \times \text{SPEED} \qquad dy = a_y \times \text{SPEED}

  • ax, ay มาจากเซนเซอร์ (หาร 9.81 แปลงเป็น g แล้ว) อยู่ในช่วงราว −1.0 ถึง +1.0
  • SPEED คือตัวคูณ — ยิ่งมาก เอียงนิดเดียวกล่องก็พุ่ง
  • ผลคูณ dx, dy คือ "ขยับกี่พิกเซลในเฟรมนี้"

deadzone — ค่าเอียงเล็ก ๆ จากมือสั่นหรือวางไม่ราบสนิท เราตัดทิ้งให้เป็น 0 ก่อน

ax<DEADZONE    ax=0(กล่องนิ่งเมื่อวางราบ)|a_x| < \text{DEADZONE} \;\Rightarrow\; a_x = 0 \quad(\text{กล่องนิ่งเมื่อวางราบ})

เห็นภาพ: เอียง → ค่า ax → กล่องวิ่ง

  • เอียงซ้าย ax เป็นลบ → dx = ax × SPEED เป็นลบ → กล่องไปซ้าย
  • เอียงขวา ax เป็นบวก → กล่องไปขวา · ทิศทางมาจากเครื่องหมาย (+/−) ของ ax เอง
  • ขนาดการเอียง (มาก/น้อย) คุม ความเร็ว ของกล่อง — เอียงนิด ค่อย ๆ ไถล เอียงสุด พุ่งเลย

เราไม่ต้องเขียน if เลือกซ้าย/ขวา/บน/ล่างเลย — แค่คูณค่าเซนเซอร์ ทั้งทิศและความเร็วโผล่มาเองจากตัวเลข

ดูแบบเคลื่อนไหว: เอียงแล้วกล่องวิ่งตามทันที

  • วางราบ กล่องนิ่งกลางจอ · เอียงขวา กล่องไปขวา · เอียงซ้าย กล่องไปซ้าย
  • ทิศของกล่องมาจากเครื่องหมายของ ax · ความเร็วมาจากขนาดที่เอียง
  • พอเลิกเอียง (วางราบ) กล่อง หยุดทันที — เพราะเราคูณ ax ตรง ๆ ยังไม่มี "ความหน่วง"

อยากให้มัน ไหลต่อ เหมือนลูกแก้วจริง ต้องเก็บ "ความเร็ว" ไว้ — นั่นคือเรื่องของ physics simulation ที่รอให้น้องไปต่อยอด

อยากเห็นว่า ข้างในชิป BMI270 ทำงานยังไงตอนเราเอียง ดูคลิปนี้จากผู้ผลิตชิปเอง (Bosch):

Bosch MEMS sensors: Working principle of an accelerometer — Bosch Sensortec

เอียงคุมเกม — มีอยู่จริงในเกมดัง

โค้ด map สั้น ๆ ที่น้องเพิ่งเขียน คือหัวใจเดียวกับเกมพวกนี้:

  • เขาวงกตลูกแก้ว — เอียงพื้นให้ลูกแก้วกลิ้งเลี่ยงหลุม (เหมือน Super Monkey Ball / Labyrinth)
  • เครื่องบิน / ยานอวกาศ — เอียงบอร์ดเป็นพวงมาลัย คุมหัวเครื่องเลี้ยว
  • เกมทรงตัว — ถือถาดไม่ให้ของหล่น เอียงนิดเดียวมีผลทันที
  • รถแข่ง — เอียงเลี้ยว เหมือนจับพวงมาลัยจริง

เกมเขาวงกตลูกแก้วของจริง — เอียงพื้นให้ลูกกลิ้ง

ที่มา: "PuzzleOfDexterity" — Andreas.Roever, CC BY-SA 3.0, Wikimedia Commons

Game Simulation Machine: บอร์ดในมือน้องกลายเป็น ตัวคุมจริง ๆ ของโลกในจอ — เอียงมือ = เอียงโลกในเกม นี่คือก้าวแรกของการทำ simulator
ลองจินตนาการ: ถ้าเปลี่ยนกล่องเป็น "ลูกแก้ว" แล้วเติม "หลุม" ที่ห้ามตก — เกมเขาวงกตของน้องก็เกิดขึ้นแล้ว

70 / 30 — น้อง ๆ เติมแค่ 3 ช่อง

70% core (มีให้แล้ว)
  • game.start() — ล้างจอ + เตรียมระบบ
  • game.Box / game.Text — กล่อง + ป้าย
  • ลูป while True + อ่านปุ่ม BACK
  • โครง deadzone + time.sleep_ms(30)
30% น้อง ๆ เติม
  • sensors.init()
  • อ่าน sensors.bmi270.acceleration()
  • box.move() ด้วยค่า map

ของใหม่จริง ๆ ของคาบนี้คือ การคุยกับเซนเซอร์ — 3 บรรทัดที่เชื่อมฮาร์ดแวร์เข้ากับเกม

ไฟล์ฝึก: practise_codes/s09_tilt.py — มองหาคำว่า "เติม:" ทุกจุด

โค้ดฝึก — ช่องที่น้อง ๆ ต้องเติม (1/2)

import bentogame as game
import sensors          # ชุดเซนเซอร์บนบอร์ด (ต้อง init ก่อนใช้)
# bmi270 อยู่ใต้ sensors แล้ว — เรียก sensors.bmi270.acceleration() ได้เลย
import time

game.start()            # เคลียร์จอ + เตรียมระบบ

# เติม: ปลุกเซนเซอร์ทั้งชุดก่อนใช้งาน ด้วย sensors.init()
pass

# สร้างกล่องไว้กลางจอ ขนาด 40x40
box = game.Box(game.WIDTH // 2, game.HEIGHT // 2, 40, 40, color=game.CYAN)
label = game.Text("เอียงบอร์ดเพื่อขยับกล่อง", 20, 20)

DEADZONE = 0.10   # ค่าเอียงเล็ก ๆ ถือว่ายังนิ่ง กันกล่องสั่น
SPEED = 120.0     # ตัวคูณ: ยิ่งเอียงมาก กล่องยิ่งวิ่งไว

บรรทัดที่เขียนว่า # เติม: กับ pass คือจุดที่รอน้อง ๆ เติม อย่าลืมลบ pass ออกตอนเติมเสร็จ

โค้ดฝึก — ช่องที่น้อง ๆ ต้องเติม (2/2)

while True:
    keys = game.keys()          # อ่านปุ่มระบบ (BACK = ออก)
    if keys.back:
        game.clear()
        break

    # เติม: อ่านความเร่ง 3 แกนด้วย sensors.bmi270.acceleration() -> คืน (ax, ay, az) หน่วย m/s²
    ax, ay, az = (0.0, 0.0, 0.0)
    ax, ay, az = ax/9.81, ay/9.81, az/9.81  # แปลงเป็น g (วางราบ แกนหนึ่ง ≈ 1.0)

    # ตัด noise ในช่วง deadzone ให้เป็น 0 (กล่องจะนิ่งเมื่อวางราบ)
    if abs(ax) < DEADZONE:
        ax = 0.0
    if abs(ay) < DEADZONE:
        ay = 0.0

    # เติม: map ค่าความเอียงเป็นระยะเลื่อนต่อเฟรม แล้วเรียก box.move(dx, dy)
    #       ใบ้: dx = ax * SPEED, dy = ay * SPEED  (box.move คุมขอบจอให้แล้ว)
    pass

    time.sleep_ms(30)           # ~30 fps

เฉลย — เติมครบ 3 ช่อง

game.start()            # เคลียร์จอ + เตรียมระบบ
sensors.init()          # ปลุกเซนเซอร์ทั้งชุด (รวม BMI270) ครั้งเดียวพอ

box = game.Box(game.WIDTH // 2, game.HEIGHT // 2, 40, 40, color=game.CYAN)
label = game.Text("เอียงบอร์ดเพื่อขยับกล่อง", 20, 20)

DEADZONE = 0.10
SPEED = 120.0

while True:
    keys = game.keys()                     # อ่านปุ่มระบบ (BACK = ออก)
    if keys.back:
        game.clear()
        break

    ax, ay, az = sensors.bmi270.acceleration()     # เซนเซอร์คืน m/s²
    ax, ay, az = ax/9.81, ay/9.81, az/9.81 # แปลงเป็น g (วางราบ แกนหนึ่ง ≈ 1.0)

    if abs(ax) < DEADZONE:                  # ตัด noise ในช่วง deadzone
        ax = 0.0
    if abs(ay) < DEADZONE:
        ay = 0.0

    box.move(ax * SPEED, ay * SPEED)        # map ค่าเอียง -> ระยะเลื่อนต่อเฟรม
    time.sleep_ms(30)                       # ~30 fps

ลองเติมเองให้สุดก่อนนะ — เดี๋ยวเฉลยพร้อมกันในห้องเรียน แล้วเทียบกับที่ตัวเองเติมดูว่าตรงไหม

ทำไมอ่าน az ทั้งที่ไม่ได้ใช้

เซนเซอร์คืนความเร่ง 3 แกนพร้อมกันเป็น tuple เดียว จะหยิบมาแค่ 2 แกนไม่ได้ ต้อง unpack ให้ครบ:

ax, ay, az = sensors.bmi270.acceleration()   # s09_tilt.py:34 — คืน 3 ค่าเสมอ
ax, ay, az = ax/9.81, ay/9.81, az/9.81        # :36 — หาร 9.81 ทั้ง 3 ค่า
...
box.move(ax * SPEED, ay * SPEED)              # :45 — ใช้แค่ ax, ay — az ถูกทิ้ง
  • เกมนี้ขยับกล่องบน จอ 2 มิติ ใช้แค่ ax (ซ้าย-ขวา) กับ ay (หน้า-หลัง) พอ
  • az คือแกนที่ตั้งฉากกับหน้าจอ (ชี้เข้า-ออกจากจอ) — วางราบจะได้ ≈ 1.0 g เอียงแล้วลดลง มันไม่ได้บอก "ทิศบนจอ" จึงไม่ถูกนำไป move()
  • เรายัง unpack az ไว้เพราะต้องมีชื่อรับค่าตัวที่ 3 (ถ้าเขียน ax, ay = ... จะ ValueError: too many values to unpack) — ใช้ _ แทนก็ได้ แต่ตั้งชื่อ az ไว้อ่านง่ายและเผื่อต่อยอด

ต่อยอด (ไม่มีในไฟล์เฉลย) — ทำค่าเซนเซอร์ให้นิ่งด้วย EMA: ค่าดิบจาก BMI270 สั่นเป็น noise ทุกเฟรม เรากรองให้เนียนได้ด้วย low-pass filter (EMA) ใส่ หลังบรรทัดอ่านค่า :36 ก่อน deadzone:

filt_x = ALPHA * ax + (1 - ALPHA) * filt_x   # ALPHA ~ 0.2 : ยิ่งน้อยยิ่งนิ่ง(หน่วงกว่า)

สูตร EMA คือ filt = α·new + (1−α)·filt — ผสมค่าใหม่นิดเดียวกับค่าเก่าที่สะสมไว้ ทำให้การกระตุกหายไป กล่องลื่นขึ้น (แต่ตอบสนองช้าลงเล็กน้อย) นี่คือจุดต่อยอดของ DEADZONE ที่ตัด noise แบบ on/off ให้กลายเป็นการกรองแบบนุ่ม

ทั้งโปรแกรม s09_tilt.py · ไหลยังไง

ทุกเฟรม (~30 Hz) ข้อมูลวิ่งจาก เอียงบอร์ด → ค่าเซนเซอร์ → deadzone → ระยะเลื่อน → กล่องบนจอ แล้ววนกลับ:

notation: เริ่ม/จบ = เทา · อ่าน/วาด (I/O) = เขียว · กระบวนการ = ฟ้า · ตัดสินใจ = สี่เหลี่ยมข้าวหลามตัดม่วง STARTsensors.init() :18 อ่าน keysgame.keys() :28 อ่านความเร่งacceleration()/9.81 :34,36 back?:29 No Yes ENDclear()+break :30 |ax|<DEAD?:39-42 ax/ay = 0ตัด noise box.moveax·SPEED,ay·SPEED :45 Yes No while True + time.sleep_ms(30) → วนรอบถัดไป (~30 fps) :47

box.move() เป็นตัว clamp ขอบจอ ให้ในตัว (engine 70%) เราแค่ป้อน ax·SPEED, ay·SPEED เข้าไป — สังเกตว่าไม่มี if เลือกทิศเลย ทิศ (+/−) และความเร็ว (ขนาด) โผล่มาเองจากค่าเซนเซอร์

วิธีรันบนบอร์ดจริง

  1. เปิดไฟล์ s09_tilt.py ใน BENTO IDE
  2. กดปุ่ม Program to Device เพื่อส่งโค้ดลงบอร์ด
  3. วางบอร์ดราบ บนโต๊ะก่อน กล่อง CYAN ควรนิ่งอยู่กลางจอ
  4. ค่อย ๆ เอียงซ้าย-ขวา กล่องจะเลื่อนตามแกน X
  5. เอียงหน้า-หลัง กล่องจะเลื่อนตามแกน Y
  6. กด BACK เพื่อออกจากโปรแกรม


ปุ่ม Program to Device (สีเขียว มุมซ้ายบน) ใน BENTO IDE — คือปุ่มเดียวที่ข้อ 1-2 พูดถึง

เรารันด้วยปุ่ม Program to Device เท่านั้น ไม่ต้องไปพิมพ์ exec(open(...)) ที่ REPL นะ

จูนค่าให้เป็น "ความรู้สึก" ของเรา

ลองปรับ ทีละค่า แล้ว Program to Device ใหม่ สังเกตความต่าง:

ลองเปลี่ยน ผลที่เห็น
SPEED = 60.0 กล่องวิ่งช้า ต้องเอียงเยอะกว่าจะขยับ
SPEED = 240.0 เอียงนิดเดียวกล่องก็พุ่ง คุมยากขึ้น
DEADZONE = 0.02 ไวมาก วางราบก็ยังขยับเล็กน้อย (สั่น)
DEADZONE = 0.30 ต้องเอียงเยอะกว่ากล่องถึงจะเริ่มขยับ
เอียงบอร์ด (ax): +0.00 g
SPEED: 120
DEADZONE: 0.10
dx ต่อเฟรม = ax × SPEED = 0.0 px
ลากแถบ "เอียงบอร์ด" เพื่อจำลองการเอียง แล้วลอง SPEED/DEADZONE — กล่องตอบสนองทันทีโดยไม่ต้อง re-flash

นี่คือหัวใจของงานเซนเซอร์จริง — ค่าดิบจากชิปไม่เคย "พอดี" ในครั้งแรก เราต้องจูน SPEED กับ DEADZONE ให้เข้ากับมือคนเล่น

ค่าสำคัญประจำคาบ (จดไว้)

ค่า ความหมาย
9.81 m/s² ต่อ 1g — หารค่าดิบให้เป็นหน่วย g
±2 g ช่วงที่เซนเซอร์ตั้งไว้ (เอียงปกติไม่เกินนี้)
1.0 g วางราบ แกน Z อ่านได้ราวนี้ (ใช้เป็นตัวเช็ก)
DEADZONE 0.10 ต่ำกว่านี้ถือว่ายังนิ่ง (กันกล่องสั่น)
SPEED 120 ตัวคูณ ax → พิกเซลต่อเฟรม (จูนได้)
0x68 address ของ BMI270 บนบัส I2C
~30 fps time.sleep_ms(30) หนึ่งรอบลูป

ตัวเลขพวกนี้ไม่ใช่เวทมนตร์ — มาจาก ฟิสิกส์ (9.81, 1g) บวกกับ การจูนให้เข้ามือคนเล่น (DEADZONE, SPEED)

กับดักที่เจอบ่อย

  • ลืม sensors.init()sensors.bmi270.acceleration() จะ โยน OSError แล้วโปรแกรมหยุดทันที (ไม่ใช่คืนค่าศูนย์เงียบ ๆ) — เห็น error ที่คอนโซลแบบนี้ให้ย้อนไปเช็กว่าลืม init รึเปล่า
  • เรียง init ผิดที่ — ต้อง init ก่อนเข้าลูป ไม่ใช่ในลูป (สิ้นเปลือง + เซนเซอร์รีเซ็ตซ้ำ)
  • ลืมลบ pass — ในไฟล์ฝึกมี pass ค้างไว้ ถ้าไม่ลบ บรรทัดที่เราเติมจะไม่ทำงานตามตั้งใจ
  • DEADZONE เล็กไป กล่องสั่นตลอดเวลาแม้วางราบ — เพิ่มค่าขึ้น
  • SPEED มากไป เอียงนิดเดียวกล่องหายไปชนขอบ — ลดค่าลง
  • สลับแกน X/Y เอียงซ้าย-ขวาแต่กล่องขยับขึ้น-ลง — ลองสลับ ax/ay ใน box.move()

ถ้าค่าที่อ่านได้ดูแปลก ๆ ลองพิมพ์ print(sensors.bmi270.acceleration()) ในลูปดูค่าจริงก่อน แล้วค่อยจูน

สรุป + ทำเอง 30%

วันนี้เราได้
  • เข้าใจว่า accelerometer วัดความเอียงเป็นตัวเลข 3 แกน
  • คุยกับชิป BMI270 ผ่าน I2C ด้วย sensors.bmi270.acceleration()
  • map ค่าเอียง → การขยับ game.Box
  • เข้าใจ deadzone + การจูนค่าเซนเซอร์
การบ้าน 30%
  • เติม practise_codes/s09_tilt.py ให้ครบ 3 ช่อง
  • จูน SPEED + DEADZONE ให้คุมง่ายที่สุด แล้วจดค่า + เหตุผล
  • คลิป 10 วิ เอียงบอร์ดคุมกล่องวิ่ง 4 ทิศ

ต่อยอด: เปลี่ยนสีกล่องตามความแรงที่เอียง (เอียงมาก = สีแดง) หรือเอาค่า az มาทำอะไรสักอย่าง — ลองคิดเล่นดูนะน้อง ๆ

เฉลย s09_tilt.py — อ่านให้เข้าใจ ปิดไฟล์ แล้วพิมพ์เอง

เฉลยนี้มีไว้เทียบกับที่น้องเติมเอง ไม่ได้มีไว้ copy วาง คะแนนของคาบอยู่ที่ใบงานกับการอธิบายด้วยคำพูดตัวเอง ไม่ใช่ที่โค้ดตรงเฉลย วิธีใช้ให้ได้ผลจริง: อ่านทีละส่วนให้เข้าใจว่าทำไมต้องเป็นแบบนี้ แล้วปิดไฟล์ กลับไปพิมพ์ใหม่ด้วยมือ ตอนพิมพ์เองนี่แหละที่สมองจำ pattern การคุยกับเซนเซอร์ได้จริง เราจะแกะทีละก้อน ไม่ดูรวดเดียว เพราะทุกบรรทัดมีเหตุผลของมัน

ก้อนแรก — เปิดเครื่องมือ แล้วปลุกเซนเซอร์ก่อนใช้:

import bentogame as game
import sensors          # ชุดเซนเซอร์บนบอร์ด (ต้อง init ก่อนใช้)
# bmi270 อยู่ใต้ sensors แล้ว — เรียก sensors.bmi270.acceleration() ได้เลย
import time

game.start()            # เคลียร์จอ + เตรียมระบบ
sensors.init()          # ปลุกเซนเซอร์ทั้งชุด (รวม BMI270) ครั้งเดียวพอ
  • sensors.init() (:18) ต้องมา ก่อน เข้าลูป ไม่ใช่ในลูป — เหมือนสไลด์ "กับดักที่เจอบ่อย" เตือนไว้ ปลุกเซนเซอร์ครั้งเดียวพอ ถ้า init ซ้ำทุกเฟรมจะสิ้นเปลืองและเซนเซอร์รีเซ็ตวนไม่หยุด
  • ลำดับสำคัญมาก: ถ้าลืม init แล้วเรียก sensors.bmi270.acceleration() เลย มันจะ โยน OSError โปรแกรมหยุดทันที ไม่ใช่คืน 0 เงียบ ๆ (ตรงกับสไลด์ "API ที่ใช้คาบนี้") — นิสัยที่สร้างคือ "อุปกรณ์ต้องเปิดก่อนอ่าน"
  • import sensors แยกจาก import bentogame as game เพราะเกมกับเซนเซอร์เป็นคนละโมดูล วันนี้เราต่อสะพานสองโลกนี้เข้าหากันเป็นครั้งแรกของคอร์ส

ก่อนสั่งอ่านค่าจากอุปกรณ์ใด ให้ถามตัวเองก่อนว่า "มันตื่นหรือยัง" — sensors.init() คือการปลุกให้ตื่น

เฉลย · ก้อนที่สอง — วางกล่อง แล้วตั้งค่าปรับมือ

# สร้างกล่องไว้กลางจอ ขนาด 40x40
box = game.Box(game.WIDTH // 2, game.HEIGHT // 2, 40, 40, color=game.CYAN)
label = game.Text("เอียงบอร์ดเพื่อขยับกล่อง", 20, 20)

DEADZONE = 0.10   # ค่าเอียงเล็ก ๆ ถือว่ายังนิ่ง กันกล่องสั่น
SPEED = 120.0     # ตัวคูณ: ยิ่งเอียงมาก กล่องยิ่งวิ่งไว
  • วางกล่องที่ game.WIDTH // 2, game.HEIGHT // 2 (:21) คือ กึ่งกลางจอ ไม่ hard-code เลขพิกเซล — บอร์ดจอคนละขนาดก็ยังกลางจอเสมอ นิสัย "ถามค่าจริงจากระบบ" ที่ย้ำมาตั้งแต่คาบต้น ๆ
  • ยก DEADZONE กับ SPEED ขึ้นมาเป็น ค่าคงที่ชื่อชัด ไว้บนสุด (:24-25) ไม่ใช่ตัวเลขลอย ๆ กลางลูป เวลาจูนเราแก้ที่เดียวจบ ตรงกับสไลด์ "จูนค่าให้เป็นความรู้สึกของเรา"
  • ค่า 0.10 กับ 120.0 ไม่ใช่เวทมนตร์ มาจากฟิสิกส์ (ช่วง ax ≈ −1..1 ในสไลด์ "ฟิสิกส์เบื้องหลัง") บวกการลองจนเข้ามือ ตั้ง SPEED ใหญ่ไปกล่องพุ่งชนขอบ ตั้ง DEADZONE เล็กไปกล่องสั่นตลอดเวลา
  • แยก "ค่า" (DEADZONE, SPEED) ออกจาก "โครงโค้ด" (ลูป) คือบทเรียนเดิมที่เจอมาหลายคาบ เปลี่ยนพฤติกรรมได้โดยไม่ต้องแตะตรรกะสักบรรทัด

ตัวเลขที่ต้องจูนบ่อย ให้ยกขึ้นมาตั้งชื่อไว้บนสุดเสมอ ตัวเราในอีกสิบนาทีจะขอบคุณ

เฉลย · ก้อนที่สาม — หัวใจ: อ่าน → แปลง → กรอง → ขยับ

while True:
    keys = game.keys()          # อ่านปุ่มระบบ (BACK = ออก)
    if keys.back:
        game.clear()
        break

    # อ่านความเร่ง 3 แกน เซนเซอร์คืน m/s²
    ax, ay, az = sensors.bmi270.acceleration()
    # แปลงเป็น g (วางราบ แกนหนึ่ง ≈ 1.0, ช่วงเอียงราว -1..1)
    ax, ay, az = ax/9.81, ay/9.81, az/9.81

    # ตัด noise ในช่วง deadzone ให้เป็น 0 (กล่องจะนิ่งเมื่อวางราบ)
    if abs(ax) < DEADZONE:
        ax = 0.0
    if abs(ay) < DEADZONE:
        ay = 0.0

    # map ค่าความเอียง -> ระยะเลื่อนต่อเฟรม (box.move คุมขอบจอให้แล้ว)
    box.move(ax * SPEED, ay * SPEED)

    time.sleep_ms(30)           # ~30 fps
  • ax, ay, az = sensors.bmi270.acceleration() (:34) รับค่า 3 แกนพร้อมกันเป็น tuple เดียว ต้อง unpack ให้ครบ ถ้าเขียน ax, ay = ... จะ ValueError (สไลด์ "ทำไมอ่าน az") เกม 2 มิติใช้แค่ ax, ay แต่ az ต้องมีที่รับ
  • หาร 9.81 (:36) แปลง m/s² → g ทั้ง 3 ค่า ค่า ax ที่ได้ คือ sinθ ของมุมเอียงโดยตรง ตามสไลด์ "ฟิสิกส์เบื้องหลัง" เราจึงคูณ SPEED ได้เลย ไม่ต้องเรียก math.asin() หามุมจริง
  • deadzone (:39-42) ตัดค่าสั่นเล็ก ๆ ให้เป็น 0 ด้วย abs() วางราบกล่องจึงนิ่งสนิท ถ้าไม่ตัด มือสั่นนิดเดียวกล่องก็ไถลตลอด
  • box.move(ax * SPEED, ay * SPEED) (:45) คือสมการ map จากสไลด์ "จาก ค่าเอียง ไปเป็น การขยับ" — ทิศ (+/−) กับความเร็ว (ขนาด) โผล่มาเองจากค่าเซนเซอร์ ไม่มี if เลือกทิศเลย และ box.move clamp ขอบจอให้แล้ว (engine 70%)
  • time.sleep_ms(30) (:47) คุมให้วน ~30 fps นี่คือ game loop โครงเดิม read → update → wait ที่เจอมาทุกคาบ

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

เฉลย · ชิ้นส่วนไต่ระดับกันยังไง

โค้ดสั้น ๆ นี้ไม่ได้เรียงมั่ว แต่ละบรรทัดต่อยอดของเดิมที่เรามีอยู่แล้ว แล้วเติมแนวคิดใหม่ทีละอย่าง เหมือนวิธีที่เราจะค่อย ๆ ต่อเกมทั้งเครื่องขึ้นมา:

ชิ้นส่วน บรรทัด แนวคิดใหม่ที่เพิ่ม ของเดิมที่เอากลับมาใช้
ปลุกเซนเซอร์ :18 peripheral ต้อง init ก่อนอ่าน game.start() จากคาบก่อน
ตั้งค่าปรับมือ :24-25 แยกค่า (DEADZONE/SPEED) ออกจากโครง ตัวแปร constant ชื่อชัด
อ่าน 3 แกน :34 I2C read คืน tuple → unpack อ่าน game.keys() ท่าเดียวกัน
หาร 9.81 :36 หน่วยฟิสิกส์ m/s²→g, ax ≈ sinθ คูณ/หารเลขธรรมดา
deadzone :39-42 ตัด noise แบบ on/off if + abs()
map → move :45 ax·SPEED รวมทิศ+ความเร็วในเลขเดียว box.move clamp ขอบ (engine)
หน่วงเฟรม :47 คุมเฟรมเรต ~30 fps while True game loop
  • แถวบน (init, ตั้งค่า) คือ "เตรียมของ" ทำครั้งเดียว · แถวล่างในลูปคือ "ทำซ้ำทุกเฟรม" นี่คือโครงเดียวกับ setup/loop ทุกโปรแกรม embedded
  • ของใหม่จริง ๆ ของคาบนี้มีแค่ 3 บรรทัด: :18, :34-36, :45 — ที่เหลือคือโครงเกมเดิมที่เราคุ้นแล้ว (ตรงกับ 70/30 ในสไลด์ก่อน)
  • อ่านตารางจากบนลงล่างจะเห็นเส้นทางข้อมูล: ฮาร์ดแวร์ตื่น → อ่านดิบ → แปลงหน่วย → กรอง → ขยับ → รอ → วนใหม่

ถ้าจับจังหวะไต่ระดับนี้ได้ น้องจะต่อเซนเซอร์ตัวใหม่เองได้โดยไม่ต้องรอเฉลย เพราะโครงมันซ้ำเดิมเสมอ

เชื่อมจุด — ค่าเอียงก้อนนี้มาจากไหน จะพาเราไปถึงไหน

ที่มา — วันนี้ต่อยอดจากตรงไหน ย้อนไปสไลด์ "accelerometer คืออะไร" เราบอกว่าก้อนตัวเลขจากเซนเซอร์คือ analog ของจริงในโลก แล้วสไลด์ "ฟิสิกส์เบื้องหลัง" อธิบายว่าทำไมค่ามันแทนมุมเอียงได้ (ax ≈ sinθ) ปัญหาดั้งเดิมที่เรากำลังแก้คือ "จะเอาปริมาณจริงในโลก (แรงโน้มถ่วง) มาเป็นคำสั่งในโปรแกรมได้ยังไง" — สไลด์ "จาก ค่าเอียง ไปเป็น การขยับ" คือคำตอบสั้น ๆ: อ่าน → map → ขยับ

ที่ไป — ทักษะเล็กนี้จะโตเป็นอะไร สี่จังหวะ อ่าน→แปลง→กรอง→ขยับ ที่น้องเพิ่งเขียน คือ แม่แบบของการอ่าน peripheral ทุกชนิด เปลี่ยน BMI270 เป็นไมโครโฟน แสง หรือปุ่มอนาล็อก โครงก็เหมือนเดิม คาบหน้า (Shooter: บังคับมีน้ำหนัก) เราจะเอา input แบบนี้ไปคุมยานที่ มีแรงเฉื่อย ไม่หยุดทันทีเหมือนกล่องวันนี้ ตรงกับที่สไลด์ "ดูแบบเคลื่อนไหว" ทิ้งท้ายไว้ว่าอยากให้ "ไหลต่อเหมือนลูกแก้ว"

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

  • จำ ax ≈ sinθ จากสไลด์ "ฟิสิกส์เบื้องหลัง" ได้ไหม ถ้าค่านั้นคือ "มุมเอียง" แล้วเราเอาไปคูณ SPEED มันกลายเป็น "ความเร็ว" ได้ยังไง (ใบ้: ตัวคูณเปลี่ยนความหมายของตัวเลข)
  • ถ้าวันนี้เราแปลง การเอียง เป็นการขยับกล่องได้ พรุ่งนี้เราจะแปลง การเขย่า (แกน az หรือ magnitude) เป็น "กระโดด" หรือ "ยิง" ได้ไหม
  • สังเกตไหมว่า sensors.bmi270.acceleration() แล้ว map ไป box.move มันคือเรื่องเดียวกับ game.keys() แล้วเลือกทิศจากคาบปุ่ม ต่างแค่ input มาจากเซนเซอร์แทนนิ้ว
ที่มา แรงโน้มถ่วง → ตัวเลข (analog) ax ≈ sinθ วันนี้ เอียง → map → ขยับกล่อง อ่าน→แปลง→กรอง→ขยับ ที่ไป peripheral ใด ๆ + แรงเฉื่อย (Shooter คาบหน้า)

ถ้าตอบได้ว่า "อ๋อ เซนเซอร์ก็คือปุ่มอีกแบบ" — นั่นคือการหยั่งรู้ที่อยากให้เกิด: input ทุกชนิดคือ "ตัวเลขที่เราเอาไป map"

ใช้จริงที่ไหน — เอียง/ความเร่ง → คำสั่ง

โค้ด map ค่าความเร่งสั้น ๆ ที่น้องเพิ่งเขียน คือหัวใจเดียวกับของพวกนี้ ทุกอันมีเครื่องจริงในโลก:

เอียงเครื่อง = พวงมาลัย เอียง ax map ทิศ + ความเร็ว box.move(ax·SPEED, ay·SPEED) ตรวจล้ม / นับก้าว · |a| = √(ax²+ay²+az²) 1g threshold ล้ม! เดินแกว่งใกล้ 1g · ล้ม = หลุด threshold โดรน / หุ่นทรงตัว · วนลูปคุมมอเตอร์ อ่านเอียง map มอเตอร์ read → map → สั่ง ทุกไม่กี่ ms กันกระแทก HDD / ถุงลม · จับตกอิสระ 1g ≈ 0 g (ตกอิสระ) trigger พักหัวอ่าน · กางถุงลม
  • สมาร์ตโฟนหมุนจอ + เกมแข่งรถเอียงบังคับ (Asphalt) — อ่าน ax/ay แล้ว map เป็นทิศ ตรงกับ box.move(ax * SPEED, ay * SPEED) (:45) เอียงเครื่องเป็นพวงมาลัย
  • สายรัดข้อมือนับก้าว / ตรวจล้ม — ใช้ magnitude ax2+ay2+az2\sqrt{ax^2+ay^2+az^2}: เดินปกติแกว่งใกล้ 1g ล้มกระชากหลุดจาก 1g มาก เป็น threshold แบบเดียวกับที่ตัด noise ด้วย DEADZONE
  • โดรน / หุ่นยนต์ทรงตัว — วนลูปอ่านเอียง → map → สั่งมอเตอร์ ทุกไม่กี่มิลลิวินาที คือ game loop read → update → wait อันเดียวกับ time.sleep_ms(30) (:47) แค่ actuator เป็นมอเตอร์แทนกล่อง
  • ฮาร์ดดิสก์กันกระแทก / ถุงลมรถ — จับความเร่งที่หลุด threshold (ตกอิสระ = magnitude ใกล้ 0) แล้วสั่งพักหัวอ่าน / กางถุงลมทันที เป็น deadzone กลับด้าน: สนใจเฉพาะตอนค่า หลุด ช่วงปกติ

สี่กรณีนี้ ไม่มีอันไหนเป็นของสมมติเลย ทุกอันมีอุปกรณ์จริงในกระเป๋าน้องที่ทำงานด้วยหลักการเดียวกับโค้ดวันนี้อยู่

ต่อยอด — คิดต่อเอง

ลองหยิบสักข้อไปคิดต่อ ไม่มีคำตอบเดียวตายตัว ทุกข้อโยงกลับไปหาโปรเจกต์จริงได้:

แบบวันนี้ — box.move(ax·SPEED) : ตำแหน่ง = ค่าเอียงตรง ๆ ปล่อยมือ ax=0 หยุด ปล่อยมือ = หยุดกึก ไม่มีแรงเฉื่อย เปลี่ยนวิธีคิด: ตำแหน่ง → ความเร็ว (vx) คาบหน้า — เอียง 'บวกเพิ่ม' vx : ยานมีแรงเฉื่อย ไหลต่อ ปล่อยมือ ax=0 ปล่อยมือแล้วยังไหลต่อ แล้วค่อย ๆ ช้าลง (แรงเฉื่อย)
  • จับการเขย่า (shake) — อยากให้ "เขย่าแรง ๆ = เริ่มเกมใหม่" จะใช้ magnitude มาตั้ง threshold ยังไง วางนิ่ง ≈ 1g เขย่าพุ่งเกิน 2g ออกแบบเงื่อนไขให้ไวพอแต่ไม่ลั่นเอง
  • ทำค่าให้ลื่นด้วย EMA — ใส่ low-pass filter หลังบรรทัด :36 แล้วหา ALPHA ที่ลื่นแต่ไม่หน่วงจนคุมยาก เขียนค่าที่เลือก + เหตุผลลงใบงาน
  • เกมเขาวงกตลูกแก้ว — เปลี่ยนกล่องเป็นลูกแก้ว เติม "หลุม" ที่ห้ามตก จะเก็บตำแหน่งหลุมหลาย ๆ จุดยังไง แล้วเช็กว่าลูกแก้วทับหลุมไหนในแต่ละเฟรม
  • สะพานสู่คาบหน้า (Shooter: บังคับมีน้ำหนัก) — วันนี้กล่องหยุดทันทีที่วางราบ เพราะคูณ ax ตรง ๆ ไม่เก็บความเร็ว คาบหน้ายานต้อง มีแรงเฉื่อย ไหลต่อแม้ปล่อยมือ ถ้าเก็บ vx (ความเร็ว) ไว้ แล้วให้ความเอียง บวกเพิ่ม vx แทนกำหนดตำแหน่งตรง ๆ กล่องจะเคลื่อนต่างจากวันนี้ยังไง

เลือกมาสักข้อ แล้วเขียนลงใบงานว่า "ถ้าเป็นเรา จะออกแบบยังไง" ไม่ต้องถูกตั้งแต่แรก ขอแค่คิดต่อจากสี่จังหวะที่พิมพ์เองวันนี้ — ตรงนั้นแหละที่น้องเริ่มเป็นวิศวกรเซนเซอร์ ไม่ใช่แค่คนพิมพ์ตามเฉลย

fit-css

← Roadmap (TOC)