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

บทเรียน 2.8 — กรองสัญญาณ: EMA กับ Median แล้วแกะโค้ดเกจ

อนาล็อกและสัมผัส · Potentiometer + CapSense + กรองสัญญาณให้อ่านรู้เรื่อง

โมดูล 2 — จากจอสู่ฮาร์ดแวร์

ต่อจากบทเรียน 2.7 — อนาล็อกและสัมผัส: ADC ลูกบิด และ CapSense

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

ทำไมค่าดิบถึงสั่น ทั้งที่ไม่มีใครแตะ

ชั้นไฟฟ้า จอ · WiFi · วงจรข้างเคียง ชั้นตัวอุปกรณ์ หน้าสัมผัสของ wiper ถูตัวเอง ชั้นการปัดขั้น ค่าจริงอยู่กึ่งกลางสองขั้นพอดี สามชั้นนี้บวกกันแล้วโผล่ออกมาเป็นหลักสุดท้ายที่กระพริบบนจอ

สาเหตุมีจริงและซ้อนกันหลายชั้น ไม่ใช่เรื่องลึกลับ

ชั้นไฟฟ้า — สายทุกเส้นรับสัญญาณรบกวนจากวงจรข้างเคียง จอที่กำลังรีเฟรช และ WiFi ที่กำลังส่ง ล้วนเหวี่ยงแรงดันระดับมิลลิโวลต์เข้ามาได้

ชั้นตัวอุปกรณ์เอง — ลูกบิดคือแผ่นตัวต้านทานที่มีหน้าสัมผัสถูตัวมันเอง ความต้านทานตรงจุดสัมผัสไม่ได้นิ่ง 100%

ชั้นการปัดขั้น — ถ้าแรงดันจริงตกอยู่ กึ่งกลาง ระหว่างสองขั้นบันได ADC จะปัดขึ้นบ้างลงบ้างสลับกันไป บิตล่างสุดจึงกระพริบตลอดเวลา

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

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

dsp.EMA — ค่าเฉลี่ยที่จำอดีตแบบจาง ๆ

import dsp
ema = dsp.EMA(alpha=0.2)   # keyword-only — dsp.EMA(0.2) ขึ้น TypeError
smooth = ema.update(pct)   # ป้อนค่าใหม่ ได้ค่ากรองแล้ว
ema.value()                # ขอค่าล่าสุดซ้ำ ไม่ป้อนอะไรเข้าไป
ema.reset()                # ล้างความจำ รอบหน้าเริ่มนับหนึ่งใหม่
dsp.EMA()                  # ไม่ใส่ alpha ได้ ค่าตั้งต้นคือ 0.1 ไม่ใช่ 0.5

y[n]=α⋅x[n]+(1−α)⋅y[n−1]y[n] = \alpha \cdot x[n] + (1-\alpha)\cdot y[n-1]

ค่าใหม่มีน้ำหนัก α\alpha ที่เหลือคือค่าเดิมทั้งก้อน อดีตไม่ได้หายไปไหน แต่ จางลงเรื่อย ๆ ทุกรอบ

คิดให้ดูจริง ๆ ค่าเดิม y=62.00y=62.00 ค่าใหม่เข้ามา x=63.00x=63.00 ที่ α=0.2\alpha=0.2 → y=0.2(63.00)+0.8(62.00)=62.20y = 0.2(63.00)+0.8(62.00) = 62.20 ขยับแค่ 0.20 ทั้งที่อินพุตกระโดดไป 1.00 นั่นคือความนิ่งที่เราซื้อมาด้วยความหน่วง

ตัวอย่างแรกไม่ได้ถูกคูณ α\alpha ค่าที่ป้อนเข้าไปครั้งแรกถูกใช้เป็นค่าตั้งต้นตรง ๆ ไม่งั้นทุกฟิลเตอร์จะเริ่มจากศูนย์แล้วต้องไต่ขึ้นมาก่อนหลายวินาที ทั้งที่ค่าจริงอยู่ตรงนั้นตั้งแต่แรก · .value() กับ .reset() มีเหมือนกันทุกตัวในตระกูลนี้

กราฟแท่งน้ำหนักของค่าย้อนหลังใน EMA ค่าล่าสุดหนักที่สุดแล้วลดลงแบบเอกซ์โพเนนเชียล

น้ำหนักของค่าย้อนหลังแต่ละตัวใน EMA — ตัวล่าสุดหนักสุด แล้วลดแบบเอกซ์โพเนนเชียล ไม่มีตัวไหนถูกตัดทิ้งสนิท · ภาพ: “Exponential moving average weights N=15” โดย Д.Ильин — CC0 1.0 · Wikimedia Commons
TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

dsp.EMA — alpha คือ "ความรู้สึก" และไฟล์ที่แปลงมันเป็นวินาที

alpha ผลที่เห็นบนจอ เหมาะกับ
0.05 นิ่งมาก แต่ตามมือช้าจนรู้สึกหน่วง ค่าที่เปลี่ยนช้า เช่น อุณหภูมิ
0.2 นิ่งพอควรและยังตามทัน (ค่าที่เราใช้วันนี้) ลูกบิด เกจทั่วไป
0.8 เกือบเท่าค่าดิบ กรองได้นิดเดียว สัญญาณที่ต้องการความไวสูง

ภาพถ่ายจอบอร์ด Eva Kit ขณะรัน 06_ema_time_constant.py ที่แปลงค่า alpha เป็นวินาที

หน้าจอจริงตอนรัน 06_ema_time_constant.py — ตารางข้างบนบอกว่า alpha ให้ "ความรู้สึก" แบบไหน ไฟล์นี้เปลี่ยนความรู้สึกนั้นให้เป็นวินาที · ภาพจับที่ขั้นแรกซึ่ง alpha = 1.00 คือไม่กรองเลย เส้นแดงจึงทับเส้นฟ้าสนิท และตัวเลขที่ตามมาคือ tau = 0 ms กับ "ถึง 63.2% ที่ตัวอย่างที่ 1" — ไล่กดเดินหน้าแล้ว alpha จะลดลง ทั้งสองค่านั้นจะโตขึ้นให้เห็นเป็นตัวเลข ไม่ใช่ความรู้สึก · ภาพหน้าจอจริงจากบอร์ด Eva Kit บันทึกโดยผู้สอน

EMA ใช้หน่วยความจำแค่ตัวเลขเดียว จึงเป็นฟิลเตอร์ที่ระบบฝังตัวเล็ก ๆ เลือกใช้บ่อยที่สุด

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

dsp.Median — คนละหน้าที่กับ EMA อย่าสับสน

med = dsp.Median(window=5)    # window เป็น keyword-only เช่นเดียวกัน
clean = med.update(raw)       # เก็บ 5 ค่าล่าสุด เรียงลำดับ แล้วคืนค่าตรงกลาง
dsp.Median(window=4)          # ขอ 4 ได้ 5 — มันบังคับเป็นเลขคี่ให้เงียบ ๆ
dsp.Median(window=99)         # ขอ 99 ได้ 15 — เพดานคือ 15 พื้นคือ 3

หนีบค่าเงียบ ๆ ไม่มี error เพราะบัฟเฟอร์ข้างในเป็นอาเรย์ขนาดคงที่ในภาษา C · ต้องเป็นเลขคี่เพราะ "ตัวกลาง" ของจำนวนคู่ไม่มีตัวเดียว · พิมพ์ print(med) แล้วมันบอกค่าที่ได้จริงมาให้ ไม่ต้องเดา

ค่าที่ไหลเข้ามาตามเวลา — หน้าต่างกว้าง 5 เลื่อนตามไปเรื่อย ๆ 41 42 95 43 42 44 43 หน้าต่างปัจจุบัน 5 ค่า เรียง: 41 42 42 43 95 ตัวกลาง = 42 spike 95 ถูกโยนทิ้งทั้งก้อน ถ้าใช้ค่าเฉลี่ยธรรมดา 95 จะถูกหารเฉลี่ยเข้าไปในคำตอบเสมอ

ภาพเคลื่อนไหวหน้าต่างเลื่อนไปบนข้อมูลทีละจุดเพื่อคำนวณค่าที่กรองแล้ว

ภาพ: MothNik, Wikimedia Commons, CC0 1.0 — หน้าต่างในภาพบนวาดไว้นิ่ง ๆ ได้ตำแหน่งเดียว แต่ของจริงมันเลื่อนไปทีละค่าตามข้อมูลที่ไหลเข้ามา · คำว่า window=5 ในบรรทัดโค้ดบนสุดของสไลด์นี้คือความกว้างของกรอบที่กำลังเลื่อนอยู่นี้ และเหตุผลที่มันหน่วง ก็เพราะคำตอบของแต่ละรอบต้องรอให้กรอบเต็มก่อน
TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

dsp.Median — คิดให้ดูจริง ๆ แล้วเทียบกับ EMA

yt=median⁡(xt−N+1, …, xt)y_t = \operatorname{median}\bigl(x_{t-N+1},\,\dots,\,x_{t}\bigr)

ไทย: เรียงค่าล่าสุด N ตัวแล้วหยิบตัวกลาง — ค่าที่กระโดดโดดเดี่ยวถูกดันไปอยู่ริมสุด ไม่มีใครหยิบมันขึ้นมา

คิดให้ดูจริง ๆ จากชุดตัวเลขในสไลด์ก่อน Median คืน 42 เหมือน spike ไม่เคยเกิด · ส่วน EMA(α=0.2\alpha=0.2) ที่ค่าเดิม 42 เจอ 95 ได้ 0.2(95)+0.8(42)=52.60.2(95)+0.8(42) = 52.6 คือกระเด็นตาม · ที่ลูป 200 ms หน้าต่าง 5 ค่า = 1.0 วินาที หน่วงราว 0.4 วินาที

dsp.EMA dsp.Median
เก่งเรื่อง สั่นเล็ก ๆ ต่อเนื่อง spike กระโดดเดี่ยว
วิธีคิด ถ่วงน้ำหนักเก่า/ใหม่ เรียงแล้วเลือกตัวกลาง
spike เดี่ยว ถูกเฉลี่ยเข้าไปบางส่วน หายไปทั้งก้อน
ความหน่วง จางลงต่อเนื่อง ราวครึ่งหนึ่งของ window
หน่วยความจำ 1 ค่า เท่ากับ window

ใช้ร่วมกันได้: Median เก็บกวาด spike ก่อน แล้วส่งต่อให้ EMA · เลือกฟิลเตอร์จาก หน้าตาของ noise ไม่ใช่จากชื่อ

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

แผนที่โมดูล dsp ทั้งโมดูล (1/3) — 8 คลาส 8 ฟังก์ชัน ใช้ได้ครบทุกตัวทั้งสองบอร์ด

บทเรียน 1.1–1.3 เราเรียก dsp.EMA dsp.Median และ dsp.tilt ไปแล้วอย่างละครั้ง ที่ 13_raw_and_filtered.py — สามชื่อจากสิบหก นี่คืออีกสิบสามชื่อที่เหลือ

คลาส — สร้างครั้งเดียวนอกลูป เพราะทุกตัวมีความจำ ทุกตัวมี .update() และ .reset()

คลาส อาร์กิวเมนต์ (keyword-only) ค่าตั้งต้น มีเมธอดอะไรอีก
EMA alpha= 0.1 .value()
SMA window= (หนีบ 2-64) 10 .value()
LPF cutoff= fs= 5.0 Hz, 100 Hz .value()
HPF cutoff= fs= 0.5 Hz, 100 Hz .value()
Median window= (หนีบ 3-15 บังคับคี่) 5 .value()
Kalman1D q= r= 0.01, 0.1 .value()
Madgwick beta= fs= 0.1, 100 Hz .quaternion() — ไม่มี .value()
Pedometer threshold= min_interval= 1.5, 300 ms ไม่มี .value()
TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

แผนที่โมดูล dsp ทั้งโมดูล (2/3) — 8 ฟังก์ชัน ไม่มีความจำ เรียกตรงได้เลย

ฟังก์ชัน รับ คืน บทเรียนไหน
tilt(ax,ay,az) ความเร่ง 3 แกน (roll, pitch) องศา บทเรียน 3.1–3.3
compass(mx,my,mz) สนามแม่เหล็ก 3 แกน ทิศ 0-360 องศา บทเรียน 3.1–3.3
altitude(hpa, sea=1013.25) ความดัน hPa (อาร์กิวเมนต์เรียงตำแหน่ง ไม่ใช่ keyword) ความสูงเป็นเมตร อ้างอิง
dew_point(t, rh) องศา C, %RH จุดน้ำค้าง องศา C อ้างอิง
heat_index(t, rh) องศา C, %RH อุณหภูมิที่รู้สึก องศา C อ้างอิง
comfort_zone(t, rh) องศา C, %RH สตริง: cold hot dry humid comfortable acceptable อ้างอิง
fft_mag(ชุด, n=256, window=True, demean=True) list หรือ buffer int16 ทั้งชุด ขนาดสเปกตรัม n/2 ค่า สเกล 2/N บทเรียน 3.4–3.6
s16(บัฟเฟอร์, step=1) buffer int16 LE (เช่น mic.raw()) list ของจำนวนเต็ม บทเรียน 3.4–3.6

สองแถวล่างเพิ่มเข้าโมดูลเมื่อ 2026-08-20 — firmware รุ่นก่อนหน้ายังไม่มี ใช้ในห้องต้องแฟลชรุ่นนั้นขึ้นไป

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

แผนที่โมดูล dsp ทั้งโมดูล (3/3) — สี่ฟังก์ชันอ้างอิง และกฎ "ป้อนทีละค่า"

สี่ฟังก์ชันอ้างอิง (altitude dew_point heat_index comfort_zone) เป็นคณิตศาสตร์ล้วน เรียกได้ปกติทุกเมื่อ แต่ Eva Kit ไม่มี DPS368 (ความดัน) และไม่มี SHT40 (อุณหภูมิ/ความชื้น) จึง ไม่มีแหล่งข้อมูลบนบอร์ดให้ป้อน ป้อนตัวเลขที่พิมพ์เองหรือรับมาจากเครือข่ายได้ · Dev Kit มีชิปทั้งสองตัว (sensors.dps368 / sensors.sht40) จึงป้อนค่าจริงเข้าสี่ฟังก์ชันนี้ได้ — แต่ทั้งสี่ตัวยังอยู่ในตารางในฐานะแถวอ้างอิง ไม่ใช่งานลงมือของชุดบทเรียนนี้ ไม่ว่าบอร์ดไหน

ตัวกรองทุกตัวรับทีละค่า ป้อนทีละตัวอย่าง — ใครมาจาก numpy ต้องเปลี่ยนวิธีคิดตรงนี้ก่อน · ข้อยกเว้นเดียวของโมดูลคือคู่ fft_mag กับ s16 ในตารางก่อนหน้า ที่รับทั้งชุด เพราะสเปกตรัมคำนวณจากตัวอย่างทั้งก้อนพร้อมกัน — บทเรียน 3.4–3.6 ได้ใช้จริง

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

อีกสี่ตัวกรองที่ยังไม่ได้ลอง — และแต่ละตัวเก่งคนละเรื่อง

ตัวกรอง วิธีคิด เก่งเรื่อง ราคาที่จ่าย
SMA(window=8) เฉลี่ยตรง ๆ ของ N ค่าล่าสุด เข้าใจง่าย อธิบายให้ใครก็ได้ฟัง จำ N ค่า และหน่วงราวครึ่ง window
LPF(cutoff=, fs=) EMA ที่ตั้งด้วย ความถี่ แทนน้ำหนัก บอกเป็น Hz ได้ว่าตัดอะไรทิ้ง ต้องรู้คาบลูปจริง ไม่งั้น fs โกหก
HPF(cutoff=, fs=) เก็บเฉพาะส่วนที่ เปลี่ยนเร็ว จับการสั่น การเคาะ การกระแทก ทิ้งระดับของสัญญาณไปหมด
Kalman1D(q=, r=) ชั่งน้ำหนักระหว่าง "เชื่อการวัด" กับ "เชื่อค่าเดิม" ทุกรอบ ปรับตัวเองได้ นิ่งกว่า EMA ที่ความไวเท่ากัน ต้องจูนสองตัวเลข ไม่ใช่ตัวเดียว

LPF กับ EMA เป็นสมการเดียวกัน ต่างแค่ทางเข้า — LPF คำนวณ α=ΔtRC+Δt\alpha = \dfrac{\Delta t}{RC+\Delta t} ให้จากค่า cutoff และ fs ที่เราบอก ส่วน EMA ให้เราใส่ α\alpha เอง เลือกทางไหนก็ได้ แต่ ถ้าบอก fs=100 ทั้งที่ลูปเดินจริงที่ 5 Hz ตัวเลข cutoff ที่ตั้งไว้จะไม่เป็นความจริงเลย

HPF คืน 0.0 เสมอในรอบแรก ไม่ใช่บั๊ก — มันตอบว่า "เปลี่ยนไปเท่าไร" และรอบแรกยังไม่มีค่าก่อนหน้าให้ลบ · Kalman1D ที่ r สูงแปลว่าไม่ค่อยเชื่อเซนเซอร์ จึงนิ่งมากแต่ตามช้า ส่วน q สูงแปลว่าคิดว่าโลกเปลี่ยนเร็ว จึงไวขึ้น

ลงมือ: 08_six_filters_one_signal.py ป้อนสัญญาณเส้นเดียวกันเข้าทั้งหกตัวพร้อมกัน แล้วโชว์ .value() ของทุกตัวเรียงกัน — สัญญาณสร้างเองในไฟล์ จึงรันซ้ำได้ผลเดิมทุกครั้ง เถียงกันด้วยตัวเลขได้

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

เกร็ด: ฟิลเตอร์ตัวนี้เคยเป็นตัวต้านทานกับตัวเก็บประจุ

ก่อนจะมีไมโครคอนโทรลเลอร์ราคาถูก การทำสัญญาณให้เรียบทำด้วยของจริงสองชิ้น คือตัวต้านทานต่อกับตัวเก็บประจุ เรียกว่าวงจร RC low-pass filter ความถี่สูง ๆ ถูกกลืนหายไปกับตัวเก็บประจุ เหลือแต่ส่วนที่เปลี่ยนช้า ๆ ออกมา

ถ้าเขียนสมการของวงจร RC ออกมาในรูปดิจิทัล จะได้หน้าตาเหมือน EMA ทุกประการ โดยที่

α=ΔtRC+Δt\alpha = \frac{\Delta t}{RC + \Delta t}

กราฟแรงดันคร่อมตัวเก็บประจุในวงจร RC ที่ไต่ขึ้นเมื่อป้อนสัญญาณขั้นบันได

แรงดันคร่อมตัวเก็บประจุเมื่อป้อนสัญญาณขั้นบันได — ไต่ถึงราว 63% ที่เวลา τ = RC เหมือนกับที่ EMA ไต่ตามค่าใหม่ · ภาพ: “Series RC capacitor voltage” — สาธารณสมบัติ · Wikimedia Commons

โมดูล dsp บนบอร์ดนี้มี dsp.LPF(cutoff=, fs=) ที่คำนวณ α\alpha จากสูตรนี้ให้เลย — พูดง่าย ๆ คือ LPF กับ EMA เป็นตัวเดียวกัน ต่างกันแค่เราตั้งค่าด้วยความถี่ตัดหรือด้วยน้ำหนักโดยตรง

เชื่อมกับวันนี้: ตอนที่ผู้เรียนปรับ alpha แล้วรู้สึกว่า "หน่วงขึ้น" นั่นคือการหมุนลูกบิดเสมือนของตัวเก็บประจุที่ไม่มีอยู่จริง วิศวกรรุ่นก่อนต้องบัดกรีเปลี่ยนตัว C เพื่อทำสิ่งเดียวกับที่เราแก้ตัวเลขบรรทัดเดียว

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

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

เฟิร์มแวร์ทำให้แล้ว 70% งานของเรา 30% ตั้งค่า ADC · แปลงหน่วย · คุย I2C · หัก baseline · ฟิลเตอร์เขียนด้วย C ตัดสินใจว่าจะเชื่อค่าไหน คณิตศาสตร์ทั้งหมดถูกทำไว้ให้แล้ว เลือก widget · alpha · จังหวะลูป

สิ่งที่เฟิร์มแวร์ทำให้แล้ว (70%)
ตั้งค่า SAR ADC และอ่านค่าจากขาลูกบิด (Eva: P15[1] · Dev Kit: VR1 บนฐาน) · สเกลค่า 12 บิตที่วัดได้ให้เป็นช่วง 0-65535 และคิดเปอร์เซ็นต์/โวลต์ให้ · คุย I2C กับชิป 4000T และหักลบ baseline ให้เรียบร้อย · ฟิลเตอร์ทั้งชุดใน dsp เขียนเป็นภาษา C มาแล้ว · การวาดทุกอย่างบนจอ

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

โจทย์ของวิศวกรวันนี้ไม่ใช่ "เขียนฟิลเตอร์" แต่คือ เลือกฟิลเตอร์และปกป้องตัวเลือกนั้นได้

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

แกะโค้ดจริง — ท่าที่ 1 ตั้งจอ แล้วรอให้เซนเซอร์ตอบ

import ui
ui.screen()          # ล้าง widget เดิมทั้งหมด เริ่มจากจอว่างที่รู้แน่
import time
import sensors
import dsp

ui.clear()
time.sleep_ms(200)
...
# ไม่มี sensors.init() ทั้งสองบอร์ด (Eva: ขึ้น OSError / Dev Kit: ไม่จำเป็น)
try:
    sensors.pot.read()       # อุ่นเครื่อง รอบแรกหลังรีเซ็ตอาจต้องรอคอร์จอตอบ
except OSError:
    print("อ่านเซนเซอร์รอบแรกยังไม่ได้ - ลองใหม่ในลูป")
ใครเป็นคนอ่านเซนเซอร์ให้เรา ui.screen() 200 ms — ให้ CM55 ล้างจบ sensors.pot.read() ใน try ครั้งแรกหลังรีเซ็ตอาจต้องรอ (Eva วัดได้ถึง ~16 s) อ่านค่าแรกได้ Eva: init() = OSError · Dev Kit: ผ่าน ไม่ต้องเรียก

ui.screen() มาก่อนเสมอ เพราะการใช้ ui.* ครั้งแรกจะสั่งหยุด sensor auto-task ของเฟิร์มแวร์ หลังจากนี้ เราต้องอ่านค่าเองทุกรอบในลูป · อุ่นเครื่องใน try/except เพราะรอบแรกหลังรีเซ็ตอาจต้องรอ (Eva วัดได้ถึง 16 วินาที) ยังไม่ตอบก็พิมพ์บอกแล้วลองใหม่ในลูป

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

เส้นทางของค่า — Eva: ทุกตัวมาจาก snapshot ของ CM55 · Dev Kit: pot.read() อ่าน SAR ตรง (ลูกบิดไม่อยู่บนบัส) IMU อ่านสดจาก CM33 ส่วนแถบสัมผัส 4000T และช่อง pot ใน snapshot() มาจากคอร์จอทุก 200 ms

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

แกะโค้ดจริง — ท่าที่ 2 ค่าที่วัดได้ ต้องมาพร้อมพิสัยและเกณฑ์

pot_bar = ui.Bar(x=28, y=80, w=428, h=16, color=0x4A9EFF, min=0, max=100, value=0)
pot_scale = ui.Scale(x=28, y=92, w=428, h=44, color=COL_TEXT, min=0, max=100)
pot_scale.ticks(11, 2)      # 11 ขีด ใส่เลขทุกขีดที่สอง
...
lbl_pct = ui.Label("0.0 %", x=28, y=180, color=COL_TEXT, value=28)
...
sp_th = ui.Spinbox(x=508, y=76, w=256, h=88, color=COL_TEXT,
                   min=TH_MIN, max=TH_MAX, value=th)
sp_th.digits(2, 0)          # ไม่บอกจะเห็น 0070
btn_up = ui.Button("เพิ่ม", x=644, y=172, w=120, h=88, color=0x3A4150, value=16)
btn_dn = ui.Button("ลด", x=508, y=172, w=120, h=88, color=0x3A4150, value=16)
...
led_ok = ui.Led(x=492, y=304, w=48, h=48, color=COL_OK, value=1)
...
led_bad = ui.Led(x=492, y=348, w=48, h=48, color=COL_BAD, value=0)
0 40 80 55.4 % ตัวเลขเดียวกัน แต่ตอบได้แล้วว่าสูงไหม 70 เกณฑ์ที่ผู้ใช้ตั้งเอง ต่ำกว่า เกิน ค่า · พิสัย · เกณฑ์ อยู่ในสายตาเดียว

ตัวเลข 55.4 % ลอย ๆ ตอบไม่ได้ว่าสูงหรือต่ำ ui.Scale คือไม้บรรทัดที่พาพิสัยมาอยู่บนจอด้วยกัน และเพราะเป็นไม้บรรทัด มันจึง ไม่รับ .value() ตัวที่ขยับคือ ui.Bar ที่วางทับ (หน้าปัดวงกลมมีเข็มจริง — บทเรียน 3.1–3.3) · .ticks(ทั้งหมด, ใส่เลขทุกกี่ขีด) คุมความหนาแน่น ที่นี่ 11 ขีดใส่เลขทุกขีดที่สอง ได้ 0 20 40 60 80 100 พอดี

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

แกะโค้ดจริง — ท่าที่ 2 (ต่อ) เกณฑ์ที่ผู้ใช้ตั้งเอง และไฟที่หรี่ ไม่ใช่หาย

เกณฑ์เตือนไม่ควรเป็นค่าคงที่ในโค้ด คนหน้างานคือคนรู้ว่างานนี้ยอมได้แค่ไหน ui.Spinbox หนีบค่าในพิสัยให้เอง แต่บนจอสัมผัส spinbox เปล่า ๆ นิ้วเปลี่ยนค่าไม่ได้ ตัวที่เพิ่มลดค่าจริงคือ ui.Button สองปุ่มข้าง ๆ (สูง 88 px ตามขนาดเป้าสัมผัสของหลักสูตร)

ไฟสองดวงแทนสถานะด้วยสีตัวอักษร เพราะไฟมี รูปทรงและความสว่าง ถ่ายจอขาวดำแล้วยังแยกออก และ .value(0) คือ หรี่ ไม่ใช่หาย โดยตั้งใจ · จำกฎบทเรียน 2.4–2.6: value= ของ ui.Label คือ ขนาดฟอนต์ ส่วน Bar/Scale ใช้ min/max เป็นพิสัยจริง — ตัวอย่างประกอบที่ 09_scale_led_spinbox.py

ค่าดิบกับโวลต์ยังอยู่ครบ แต่ลดเป็นบรรทัดกำกับ เพราะคนเดินมาดูจอถามคำถามเดียวก่อน: ตอนนี้สูงเกินหรือยัง

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

แกะโค้ดจริง — ท่าที่ 3 แถบเลื่อนสัมผัสและไฟสองดวง

touch_bar = ui.Bar(x=28, y=284, w=428, h=16, color=0x4A9EFF, min=0, max=100, value=0)
touch_scale = ui.Scale(x=28, y=300, w=428, h=40, color=COL_TEXT, min=0, max=100)
touch_scale.ticks(11, 2)
...
led_b0 = ui.Led(x=28, y=344, w=48, h=48, color=COL_OK, value=0)
...
led_b1 = ui.Led(x=160, y=344, w=48, h=48, color=COL_OK, value=0)
...
        slider = sensors.capsense.slider()    # 0 - 100 อยู่แล้ว ไม่ต้องแปลงหน่วย
        b0, b1 = sensors.capsense.buttons()   # คืน tuple สองช่อง แกะพร้อมกันได้เลย
...
    touch_bar.value(max(0, min(100, slider)))
...
    led_b0.value(1 if b0 else 0)
    led_b1.value(1 if b1 else 0)
นิ้วอยู่ตรงไหน แถบไปตรงนั้น แถบทองแดง 5 ช่องบนบอร์ด 0 50 100 ไฟแทนคำว่า ON กับขีด ครอบ max(0, min(100, ...)) เสมอ

ui.Bar ไม่รับการแตะและไม่ส่ง event มันเป็น จอแสดงผลอย่างเดียว ซึ่งตรงกับที่ต้องการพอดี เพราะตัวสั่งงานคือนิ้วบนแถบทองแดง ไม่ใช่นิ้วบนกระจก และไม้บรรทัดชุดเดิมถูกใช้ซ้ำด้วยเหตุผลเดียวกับการ์ดบน: 62 ไม่มีความหมายจนกว่าจะรู้ว่าเต็มสเกลคือเท่าไร

สถานะปุ่มทองแดงเคยเขียนเป็นข้อความ BTN0 ON BTN1 - ซึ่ง ไม่ผ่านการทดสอบขาวดำ — เป็นเกรย์สเกลแล้ว ON สีม่วงกับขีดสีเทาแยกกันไม่ออก ไฟสองดวงแยกออกทันทีเพราะดวงที่ติดสว่างกว่าดวงที่หรี่

ครอบด้วย max(0, min(100, ...)) ไม่ใช่เพราะไม่เชื่อไดรเวอร์ แต่ถ้าชิป 4000T ไม่พร้อม ไบต์ที่ได้อาจเป็น 255 การครอบทำให้จอยังแสดงผลได้แทนที่จะพังทั้งหน้า

ค่าที่มาจากภายนอกโปรแกรมเราเสมอ ๆ ควรถูก ตรวจขอบเขตก่อนใช้ นี่คือนิสัยที่ติดตัวไปทุกภาษา

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

แกะโค้ดจริง — ท่าที่ 4 เทียบค่าดิบกับค่ากรองแล้ว และบอกคุณภาพของค่า

lbl_health = ui.Label("อ่านค่าปกติ", x=292, y=364, color=COL_TEXT, value=16)
...
ema = dsp.EMA(alpha=0.2)   # สร้างครั้งเดียว นอกลูป
...
    ema_pct = ema.update(pct)         # ป้อนค่าใหม่ ได้ค่าที่กรองแล้วกลับมาทันที
...
        raw_line.text("ดิบ    {:.2f} %".format(pct))
        ema_line.text("กรอง  {:.2f} %".format(ema_pct))
...
        if not fresh:                 # รอบนี้อ่านเซนเซอร์ไม่ได้
            lbl_health.color(COL_WARN)
            health = "ค่าค้าง - เลขคือค่าล่าสุด"
...
    lbl_health.text(health)           # นอกประตูหนึ่งวินาที - ส่งซ้ำทุกรอบโดยตั้งใจ
สองเส้นบนจอเดียว RAW — สั่นทุกรอบ EMA — นิ่งกว่า แต่ตามช้ากว่า spike เดี่ยว ๆ ตรงนี้ ไม่มีของฟรี — นิ่งขึ้นแลกกับหน่วงขึ้น

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

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

แกะโค้ดจริง — ท่าที่ 4 (ต่อ) บรรทัดคุณภาพของค่า และประตูหนึ่งวินาที

บรรทัด คุณภาพของค่า แก้ปัญหาที่ไม่มีใครเห็นจนกว่าจะเกิด: อ่านรอบนี้ขึ้น OSError โปรแกรมใช้ค่าเดิมต่อ จอจึงยังโชว์ตัวเลขสวยงามทั้งที่ไม่มีค่าใหม่แล้ว ค่าที่ค้างจึงต้องเขียนกำกับว่าไม่ใช่ค่าปัจจุบัน · สังเกตลำดับ ตั้งสีก่อน แล้วค่อยเขียนข้อความ — สองคำสั่งข้ามคอร์คนละครั้ง สลับกันจะมีเสี้ยววินาทีที่ข้อความใหม่ใส่สีของสถานะเก่า

lbl_health.text(health) อยู่ นอก ประตูหนึ่งวินาทีโดยตั้งใจ — ฝั่งจออยู่ โหมดเร็ว ต่อไปอีก 500 ms ทุกครั้งที่ได้คำสั่งเขียนข้อความ แต่ .value() ของแถบกับไฟ ไม่ปลุก โหมดนั้น ลูปที่มีแต่แถบกับไฟจึงถอยไปโหมดช้า ภาพกระตุก · ข้อความเท่าเดิมเฟิร์มแวร์ไม่วาดซ้ำ เราจ่ายแค่ค่าส่ง

อยากเห็นฟิลเตอร์ทำงาน ต้อง แสดงคู่กันบนจอเดียว ถ้าดูทีละอันจะไม่มีวันรู้ว่ามันช่วยจริงหรือแค่รู้สึกไปเอง

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

แกะโค้ดจริง — ท่าที่ 5 ลูปหลักและจังหวะที่ปลอดภัย

while True:
    fresh = True
    try:
        raw   = sensors.pot.read()
        volts = sensors.pot.voltage()
        pct   = sensors.pot.percent()
        slider = sensors.capsense.slider()
        b0, b1 = sensors.capsense.buttons()
    except OSError:
        fresh = False        # ใช้ค่าเดิมต่อ แต่ต้องบอกคนดู
        b0, b1 = 0, 0

    pot_bar.value(int(max(0, min(100, pct))))
    # ... ไฟกับแถบอัปเดตทุกรอบ ...

    for ev in ui.poll():     # ต้องมีทุกรอบ ปุ่มถึงจะกดติด
        ...

    sec = time.ticks_ms() // 1000
    if sec != last_sec:      # ตัวเลขเปลี่ยนวินาทีละครั้ง
        last_sec = sec
        lbl_pct.text("{:.1f} %".format(pct))

    time.sleep_ms(200)   # จังหวะที่ผ่านการทดสอบ
หนึ่งรอบลูป = 200 ms อ่านเซนเซอร์ → อัปเดต widget → poll sleep 200 ms — คืนเวลาให้ CM55 วาด 5 ครั้งต่อวินาที — เร็วกว่าตาคนแยกออก ถ้าลูปเร็วเกิน เช่น 20 ms เฟรมส่วนเกินถูกทิ้งเงียบ ๆ ไม่มี error จอกระตุกทั้งที่โค้ดดูถูกทุกบรรทัด
TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · ดัดแปลงจาก AIoT in Action (AIC มหาวิทยาลัยบูรพา) · CC BY-NC 4.0

แกะโค้ดจริง — ท่าที่ 5 (ต่อ) ทำไม 200 ms และทำไมตัวเลขเดินช้ากว่าแถบ

ui.poll() มีหน้าที่มากกว่ารับ event จากการแตะจอ มันคือจังหวะที่ฝั่ง Python เปิดโอกาสให้ระบบ UI จัดการคิวของตัวเอง ถ้าไม่เรียก widget บางตัวจะถูกซ่อนไว้นานถึงสองวินาที ชุดบทเรียนนี้ใช้ event แค่ปุ่มเพิ่ม/ลดเกณฑ์ แต่ต่อให้ไม่มี event เลยก็ยังต้องเรียกทุกรอบอยู่ดี

ทำไม 200 ms ไม่ใช่ 20 ms — เพราะลูปที่เร็วเกินไปจะยิงคำสั่งวาดข้ามคอร์ถี่กว่าที่ CM55 วาดทัน เฟรมส่วนเกินจะถูกทิ้งเงียบ ๆ ไม่มี error ให้เห็น ผลคือจอกระตุกโดยที่โค้ดดู "ถูกต้อง" ทุกบรรทัด

อีกมุมหนึ่ง 200 ms = อ่านเซนเซอร์ 5 ครั้งต่อวินาที ซึ่งเร็วกว่าที่ตาคนแยกออกอยู่แล้วสำหรับการหมุนลูกบิดด้วยมือ

แต่แถบกับตัวเลขไม่ได้เดินจังหวะเดียวกัน และนี่คือรายละเอียดที่แยกหน้าจอควบคุมออกจากหน้าจอสาธิต แถบและไฟอัปเดตทุกรอบ คือ 5 ครั้งต่อวินาที เพราะตาคนอ่าน "ตำแหน่ง" ได้โดยไม่ต้องหยุดอ่าน ส่วน ตัวเลขที่ต้องอ่านเป็นตัวเลข เขียนใหม่ไม่เกินวินาทีละครั้ง ตัวเลขทศนิยมที่วิ่งห้าครั้งต่อวินาทีคือตัวเลขที่อ่านไม่ทัน แล้วคนจะเลิกอ่านมันไปเลย ประตูที่ใช้กั้นคือบรรทัด if sec != last_sec: ซึ่งเทียบแค่ว่า "วินาทีเปลี่ยนแล้วหรือยัง"

for ev in ui.poll(): ทำสองอย่างพร้อมกันในบรรทัดเดียว คือเรียก poll ตามกฎ และรับ event ของปุ่มเพิ่ม/ลดเกณฑ์ ถ้าลืมบรรทัดนี้ อาการที่เห็นจะเป็น "จอกระตุกและปุ่มกดไม่ติด" ซึ่งดูเหมือนของสองเรื่องแต่มีสาเหตุเดียว

เร็วกว่าที่จำเป็นไม่ได้แปลว่าดีกว่า ในระบบฝังตัวมันมักแปลว่า เปลืองพลังงานและได้ภาพที่แย่ลง

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

ข้อมูลไหลไปทางไหน — จากมือถึงจอ

ลูกบิด แรงดัน 0 ถึง Vref นิ้วบนแผ่นทองแดง ความจุเปลี่ยน SAR ADC 12 บิต → 0-65535 PSoC 4000T I2C → คอร์จอ CM55 CM33 · Python ขอค่าจากคอร์จอ dsp.EMA กรอง IPC คำสั่งวาด CM55 · LVGL Bar · Scale · Led ค่าดิบสั่นตั้งแต่ต้นทาง — เราจึงเลือกกรองที่ CM33 ก่อนส่งภาพข้ามไปให้ CM55 วาด

สังเกตว่าสองเส้นทางต้นทางต่างกันคนละแบบ (ADC กับ I2C) แต่มาบรรจบเป็นโค้ด Python บรรทัดเดียวกันที่ CM33

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

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

จอบอร์ด เปิด Playground ค้างไว้ ยกนิ้วออกให้หมด ตอนบอร์ดบูต — baseline เติม pass ทีละจุด แล้ว Program to Device หมุน · ลาก · แตะ ครบทั้งสามอย่าง ลำดับการรัน — ขั้นที่สองคือขั้นที่ทีมส่วนใหญ่ลืม วางนิ้วค้างตอนบอร์ดบูต = ปุ่มรายงานกลับด้านไปทั้งบทเรียน
  1. บนจอบอร์ด แตะการ์ด BENTO Playground บนหน้า Home ค้างหน้านี้ไว้
  2. ตอนเสียบสาย USB (หรือถอดแล้วเสียบกลับ) ยกนิ้วออกจากปุ่มสัมผัสให้หมด เพราะ baseline ถูกเก็บตอนคอร์จอเริ่มอ่านชิป ไม่ใช่ตอนสคริปต์เริ่ม · "รีเซ็ต" ในคอร์สนี้แปลว่าถอด USB แล้วเสียบกลับเท่านั้น — บน Dev Kit ห้ามโยกสวิตช์บนฐาน นั่นคือสวิตช์ไฟ
  3. เปิด s05_pot_capsense.py ใน BENTO IDE
  4. เติมช่องว่าง pass ให้ครบทั้ง 6 จุดตามคำใบ้ # เติม: — เติมทีละจุด รันดูทีละครั้ง
  5. กด Program to Device แล้วหมุนลูกบิด ลากนิ้วบนแถบเลื่อน และแตะปุ่มสัมผัสทั้งสองปุ่ม

หมายเหตุผู้สอน: ขึ้น OSError: CapSense: CM55 did not answer... ในไม่กี่บรรทัดแรก ให้รอสักครู่แล้วรันซ้ำก่อน (หลังรีเซ็ต คอร์จอต้องใช้เวลาก่อนตอบสายเซนเซอร์ — Eva วัดได้ถึง 16 วินาที) ยังขึ้นซ้ำค่อยตรวจว่าชิป PSoC 4000T ถูก flash แล้วหรือยัง · ขึ้น OSError ที่ sensors.init() = ลอกโค้ดรุ่นเก่ามา ให้ลบบรรทัดนั้นทิ้ง (บน Dev Kit ผ่านเงียบ ๆ ก็ลบเหมือนกัน) · ลูกบิดและ CapSense มีทั้ง Eva Kit และ Dev Kit — AI Kit เปล่า ๆ ที่ไม่มีฐาน QWA309 ไม่มีทั้งสองอย่าง · เจอ error ตอนอ่าน CapSense ให้เรียกผู้สอนก่อน อย่าเสียเวลาทั้งบทเรียนแก้โค้ดที่ไม่ได้ผิด

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

โน้ตผู้สอน: การอ่านครั้งแรกหลังรีเซ็ตอาจรอคอร์จอนาน (Eva วัดได้ถึง 16 วินาที · Dev Kit ยังไม่ได้วัด) ไฟล์จึงห่ออุ่นเครื่องใน try/except — ยังไม่ตอบก็พิมพ์บอกแล้วไปลองใหม่ในลูป ไม่หยุดทั้งสคริปต์