ภาพพื้นหลังปกบทเรียน

บทเรียน 3.8 — ประกอบแดชบอร์ด: สี่การ์ดในลูปเดียว

Mini-HMI Dashboard · ประกอบทุกอย่างที่เรียนมาให้เป็นหน้าจอเดียว ภายใต้งบ 32 widgets ที่ตั้งเอง

โมดูล 3 — แสดงผลเซนเซอร์บน HMI

ต่อจากบทเรียน 3.7 — ออกแบบ HMI: การ์ด ลำดับสายตา สี และงบ widget

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

เรื่องที่เราให้ 70% ผู้เรียนเขียน 30%

70% — เฟิร์มแวร์ทำให้แล้ว วาดทุก widget ด้วย LVGL · จัดคิว IPC ข้ามคอร์ อ่านเซนเซอร์สี่ตัว · แปลงสนามแม่เหล็กเป็นองศา 30% — งานของเรา อะไรอยู่ด้วยกัน อะไรต้องเด่น · สีแปลว่าอะไร ถี่แค่ไหน · งบให้ใคร

สิ่งที่เฟิร์มแวร์ทำให้แล้ว (70%)
วาดทุก widget ด้วย LVGL, จัดคิว IPC ข้ามคอร์, อ่านค่าจากเซนเซอร์ทั้งสี่ตัว, แปลงสนามแม่เหล็กเป็นองศา, จัดการ touch

สิ่งที่เป็นงานของเรา (30%)
ตัดสินใจว่า ค่าไหนควรอยู่ด้วยกัน · ค่าไหนต้องเด่น · สีอะไรแปลว่าอะไร · อัปเดตถี่แค่ไหน · และงบที่มีจะแบ่งให้ใคร

ห้าข้อนี้ไม่มีข้อไหนเป็นเรื่องไวยากรณ์ภาษา Python เลย มันคืองานออกแบบล้วน ๆ ซึ่งเป็นงานที่ยังไงเครื่องก็ทำแทนเราไม่ได้

โค้ดวันนี้ยาวขึ้นก็จริง แต่ส่วนที่ยากคือตอนตัดสินใจก่อนพิมพ์ ไม่ใช่ตอนพิมพ์

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

แกะโค้ดจริง — ท่าที่ 1 เตรียมจอ ชุดสี และเซนเซอร์

ลำดับเวลาตอนเปิดโปรแกรม — ไม่มี init ให้เรียก มีแต่การรอ ui.screen() ล้าง widget เดิม sleep_ms(200) ให้ CM55 ตามทัน motion() ใน try อ่านครั้งแรก = การรอ เข้าลูปหลัก ครั้งต่อไปรอไม่เกิน 1 วิ Eva: sensors.init() = OSError · Dev Kit: ผ่านแต่ไม่ต้องเรียก เฟิร์มแวร์ปลุกเซนเซอร์ให้แล้ว
import ui
import sensors
import time

ui.screen()          # เปิดหน้าจอ Playground แบบว่าง (ล้าง widget เดิมทุกตัวให้เอง)
time.sleep_ms(200)   # ให้ CM55 ตามทันก่อนเราเริ่มสร้างของใหม่
...
BG_CARD   = 0x171B22   # พื้นการ์ด เทาเข้มอมน้ำเงิน
COL_WHITE = 0xE8EAED   # ตัวเลขพระเอก
COL_GRAY  = 0x9AA3AF   # ข้อความประกอบ / สถานะเงียบ เทาอ่อน
...
COL_STAT  = 0x30A46C   # เขียว "ปกติ"
...
# ไม่มี sensors.init() ทั้งสองบอร์ด (Eva: ได้ OSError / Dev Kit: ไม่จำเป็น)
try:
    sensors.bmi270.motion()      # อ่านทิ้งหนึ่งครั้ง ให้การรอไปเกิดตรงนี้
except OSError:
    print("อ่านเซนเซอร์รอบแรกยังไม่ได้ - ลองใหม่ในลูป")

เรียกอ่านค่าครั้งแรกแล้วเงียบไปสิบกว่าวินาที นั่นคือการรอ ไม่ใช่การค้าง (Eva วัดได้ถึง 16 วินาที · Dev Kit ยังไม่ได้วัด) — อย่าเพิ่งถอดสาย USB และบน Dev Kit ห้ามโยกสวิตช์บนฐาน นั่นคือสวิตช์ไฟ

ห้ามเรียก sensors.init() บน Eva Kit — เฟิร์มแวร์ปฏิเสธด้วย OSError เพราะคอร์จอ (CM55) เป็นเจ้าของบัส I2C ของเซนเซอร์ทั้งชุด ขับบัสซ้อนแล้วบอร์ดค้างจนต้องถอดไฟ · บน Dev Kit บรรทัดนั้นผ่านแต่ไม่จำเป็น เฟิร์มแวร์ปลุกทุกตัวไว้ตั้งแต่บูต — ไฟล์ของคอร์สจึงไม่มี init() และรันได้ทั้งสองบอร์ด

ค่ามาจากไหน — Eva: CM55 อ่านเซนเซอร์ให้ทุก 200 ms แล้วเก็บเป็นภาพสแกน · Dev Kit: CM33 อ่าน IMU และลูกบิดตรงจากบัสของตัวเอง ส่วนแถบสัมผัสถามคอร์จอ · ui.screen() ล้าง widget เดิมทั้งหมด ไม่เรียกแล้วของจากสคริปต์ก่อนหน้าจะกินงบตั้งแต่ยังไม่เริ่ม

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

แกะโค้ดจริง — ท่าที่ 2 การ์ด IMU พร้อมกราฟสามแกน

ความเร่ง BMI270 (m/s2) X +0.2 Y -0.4 Z +9.8 Chart x=40 y=136 w=336 h=56 แกน -150 ถึง 150 คือ -15.0 ถึง +15.0 ม่วง = series แรก (color=) เขียว = add_series() ฟ้า = add_series() คูณ 10 ก่อนป้อน ไม่งั้นเป็นขั้นบันได Label ค่า y=200
imu_panel = ui.Panel(x=24, y=100, w=368, h=136,
                     color=BG_CARD, min=COL_IMU, max=12, value=2)
imu_title = ui.Label("ความเร่ง BMI270 (m/s2)", x=40, y=108, color=COL_IMU, value=16)
# Chart เก็บ 50 จุด รับเฉพาะจำนวนเต็ม เราจึงคูณ 10 ก่อนป้อน (-150..150 = -15.0..+15.0)
imu_chart = ui.Chart(x=40, y=136, w=336, h=56, min=-150, max=150, color=COL_IMU)
sy = imu_chart.add_series(COL_STAT)     # แกน Y เขียว
sz = imu_chart.add_series(0x448AFF)     # แกน Z ฟ้า
imu_val = ui.Label("X+0.0 Y+0.0 Z+9.8", x=40, y=200, color=COL_WHITE, value=20)

ui.Chart รับเฉพาะ จำนวนเต็ม เราจึงคูณค่าจริงด้วย 10 ก่อนป้อน แล้วตั้งแกน −150 ถึง 150 (= −15.0 ถึง +15.0 m/s²) ป้อน int(ax) ตรง ๆ ความละเอียดจะเหลือขั้นละ 1 m/s² กราฟเป็นขั้นบันได · series แรกได้สีจาก color= ของ Chart (COL_IMU สีม่วงประจำการ์ด ไม่ใช่สีเตือน) series ที่สองและสามได้จาก add_series() ซึ่งคืน index มาให้เก็บไว้ใช้ตอน set_next() · imu_val วางที่ y=200 คือใต้กราฟแต่ยังอยู่ในกรอบการ์ด (100 + 136 = 236)

แผนภาพแกนของตัวเครื่องเทียบกับแรงโน้มถ่วงเมื่อเอียง ใช้คำนวณมุมจาก accelerometer

ภาพเคลื่อนไหวแอปเปิลในลิฟต์ที่เร่งขึ้นลง แสดงว่าความเร่งที่วัดได้เปลี่ยนตามการเคลื่อนที่ของลิฟต์

ซ้าย: Rodriguez V.H. et al., Sensors 22(20):7690 (2022) — CC BY 4.0 · ภาพถ่ายการติดตั้งเซนเซอร์จริงบนตัวคน พร้อมแกน roll/pitch/yaw กำกับ อธิบายว่าทำไมกราฟสามเส้นถึงเปลี่ยนพร้อมกันเมื่อเอียงบอร์ดเพียงแกนเดียว · ขวา: Vkidambi / Wikimedia Commons — CC BY-SA 4.0 · แรงโน้มถ่วงที่มองจากในลิฟต์ที่กำลังเร่ง เหตุผลที่ accelerometer วางนิ่ง ๆ แล้วยังอ่านได้ 9.8

กราฟบอกแนวโน้ม ตัวเลขบอกค่าปัจจุบัน — ใส่คู่กันเสมอ อย่างละครึ่งไม่พอสำหรับคนตัดสินใจ

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

แกะโค้ดจริง — ท่าที่ 3 การ์ดเข็มทิศ

comp_panel = ui.Panel(x=408, y=100, w=360, h=136,
                      color=BG_CARD, min=COL_COMP, max=12, value=2)
comp_title = ui.Label("เข็มทิศ BMM350", x=424, y=108, color=COL_COMP, value=16)
compass = ui.Compass(x=424, y=132, w=96, h=96, color=COL_COMP)
comp_deg = ui.Label("000 deg", x=544, y=140, color=COL_WHITE, value=28)
comp_dir = ui.Label("N", x=544, y=188, color=COL_COMP, value=24)
...
DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
...
            comp_dir.text(DIRS[int((heading + 22.5) / 45.0) % 8])

ui.Compass ใช้ w เป็น เส้นผ่านศูนย์กลาง — ไฟล์ส่ง h เท่ากันไว้ด้วย เพื่อให้ด่านตรวจพิกัดอ่านขนาดของมันได้ · รับค่าทิศผ่าน .value(องศา) โดย 0 คือทิศเหนือ · สีของเข็มทิศคือ COL_COMP สีประจำการ์ด ไม่ใช่สีเตือน

หน้าปัดกินซ้าย ตัวเลขกินขวา ตัวเลของศาได้ฟอนต์ 28 ใหญ่ที่สุดในหน้าจอ เพราะเป็นค่าที่ต้องอ่านจากไกลที่สุด ส่วนตัวอักษรทิศได้ 24 · แปลงองศาเป็นชื่อทิศด้วยเลขคณิตบรรทัดเดียว ไม่ต้องมี if ยาว ๆ — บวก 22.5 ก่อนหารด้วย 45 คือ "เลื่อนขอบช่อง" ให้ทิศเหนือกินช่วง 337.5–22.5 องศา แทนที่จะเป็น 0–45

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

แผนที่โลกแสดงค่าเบี่ยงเบนแม่เหล็ก (declination) ปี 2025 เป็นเส้นชั้นค่า

บน: NASA/USGS (สาธารณสมบัติ) = สนามแม่เหล็กโลกที่ BMM350 วัด · Brosen (CC BY 2.5) = ช่องทิศ 8 ช่องที่ `DIRS` แบ่ง · ล่าง: NOAA NCEI / BGS, World Magnetic Model 2025.0 (สาธารณสมบัติ) — ค่าเบี่ยงเบนแม่เหล็กทั่วโลก ตัวเลของศาที่การ์ดแสดงยังไม่ใช่ทิศเหนือจริง ต้องบวกค่าจากแผนที่นี้ก่อน

ตัวเลขให้ความแม่นยำ ตัวอักษรทิศให้ความหมาย คนที่ยืนดูใช้อย่างหลังก่อนเสมอ

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

sensors.bmm350 มีห้าชื่อ — และหนึ่งในนั้นเรายังตอบเรื่องหน่วยไม่ได้

การ์ดเข็มทิศใช้ heading() ตัวเดียว แต่ชิปเปิดให้เราอีกสี่ชื่อ ทุกตัว ไม่รับอาร์กิวเมนต์เลย และโยน OSError เมื่ออ่านไม่สำเร็จ

เรียกอะไร คืนอะไรกลับมา ใช้ตอนไหน
heading() float 0 ถึง 360 องศา 0 คือทิศเหนือ ค่าเดียวที่การ์ดเข็มทิศต้องใช้
magnetic() tuple สามค่า (mx, my, mz) เป็น float อยากเห็นสามแกนดิบ เช่นตรวจว่ามีแม่เหล็กเข้ามาใกล้
cal_reset() None และ พิมพ์หนึ่งบรรทัดลง REPL ว่าให้หมุนบอร์ดครบรอบ เริ่มคาลิเบรต hard-iron ใหม่
cal_status() dict สามคีย์ {'valid': bool, 'offset_x': float, 'offset_y': float} ดูว่าค่าชดเชยลู่เข้าหรือยัง
chip_id() int ยืนยันว่ากำลังคุยกับชิปถูกตัว ตอนสงสัยว่าสายหลุด

สามข้อที่ทำให้เข็มทิศบนการ์ดนิ่งหรือไม่นิ่ง

  1. มีแต่ heading() เท่านั้นที่ขยับตัวคาลิเบรต — magnetic() ไม่ได้ป้อนอะไรให้มันเลย โปรแกรมที่เรียกแต่ magnetic() จะรอค่าชดเชยจนวันตายก็ไม่ลู่เข้า
  2. heading() คืนค่าเฉลี่ยของสิบครั้งหลังสุด เฉลี่ยแบบวงกลมเพื่อไม่ให้พังตอนข้ามรอยต่อ 0 กับ 360 องศา สิบครั้งแรกหลังบูตจึงเป็นค่าที่ยังเฉลี่ยไม่เต็มถัง — อย่าเพิ่งตัดสินจากค่าแรก
  3. cal_status()['valid'] เป็น True ก็ต่อเมื่อครบสองเงื่อนไข คือเก็บครบ 50 ตัวอย่าง และ ค่าที่กวาดได้กระจายพอทั้งสองแกน หมุนบอร์ดไม่ครบรอบ เงื่อนไขที่สองไม่ผ่าน · cal_reset() ไม่ได้เขียนอะไรลงชิป มันล้างตัวแปรฝั่งบอร์ดเท่านั้น ค่าคาลิเบรตจึงหายทุกครั้งที่รีเซ็ต

ชิปตัวนี้ตั้ง ODR ไว้ที่ 25 Hz อ่านถี่กว่านั้นจะได้ค่าเดิมซ้ำ ไม่ใช่ค่าใหม่ — ลูป 200 ms ของเราอยู่ห่างจากเพดานนี้มาก จึงไม่ต้องกังวล

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

sensors.bmm350 — ข้อบกพร่องที่ยังเปิดอยู่สองข้อ พูดตรง ๆ ดีกว่าให้ทีมไปเจอเอง

หนึ่ง หน่วยของ magnetic() ตอนนี้ตอบไม่ได้ว่าคืออะไร คอมเมนต์ในไดรเวอร์และป้ายในไฟล์ตัวอย่างเขียนว่า uT (ไมโครเทสลา) แต่ภาพที่จับจากบอร์ดวัดขนาดรวมสามแกนได้ราว 1532 ขณะที่สนามแม่เหล็กโลกทั้งใบอยู่ที่ 25 ถึง 65 uT — ตัวเลขกับหน่วยเป็นจริงพร้อมกันไม่ได้ อย่างน้อยหนึ่งอย่างผิด และเรายังไม่ได้ตัดสินว่าอันไหน

สิ่งที่ ไม่ ผิดคือทิศ: heading() วัดบนบอร์ดจริงได้ 215.8 เมื่อ 14 ส.ค. 2026 ซึ่งตรงกับความจริง เพราะการหาทิศใช้ atan2 ซึ่งสนใจแค่ อัตราส่วน ระหว่างสองแกน ตัวคูณที่ผิดเท่ากันทั้งคู่จึงหักล้างกันหมด นี่คือเหตุผลที่ข้อบกพร่องนี้อยู่มาได้นานโดยไม่มีใครสังเกต

กติกาของชุดบทเรียนนี้: ห้ามเขียนตัวเลขจาก magnetic() ลงรายงานพร้อมหน่วย uT ให้เขียนว่า "หน่วยของบอร์ด" และเทียบเป็น ค่าต่างจากเส้นฐานที่ทีมวัดเอง เสมอ — ซึ่งบังเอิญเป็นสิ่งที่ 04_magnet_presence.py กับ 05_door_open_switch.py ทำอยู่แล้วทั้งคู่ ทั้งสองไฟล์จึงยังใช้ได้ตามปกติ เกณฑ์ของมันเป็น "เบี่ยงไปเท่าไรจากตอนเริ่ม" ไม่ใช่ "กี่ไมโครเทสลา"

สอง cal_reset() กับ cal_status() ยังไม่มีใครรันบนบอร์ดจริงแม้แต่ครั้งเดียว ทั้ง Eva Kit และ Dev Kit ไฟล์ที่ใช้มันคือ 14_hard_iron_calibration.py และมันเขียนคำเตือนข้อนี้ไว้ในหัวไฟล์ของตัวเอง เปิดได้ ลองได้ แต่ถ้าได้ OSError นั่นคือข้อมูลใหม่ที่ยังไม่มีใครมี ให้บอกผู้สอน

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

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

แกะโค้ดจริง — ท่าที่ 4 การ์ดสัมผัสและการ์ดลูกบิด

ui.Slider — ของที่ "คนลาก" บนจอ นิ้วคน ใช้ผิดที่ = คนดูเข้าใจว่าลากบนจอได้ ui.Bar — ของที่ "เครื่องแสดง" CapSense ค่ามาจากแถบสัมผัสจริงบนบอร์ด ไม่ใช่จากจอ การ์ดลูกบิด — Arc กับ Seg7 ตอบคนละคำถาม Arc = "อยู่ตรงไหนของช่วง" รู้ในพริบตา 48.2 Seg7 = ตัวเลขที่ จดลงบันทึกการเรียนได้
cap_led0 = ui.Led(x=40, y=288, w=48, h=48, color=COL_TOUCH, value=0)
ui.Label("ปุ่ม 0", x=96, y=300, color=COL_GRAY, value=16)
cap_led1 = ui.Led(x=160, y=288, w=48, h=48, color=COL_TOUCH, value=0)
ui.Label("ปุ่ม 1", x=216, y=300, color=COL_GRAY, value=16)
cap_pct = ui.Label("0 %", x=272, y=256, color=COL_WHITE, value=20)
cap_bar = ui.Bar(x=40, y=348, w=288, h=16, min=0, max=100, value=0, color=COL_TOUCH)
...
pot_arc = ui.Arc(x=376, y=288, w=88, h=88, min=0, max=100, value=0)
pot_seg7 = ui.Seg7(x=480, y=288, w=112, h=44, color=COL_POT, min=0, max=100)
pot_volt = ui.Label("0.000 V", x=480, y=340, color=COL_WHITE, value=20)

ทำไมการ์ดสัมผัสใช้ Bar ไม่ใช่ Slider ทั้งที่หน้าตาคล้ายกัน — เพราะ Slider เป็นของที่ คนลาก ส่วน Bar เป็นของที่ เครื่องแสดง ค่าที่นิ้วเลื่อนอยู่บนแถบ CapSense จริง ๆ ถ้าใช้ Slider คนดูจะเข้าใจผิดว่าลากบนจอได้ นี่คือหลัก HMI ที่เรียกว่า ความสอดคล้องระหว่างหน้าตากับสิ่งที่ทำได้จริง · การ์ด pot ใช้สองตัวคู่กันด้วยเหตุผลเดียวกับกราฟ+ตัวเลข: Arc ตอบว่า "อยู่ตรงไหนของช่วง" ในพริบตา ส่วน Seg7 ให้ตัวเลขที่จดลงบันทึกการเรียนได้

ปุ่ม CapSense ใช้ ไฟสองดวง แทนป้ายที่เปลี่ยนสี — ถ่ายจอเป็นขาวดำแล้วยังแยกออกว่าดวงไหนติด · ไฟหรี่ ไม่ใช่หาย ตอนไม่ได้แตะ:

cap_led0.value(1 if cap['btn0'] else 0)
cap_led1.value(1 if cap['btn1'] else 0)
cap_bar.value(int(cap['slider']))

ออสซิลโลแกรมสัญญาณปุ่มกดที่เด้งขึ้นลงหลายครั้งในช่วงราว 250 ไมโครวินาที

ภาพ: Arctanx / Wikimedia Commons — สาธารณสมบัติ · สัญญาณเด้งของหน้าสัมผัสจริงราว 250 ไมโครวินาที — ตัวเลขที่ตอบว่าการ์ดสัมผัสต้องรอนานแค่ไหนก่อนเชื่อค่า

เลือก widget ตาม "ใครเป็นคนเปลี่ยนค่านี้" ไม่ใช่ตามว่าอันไหนดูดีกว่า

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

แกะโค้ดจริง — ท่าที่ 5 ลูปหลัก และการอ่านเซนเซอร์แบบ sync

t0 = ticks_ms() จับเวลาต้นรอบ อ่าน 4 ตัว sync แตะบัสให้น้อยครั้ง อัปเดต widget set_next · text · value ui.poll() ระบายคิวฝั่ง CM55 sleep_ms(200) คืนเวลาให้ระบบ ลืม ui.poll() แล้วจอจะซ่อน widget ประมาณสองวินาที แล้วกลับมาใหม่ วนแบบนั้น รันสิบนาทีที่ 200 ms = ประมาณสามพันรอบ บรรทัดที่แพงจะโผล่ให้เห็นเอง
    while True:
        t0 = time.ticks_ms()
        ok = True
        if running:
            try:
                ax, ay, az, gx, gy, gz = sensors.bmi270.motion()
            except OSError:
                ok = False              # คงค่าเดิมไว้ ไม่เขียนศูนย์ทับ
            imu_chart.set_next(0, int(ax * 10))
            ...
        if time.ticks_diff(time.ticks_ms(), last_text) >= UI_TEXT_MS:
            last_text = time.ticks_ms()
            ...
            head.text("รอบที่ {} | loop {} ms".format(
                rounds, time.ticks_diff(time.ticks_ms(), t0)))
        rounds += 1
        for ev in ui.poll():
            ...
        time.sleep_ms(200)

กฎเหล็กข้อ 4 มีผลเต็ม ๆ ตรงนี้: พอเรียก ui.* ครั้งแรก ระบบอ่านเซนเซอร์อัตโนมัติของเฟิร์มแวร์จะหยุด เราจึงต้องอ่านเองทุกตัวในลูป แบบ synchronous เรียกแล้วรอค่าตรงนั้น ทั้งสี่ตัวเรียงกันในรอบเดียว

motion() ให้หกแกนจากการอ่านครั้งเดียว เร็วกว่าเรียก acceleration() แล้ว gyroscope() แยกสองครั้ง เช่นเดียวกับ capsense.read() ที่คืนสองปุ่มและ slider ในครั้งเดียว — หนึ่งรอบลูป แตะเซนเซอร์ให้น้อยครั้งที่สุด

ui.poll() ต้องเรียกทุกลูป — ชุดบทเรียนนี้มีปุ่มเดินหน้า/หยุดภาพและ Spinbox ให้กดจริง แต่ต่อให้หน้าไหนไม่มีปุ่มก็ต้องเรียก เพราะมันคือจังหวะที่ CM55 ได้ระบายคิว ไม่เรียกแล้วจอจะซ่อน widget ราวสองวินาทีแล้วกลับมาใหม่วน ๆ · except ไม่เขียนศูนย์ทับค่าเดิม แค่ยกธง ok แล้วไฟค่าค้างเป็นคนบอก · t0 ต้นลูปแล้ววัดเวลาที่ใช้ไป คือเครื่องมือหลักตอน soak test

ทุกบรรทัดในลูปนี้จะถูกรันประมาณสามพันรอบระหว่างการทดสอบสิบนาที บรรทัดที่แพงจะโผล่ให้เห็นเอง

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

ข้อมูลไหลไปทางไหน — สี่เซนเซอร์ หนึ่งลูป หนึ่งหน้าจอ

BMI270 BMM350 CapSense Potentiometer ลูป Python บน CM33 อ่าน sync ทั้งสี่ตัว แปลงหน่วย + จัดรูปแบบ sleep_ms(200) IPC คิวคำสั่งวาด ตาราง 64 ช่อง CM55 + LVGL วาดการ์ดสี่ใบ 32 widgets จอ 792 x 398 คอขวดอยู่ที่นี่: ถ้าลูปเร็วเกิน คิวฝั่งขวาจะล้นแบบเงียบ ๆ

ดูเพิ่ม (1 นาที): Working principle of an accelerometer — Bosch Sensortec — 1:01 — ผู้ผลิต BMI270 บนบอร์ดเราแสดงให้เห็นว่ามวลพิสูจน์ในชิปขยับจริง ซึ่งคือที่มาของเส้นกราฟสามเส้นนี้

เซนเซอร์สี่ตัวไม่ได้วิ่งขนานกัน มันต่อคิวกันอยู่ในลูปเดียวของเรา — นี่คือเหตุผลที่ต้องอ่านให้น้อยครั้งที่สุด

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

การ์ดใบที่ห้าที่ทีมเลือกได้ — โมดูล mic ทั้งแปดชื่อ

ไมโครโฟนบนบอร์ดใช้จาก Python ได้แล้ว โมดูล mic ฝังมากับเฟิร์มแวร์ ทั้ง Eva Kit, TESAIoT Dev Kit และ AI Kit import mic ได้เลย ไม่ต้องคัดลอกไฟล์ไปไว้บนบอร์ด และมีอยู่แปดชื่อพอดี

เรียกอะไร คืนอะไร หมายเหตุ
mic.start(sens=3, rate=16000, samples=256) True ต้องเรียกก่อนทุกอย่าง ตัวอื่นจะโยน OSError ถ้ายังไม่เปิด
mic.stop() None ปิดแล้วคิวหยุดโต
mic.read(n=None) list ของจำนวนเต็ม หักค่ากลางออกแล้ว ยาวเท่า samples (ตั้งต้น 256) ตัวเดียวที่ให้ รูปคลื่นดิบ · ไม่ทิ้งของค้าง อ่านของเก่าก่อน
mic.stats(fresh=True) tuple สามค่า (rms, peak, dc) สามค่ามาจาก หน้าต่างเดียวกัน เป็นวิธีเดียวที่จะได้ชุดที่สอดคล้องกัน
mic.rms() จำนวนเต็ม 0 ถึง 32768 ความดังเฉลี่ยแบบยกกำลังสองก่อน
mic.peak() จำนวนเต็ม 0 ถึง 32768 ยอดสูงสุดในหน้าต่าง เห็นเสียงพุ่งสั้น ๆ ที่ rms() เฉลี่ยจนหาย
mic.level() จำนวนเต็ม 0 ถึง 100 สเกลอ็อกเทฟ ไม่ใช่เศษส่วนของสเกลเต็ม อ่านหัวข้อถัดไปก่อนใช้
mic.lag() จำนวนเต็ม มิลลิวินาที ที่ค้างอยู่ในคิว ตัวเลขนี้ คือ ความหน่วงที่ตาเราเห็น ไม่ใช่ตัวประมาณ
TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

โมดูล mic (ต่อ) — คิวเสียงยาว 625 ms และมันเสิร์ฟของเก่าก่อน

ไมค์ผลิตเสียงตลอดเวลา ไม่ว่าโปรแกรมเราจะอ่านหรือไม่ ตัวรับปลายทางคือวงแหวนที่จุได้ 624.9 ms พอดี (20,000 ไบต์ ที่ 16 kHz แบบสองไบต์ต่อตัวอย่าง) และมันจ่าย ตัวที่เก่าที่สุดก่อน

Tring=19,999 ไบต์2 ไบต์/ตัวอย่าง×16,000 ตัวอย่าง/วินาที=0.625 วินาทีT_{\text{ring}}=\frac{19{,}999\ \text{ไบต์}}{2\ \text{ไบต์/ตัวอย่าง}\times 16{,}000\ \text{ตัวอย่าง/วินาที}} = 0.625\ \text{วินาที}

ผลคือ ลูปที่อ่านช้ากว่าที่ไมค์ผลิต จะตามหลังถาวร และเพดานของการตามหลังคือ 625 ms — สิ่งที่การ์ดแสดงจะเป็นห้องเมื่อครึ่งวินาทีที่แล้ว ไม่ใช่ห้องตอนนี้ ทางแก้มีอยู่แล้วในตัว: fresh=True ซึ่งเป็น ค่าตั้งต้น ของ stats() แปลว่า "ทิ้งของค้างทั้งหมด แล้วเอาเฉพาะหน้าต่างล่าสุด"

อ่านแบบไหน lag() ที่วัดได้จริงบนบอร์ด 14 ส.ค. 2026
stats(fresh=True) (ค่าตั้งต้น) 0 ถึง 48 ms
stats(fresh=False) — เก็บของค้างไว้ 496 ถึง 624 ms

read() ใช้ทางที่ ไม่ทิ้ง เสมอ อยากได้รูปคลื่นสด ๆ ต้องอ่านให้ทันเอง หรือเรียก stats() นำหน้าเพื่อเคลียร์คิวก่อน

level() เป็นสเกลอ็อกเทฟ ไม่ใช่เปอร์เซ็นต์ของสเกลเต็ม

level=(log⁡2(rms)×256−1024)×1002816,rms<16⇒0\text{level} = \frac{\bigl(\log_2(\text{rms})\times 256 - 1024\bigr)\times 100}{2816},\qquad \text{rms} < 16 \Rightarrow 0

ไทย: ทุกครั้งที่ rms เพิ่มเป็นสองเท่า ตัวเลข level ขึ้นราว 9 หน่วย ไม่ใช่ขึ้นเป็นเท่าตัว สิบเอ็ดอ็อกเทฟถูกยืดลงบนช่วง 0 ถึง 100 พอดี

ตัวเลขจากบอร์ดนี้ ห้องเงียบ rms 30 ได้ level 7 · พูดปกติ rms ราว 1000 ได้ 54 · ตบมือหรือเปิดโทนใส่ไมค์ rms 19335 ได้ 92 — ถ้าใช้เศษส่วนของสเกลเต็มแบบตรง ๆ ทั้งห้องเงียบและเสียงพูดจะกลายเป็น 0 เท่ากันหมด นั่นคือเหตุผลที่มันเป็นอ็อกเทฟ

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

โมดูล mic (ต่อ) — ราคาที่ต้องจ่าย และกับดักที่เจอบ่อยที่สุด

mic.level() หนึ่งครั้งกินราว 32 ms วัดบนบอร์ด ในนั้น 16 ms คือตัวเสียงเอง (หน้าต่าง 256 ตัวอย่างที่ 16 kHz ก็คือ 16 ms ของเวลาจริง) ส่วนนั้น ลดไม่ได้ ที่เหลือคือค่าใช้จ่ายของการอ่าน — การ์ดเสียงที่ลูป 200 ms จึงจ่ายค่านี้ไปราวหนึ่งในหก ของงบเวลาทั้งรอบ

กับดัก: rms() peak() level() แต่ละตัว อ่านไมค์ใหม่หนึ่งครั้ง เรียก peak() แล้ว rms() ติดกันคือการอ่านสองหน้าต่างคนละช่วงเวลา และจ่ายค่า 32 ms ไปสองรอบ ต้องการทั้งคู่ให้เรียก stats() ครั้งเดียว แล้วแกะออกมาสามค่า

อีกสองข้อ: sens= รับ 1 ถึง 5 (ยิ่งมากยิ่งไวต่อเสียงเบา) ใส่ค่านอกช่วงมัน ตกกลับไปเป็น 3 เงียบ ๆ ไม่มี error ให้เห็น · read(n) ตัดให้สั้นได้อย่างเดียว ขอ 1000 จากบัฟเฟอร์ 256 จะได้ 256 ไม่ใช่ 1000 และมันไม่รอเก็บเพิ่มให้

ลองสามไฟล์ตามลำดับ: 02_mic_sound_level_meter.py (level()) · 03_mic_clap_trigger.py (peak() กับเกณฑ์ที่วัดจากห้องเอง) · 07_mic_window_stats.py (read() stats() lag() และการทดลองสลับ fresh ให้เห็นคิวโตกับตา)

ตัวเลขความดังที่อ่านมาช้ากว่าความจริงครึ่งวินาที ยังเป็นตัวเลขที่ถูก — แต่มันตอบคนละคำถามกับที่คนดูจอกำลังถาม

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

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

1 · เปิด Playground ค้างไว้ ห้ามกดกลับระหว่างทดสอบ 2 · เปิดไฟล์ฝึกใน IDE s08_dashboard.py 3 · วาดผังบนกระดาษ กรอกตารางงบ widget ให้ครบ 4 · เติมทีละจุด แล้วรัน เจ็ดจุด เจ็ดครั้ง ห้ามรวบ 5 · แก้ชื่อทีมในบรรทัด head ให้เป็นของทีมเราเอง 6 · รันทิ้งไว้ 10 นาที จดเลขรอบทุกสองนาที
  1. บนจอบอร์ด แตะการ์ด BENTO Playground แล้วค้างหน้านี้ไว้ ห้ามกดกลับระหว่างทดสอบ
  2. เปิด s08_dashboard.py ใน BENTO IDE
  3. วาดผังบนกระดาษก่อน — กรอกตารางงบ widget ในบันทึกการเรียน ให้ครบก่อนแตะคีย์บอร์ด
  4. เติมช่องว่างทั้ง 7 จุด ทีละจุด แล้วกด Program to Device ดูผลทุกครั้ง
  5. เมื่อครบทั้งเจ็ดจุดแล้ว แก้ชื่อทีมในบรรทัด TEAM ให้เป็นของทีมเรา
  6. รันทิ้งไว้ 10 นาที พร้อมจับเวลาและจดเลขรอบตามบันทึกการเรียน

ถ้าจอค้างระหว่างทาง กด RESTART บนหน้า Playground แล้วเริ่มจับเวลาใหม่ตั้งแต่ศูนย์ — ห้ามนับต่อ

ห้ามเติมครบเจ็ดจุดแล้วค่อยรันทีเดียว ถ้าพังจะไม่รู้เลยว่าพังที่จุดไหนในเจ็ดจุด

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

วิธีทดสอบ soak run 10 นาที — และแยก "ค้าง" ออกจาก "ช้า"

ปกติ — loop ms คงที่ รอบ 590-600 ต่อสองนาที · loop 3 ms ตลอด ช้าลง — ไม่ใช่ค้าง เลขรอบยังเดิน แต่ loop โตจาก 3 เป็น 40 ms วิธีแยกที่เร็วที่สุด: หมุนลูกบิด แล้วดูสามที่พร้อมกัน — Seg7 · เลขรอบ · loop ms

การรันสิบนาทีไม่ใช่การนั่งรอเฉย ๆ เรากำลังเก็บข้อมูลอยู่ จดเลขรอบทุกสองนาที

ที่ cadence 200 ms ลูปควรเดินราว 5 รอบต่อวินาที = ประมาณ 600 รอบต่อสองนาที ถ้าจดได้ 590–600 ทุกช่วง แปลว่าคงที่ ถ้าช่วงหลังเหลือ 300 แปลว่าเริ่มช้าลงแล้ว

อาการที่เห็น เลขรอบ loop ms แปลว่า
ทุกอย่างนิ่งสนิท ภาพค้างที่เฟรมสุดท้าย หยุด หยุด ค้างจริง — ลูปตายหรือ CM55 หยุดตอบ
ตัวเลขยังเดินแต่ช้าลงเรื่อย ๆ เดินต่อ โตขึ้น 3 → 40 ช้า ไม่ใช่ค้าง — มีอะไรสะสมในลูป
จอนิ่ง แต่คอนโซลขึ้น Traceback หยุด หยุด สคริปต์ตายที่ Python — อ่าน error ได้ตรง ๆ
widget หายไปแวบหนึ่งแล้วกลับมา เดินต่อ ปกติ ลืม ui.poll() ในลูป
ค่าเซนเซอร์ค้างค่าเดิม แต่รอบยังเดิน เดินต่อ ปกติ เซนเซอร์ไม่ตอบ ไม่ใช่จอมีปัญหา

วิธีแยกที่เร็วที่สุด: หมุนลูกบิด แล้วดูสามที่พร้อมกัน — เลข Seg7, เลขรอบ, และ loop ms ถ้าเลขรอบเดินแต่ Seg7 ไม่ขยับ ปัญหาอยู่ฝั่งเซนเซอร์ ถ้าเลขรอบหยุด ปัญหาอยู่ฝั่งลูปหรือจอ

อีกอย่างที่ต้องดูคือ ความร้อนและการเสียบสาย — สาย USB ที่หลวมทำให้บอร์ดรีเซ็ตกลางทาง แล้วเราจะไปโทษโค้ดผิด ๆ

"ค้าง" กับ "ช้า" แก้คนละวิธีกันสิ้นเชิง เสียเวลาห้าวินาทีแยกให้ออกก่อน ประหยัดไปได้ครึ่งชั่วโมง

TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0