ราวปี 1661 Melchisédech Thévenot ทำเครื่องวัดระดับแบบหลอดแก้วใส่ของเหลวที่มีฟองอากาศ — ฟองเบากว่าของเหลว มันจึงลอยไปอยู่จุดสูงสุดของหลอดเสมอ ช่างไม้ทั่วโลกใช้มันมาสามร้อยกว่าปีโดยไม่ต้องมีแบตเตอรี่
ปี 1991 Analog Devices ออก ADXL50 ซึ่งเป็น accelerometer แบบ MEMS ตัวแรกที่ผลิตขายจำนวนมาก เป้าหมายแรกคือถุงลมนิรภัย — ต้องแยก "ชนจริง" ออกจาก "ตกหลุมถนน" ให้ได้ในไม่กี่มิลลิวินาที
ภาพขวาบนคือ IMU ทั้งชุดวางเทียบกับเหรียญเพนนี ภาพขวาล่างคือภาพถ่ายไดจริงของ IMU ที่เปิดฝาออก
เชื่อมกับวันนี้: โปรแกรมที่เราจะเขียนอีกครู่คือหลอดแก้วของ Thévenot ที่ถูกเขียนใหม่ด้วย Python สิบกว่าบรรทัด และมันทำสิ่งที่หลอดแก้วทำไม่ได้ — บอกเป็นตัวเลของศา จำค่าอ้างอิงได้ และส่งค่าขึ้นเครือข่ายได้ในบทเรียนที่ 10


สิ่งที่เฟิร์มแวร์ทำให้แล้ว (70%)
ตั้งค่าและปลุก BMI270 ผ่าน I2C, จัดการบัสให้ (Eva: คอร์จออ่านแล้วส่ง snapshot มาให้ · Dev Kit: CM33 อ่านตรงใต้ล็อกบัส), แปลงข้อมูลดิบเป็น m/s² และ deg/s, สูตร atan2 ใน dsp.tilt(), คลาสฟิลเตอร์ dsp.EMA, วาด Bar, Scale, Led และ Seg7 บนจอผ่าน IPC
สิ่งที่เป็นงานของเรา (30%)
เลือกว่าจะอ่านด้วย motion() หรืออ่านแยก · เลือก alpha ของฟิลเตอร์ · ออกแบบว่า "ศูนย์" ของเครื่องนี้หมายถึงอะไรและให้ผู้ใช้ตั้งเมื่อไร · ตัดสินใจว่าจะแสดงกี่ตำแหน่งทศนิยม · จัดวางหน้าจอให้คนอ่านเข้าใจใน 2 วินาที
คนที่เขียน
atan2เองได้แต่เลือก alpha ไม่เป็น จะได้เครื่องมือที่ใช้งานจริงไม่ได้
import ui
ui.screen()
import time
import sensors
import dsp
ui.clear()
time.sleep_ms(200)
...
try: # ไม่มี sensors.init() ทั้งสองบอร์ด
sensors.bmi270.motion() # อุ่นเครื่อง ครั้งแรกหลังรีเซ็ตอาจต้องรอ
except OSError:
print("อ่านเซนเซอร์รอบแรกยังไม่ได้ - ลองใหม่ในลูป")
บน Eva Kit ไม่มี sensors.init() ให้เรียกแล้ว บัส I2C ของ BMI270 เป็นของคอร์จอ (CM55) ขับจากสคริปต์แล้วบอร์ดค้างถาวร เฟิร์มแวร์จึง ปฏิเสธด้วย OSError · บน Dev Kit ผ่านแต่ไม่ต้องเรียกเช่นกัน — เฟิร์มแวร์ปลุก BMI270 ไว้ตั้งแต่บูต CM33 อ่านมันตรงจาก I2C · อุ่นเครื่องใน try/except เพราะรอบแรกหลังรีเซ็ตอาจต้องรอ (Eva วัดได้ถึง 16 วินาที) ยังไม่ตอบก็พิมพ์บอกแล้วลองใหม่ในลูป
motion() .acceleration() .gyroscope() คืนค่าหน้าตาเดียวกันทั้งสองบอร์ด ต่างกันแค่ต้นทาง (Eva: snapshot ของคอร์จอ · Dev Kit: อ่านสดจากชิป) · กฎเหล็กข้อ 4 — แตะ ui.* ครั้งแรก sensor auto-task จะหยุด จากนั้นเราต้องอ่านค่าเองทุกรอบ · ui.screen() วางผิดที่เมื่อไร widget หายทั้งหน้าทันที
bmi270.temperature()/.chip_id()ขึ้นOSErrorบน Eva Kit (Dev Kit ใช้ได้) — โค้ดของคอร์สจึงไม่พึ่งสองคำสั่งนี้
ui.Label("ROLL เอียงซ้าย-ขวา (องศา)", x=40, y=60, color=COL_DIM, value=16)
roll_bar = ui.Bar(x=40, y=96, w=160, h=16, color=0x4A9EFF, min=-90, max=90, value=0)
roll_scale = ui.Scale(x=40, y=112, w=160, h=44, color=COL_TEXT, min=-90, max=90)
roll_scale.ticks(9, 4) # -90 0 90
roll_seg = ui.Seg7("+00.0", x=216, y=96, w=96, h=56, color=COL_TEXT)
# pitch ชุดเดียวกัน เลื่อนไปขวา 288 พิกเซล
...
led_in = ui.Led(x=632, y=72, w=48, h=48, color=COL_OK, value=1)
led_out = ui.Led(x=704, y=72, w=48, h=48, color=COL_BAD, value=0)
min=-90, max=90 คือหัวใจของแถบตัวนี้ ค่าเริ่มต้นของ ui.Bar คือ 0-100 ถ้าไม่กำหนดช่วงเอง พอส่ง −30 องศาเข้าไปมันจะถูกปัดเป็น 0 แล้วแถบนิ่งสนิททั้งที่โค้ดคำนวณถูก
ui.Scale แนวนอนคือไม้บรรทัด ไม่ใช่หน้าปัด ไม่รับ .value() ตัวที่ขยับคือ ui.Bar ที่วางทับ ประโยชน์คือพา พิสัย มาอยู่บนจอ (แบบวงกลมมีเข็มจริง — อีกสองสไลด์) · .ticks(9, 4) คือ 9 ขีด ใส่ตัวเลขทุกขีดที่สี่ ได้ -90 0 90 — สองแกนวางเคียงกัน ไม้บรรทัดกว้างแค่ 160 พิกเซล ใส่ถี่กว่านี้ตัวเลขจะเบียดกัน
ui.Seg7 รับสตริง จึงใส่ + − และทศนิยมได้ · ไฟสองดวงแทนตัวหนังสือสี ถ่ายจอเป็นขาวดำแล้วยังแยกออกว่าดวงไหนติด
ตั้งช่วง min/max ให้ตรงกับปริมาณจริงเสมอ ไม่งั้นเราจะไปไล่หาบั๊กผิดที่ทั้งวัน — และค่าที่วัดได้ห้ามอยู่ลำพัง ต้องมีพิสัยหรือเกณฑ์อยู่ข้าง ๆ
sp_tol = ui.Spinbox(x=40, y=240, w=96, h=88, color=COL_TEXT,
min=TOL_MIN, max=TOL_MAX, value=tol)
sp_tol.digits(2, 0)
btn_dn = ui.Button("ลด", x=168, y=240, w=88, h=88, color=0x3A4150, value=20)
btn_up = ui.Button("เพิ่ม", x=288, y=240, w=88, h=88, color=0x3A4150, value=20)
...
zero_btn = ui.Button("ตั้งศูนย์", x=472, y=240, w=144, h=88, color=0x30A46C, value=20)
zero_id = zero_btn.id()
exit_btn = ui.Button("จบการวัด", x=648, y=240, w=104, h=88, color=0x3A4150, value=20)
exit_id = exit_btn.id()
ui.Spinbox ทำให้เกณฑ์เป็นของผู้ใช้ ไม่ใช่ของโปรแกรมเมอร์ งานวางกล้องยอมได้ 1 องศา งานวางตู้เย็นยอมได้ 10 คนที่รู้คือคนหน้างาน TOL_MIN, TOL_MAX = 1, 30 หนีบให้ตั้งเกินพิสัยไม่ได้ และ .digits(2, 0) ทำให้เห็น 05 แทน 0005
บนจอสัมผัส spinbox เปล่า ๆ นิ้วเปลี่ยนค่าไม่ได้ การแตะแค่เลือกตำแหน่งหลัก จึงต้องมี ui.Button เพิ่ม/ลดข้าง ๆ เสมอ — ทั้งห้าเป้าสัมผัสสูง 88 px ตามขนาดเป้าสัมผัสของหลักสูตร เรียงแถวเดียวเว้น 32 px และปุ่มขวาสุดจบเหนือมุมที่ปุ่ม Console จองไว้ (x>690 และ y>340) พอดี
zero_btn.id() / exit_btn.id() เก็บไว้ตั้งแต่สร้าง เพราะในลูปเราจะเทียบ ev['handle'] กับเลขนี้ · ปุ่มสั่งงานสองปุ่มแยกหน้าที่กันคนละปุ่ม ไม่มีปุ่มไหนสลับสองความหมายในตัวเดียว · widget ทั้งหน้า 25 ตัว — อยู่ในงบ 32 ที่ตั้งเอง (เพดานเฟิร์มแวร์ 64)
เกณฑ์ที่ฝังในโค้ดคือเกณฑ์ของคนเขียนโปรแกรมเมื่อสามเดือนก่อน — เกณฑ์ที่ตั้งได้บนจอคือเกณฑ์ของคนหน้างานวันนี้
SC_X, SC_Y, SC_W = CX - 100, 95, 200
sc = ui.Scale(x=SC_X, y=SC_Y, w=SC_W, h=SC_W, color=0x1565C0, min=0, max=100)
sc.prop(ui.PROP_SCALE_MODE, ui.SCALE_ROUND_OUT)
sc.ticks(21, 5)
...
NEEDLE = 85
sc.prop(ui.PROP_SCALE_NEEDLE_COLOR, 0xFF9800) # ครั้งเดียว
...
def draw_needle(v):
# ทุกครั้งที่ค่าเปลี่ยน: เขียน prop เดียว เข็มหมุนเอง
sc.prop(ui.PROP_SCALE_NEEDLE, (NEEDLE << 16) | (int(v) & 0xFFFF))
ค่าที่ส่งแพ็กสองเรื่องในเลขเดียว — สูง 16 บิตคือความยาวเข็มเป็นพิกเซล (NEEDLE = 85) ต่ำ 16 บิตคือค่าบนสเกล · CX = 396 คือกึ่งกลางจอ
เข็มของ lv_scale หมุนอยู่กับที่ จอวาดซ้ำเฉพาะบริเวณเข็ม — วิธีเก่าที่ลบ ui.Line แล้วสร้างใหม่ทุกค่า ทำให้ทั้งหน้าปัดวาบทุกจังหวะ (เจอจริงบนบอร์ด 2026-08-20)
ต้องใช้ firmware 2026-08-20 ขึ้นไป — รุ่นก่อนหน้าใช้ ui.Bar ทับแบบหน้าที่แล้วไปก่อน
ของจริงทั้งไฟล์: ex10_scale_pitch.py — pitch จาก dsp.tilt ขับเข็มสด

# --- ท่าที่ 3: อ่านหกแกนใน lock เดียว แล้วแปลงเป็นองศา ---
ax, ay, az, gx, gy, gz = sensors.bmi270.motion()
roll, pitch = dsp.tilt(ax, ay, az)
สองบรรทัดนี้คือแก่นของทั้งบทเรียน บรรทัดบนได้ภาพนิ่งของบอร์ด บรรทัดล่างเปลี่ยนมันเป็นองศา
dsp.tilt() รับแค่ สามค่าแรก เท่านั้น มันไม่ต้องการ gyro เพราะคำนวณจากแรงโน้มถ่วงล้วน ๆ ส่วน gx, gy, gz ที่รับมาด้วยเราจะเอาไปแสดงเป็นข้อมูลอ้างอิงท้ายจอ
การอ่าน I2C พลาดได้เป็นครั้งคราวโดยเฉพาะตอนบัสหนาแน่น ในโค้ดจริงเราจึงห่อบรรทัดนี้ด้วย try / except OSError แล้วข้ามรอบนั้นไป
การอ่านเซนเซอร์ล้มเหลวเป็นเรื่อง ปกติ ที่ต้องออกแบบรองรับ ไม่ใช่เรื่องผิดปกติที่รอให้เกิดแล้วค่อยแก้
# --- ท่าที่ 4: กรองความสั่น แล้วหักค่าอ้างอิงที่ตั้งศูนย์ไว้ ---
roll_f = ema_roll.update(roll)
pitch_f = ema_pitch.update(pitch)
roll_show = roll_f - roll_zero
pitch_show = pitch_f - pitch_zero
.update() ทำสองอย่างพร้อมกัน: จำสถานะใหม่ไว้ในตัวมันเอง และคืนค่าที่กรองแล้วออกมาทันที
ลำดับสำคัญมาก ต้องกรองก่อน แล้วค่อยหักค่าศูนย์ เพราะตอนกดปุ่มตั้งศูนย์เราต้องเก็บ roll_f (ค่าที่กรองแล้ว) ไม่ใช่ค่าดิบ — ถ้าเก็บค่าดิบที่กำลังสั่นอยู่พอดี จุดอ้างอิงจะเพี้ยนตามความบังเอิญของเสี้ยววินาทีนั้น
ตั้งศูนย์จาก ค่าที่กรองแล้ว เสมอ — จุดอ้างอิงที่สั่น แย่กว่าไม่มีจุดอ้างอิง
ui.poll()# --- ท่าที่ 5: รับเหตุการณ์จากจอ ---
for ev in ui.poll():
h = ev['handle']
if h == zero_id:
roll_zero = roll_f
pitch_zero = pitch_f
ref.text("อ้างอิง R {:+.1f} P {:+.1f}".format(roll_zero, pitch_zero))
elif h == exit_id:
running = False
elif h == btn_up.id():
tol = min(TOL_MAX, tol + TOL_STEP)
sp_tol.value(tol)
elif h == btn_dn.id():
tol = max(TOL_MIN, tol - TOL_STEP)
sp_tol.value(tol)
ui.poll() คืน list ของ dict แต่ละใบมีคีย์ handle, type, value (สูงสุด 8 event ต่อครั้ง)
ต้องเรียกทุกรอบลูป แม้รู้อยู่แล้วว่าไม่มีใครกด เพราะฝั่ง CM55 ใช้การ poll เป็นสัญญาณว่าโปรแกรมยังมีชีวิต เงียบไปราวสองวินาที widget จะถูกซ่อนทั้งหน้า
ปุ่มเพิ่มกับปุ่มลดเป็นคนละปุ่มกันโดยตั้งใจ ปุ่มเดียวที่สลับสองทิศบอกไม่ได้ว่าตอนนี้กดแล้วจะขึ้นหรือจะลง คนกดจึงต้องเดา และเดาผิดได้เสมอ กฎเดียวกันนี้ใช้กับปุ่มเปิด-ปิดอุปกรณ์จริงทุกชนิด · sp_tol.value(tol) คือการเขียนค่ากลับลงช่อง ไม่ใช่การอ่าน — ตัวแปร tol ฝั่ง Python คือความจริง ช่องบนจอคือการแสดงผลของมัน
ui.poll()ไม่ใช่แค่ "อ่านปุ่ม" มันคือ ชีพจรที่เราส่งให้อีกคอร์รู้ว่าเรายังอยู่
# --- ท่าที่ 6: ส่งค่าขึ้นหน้าปัด แล้วเว้นจังหวะ ---
roll_bar.value(int(clamp90(roll_show))) # แถบขยับทุกรอบ
pitch_bar.value(int(clamp90(pitch_show)))
in_tol = abs(roll_show) <= tol and abs(pitch_show) <= tol
led_in.value(1 if in_tol else 0) # ติดทีละดวงเท่านั้น
led_out.value(0 if in_tol else 1)
sec = time.ticks_ms() // 1000 # ตัวเลขวินาทีละครั้ง
if sec != last_sec:
last_sec = sec
roll_seg.text("{:+.1f}".format(roll_show))
pitch_seg.text("{:+.1f}".format(pitch_show))
if not ok_read: # รอบนี้อ่านไม่ได้ - บอกเป็นคำ
lbl_health.color(COL_WARN)
health = "ค่าค้าง นี่คือค่าล่าสุด"
lbl_health.text(health) # ชีพจร - ส่งข้อความเดิมซ้ำทุกรอบ
time.sleep_ms(200)
lbl_health) เขียนคำว่า "ค่าค้าง" ด้วยสีเตือนเมื่อ motion() ขึ้น OSError แล้วเราใช้ค่าเดิมต่อ — ตั้งสีก่อน แล้วค่อยเขียนข้อความ · lbl_health.text(health) ส่งซ้ำทุกรอบ นอก ประตูหนึ่งวินาทีโดยตั้งใจ เพราะคำสั่งเขียนข้อความคือตัวปลุกจอให้อยู่โหมดเร็ว ส่วน .value() ของแถบกับไฟไม่ปลุก (กลไกเดียวกับบทเรียน 2.7–2.9) · sleep_ms(200): 5 ครั้ง/วินาที IPC ไม่ล้น และ EMA α=0.2 ให้ τ ≈ 1 วินาทีพอดีint() ก่อนส่งให้ ui.Bar ทุกครั้ง แถบรับจำนวนเต็ม ส่วน ui.Seg7 รับสตริงจึงโชว์ทศนิยมได้ · clamp90() บีบค่าไว้ใน −90..+90 เพราะคว่ำบอร์ดจนเกือบกลับหัว roll วิ่งถึง 180° เกินช่วงแถบแล้วค้างปลายสุดโดยไม่บอกอะไร · แถบกับไฟขยับทุกรอบ (5 ครั้ง/วินาที) เพราะตาอ่าน "ตำแหน่ง" ได้ ส่วนตัวเลขบน Seg7 เขียนใหม่ ไม่เกินวินาทีละครั้ง — เลขที่วิ่งห้าครั้งต่อวินาทีคือเลขที่อ่านไม่ทัน ประตูคือ if sec != last_sec:
ทุกตัวเลขในสองบรรทัดสุดท้ายนี้ผูกกันหมด — เปลี่ยน cadence เมื่อไร ต้องกลับไปคิดเรื่อง alpha ใหม่ด้วย
ทุกกล่องในภาพนี้แทนได้ด้วยหนึ่งบรรทัด Python ยกเว้นกล่องแรกกล่องเดียว
s06_digital_level.py ใน BENTO IDE แล้วเติมช่องว่าง pass ให้ครบ 6 จุดเอียงช้าคือการทดสอบที่ถูกต้อง เอียงเร็วคือการทดสอบ ข้อจำกัด ของฟิลเตอร์ ทำทั้งสองอย่างแต่ให้รู้ว่ากำลังทำอะไร
โน้ตผู้สอน (รายละเอียดของข้อบกพร่องที่ยังค้าง): ลงมือดูเองที่ 04_compass_and_magnetometer.py · ทิศถูกแปลว่าอัตราส่วนระหว่างแกนและระบบแกนถูก · ซอร์สหารค่าดิบด้วยค่าคงที่ 14.55 (แกน X,Y) และ 9.0 (แกน Z) โดยคอมเมนต์เขียนเองว่า approximate, without OTP calibration และ "สำหรับ atan2 แค่นี้พอ" — ยังไม่มีใครตัดสินว่าตัวคงที่ผิดหรือป้ายหน่วยผิด
โน้ตผู้สอน: ฝั่งจอมีตัวจับเวลาสองจังหวะ โหมดเร็ว 5 ms กับโหมดปกติ 200 ms และอยู่โหมดเร็วต่ออีก 500 ms ทุกครั้งที่ได้รับคำสั่งเขียนข้อความ/ย้าย/เปลี่ยนสี ลูปที่อัปเดตเฉพาะแถบกับไฟจึงเงียบเกินครึ่งวินาทีแล้วจอถอยไปโหมดช้า ภาพกระตุกทั้งที่โค้ดไม่ได้เปลี่ยน · ข้อความเท่าเดิมเฟิร์มแวร์ไม่วาดซ้ำ เราจ่ายแค่ค่าส่งข้ามคอร์