| คำถาม | คำตอบของคาบนี้ | อยู่ช่วงไหน | |
|---|---|---|---|
| Why | คาบ 3 กดปุ่มจริงก็สั่งไฟได้แล้ว ทำไมต้องมีปุ่มบนจออีก | เพราะเครื่องที่ส่งมอบไปแล้ว คนอื่นเป็นคนใช้ และเขาเห็นแค่จอ · ทั้งสองบอร์ดมีปุ่มจริงให้ Python แตะได้ปุ่มเดียว แต่คำสั่งของเครื่องหนึ่งเครื่องมีมากกว่าหนึ่งคำสั่ง · และแผงที่รายงานไม่ตรงกับของจริง คือแผงที่หลอกคนคุมเครื่อง | ครึ่งแรก · สไลด์ "สถานะบนจอ กับ สถานะจริง" และ "ใช้จริงที่ไหน" |
| What | มีอะไรให้ใช้บ้าง | โมดูล ui ทั้ง 132 ชื่อ (นับบน Eva Kit — Dev Kit มี Sprite เพิ่ม) — ตัวสร้าง widget 33 · ฟังก์ชันระดับโมดูล 8 · เสียง 2 กับค่าคงที่ของเสียงอีก 25 · ค่าคงที่อื่นอีก 63 · ชนิด Widget 1 · บวกเมธอดของ Widget อีก 38 ตัว |
สไลด์ฝั่งอินพุต + สไลด์บัญชี 132 ชื่อ |
| How | ประกอบยังไงให้ใช้งานได้จริง | event loop ที่เรียก ui.poll() ทุกรอบ แยกว่าเหตุการณ์มาจากใครด้วย handle แล้วสั่ง gpio.led() พร้อมเขียนบรรทัดสถานะกลับในจังหวะเดียวกัน |
แปดไฟล์ตัวอย่าง + ใบฝึก |
ปลายทางที่จับต้องได้ — แผงควบคุม LED สามสี แถวละ ไฟสถานะ + ปุ่มเปิด + ปุ่มปิด พร้อม "เปิดทั้งหมด" และ "ปิดทั้งหมด" ที่ถามยืนยันก่อน แตะแล้วหลอดจริงบนบอร์ดเปลี่ยนตาม พร้อมตัวเลขสรุปและบรรทัดสถานะที่ไม่เคยโกหก
คาบ 3 เราเป็นคนสั่งไฟเอง · คาบนี้เราเปิดให้คนอื่นสั่งได้ โดยที่เรายังรับผิดชอบว่าจอพูดความจริง
ui.Button, ui.Led, ui.Label, ui.MsgBox วางตำแหน่งเองได้ และรู้ว่าเมื่อไรควรปล่อยให้จอจัดวางให้.id() ไว้ui.poll() ที่แยกได้ว่าเหตุการณ์ไหนมาจาก widget ตัวไหนgpio.led() แล้วทำให้ สถานะบนจอตรงกับหลอดไฟจริงตลอดเวลาปลายทางของวันนี้: แผงควบคุม LED สามสี แถวละ ไฟสถานะ + เปิด + ปิด และคำสั่งทั้งชุดที่ยืนยันก่อน ใช้งานได้จริง และมีตัวเลขสรุปกับบรรทัดสถานะที่ไม่เคยโกหก
คาบนี้ยากกว่าคาบ 3 ตรงที่ของสองฝั่ง (จอกับหลอด) ต้องพูดตรงกัน ไม่ใช่แค่ทำงานได้

set_led() ทำงานตั้งแต่คาบนี้ไป จอไม่ใช่แค่ที่พิมพ์ข้อความออก แต่เป็นทางที่คนสั่งงานเข้ามา
คาบ 3 จบที่ examples/s03/07_button_to_broker.py↗ กดปุ่มผู้ใช้บนบอร์ดหนึ่งครั้ง ได้ event หนึ่งใบที่ bento-aiot/team03/event · สถานะไฟเดินทางไปกับ telemetry เป็นจังหวะ · และหน้าเว็บ examples/web/mqtt_dashboard.html ส่ง cmd กลับมาจุดไฟบนโต๊ะเราได้
| คาบ 3 | คาบ 4 วันนี้ |
|---|---|
| ปุ่มจริงปุ่มเดียว อ่านด้วยการวนถาม และต้องกันเด้งเอง | ปุ่มบนจอกี่ปุ่มก็ได้ มาเป็นเหตุการณ์ทีละรายการจาก ui.poll() |
| ไฟจริงเปลี่ยนเพราะปุ่มจริง | ไฟจริงเปลี่ยนเพราะนิ้วแตะจอ และจอต้องบอกสถานะ ตรงกับ หลอดเสมอ |
| กดปุ่มจริงแล้วเว็บเห็น | ส่วนขยายท้ายคาบ: แตะบนจอแล้วเว็บเห็น และเว็บสั่งให้แถบบนจอขยับ |
โครงลูปยังเป็นตัวเดิม อ่าน → ตัดสินใจ → สั่ง → หน่วง → วนใหม่ เปลี่ยนแค่ว่าอินพุตมาจากนิ้วบนกระจก
ทีมที่ยังไม่ได้ลองไฟล์ 07 ของคาบ 3 ไม่เสียอะไร เนื้อหาหลักของวันนี้ไม่พึ่งมัน มันกลับมาอีกครั้งเฉพาะในส่วนขยายท้ายคาบ
คาบ 3 เราคุมฮาร์ดแวร์ด้วยโค้ดล้วน ๆ — บรรทัดที่ยังใช้ต่อทั้งหมดในคาบนี้ ตัดจาก solution_codes/s03_led_button.py↗ ตรง ๆ
NUM_LEDS = gpio.num_leds()
...
btn = gpio.button(0)
...
for i in range(NUM_LEDS):
gpio.led(i).off()
...
gpio.led(led_index).off() # ดับดวงเดิมก่อน
led_index = (led_index + 1) % NUM_LEDS # เลื่อนไปดวงถัดไป วนกลับที่ 0 เอง
gpio.led(led_index).on() # จุดดวงใหม่
...
raw = btn.is_pressed()
ดัชนีดวงของแต่ละ สี เป็นค่าที่ถามบอร์ด ไม่ใช่เลขที่จำมา — Eva: ดวง 0 แดง 1 เขียว 2 น้ำเงิน (ดวง 2 ชื่อ RGB_RED แต่ส่องน้ำเงิน) · Dev Kit: หาจากชื่อ RGB_RED / RGB_GREEN / RGB_BLUE ใน led_names (เฉลยส่วนที่หนึ่งทำให้ดู) · ปุ่มผู้ใช้มีตัวเดียว .name() คืน "USER Button 1" ไม่ใช่ป้ายบนแผ่นวงจร


gpio.led() วันนี้ คือขา USER_LED1-3 ที่มุมซ้ายของวงจรนี้ (วงจรของ Eva Kit — Dev Kit ยังไม่ได้เปิดคู่มือตรวจ แต่ฝั่งโค้ดสั่งเหมือนกัน) — ขา MCU ขับที่ gate ของ MOSFET ไม่ได้จ่ายกระแสให้หลอดเอง แพทเทิร์นลูปเดิมยังอยู่ครบ: อ่าน input → ตัดสินใจ → สั่ง output → หน่วงเวลา → วนใหม่ คาบนี้เปลี่ยนแค่แหล่ง input จาก "ปุ่มจริง" เป็น "นิ้วบนกระจก"โครงลูปเดิม แต่ input มาจากคนละโลก — ตรงนี้แหละที่ทำให้ต้องมีกลไกใหม่ชื่อ event
ปุ่มจริงบนบอร์ดต่อสายตรงเข้าขาชิป โค้ดของเราอ่านค่าขาได้ทันทีเมื่อไรก็ได้ — นั่นคือ polling
ปุ่มบนจอไม่มีสาย มันเป็นภาพที่ CM55 วาด และนิ้วเราไปโดนตัวตรวจจับสัมผัสของจอ ซึ่งอยู่ฝั่ง CM55 ทั้งหมด ส่วนโค้ด Python ของเรารันอยู่ที่ CM33 คนละคอร์กัน
CM55 จึงต้อง จดเหตุการณ์ใส่คิวไว้ แล้วรอให้ CM33 มาถามว่า "มีอะไรใหม่ไหม" คำถามนั้นคือ ui.poll()
ปุ่มบนบอร์ดเราไป "อ่าน" เอง ส่วนปุ่มบนจอเราต้องไป "รับของที่ฝากไว้"


ทั้งสองคลิปเป็นของนอกเวลา (ต้องมีอินเทอร์เน็ต) ไม่ต้องเปิดในคาบ — เนื้อหาวันนี้เข้าใจได้ครบโดยไม่ต้องดู หลักการเดียวกันนี้กลับมาอีกครั้งในคาบ 5 ตอนที่เราอ่านปุ่ม CapSense บนบอร์ดโดยตรง
จอสัมผัสไม่ได้วัด "แรงกด" มันวัด ความใกล้ของตัวนำ — จำประโยคนี้ไว้ คาบหน้าใช้ซ้ำ
แพทเทิร์นนี้ยกมาจากคอร์สภาษา C ที่เราเคยสอน — บน MicroPython โค้ดสั้นลงมาก แต่ ลำดับความคิดเหมือนเดิมทุกขั้น
| ขั้น | ในภาษา C (LVGL ดิบ) | ในโค้ดของเรา |
|---|---|---|
| 1 สร้างตัว widget | lv_button_create(parent) |
btn = ui.Button("แดง") |
| 2 กำหนดตำแหน่ง | lv_obj_align(...) |
x=20, y=110 ตอนสร้าง หรือ .pos(x, y) |
| 3 ใส่ข้อความ/หน้าตา | สร้าง label ลูกแล้ว lv_label_set_text |
text="แดง", color=0xE53935 |
| 4 ผูกเหตุการณ์ | lv_obj_add_event_cb(btn, cb, ...) |
เก็บ btn.id() ไว้เทียบใน ui.poll() |
| 5 กรองชนิดเหตุการณ์ | if(code == LV_EVENT_CLICKED) |
if ev['type'] == 'clicked' |
ขั้น 4 ต่างกันที่สุด: C ฝาก callback ไว้ให้ระบบเรียก ส่วนเราเก็บ หมายเลขประจำตัว ไว้เช็กเองในลูป
จำห้าขั้นนี้ให้ขึ้นใจ ทุก widget ที่เหลือในคอร์ส (Slider, Arc, Chart) ใช้ลำดับเดียวกันหมด
import ui
ui.screen() # ล้างของเก่าทั้งหน้าก่อนเริ่มเสมอ
title = ui.Label("แผงควบคุมของทีม", x=20, y=20, color=0xFFFFFF, value=24)
btn = ui.Button("แดง", x=20, y=110, w=170, h=80, color=0xE53935)
sw = ui.Switch(x=620, y=120)
x, y มุมซ้ายบนเป็นพิกเซล · w, h ถ้าไม่ใส่ จอเลือกขนาดพอดีข้อความให้เองcolor สีเป็นเลขฐานสิบหก 0xRRGGBB แบบเดียวกับ CSSx=-1 (ค่าเริ่มต้น) = auto-layout — ปล่อยให้จอเรียงให้ เหมาะกับตอนลองของเร็ว ๆ ไม่เหมาะกับแผงควบคุมแผงควบคุมจริงต้องกำหนด x, y เอง เพราะ "ปุ่มอยู่ตรงไหน" เป็นส่วนหนึ่งของการออกแบบ ไม่ใช่เรื่องบังเอิญ
value= คือขนาดฟอนต์ui.Label("อุณหภูมิ", value=24) # 24 = ตัวอักษรสูง 24 พิกเซล ไม่ใช่ค่า 24 องศา
ui.Button("แดง", value=20) # ตัวหนังสือบนปุ่มขนาด 20
สำหรับ Label, Button, Dropdown, Textarea พารามิเตอร์ value= หมายถึง ขนาดฟอนต์ และรับได้แค่ห้าค่า: 14 / 16 / 20 / 24 / 28
ส่วน min, max, value ที่เป็น "ค่าจริง" ตามชื่อ ใช้กับ Slider, Arc, Bar เท่านั้น (คาบ 5 กับ 6 เราจะได้ใช้)
อยากเปลี่ยนข้อความของ Label ให้ใช้เมธอด .text("ข้อความใหม่") เสมอ ไม่ใช่ value=
เห็น
value=ที่ Label เมื่อไร ให้อ่านในใจว่า "ขนาดตัวอักษร" ทันที จะไม่พลาดอีกเลย
ตอนสร้าง widget สิ่งที่เราได้กลับมาคือ ตัวจัดการ (handle) ที่ผูกกับ widget จริงบน CM55 อีกฝั่งหนึ่ง
btn_red = ui.Button("แดง", x=20, y=110, w=170, h=80)
red_id = btn_red.id() # หมายเลขประจำตัว ใช้เทียบตอนรับเหตุการณ์
เหตุการณ์ที่ ui.poll() คืนมาไม่ได้บอกว่า "ปุ่มแดงถูกกด" มันบอกแค่ หมายเลข ว่า widget เบอร์นี้ถูกกด งานของเราคือจำไว้ว่าเบอร์ไหนคือใคร
วิธีที่สะอาดที่สุดเมื่อมีปุ่มหลายตัวคือเก็บเป็น list แล้วใช้ .index() หาลำดับ
on_ids = [b.id() for b in btn_on]
i = on_ids.index(ev['handle']) # ได้ 0, 1 หรือ 2 = แถวไหน (แดง เขียว น้ำเงิน)
ถ้าไม่เก็บ
.id()ไว้ ตอนเหตุการณ์เข้ามาเราจะแยกไม่ออกเลยว่าใครเป็นคนส่ง

examples/s04/05_sound_feedback.py↗ — ทุกครั้งที่แตะ จอเปลี่ยนและมีเสียงตอบกลับในรอบเดียวกัน คือหลักฐานว่าวงกลมสี่ขั้นนี้ปิดวงครบ · ภาพหน้าจอจริงจากบอร์ด Eva Kit บันทึกโดยผู้สอนขั้นที่ 4 ไม่ใช่ของฟุ่มเฟือย ถ้าไม่มี ระบบจะไม่มีเวลาไปวาดจอให้เราเลย
for ev in ui.poll():
print(ev) # {'handle': 3, 'type': 'clicked', 'value': 0}
ทุก dict มีสามช่องเสมอ ไม่มีช่องไหนหายไปบางครั้ง จึงเขียน ev['value'] ได้เลยโดยไม่ต้องเช็กก่อน
คิวเป็นวงแหวน 16 ช่อง ใส่ได้จริง 15 และหยิบได้ครั้งละไม่เกิน 8 — ที่ใส่ได้ 15 ไม่ใช่ 16 เพราะวงแหวนต้องเว้นหนึ่งช่องไว้แยกว่า "เต็ม" กับ "ว่าง" · และถ้าเต็มจริง เฟิร์มแวร์ ทิ้งเหตุการณ์ใหม่ที่เพิ่งเข้ามา ไม่ได้ทิ้งของเก่า และไม่ได้ให้รอ (ui_widget_mgr.c:2687-2690)
แปลว่านิ้วที่แตะรัว ๆ ตอนลูปเราหลับยาว คือการแตะที่ หายไปเลย ไม่ใช่แตะที่มาถึงช้า และไม่มี error ให้จับสักตัว · นี่คือเหตุผลจริง ๆ ที่กฎข้อ 2 บอกให้หลับ 50 ms ไม่ใช่ 500
เหตุการณ์ที่หายไปเงียบ ๆ คือบั๊กที่หาไม่เจอด้วย print — หาเจอด้วยการนับ
value แปลว่าอะไร| widget | ชนิดเหตุการณ์ที่ส่ง | ความหมายของ ev['value'] |
|---|---|---|
ui.Button |
'clicked' |
ไม่ใช้ |
ui.Switch |
'toggled' |
1 = เปิด, 0 = ปิด |
ui.Checkbox |
'toggled' |
1 = ติ๊ก, 0 = ไม่ติ๊ก |
ui.Slider / ui.Arc / ui.Dropdown |
'value_changed' |
ค่าใหม่ · Dropdown ให้ ลำดับของตัวเลือก เริ่มที่ 0 |
ui.Textarea |
'value_changed' |
เป็น 0 เสมอ event ไม่พาข้อความมาด้วย · รู้ว่า "มีการพิมพ์" แล้วค่อยถาม .text() เอาข้อความ (อ่านกลับได้แล้ว modui.c:352-383) |
| ชนิดที่สี่ | 'unknown' |
เฟิร์มแวร์แปลรหัสที่ได้มาไม่ออก อย่าละเลย ให้เขียนลง Console ไว้ |
ui.Label / ui.Bar / ui.Seg7 / ui.Panel / ui.Chart |
ไม่ส่งอะไรเลย | เป็นตัวแสดงผลอย่างเดียว |
'unknown' มีจริงและต้องเผื่อไว้ — โค้ดที่เขียน if t == 'clicked': ... else: ... จะกวาด unknown เข้าไปอยู่ใน else เงียบ ๆ เขียนเป็น elif ให้ครบทุกชนิดที่เรารู้จัก แล้วเหลือกิ่งสุดท้ายไว้พิมพ์ของแปลกลง Console ดีกว่าเดาแทนมัน
examples/s04/02_event_types.py↗ — ตารางข้างบนเอามาแตะจริงได้ ไฟล์นี้วาง Button Switch Checkbox และ Slider ไว้บนแถวเดียวกัน แล้วเดินทีละท่า ท่าละหนึ่ง widget (ในภาพคือท่า 1/4 ของ Button) บอกล่วงหน้าว่าต้องได้ชนิดไหน แล้วรอให้เราแตะพิสูจน์ · ท่าที่ 3 คือกับดักของไฟล์ — Checkbox ส่ง toggled ไม่ใช่ clicked ทั้งที่หน้าตาเหมือนของกด · ภาพหน้าจอจริงจากบอร์ด Eva Kit บันทึกโดยผู้สอนสอง widget ที่หน้าตาต่างกันมาก อาจส่งเหตุการณ์ชนิดเดียวกัน — เช็ก
typeคู่กับhandleเสมอ
ข้อ 1 · เรียก ui.poll() ทุกรอบ — ตอนสร้าง widget ตัวแรก CM55 จะ ซ่อนกล่องบรรจุทั้งใบไว้ก่อน แล้วเปิดออกเมื่อ ui.poll() ครั้งแรกมาถึง ถ้าไม่เรียกเลย จอจะว่างอยู่ราว 2 วินาที จนกลไกกันเหนียวปลดล็อกเอง หลายทีมสรุปว่า "โค้ดพัง" ทั้งที่แค่ยังไม่ได้ถาม
ข้อ 2 · time.sleep_ms(50) เป็นอย่างต่ำ สำหรับ UI เบา ๆ แบบวันนี้ · แดชบอร์ดหนัก (คาบ 8) ใช้ 200 ms
ข้อ 3 · งบของคอร์ส 32 widget ต่อหน้า (เพดานเฟิร์มแวร์ 64) เกิน 64 เมื่อไรได้ RuntimeError: ui: max 64 widgets ทันที ส่วน 32 คือเส้นที่คอร์สขีดให้ตัวเอง เพื่อให้จอยังอ่านออกและเหลือที่ให้ดีบัก — นับก่อนสร้างเสมอ
ข้อ 4 · ui.* ครั้งแรกหยุดงานอ่านเซนเซอร์อัตโนมัติ เพราะบัส I2C ต้องไม่ชนกัน ตั้งแต่จุดนั้นเราอ่านเซนเซอร์เองในลูป
ข้อ 5 · ลูปเร็วเกินไป เฟรมหายเงียบ ๆ ไม่มี exception มีแต่จอที่กระตุกและค่าที่อัปเดตไม่ครบ
examples/s04/06_layout_budget.py↗ — งบ widget ที่ใช้ไปแล้วเท่าไรจากงบของคอร์ส 32 (เพดานเฟิร์มแวร์ 64) แสดงบนจอของมันเอง คือข้อ 3 ที่มองเห็นได้ · ภาพหน้าจอจริงจากบอร์ด Eva Kit บันทึกโดยผู้สอน · ภาพนี้เก่า ถ่ายก่อนไฟล์เปลี่ยนป้ายเป็น "งบ 32 (เพดาน 64)" รอถ่ายใหม่ห้าข้อนี้ไม่ใช่คำแนะนำ มันคือกติกาของบอร์ดนี้ ทุกคาบที่เหลือใช้ชุดเดียวกันหมด
คาบ 1-2 ใช้ ui เป็นฝั่ง แสดงผล วันนี้เปิดอีกฝั่ง สไลด์นี้คือรายการทั้งหมดของฝั่งนั้น — สี่ฟังก์ชันระดับโมดูลที่เกี่ยวกับอินพุตและการจัดการ widget
| เรียกอย่างไร | คืนอะไร | ใช้ตอนไหน · กับดัก |
|---|---|---|
ui.poll() |
list ของ dict คีย์ handle type value · ว่างได้ ไม่ใช่ None |
หัวใจของคาบนี้ · ต้องเรียกทุกรอบ ตามกฎข้อ 1 |
ui.list() |
list ของ dict คีย์ id กับ type (ชื่อชนิดเป็นสตริง เช่น "Button") |
ตรวจว่าบนจอมีอะไรอยู่จริงกี่ตัว · คีย์ชื่อ id ไม่ใช่ handle คนละคำ ค่าเดียวกัน |
ui.get(id) |
อ็อบเจกต์ Widget ตัวใหม่ที่ชี้ไปที่ widget เดิม |
เอา widget กลับคืนมาจากเลขอย่างเดียว · id นอกช่วง 0-63 โยน ValueError (modui.c:1541) · มีเลขแต่ไม่มีตัว ก็ ValueError เหมือนกัน · ถ้าคุยกับ CM55 ไม่ได้เลย จะเป็น RuntimeError คนละชนิด อย่าดัก except ValueError แล้วคิดว่าครอบคลุมหมด |
ui.clear() |
ไม่คืนอะไร | ลบ widget ทุกตัวทิ้ง คืนโควตาให้ครบ (งบคอร์ส 32 · เพดานเฟิร์มแวร์ 64) · ต่างจาก ui.screen() ตรงที่ไม่ได้ตั้งขนาดจอใหม่ |
ui.screen(w, h) ที่เราใช้เปิดหัวสคริปต์ทุกครั้ง จริง ๆ แล้วมันสั่ง clear ให้ก่อนแล้วค่อยตั้งขนาด ปริยายคือ 792 × 398 — เท่ากับพื้นที่จริงของ Playground · เมธอดของ Widget ที่ใช้ในฝั่งอินพุต — เก้าตัว
| เมธอด | ทำอะไร | กับดัก |
|---|---|---|
.id() |
เลขประจำตัว 0-63 ที่ตรงกับ ev['handle'] |
ไม่เก็บไว้ = แยกไม่ออกว่าใครส่ง |
.text("...") |
เขียนข้อความใหม่ทับของเดิม | เรียกแบบ ไม่ใส่อาร์กิวเมนต์คือ "อ่าน" — คืนสตริงจริงสำหรับ Label Textarea Dropdown Roller ชนิดอื่นได้ "" (modui.c:352-383) |
.value() / .value(n) |
ถามค่าปัจจุบัน / สั่งค่าใหม่ | มี สิบเอ็ดชนิด ที่ตอบ .value() ได้จริง: Slider Arc Bar Switch Checkbox Dropdown Roller Spinbox Tabview ButtonMatrix Calendar (ui_widget_mgr.c:2568-2607) · ที่เหลือคืน 0 และ 0 ยังแปลว่า "ถามไม่สำเร็จ" ได้ด้วย แยกไม่ออก · Seg7 รับ .value(n) แต่ได้จำนวนเต็ม ทศนิยมต้อง .text() |
.pos(x, y) |
ย้ายตำแหน่งหลังสร้างแล้ว | ยังนับเป็น widget ตัวเดิม ไม่กินโควตาเพิ่ม |
.size(w, h) |
เปลี่ยนขนาดหลังสร้างแล้ว | ปุ่มที่เล็กกว่า ~45 px นิ้วกดพลาด |
.color(0xRRGGBB) |
เปลี่ยนสี | ใช้เป็น "ช่องรายงานสถานะ" ได้ ตาอ่านสีเร็วกว่าตัวอักษร |
.show() / .hide() |
ซ่อน-แสดงโดยไม่ต้องลบ | ของที่ซ่อนอยู่ ยังกินโควตา อยู่ (งบคอร์ส 32 · เพดาน 64) · และแตะไม่ได้ ไม่ส่ง event |
.delete() |
ลบทิ้งจริง คืนโควตาหนึ่งช่อง | อ็อบเจกต์ฝั่ง Python ยังอยู่ แต่ชี้ไปที่ของที่ไม่มีแล้ว อย่าเอามาใช้ต่อ |
เมธอดที่เหลือของ Widget (icon set_image set_pixels add_series set_next) เป็นเรื่องของฝั่งแสดงผล — add_series กับ set_next จะได้ใช้จริงตอนวาดกราฟในคาบ 7 · หกชนิดที่รับอินพุตได้ — Button Switch Slider Checkbox Dropdown Textarea · วันนี้ลงมือกับ Button ตัวเดียวในโครงหลัก ที่เหลืออยู่ใน examples/s04/02_event_types.py↗ 03_switch_matches_led.py และ 08_dropdown_textarea.py ให้เปิดเล่นเอง
hide()ไม่ใช่delete()— ตัวหนึ่งแค่ปิดไฟ อีกตัวรื้อออกจากห้อง ถ้างบ 32 ของคอร์ส (หรือเพดาน 64 ของเฟิร์มแวร์) ใกล้เต็ม มีแค่ตัวหลังที่ช่วยได้
ทุกตัวข้างบนเป็น ของชิ้นเดียว วางเรียงกันบนจอ พอของเกินสิบชิ้น จอ 792×398 ก็เต็ม — กลุ่มนี้แก้ปัญหานั้น มันไม่วาดอะไรของตัวเองมากนัก หน้าที่ของมันคือ เป็นที่ใส่ของอื่น
| widget | ขอช่องข้างในด้วย | คืนอะไร | ใช้ตอนไหน |
|---|---|---|---|
ui.Tabview |
.add_tab("ชื่อ") |
แฮนเดิลของหน้าในแท็บนั้น | หลายหน้าจอที่ไม่เกี่ยวกัน สลับด้วยการแตะ · value= คือความสูงแถบแท็บ |
ui.Tileview |
.add_tile(col, row) |
แฮนเดิลของไทล์ | หน้าจอที่ ปัดนิ้วเลื่อน คอลัมน์เดียวกันแถวต่างกัน = ปัดขึ้นลง |
ui.Win |
.content() |
แฮนเดิลของพื้นที่ใต้แถบหัว | กล่องที่มีชื่อเรื่องติดมาด้วย ต่างจาก Panel ที่เป็นสี่เหลี่ยมเปล่า · ถามซ้ำได้ค่าเดิม |
ui.Menu |
.add_page("ชื่อ") |
แฮนเดิลของหน้า | เมนูซ้าย-เนื้อหาขวา · หน้าแรกที่สร้าง คือหน้าที่เมนูเปิดให้ตอนแรก |
กฎข้อเดียวที่ต้องจำ: ค่าที่คืนมาต้องส่งกลับเป็น parent=
tabs = ui.Tabview(x=0, y=0, w=792, h=336, value=44) # สูง 336 ไม่ใช่ 398 - มุมขวาล่างเป็นปุ่ม Console
tab_win = tabs.add_tab("หน้าต่าง") # <- เก็บไว้
...
win = ui.Win(text="บันทึกเหตุการณ์", x=16, y=12, w=740, h=280,
value=40, parent=tab_win) # <- ส่งกลับเข้าไป
body = win.content()
ui.Label("ระบบเริ่มทำงาน", x=16, y=16, color=COL_DIM, value=20, parent=body)
ลืมส่ง parent= แล้วป้ายจะไปโผล่บนจอหลัก ไม่ได้อยู่ในแท็บ — และ ไม่มีอะไรฟ้อง ไม่มี exception ไม่มีคำเตือน มีแค่ของที่ไปอยู่ผิดที่ · อีกสามตัวที่ยังไม่ได้พูดถึง
| widget | ทำอะไร | กับดัก |
|---|---|---|
ui.Spinner |
วงกลมหมุนเอง บอกว่า "กำลังทำงานอยู่" | ไม่มีค่าให้อ่านและไม่ต้องสั่ง ห้ามใช้บอกความคืบหน้า อันนั้นคือ ui.Bar |
ui.DotMatrix |
จอจุดเป็นตาราง cols/rows คือ จำนวนจุด ไม่ใช่พิกเซล |
ส่งค่าด้วย .set_pixels(list) ยาวเท่าจำนวนคอลัมน์ |
ui.Image |
ไอคอนที่คอมไพล์มากับเฟิร์มแวร์ | เปลี่ยนภาพด้วย .icon(n) ไม่ใช่ .text() แม้เบื้องหลังจะส่งเป็น SET_TEXT ก็ตาม |
ของที่อยู่ในแท็บที่ไม่ได้เปิดอยู่ ยังกินโควตาเท่าเดิม — แท็บช่วยเรื่องพื้นที่สายตา ไม่ได้ช่วยเรื่องโควตา · ลงมือกับกลุ่มนี้ที่
practise_codes/s04b_layout_widgets.py↗ เฉลยอยู่ในsolution_codes/ชื่อเดียวกัน
สไลด์ก่อนหน้าเปิดเฉพาะฝั่งอินพุต สไลด์นี้เปิดทั้งใบ — พิมพ์ dir(ui) บน Eva Kit แล้วได้ชื่อพวกนี้ ไม่มากกว่านี้ (Dev Kit ได้ชุดเดียวกันบวก Sprite กับค่าคงที่ SPR_*) ไม่ได้ให้ท่อง แต่ให้ รู้ว่าอะไรมีอยู่ จะได้ไม่ไปเขียนของที่ไม่มี
ตัวสร้าง widget — สามสิบสามตัว ทุกตัวคืนอ็อบเจกต์ Widget ชนิดเดียวกันหมด ต่างกันที่ชนิดข้างในและ event ที่มันส่ง
| ตัวสร้าง | คาบนี้ใช้ไหม | ใช้ที่ไหน |
|---|---|---|
Button Label Led Panel MsgBox |
ใช้ในโครงหลัก | ห้าตัวนี้ประกอบเป็นแผงควบคุมของคาบนี้ทั้งใบ (Panel ได้เป็นตัวเอกจริงตอนจัดการ์ดในคาบ 8) |
Switch Slider Checkbox Dropdown Textarea |
ใช้ในตัวอย่าง | อีกห้าชนิดที่ รับอินพุตได้ — 02_event_types.py 03_switch_matches_led.py และ 08_dropdown_textarea.py |
Seg7 Bar Chart |
ใช้ในตัวอย่าง | 04_seg7_takes_text.py และ 06_layout_budget.py · Chart ได้เป็นตัวเอกเต็มคาบในคาบ 7 |
Scale Spinbox |
ใช้ในตัวอย่าง | 09_scale_led_spinbox.py — สเกลมีขีด · ช่องตัวเลขที่กดเพิ่มลดได้ (คู่กับ Led อีกครั้ง) |
Tabview |
ใช้ในตัวอย่าง | 10_tabview_second_screen.py และ 14_container_coordinates.py — จอที่สองโดยไม่ต้องล้างจอ |
Menu |
ใช้ในตัวอย่าง | 11_menu_settings_tree.py — ต้นไม้หน้าตั้งค่าที่ย้อนกลับเองได้ |
Table List Line Picture |
ใช้ในตัวอย่าง | 12_table_and_list.py — สี่ตัวนี้คือฝั่งแสดงข้อมูลเป็นชุด |
ButtonMatrix |
ใช้ในตัวอย่าง | 13_confirm_before_acting.py — คู่กับ MsgBox ถามยืนยันก่อนสั่งงานที่ย้อนไม่ได้ |
Arc |
ยังไม่ใช้ | เข็มโค้งของคาบ 5 และหน้าปัดของคาบ 6 (s01/05, s01/12, s01/15) |
Compass |
ยังไม่ใช้ | การ์ดเข็มทิศในคาบ 8 (s08/06) |
DotMatrix |
ยังไม่ใช้ | เคยโผล่ที่ s01/14 และกลับมาอีกทีที่ s07/02 |
Image Spinner |
ยังไม่ใช้ | ทั้งคู่อยู่ในตัวอย่างคาบ 1-2 (s01/15, s02/06) และคอร์สนี้ไม่ได้กลับมาใช้อีก |
Roller |
ยังไม่ใช้ | ตัวเลือกแบบวงล้อ — ได้ใช้จริงที่ s05/10 · ไม่ใช่ของที่เลื่อนตัวเองได้ ดูสไลด์ปัดนิ้ว |
Keyboard |
ยังไม่ใช้ | แป้นพิมพ์บนจอ คู่กับ Textarea — คาบ 9 (s09/10, s09/11) |
Tileview Win SpanGroup Calendar |
ยังไม่ใช้ | สี่ตัวท้ายของชุด — s12/09, s08/08, s12/07, s12/08 |
ฟังก์ชันระดับโมดูล — แปดตัว สี่ตัวแรกอยู่ในสไลด์ฝั่งอินพุตครบแล้ว
| ชื่อ | ทำอะไร | คาบนี้ใช้ไหม |
|---|---|---|
poll() list() get(id) clear() |
อ่านเหตุการณ์ · สำรวจของบนจอ · ดึง widget กลับจากเลข · ล้างทั้งจอ | poll() คือหัวใจของคาบนี้ อีกสามตัวอยู่ใน 07_find_move_hide_delete.py |
screen(w, h) |
ล้างจอแล้วตั้งขนาด ปริยาย 792 × 398 | ใช้ทุกไฟล์ เปิดหัวสคริปต์ |
program(code) |
เขียน/อ่าน/ลบ /main.py เพื่อให้โค้ดรันเองหลังบอร์ดรีเซ็ต |
ไม่ใช้ในคอร์สนี้เลย เป็นของเครื่องมือฝั่ง IDE |
_ide_status(on) · _deploy() |
โชว์สถานะ IDE และหน้าจอ "กำลังอัปโหลด" | ไม่ใช่ของเรา ขึ้นต้นด้วยขีดล่างเพราะ IDE เรียกเอง อย่าเรียกเอง |
เสียง — สองฟังก์ชัน กับค่าคงที่ยี่สิบห้าตัว sfx() tone() · SFX_* 21 ชื่อ · WAVE_* 4 ชื่อ — กางไว้แล้วในสไลด์ "เอาต์พุตอีกทางที่ ui มีให้"
ค่าคงที่ที่เหลือ — หกสิบสามตัว ไม่ใช่ของที่ต้องท่อง แต่ต้องรู้ว่ามันมี เพราะเวลาต้องใช้ ให้เรียกด้วยชื่อ ไม่ใช่ตัวเลขดิบ
| กลุ่ม | กี่ตัว | ใช้ตอนไหน |
|---|---|---|
PROP_* |
25 | ค่าที่ตั้งผ่าน .prop(id, value) — ของ Scale Spinbox Led Textarea Menu Calendar และอื่น ๆ |
ICON_* |
21 | ไอคอนหน้ารายการของ List — lst.add_item("ตั้งค่า", ui.ICON_SETTINGS) |
DIR_* |
6 | ทิศที่ gesture พกมาใน value และทิศที่ .add_tile() ยอมให้ปัด |
SCALE_* |
6 | รูปแบบการวางสเกล — บน ล่าง ซ้าย ขวา และแบบวงกลมสองแบบ |
SPAN_* |
5 | เส้นใต้ ขีดฆ่า และโหมดตัดบรรทัดของ SpanGroup |
ชนิด — หนึ่งตัว ui.Widget เป็น ชนิด ไม่ใช่ฟังก์ชัน สร้างเองไม่ได้ ใช้กับ isinstance() เท่านั้น
เมธอดของ Widget — สามสิบแปดตัว นับแยกจาก 132 ชื่อบนโมดูล เพราะมันอยู่บนอ็อบเจกต์ ไม่ได้อยู่บนโมดูล
| กลุ่ม | เมธอด |
|---|---|
| ใช้แล้วในคาบนี้ (เก้า) | .id() .text() .value() .pos() .size() .color() .show() .hide() .delete() |
| ขอรับเหตุการณ์ (หนึ่ง) | .listen() — หัวใจของครึ่งหลังคาบนี้ |
| ของที่มีสมาชิกเป็นชุด (เจ็ด) | .add_item() .add_row() .cell() .add_option() .add_button() .add_point() .clear_items() |
| กรอบที่มีของอยู่ข้างใน (สาม) | .add_tab() .add_tile() .content() |
| ปุ่มปรับเฉพาะชนิด (ห้า) | .prop() .ticks() .digits() .col_width() .bind() |
Menu (ห้า) |
.add_page() .row() .section() .separator() .opens() |
| ฝั่งภาพ (สาม) | .icon() .set_image() .set_pixels() |
Chart (สอง) · SpanGroup (สอง) · Calendar (หนึ่ง) |
.add_series() .set_next() · .add_span() .pen() · .month() |
และของที่ Eva Kit ไม่มี — ui.Sprite กับค่าคงที่ SPR_* ทั้งชุด รวมถึงเมธอด .frame() มีอยู่จริงในซอร์สเดียวกัน แต่ถูกกันไว้ด้วยธงคอมไพล์ของเครื่องเกม ซึ่งเฟิร์มแวร์ Eva ไม่ได้เปิด พิมพ์ไปได้ AttributeError · Dev Kit เปิดไว้ จึงมี ui.Sprite ให้เรียก — คอร์สนี้ไม่ใช้ เพราะโค้ดต้องรันได้ทั้งสองบอร์ด
นับให้ครบก่อนออกแบบหน้าจอ: 33 + 8 + 2 + 25 + 63 + 1 = 132 ชื่อบนโมดูล และ 38 เมธอดบน widget — เท่านี้คือทั้งหมดที่จอตัวนี้ทำได้จาก Python บน Eva Kit (Dev Kit บวก
SpriteและSPR_*)
dir(ui)บน Eva Kit จะขึ้น 133 บรรทัด เพราะมี__name__ติดมาด้วยอีกหนึ่ง ซึ่งไม่ใช่ของที่เราเรียกใช้ — บน Dev Kit นับเองแล้วจดลงใบงาน
ยุคแรกของโปรแกรมมีหน้าจอ โค้ดจะนั่ง "รอ" อยู่ตรงจุดเดียว เช่นรอให้คนพิมพ์คำตอบ ระหว่างนั้นทำอย่างอื่นไม่ได้เลย พอ UI มีปุ่มหลายตัวพร้อมกัน วิธีนี้ก็ตัน เพราะโปรแกรมไม่รู้ว่าจะไปรอที่ปุ่มไหน
ทางออกที่วงการเลือกใช้ตั้งแต่ยุค 1980 จนถึงเว็บและมือถือทุกวันนี้เหมือนกันหมด: ให้ระบบ เก็บเหตุการณ์ใส่คิว แล้วให้โปรแกรมวนลูปมาหยิบไปจัดการ — addEventListener ของ JavaScript, lv_obj_add_event_cb ของ LVGL และ ui.poll() ของเรา คือความคิดเดียวกันในสามภาษา
เชื่อมกับวันนี้: ลูป while True สั้น ๆ ที่เราจะเขียนกันในอีกไม่กี่นาที คือกลไกเดียวกับที่ทำให้เว็บเบราว์เซอร์ตอบสนองการคลิกได้ ต่างกันแค่ของเราเห็นวงกลมทั้งวงด้วยตาตัวเอง ไม่มีอะไรถูกซ่อนไว้ใต้เฟรมเวิร์ก

หัวใจของ MVP วันนี้คือ สถานะบนจอต้องตรงกับ LED เสมอ ไปถามหลอดไฟแล้วเชื่อคำตอบไม่ได้ — gpio.led(2).value() อ่านกลับได้จริง แต่สิ่งที่มันตอบคือระดับของขา ณ วินาทีที่ถาม และ hold() จบด้วยขาต่ำเสมอ (brightness() ค่ากลางก็เช่นกันบนดวงที่ไม่มีเส้น PWM) อ่านตามหลังไปจึงได้ 0 ทั้งที่ผู้ใช้เพิ่งเห็นหลอดสว่างอยู่ วิธีที่ถูกคือเก็บความจริงไว้ในตัวแปรของเราเอง แล้วให้ ทุกการเปลี่ยนแปลงผ่านฟังก์ชันเดียว
def set_led(i, on):
led_on[i] = on # 1) จำไว้ก่อน (led_on = ความจริง ที่เดียว)
if on:
gpio.led(LED_IDX[i]).on() # 2) สั่งของจริง (LED_IDX = ดวงจริงของสี)
lamps[i].value(1) # 3) ไฟบนจอสะท้อนของจริง
else:
gpio.led(LED_IDX[i]).off()
lamps[i].value(0)
show_status() # 4) รายงานขึ้นจอทุกครั้ง ไม่มีข้อยกเว้น


examples/s04/03_switch_matches_led.py↗ — สวิตช์บนจอกับไฟบนบอร์ดตรงกัน หลักฐานว่าโค้ดซิงก์สองสถานะได้จริง · ภาพหน้าจอจริงจากบอร์ด Eva Kit บันทึกโดยผู้สอน | ขวาสุด: ปุ่มหยุดฉุกเฉินจริง — กรณีที่สถานะจริงต้องชนะสถานะบนจอเสมอ ถ้าจอบอกว่าเครื่องหยุดแล้วแต่เครื่องยังหมุน คนเจ็บ — ภาพ: Angus Fraser / Wikimedia Commons — CC BY 2.0มีทางเดียวที่ไฟจะเปลี่ยนสถานะได้ — จอจึงตามไม่ทันไม่ได้ นี่คือวิธีออกแบบ ไม่ใช่ความระมัดระวัง
สิ่งที่เฟิร์มแวร์ทำให้แล้ว (70%)
วาดปุ่มและสวิตช์ให้สวยตามธีม · ตรวจจับนิ้วบนกระจก · แปลงการแตะเป็นเหตุการณ์พร้อม handle · ส่งข้ามคอร์ผ่าน IPC · จัดการหน่วยความจำของ widget ทั้ง 64 ช่องของเฟิร์มแวร์ (คอร์สใช้ไม่เกิน 32) · ควบคุมขา GPIO ของหลอดไฟ
สิ่งที่เป็นงานของเรา (30%)
ตัดสินใจว่า จอควรมีอะไรบ้าง วางตรงไหน แตะแล้วต้องเกิดอะไร และ จะรักษาความจริงให้ตรงกันสองฝั่งอย่างไร
30% ก้อนนี้คือ "การออกแบบปฏิสัมพันธ์" ซึ่งเป็นงานที่เครื่องมือช่วยแทนไม่ได้
เฟิร์มแวร์วาดปุ่มให้ได้ แต่ไม่มีทางรู้ว่าปุ่มนั้นควรทำอะไรกับโรงงานของเรา
import ui
import gpio
import time
...
COL_TEXT, COL_DIM, COL_CARD = 0xE8EAED, 0x9AA3AF, 0x171B22
...
ui.screen()
time.sleep_ms(200) # ให้ CM55 ล้างเสร็จก่อนยิงคำสั่งชุดใหม่
...
ui.Label("แผงควบคุม LED ของทีม", x=24, y=8, color=COL_TEXT, value=28)
status = ui.Label("พร้อมรับคำสั่ง", x=360, y=12, color=COL_DIM, value=20)
ทุกบล็อกในห้าสไลด์นี้ตัดจาก practise_codes/s04_touch_panel.py↗ ตรง ๆ — บรรทัด # เติม: คือช่องว่างที่ทีมต้องเติมเอง
examples/s04/01_first_widgets.py↗ — widget ชุดแรกวางจริงตรงไหน และ value= ให้ฟอนต์ขนาดเท่าไร · ภาพหน้าจอจริงจากบอร์ด Eva Kit บันทึกโดยผู้สอนui.screen() สำคัญกว่าที่คิด — โปรแกรมของกลุ่มก่อนหน้าอาจทิ้ง widget ค้างไว้ ถ้าไม่ล้าง เราจะนับงบ 32 ช่องของคอร์ส (เพดานเฟิร์มแวร์ 64) ผิด แล้วเจอ RuntimeError โดยไม่รู้สาเหตุ · หน่วง 200 ms หลังล้าง เป็นการให้เวลาอีกคอร์ทำงานให้เสร็จ ก่อนที่เราจะยิงคำสั่งสร้างชุดใหม่ตามไป
value= ของ Label คือ ขนาดฟอนต์ — 28 หัวเรื่อง · 20 ค่าที่ต้องอ่าน · 16 ป้ายกำกับ สามชั้นนี้คือลำดับสายตา · สีทั้งสามมาจากจานสีของหลักสูตร (SPEC §S7.13) ไม่ใช่ 0xFFFFFF ลอย ๆ · status วางไว้ข้างหัวเรื่องตั้งแต่ท่าแรก เพราะบรรทัดสถานะต้องอยู่ที่เดิมตลอดโปรแกรม
เริ่มจากหน้าว่างที่เรารู้แน่ เป็นนิสัยเดียวกับ
lcd.clear()ในคาบ 1
LED_IDX ที่ถามจากบอร์ดBTN_TEXT = ["แดง", "เขียว", "น้ำเงิน"] # สามสีของแผง เรียงตรงกับ LED_IDX ข้างล่าง
COL_ON = [0xE53935, 0x43A047, 0x1E88E5] # สีไฟสถานะตอนติด เรียงตรงกับ BTN_TEXT
...
LED_NAMES = gpio.board_info()["led_names"]
RGB_NAMES = ("RGB_RED", "RGB_GREEN", "RGB_BLUE")
if all(n in LED_NAMES for n in RGB_NAMES):
LED_IDX = [LED_NAMES.index(n) for n in RGB_NAMES]
else:
LED_IDX = [0, 1, 2]
...
ROW_TOP = 52 # ขอบบนของแถวแรก
ROW_PITCH = 120 # ปุ่มสูง 88 + ระยะระหว่างเป้าสัมผัส 32 = 120
lamps = []
btn_on = []
btn_off = []
for i in range(len(LED_IDX)):
y = ROW_TOP + i * ROW_PITCH
lamps.append(ui.Led(x=24, y=y + 20, w=48, h=48, color=COL_ON[i], value=0))
ui.Label(BTN_TEXT[i], x=88, y=y + 32, color=COL_DIM, value=16)
# เติม: btn_on.append(ui.Button("เปิด", x=184, y=y, w=144, h=88, color=0x30A46C, value=20))
pass
btn_off.append(ui.Button("ปิด", x=360, y=y, w=144, h=88, color=0x3A4150, value=20))
...
on_ids = [b.id() for b in btn_on]
off_ids = [b.id() for b in btn_off]
แถวละ 120 px = ปุ่มสูง 88 (เป้าสัมผัสตามระบบออกแบบ) + ช่องว่าง 32 · ปุ่มเปิดกับปุ่มปิดแยกกันคนละปุ่ม เพราะปุ่มสลับปุ่มเดียวบอกไม่ได้ว่าตอนนี้อยู่สถานะไหน คนกดต้องเดา · ไฟสถานะ lamps แยกจากปุ่ม — ไฟตอบว่า "ตอนนี้เป็นยังไง" ปุ่มตอบว่า "สั่งอะไรได้"
LED_IDX คือดวงจริงของแต่ละสี ถามจากชื่อที่บอร์ดรายงาน — บอร์ดที่รายงาน RGB_RED/GREEN/BLUE ครบ (Dev Kit) ใช้ชื่อ · ที่ไม่ครบ (Eva Kit: ดวง 0 1 2 คือ แดง เขียว น้ำเงิน แม้ดวง 2 จะชื่อ RGB_RED) ใช้เลขตรง ๆ · แผงนี้คุม "สามสี" ไม่ใช่ "ทุกดวงบนบอร์ด"
เขียนเป็นลูปตั้งแต่แรก พอต้องเพิ่มสีที่สี่ในการบ้าน จะแก้แค่สาม list บนสุด (
BTN_TEXTCOL_ONRGB_NAMES)
led_on = [False] * len(LED_IDX)
...
def set_led(i, on):
# i คือแถวของแผง (0 แดง 1 เขียว 2 น้ำเงิน) ส่วนเลขดวงจริงอยู่ที่ LED_IDX[i]
led_on[i] = on # 1) จำไว้ก่อน
if on:
# 2) สั่งของจริง แล้วให้ไฟบนจอรายงานตรงกัน
# เติม: gpio.led(LED_IDX[i]).on()
pass
lamps[i].value(1)
else:
# เติม: gpio.led(LED_IDX[i]).off()
pass
lamps[i].value(0)
show_status() # 3) รายงานขึ้นจอทุกครั้ง ไม่มีข้อยกเว้น
ทุกคำสั่งที่ทำให้ไฟเปลี่ยนต้องผ่านประตูนี้ประตูเดียว ไม่ว่าจะมาจากปุ่มเปิด/ปิดรายสี จากปุ่ม "เปิดทั้งหมด" หรือจากกล่องยืนยันของ "ปิดทั้งหมด" · lamps[i].value(0) ทำให้ไฟบนจอ หรี่ ไม่ใช่หาย — ดวงที่หายไปตอนดับ ทำให้คนดูแยกไม่ออกว่าดับจริงหรือจอเสีย
ทำไมถึงคุ้ม — ถ้าเราเรียก gpio.led(LED_IDX[1]).on() ตรง ๆ ที่ไหนสักแห่ง โค้ดจะยังทำงาน ไฟยังติด แต่ led_on[1] กับไฟสถานะบนจอจะไม่รู้เรื่องด้วย แล้ววันหนึ่งจอจะโกหกโดยที่เราหาสาเหตุไม่เจอ
ในงานจริงเราเรียกวิธีนี้ว่า single source of truth — ความจริงต้องมีที่อยู่ที่เดียว
lbl_count = ui.Label("ติดอยู่ 0 จาก " + str(len(LED_IDX)) + " ดวง", x=536, y=60,
color=COL_TEXT, value=20)
...
def show_status():
# อ่านจาก led_on อย่างเดียว ไม่ถามฮาร์ดแวร์ จึงเชื่อถือได้เสมอ
n = 0
for i in range(len(LED_IDX)):
if led_on[i]:
n += 1
# ตัวเลขมาพร้อมพิสัยของมันเสมอ "ติดอยู่ 2" ไม่บอกอะไร "2 จาก 3" บอกทันที
lbl_count.text("ติดอยู่ " + str(n) + " จาก " + str(len(LED_IDX)) + " ดวง")
show_status() อ่านจาก led_on อย่างเดียว ไม่เคยไปถามฮาร์ดแวร์ — และนั่นคือเหตุผลที่มันเชื่อถือได้ เพราะค่าที่อ่านกลับจากขาบอกแค่ระดับของขา ณ วินาทีที่ถาม ไม่ได้บอกความตั้งใจของโปรแกรม ตามที่คุยกันไปแล้ว · ตัวเลขมาพร้อมพิสัยเสมอ "2 จาก 3" บอกทันทีว่าเหลืออีกดวง
สังเกตว่าเราไม่เคย "อ่านข้อความเดิมบน Label" กลับมาคำนวณต่อ แม้ .text() แบบไม่ใส่อาร์กิวเมนต์จะอ่านสตริงคืนได้แล้ว (modui.c:352-383) — ข้อความบนจอคือ ผลลัพธ์ ของความจริง ไม่ใช่ตัวความจริง ความจริงอยู่ที่ led_on ที่เดียว
Label เป็นกระดาษให้เราเขียนทับ ไม่ใช่สมุดบัญชีที่โปรแกรมควรไปเปิดอ่านย้อนหลัง
while True:
for ev in ui.poll():
h = ev['handle']
t = ev['type']
if t != 'clicked':
continue
if h in on_ids:
# เติม: set_led(on_ids.index(h), True)
pass
status.text("สั่งเปิด " + BTN_TEXT[on_ids.index(h)])
elif h in off_ids:
# เติม: set_led(off_ids.index(h), False)
pass
status.text("สั่งปิด " + BTN_TEXT[off_ids.index(h)])
elif h == btn_all_on.id():
for i in range(len(LED_IDX)):
set_led(i, True)
status.text("เปิดครบทุกสี")
elif h == btn_all_off.id() and not asking:
asking = True
box.show()
btn_yes.show()
btn_no.show()
status.hide()
...
# เติม: time.sleep_ms(50)
pass
set_led(on_ids.index(h), True) สั่งเปิด ไม่ใช่สั่งสลับ — กดซ้ำสิบครั้งได้ผลเท่ากดครั้งเดียว · asking คือธงกันกดซ้ำ: ระหว่างกล่องยืนยันเปิดอยู่ "ปิดทั้งหมด" จะไม่เปิดกล่องซ้อน และคำตอบมาจาก btn_yes / btn_no ซึ่งเป็น ui.Button จริง (กิ่งของสองปุ่มนี้อยู่ในไฟล์ต่อจากตรง ...) · status.hide() ตอนกล่องเปิด แล้ว status.show() ทุกกิ่งที่ปิดกล่อง — ลืมข้างใดข้างหนึ่ง จอโกหกทันที
ทุกครั้งที่สถานะถูกเปลี่ยนจากทางอื่น อย่าลืมดึง widget ที่ค้างอยู่ให้กลับมาตรงด้วย — และ
while Trueที่ไม่มีtime.sleep_ms()คือช่องว่างข้อสุดท้ายที่ห้ามลืม
เวลาไล่บั๊ก ให้ถามว่าขาดตอนที่ทอดไหน อย่าเดาว่าทั้งเส้นพัง
แผงควบคุมที่ตอบกลับเฉพาะทางตา บังคับให้คนต้องจ้องจอ · ui มีอีกสองฟังก์ชันไว้ตอบทางหู
ui.sfx(ui.SFX_UI_SELECT) # เสียงสำเร็จรูป เลือกจากรายการข้างล่าง
ui.tone(60, ui.WAVE_TRIANGLE, 90, 120) # โน้ต, รูปคลื่น, ความแรง, ความยาว ms
ui.tone(72) # ใส่แค่โน้ตก็ได้ ที่เหลือใช้ค่าปริยาย
ui.sfx(id) |
ui.tone(note, wave, velocity, dur_ms) |
|
|---|---|---|
| อาร์กิวเมนต์ | หนึ่งตัว คือค่าคงที่ ui.SFX_* |
หนึ่งถึงสี่ตัว เรียงตามตำแหน่งเท่านั้น |
| กับดักใหญ่ | — | เขียน ui.tone(note=60) แล้ว error ทันที ใส่ชื่อพารามิเตอร์ไม่ได้ |
note |
— | เลขโน้ต MIDI 0-127 ไม่ใช่เฮิรตซ์ · 60 = C4 · 69 = A4 (ที่เรารู้จักกันว่า 440 Hz) · ส่ง 440 ไปจะถูกตัดเหลือ 184 แล้วเพี้ยน |
| ค่าปริยาย | — | wave = WAVE_SQUARE · velocity = 100 · dur_ms = 150 |
| คืนค่า | ไม่คืนอะไร | ไม่คืนอะไร |
| รู้ได้ไหมว่าเล่นสำเร็จ | ไม่ ยิงแล้วลืม | ไม่ ยิงแล้วลืม |
รูปคลื่นมีสี่แบบ — ui.WAVE_SINE นุ่มที่สุด · ui.WAVE_SQUARE แข็งแบบเกมยุค 8 บิต (ค่าปริยาย) · ui.WAVE_TRIANGLE อยู่กลาง ๆ · ui.WAVE_SAW คมและแสบหูที่สุด
ลงมือกับเรื่องนี้ที่
examples/s04/05_sound_feedback.py↗ — ปุ่มสามใบ สองใบเรียกui.sfx()อีกใบไล่โน้ตสี่ตัวด้วยui.tone()
เสียงสำเร็จรูปมี 21 เสียง ชื่อมาจากเกมที่เฟิร์มแวร์มีอยู่แล้ว แต่หยิบมาใช้กับงานอะไรก็ได้
| กลุ่ม | ค่าคงที่ | เอามาใช้กับแผงควบคุมอย่างไร |
|---|---|---|
| อินเทอร์เฟซ (5) | SFX_UI_MOVE SFX_UI_SELECT SFX_UI_BACK SFX_UI_DENY SFX_UI_START |
ห้าตัวนี้คือชุดที่ตรงงานที่สุด · SFX_UI_DENY ใช้ตอนปุ่มถูกล็อก |
| Snake (3) | SFX_SNAKE_EAT SFX_SNAKE_TURN SFX_SNAKE_DIE |
เสียงสั้นแหลม ใช้เป็นเสียง "รับค่าแล้ว" ได้ |
| Flappy (3) | SFX_FLAPPY_FLAP SFX_FLAPPY_SCORE SFX_FLAPPY_DIE |
FLAPPY_SCORE เหมาะกับ "ผ่านเกณฑ์" |
| Pong (5) | SFX_PONG_WALL SFX_PONG_PADDLE SFX_PONG_SCORE SFX_PONG_WIN SFX_PONG_LOSE |
คู่ WIN/LOSE ใช้ปิดท้ายงานที่มีผลได้-ตก |
| ยิง (4) | SFX_SHOOT_FIRE SFX_SHOOT_HIT SFX_SHOOT_EXPLODE SFX_SHOOT_LOSE_LIFE |
SHOOT_EXPLODE หนักพอจะใช้เป็นเสียงเตือนร้ายแรง |
| ปิดท้าย (1) | SFX_GAME_OVER |
จบรอบการทดสอบ |
เช็กก่อนใช้เสมอ — สองฟังก์ชันนี้ถูกคอมไพล์เข้ามาก็ต่อเมื่อบอร์ดประกาศว่ามีชิปเสียง บอร์ดที่ไม่มีเลย จะไม่มีชื่อ ui.tone อยู่ด้วยซ้ำ เขียน if hasattr(ui, "tone"): คร่อมไว้ แล้ว AttributeError จะไม่โผล่กลางคาบ
แต่ hasattr ตอบได้แค่ว่า "ชื่อนี้มีอยู่ไหม" ไม่ได้ตอบว่า "เสียงจะออกไหม" — มีโหมดที่เฟิร์มแวร์เก็บชื่อไว้ให้สคริปต์เก่ายังรันได้ ทั้งที่บอร์ดไม่มีชิปเสียงจริง กรณีนั้น hasattr คืน True คำสั่งถูกส่งออกไปตามปกติ แล้วเงียบหายไปที่ปลายทาง ไม่มีใครฟ้อง
และเพราะมันยิงแล้วลืม จอต้องเป็นพยานแทนหู — ทุกครั้งที่สั่งเสียง ให้ตัวนับบนจอเดินขึ้นด้วย ถ้าตัวนับเดินแต่เงียบ ปัญหาอยู่ที่ลำโพงหรือชิปเสียง ถ้าตัวนับไม่เดิน ปัญหาอยู่ที่โค้ดเรา — สองอย่างนี้แก้คนละที่ และแยกออกได้ก็เพราะมีตัวนับ
practise_codes/s04_touch_panel.py↗ เติมช่องว่าง pass ให้ครบตามคำใบ้ # เติม:ข้อควรรู้: ถ้าน้อง ๆ ปัดออกจากหน้า Playground ไปเมนูอื่น widget ทั้งหมดจะถูกทำลาย กลับเข้ามาแล้วจอจะว่าง ต้องส่งโค้ดใหม่ — ไม่ใช่ความผิดพลาด แต่เป็นวิธีที่บอร์ดคืนหน่วยความจำ
ทดสอบทุกครั้งโดยมองสองที่พร้อมกัน จอกับหลอด ถ้าดูแค่จอ จะไม่มีวันรู้ว่ามันโกหกหรือเปล่า
แผงควบคุม LED สามสี (แดง เขียว น้ำเงิน — ดัชนีดวงถามจากชื่อที่บอร์ดรายงาน) แถวละ ไฟสถานะ + ปุ่มเปิด + ปุ่มปิด พร้อมปุ่มเปิดทั้งหมด / ปิดทั้งหมด โดยสถานะบนจอตรงกับ LED เสมอ
แปลเป็นสิ่งที่ตรวจได้จริง (ตรงกับหกช่องว่างใน practise_codes/s04_touch_panel.py↗):
เกณฑ์ข้อที่สี่คือข้อที่ตกกันมากที่สุด ทดสอบมันเป็นข้อสุดท้ายเสมอ
| อาการ | สาเหตุที่แท้จริง | วิธีแก้ |
|---|---|---|
| จอว่างราว 2 วินาทีหลังส่งโค้ด | ไม่ได้เรียก ui.poll() (กฎข้อ 1) |
เรียก ui.poll() ทุกรอบ |
| จอกระตุก อัปเดตไม่ครบ | ลูปเร็วเกินไป เฟรมถูกทิ้ง (กฎข้อ 5) | ใส่ time.sleep_ms(50) ท้ายลูป |
| CPU ร้อน จอหน่วง | ลูปไม่มี sleep เลย (กฎข้อ 2) | 50 ms สำหรับ UI เบา · 200 ms แดชบอร์ด |
RuntimeError: ui: max 64 widgets |
สร้าง widget เกินเพดานเฟิร์มแวร์ 64 หรือของเก่าค้าง (กฎข้อ 3 — งบของคอร์สคือ 32) | ui.screen() ต้นสคริปต์ แล้วนับ widget ใหม่ |
ค่าเซนเซอร์ค้างหลังเริ่มใช้ ui |
auto-task ถูกหยุดตั้งแต่ ui.* ครั้งแรก (กฎข้อ 4) |
อ่านเซนเซอร์เองในลูป |
| ตัวหนังสือบน Label ผิดขนาด | value= คือขนาดฟอนต์ ไม่ใช่ค่า |
ใช้เฉพาะ 14/16/20/24/28 |
| กดปุ่มแล้วไม่มีอะไรเกิดขึ้น | ลืมเก็บ .id() หรือเทียบ handle ผิด |
on_ids = [b.id() for b in btn_on] แล้วเทียบ h in on_ids |
| กล่องยืนยันปิดแล้ว แต่บรรทัดสถานะหายไป | status.hide() ตอนกล่องเปิด แล้วลืม show() ในกิ่งที่ปิดกล่อง |
ทุกกิ่งที่ปิดกล่อง (ยืนยัน และ ไม่ปิด) ต้อง status.show() |
| ไฟดวงที่สามติดแต่สถานะบอกว่าดับ | ไปอ่าน gpio.led(n).value() ซึ่งคืน 0 หลัง hold() (และหลัง brightness() ค่ากลางบนดวงที่ไม่มี PWM) |
เก็บสถานะไว้ใน led_on[] ของเราเอง |
| ปุ่มบนจอทำงาน แต่ไฟที่ติดเป็นคนละสีกับปุ่ม หรือมองไม่เห็นดวงไหนติดเลย | ใช้เลขดวงที่จำมาแทนที่จะถามบอร์ด — สองบอร์ดเรียงดวงไม่เหมือนกัน และ Dev Kit ดวง 0-1 อยู่บนโมดูล | ให้ LED_IDX มาจากชื่อใน gpio.board_info()["led_names"] แบบในเฉลย |
| กลับจากเมนูอื่นแล้วจอว่าง | ออกจากหน้า Playground = widget ถูกทำลาย | ส่งโค้ดใหม่ และอยู่หน้า Playground ตลอด |
ห้าแถวแรกคือกฎเหล็กห้าข้อในรูปของอาการจริงที่จะเจอ อ่านซ้ำก่อนเริ่มเขียน
เปิด practise_codes/s04_touch_panel.py↗ มีช่องว่างให้เติม 6 จุด
for i in range(len(LED_IDX)):
# เติม: btn_on.append(ui.Button("เปิด", x=184, y=y, w=144, h=88, color=0x30A46C, value=20))
pass
btn_off.append(ui.Button("ปิด", x=360, y=y, w=144, h=88, color=0x3A4150, value=20))
def set_led(i, on):
led_on[i] = on
if on:
# เติม: gpio.led(LED_IDX[i]).on()
pass
lamps[i].value(1)
else:
# เติม: gpio.led(LED_IDX[i]).off()
pass
lamps[i].value(0)
if h in on_ids:
# เติม: set_led(on_ids.index(h), True)
pass
elif h in off_ids:
# เติม: set_led(off_ids.index(h), False)
pass
หกจุดนี้เรียงตามลำดับที่โปรแกรมทำงานจริง เติมจากบนลงล่างแล้วส่งขึ้นบอร์ดทุกครั้ง
อย่าเติมครบหกจุดแล้วค่อยรันทีเดียว เติมทีละจุดแล้วรัน จะรู้ทันทีว่าจุดไหนพัง
ต้องทำในคาบ · เปิดตามลำดับนี้ ทั้งชุดราว 40 นาที
| ลำดับ · เรื่อง · เวลา | ไฟล์ | ลงมือทำอะไร แล้วจะเข้าใจอะไร |
|---|---|---|
| 1 · วาง widget ตัวแรก · 10 นาที | examples/s04/01_first_widgets.py↗ |
ลองสร้างปุ่มโดยไม่ใส่ x กับ y ดูสักครั้ง แล้วใส่พิกัดเอง · จะเห็นว่าจอจัดวางให้เองจนข้อความทับกัน และ widget อยู่ค้างจนกว่าจะล้าง |
| 2 · ใครส่ง event อะไร · 15 นาที | examples/s04/02_event_types.py↗ |
แตะของจริงสี่ตัวแล้วเทียบกับ type ที่ไฟล์บอกไว้ล่วงหน้า · จะไม่เสียเวลาทั้งคาบกับ ui.Checkbox ที่ส่ง toggled ไม่ใช่ clicked |
| 3 · จอกับหลอดต้องพูดตรงกัน · 15 นาที | examples/s04/03_switch_matches_led.py↗ |
กด ALL OFF แล้วดูว่าสวิตช์บนจอเด้งกลับเองไหม · จะได้แบบแผน "ฟังก์ชันเดียวที่มีสิทธิ์เปลี่ยนสถานะ" ซึ่งคือเกณฑ์ MVP ข้อที่ตกกันมากที่สุด |
ติดตรงไหน เปิดอันนี้
| อาการที่เจอ | ไฟล์ที่ตอบอาการนั้น |
|---|---|
| ร่างผังจอไว้แล้ว แต่ไม่รู้ว่าวางได้อีกกี่ตัวก่อนจะชนงบ | examples/s04/06_layout_budget.py↗ — ดูแถบโควตาที่ใช้ไปกี่ในงบคอร์ส 32 (เพดานเฟิร์มแวร์ 64) แล้วลองสร้างตัวที่ 33 ให้เห็นว่าเส้นไหนคือของคอร์ส เส้นไหนคือของบอร์ด แล้วกลับไปนับผังของทีมบนกระดาษก่อนพิมพ์โค้ดบรรทัดแรก |
| อยากได้ทศนิยมแต่จอขึ้นจำนวนเต็ม | examples/s04/04_seg7_takes_text.py↗ — .value(50) ได้ 50 ทศนิยมต้องส่งเป็นข้อความ |
| แตะปุ่มแล้วไม่แน่ใจว่าบอร์ดรับไปหรือยัง | examples/s04/05_sound_feedback.py↗ — เสียงเป็นแบบยิงแล้วลืม จอจึงต้องเป็นพยานแทนหูเสมอ และ ui.tone() รับโน้ต MIDI ไม่ใช่เฮิรตซ์ |
| สร้าง widget ไปแล้วแต่อยากย้าย ย่อ ซ่อน หรือลบทิ้งเพื่อคืนโควตา | examples/s04/07_find_move_hide_delete.py↗ — ui.list() บอกว่ามีอะไรอยู่ ui.get(id) เอากลับคืนมาจากเลข แล้ว .pos() .size() .show() .hide() .delete() ทำงานต่อได้ทันที · .hide() ไม่คืนโควตา มีแต่ .delete() ที่คืน |
| ต้องใช้ช่องเลือกหรือช่องพิมพ์ แต่ไม่รู้ว่ามันส่ง event แบบไหน | examples/s04/08_dropdown_textarea.py↗ — Checkbox Dropdown Textarea ครบทั้งสามตัวที่คาบนี้ไม่ได้ใช้ · Dropdown ส่งกลับมาแค่ ลำดับ ของตัวเลือก ไม่ได้ส่งข้อความ |
อ่านเสริมนอกเวลา — เรื่องนี้อยู่นอกเกณฑ์ผ่านของคาบ 4 แต่เป็นแบบแผนที่ทุกแผงควบคุมในโรงงานใช้: examples/usecase/07_hold_to_confirm.py↗ คำสั่งที่ย้อนกลับไม่ได้ ต้องกดค้างเพื่อยืนยัน พร้อมตัวบอกความคืบหน้าที่ปล่อยมือแล้วยกเลิกได้ และห้ามนับถอยหลังด้วย sleep เพราะจะทำให้แตะอย่างอื่นไม่ได้ทั้งช่วง
สามไฟล์แรกคือของที่ต้องเปิดจริงในคาบ ตารางล่างเปิดเฉพาะตอนเจออาการนั้น
ฝั่งระบบสมองกลฝังตัว
สถาปัตยกรรม event-driven บนระบบสองคอร์ · คิวเหตุการณ์และการ poll ข้ามคอร์ · ข้อจำกัดของทรัพยากรที่มีเพดานตายตัว (64 widgets) · ขา GPIO ที่เขียนได้แต่อ่านไม่ได้
ฝั่ง Python และวิทยาการคอมพิวเตอร์
list ของอ็อบเจกต์และ list comprehension · dict กับการเข้าถึงด้วยคีย์ · การนิยามฟังก์ชันเพื่อรวมงานที่ต้องทำพร้อมกัน · .index() เพื่อ map จาก handle กลับเป็นลำดับ · ลูปที่ไม่มีวันจบกับการคืนเวลาให้ระบบ
ฝั่งการออกแบบระบบ
single source of truth · การแยก "สถานะ" ออกจาก "การแสดงผล" · การออกแบบให้ทุกเส้นทางการเปลี่ยนแปลงผ่านประตูเดียว · การทดสอบด้วยการมองสองฝั่งพร้อมกัน
แนวคิด single source of truth จะกลับมาอีกในคาบ 10 ตอนที่คำสั่งมาจาก MQTT แทนที่จะมาจากนิ้ว
วันนี้เราได้:
สร้าง widget เป็น วางตำแหน่งเป็น และรู้ว่า value= คือขนาดฟอนต์ · เข้าใจ handle กับ .id() · เขียน event loop ครบวงจร poll → dispatch → act → sleep · ต่อเหตุการณ์บนจอเข้ากับ gpio.led() · และที่สำคัญที่สุด รักษาให้จอกับของจริงตรงกันเสมอ
การบ้านของทีม: เลือกทำ 1 ข้อจากสี่ข้อในสไลด์ "ต่อยอด" บันทึกลง worksheet ข้อ 8
คาบหน้า: เราจะเพิ่ม input แบบอนาล็อกเข้ามา — หมุนลูกบิด pot และเลื่อนนิ้วบนแถบ CapSense แล้วเอาค่ามาขับ ui.Bar บนไม้บรรทัด ui.Scale พร้อมรู้จักการกรองสัญญาณรบกวนด้วย dsp.EMA · ส่วนขยาย 17 ของวันนี้ทำให้เห็นแล้วว่าค่าหนึ่งค่าบนจอออกไปถึงหน้าเว็บได้อย่างไร ค่าลูกบิดกับแผ่นสัมผัสของคาบหน้าก็เป็นค่าแบบเดียวกัน
event loop ที่เขียนวันนี้ จะเป็นโครงเดิมของทุกโปรแกรมที่เหลือในคอร์สนี้
s04_touch_panel.py↗ — ส่วนที่หนึ่ง: เตรียมของอ่านให้เข้าใจ แล้วพิมพ์เอง อย่าคัดลอกวาง
import ui
import gpio
import time
BTN_TEXT = ["แดง", "เขียว", "น้ำเงิน"] # สามสีของแผง เรียงตรงกับ LED_IDX ข้างล่าง
COL_ON = [0xE53935, 0x43A047, 0x1E88E5] # สีไฟสถานะตอนติด เรียงตรงกับ BTN_TEXT
COL_OFF = 0x171B22 # สีเทาของปุ่มที่ไม่ได้กำลังทำงาน
COL_TEXT, COL_DIM, COL_CARD = 0xE8EAED, 0x9AA3AF, 0x171B22
# ดวงจริงของแต่ละสี ถามจากชื่อที่บอร์ดรายงาน
# Dev Kit รายงาน RGB_RED/GREEN/BLUE ครบ -> ใช้ชื่อ
# Eva Kit ไม่ครบ -> ดวง 0 1 2 คือ แดง เขียว น้ำเงิน
LED_NAMES = gpio.board_info()["led_names"]
RGB_NAMES = ("RGB_RED", "RGB_GREEN", "RGB_BLUE")
if all(n in LED_NAMES for n in RGB_NAMES):
LED_IDX = [LED_NAMES.index(n) for n in RGB_NAMES]
else:
LED_IDX = [0, 1, 2]
led_on = [False] * len(LED_IDX) # ความจริงอยู่ที่นี่
ui.screen()
time.sleep_ms(200)
led_on ถูกประกาศ ก่อน สร้าง widget ใด ๆ เพราะมันคือแกนกลางของโปรแกรม ส่วนจอเป็นแค่ผู้รายงาน · สี่สีบรรทัดที่สี่คือจานสีของหลักสูตร (SPEC §S7.13) — COL_OFF เท่ากับ COL_CARD โดยตั้งใจ ปุ่มที่ไม่ได้กำลังทำงานต้องกลืนไปกับพื้น
การเก็บสี ON ของแต่ละสีเป็น list คู่ขนานกับ BTN_TEXT ทำให้ทุกอย่างอ้างด้วยดัชนี i ตัวเดียวได้ตลอดทั้งไฟล์ — แถวที่ 0 คือแดงทั้งข้อความ ทั้งสี ทั้ง gpio.led(LED_IDX[0]) · LED_IDX คือ list ที่สี่ที่เดินด้วยดัชนีเดียวกัน แต่ค่าข้างในถามจากบอร์ด เพราะ Eva Kit กับ Dev Kit เรียงดวงไม่เหมือนกัน (บน Dev Kit สีน้ำเงินคือดวง 3 ไม่ใช่ 2 และดวง 0-1 อยู่บนโมดูล)
ข้อมูลที่คู่กันให้เรียงให้ตรงกัน แล้วดัชนีเดียวจะพาเราไปได้ทั้งโปรแกรม
ui.Label("แผงควบคุม LED ของทีม", x=24, y=8, color=COL_TEXT, value=28)
status = ui.Label("พร้อมรับคำสั่ง", x=360, y=12, color=COL_DIM, value=20)
...
ROW_TOP = 52 # ขอบบนของแถวแรก
ROW_PITCH = 120 # ปุ่มสูง 88 + ระยะระหว่างเป้าสัมผัส 32 = 120
lamps = []
btn_on = []
btn_off = []
for i in range(len(LED_IDX)):
y = ROW_TOP + i * ROW_PITCH
lamps.append(ui.Led(x=24, y=y + 20, w=48, h=48, color=COL_ON[i], value=0))
ui.Label(BTN_TEXT[i], x=88, y=y + 32, color=COL_DIM, value=16)
btn_on.append(ui.Button("เปิด", x=184, y=y, w=144, h=88, color=0x30A46C, value=20))
btn_off.append(ui.Button("ปิด", x=360, y=y, w=144, h=88, color=0x3A4150, value=20))
...
on_ids = [b.id() for b in btn_on]
off_ids = [b.id() for b in btn_off]
ทำไมเลิกใช้ ui.Switch ตัวเดียวคุมสามสี — สวิตช์คือปุ่มสลับ และปุ่มสลับบอกไม่ได้ว่าตอนนี้อยู่สถานะไหน คนกดต้องอ่านจากที่อื่นแล้วเดา แผงควบคุมจริงจึงแยก ปุ่มเปิด กับ ปุ่มปิด เสมอ ปุ่ม "เปิด" ที่กดซ้ำสิบครั้งได้ผลเดียวกับกดครั้งเดียว ซึ่งสำคัญมากตอนคนกดซ้ำเพราะไม่แน่ใจ
ไฟสถานะแยกจากปุ่ม ไม่ใช่ให้ปุ่มเปลี่ยนสีเอง เพราะสองอย่างนี้คนละหน้าที่ — ไฟตอบว่า "ตอนนี้เป็นยังไง" ปุ่มตอบว่า "สั่งอะไรได้" และไฟผ่านการทดสอบขาวดำ ส่วนปุ่มที่เปลี่ยนสีไม่ผ่าน · ปุ่มสูง 88 และห่างกัน 32 ตามระยะนิ้วจริง ปุ่มเปิดใช้สีสถานะ ok 0x30A46C ปุ่มปิดใช้สีปุ่มรอง 0x3A4150
ทุกอย่างในแถวอ้างด้วย
iตัวเดียว — ข้อความ สี ไฟ ปุ่ม และดวงจริงLED_IDX[i]
ui.Panel(x=520, y=48, w=248, h=308, color=COL_CARD, min=COL_DIM, max=12, value=1)
lbl_count = ui.Label("ติดอยู่ 0 จาก " + str(len(LED_IDX)) + " ดวง", x=536, y=60,
color=COL_TEXT, value=20)
ui.Label("คำสั่งทั้งชุด", x=536, y=96, color=COL_DIM, value=16)
btn_all_on = ui.Button("เปิดทั้งหมด", x=536, y=132, w=216, h=88, color=0x30A46C, value=20)
btn_all_off = ui.Button("ปิดทั้งหมด", x=536, y=252, w=216, h=88, color=0x3A4150, value=20)
...
box = ui.MsgBox("ปิดทั้งหมด\nไฟทุกสีจะดับพร้อมกัน", x=48, y=88, w=496, h=160,
color=COL_CARD)
btn_yes = ui.Button("ปิดทั้งหมด", x=568, y=88, w=152, h=88, color=0x3A4150, value=20)
btn_no = ui.Button("ไม่ปิด", x=568, y=208, w=152, h=88, color=0x3A4150, value=20)
box.hide()
btn_yes.hide()
btn_no.hide()
นับ widget กันชัด ๆ ทั้งหน้า: หัวเรื่อง 1 + บรรทัดสถานะ 1 + ไฟสถานะ 3 + ป้ายชื่อสี 3 + ปุ่มเปิด 3 + ปุ่มปิด 3 + การ์ดขวา 1 + ป้ายบนการ์ด 2 + คำสั่งชุด 2 + กล่องยืนยันกับปุ่มคำตอบ 3 = 22 ตัว — ต่ำกว่างบของคอร์ส 32 (เพดานเฟิร์มแวร์ 64) เหลือที่ให้การบ้านต่อยอด
ปุ่มล่างของการ์ดจบที่ y=340 พอดี ต่ำกว่านั้นคือมุมที่ปุ่ม Console จองไว้ · กล่องยืนยันกับปุ่มคำตอบสองปุ่ม สร้างพร้อมหน้าจอแล้วซ่อนไว้ ไม่ใช่สร้างตอนกด — การสร้างของตอนคนกำลังรอคำตอบ คือการเพิ่มความหน่วงในจังหวะที่แย่ที่สุด · คำในกล่องบอก สิ่งที่จะเกิด ไม่ใช่ถามลอย ๆ ว่า "แน่ใจไหม" · ปุ่มคำตอบเป็น ui.Button จริงสองปุ่ม เพราะ MsgBox ของเฟิร์มแวร์นี้มีแค่หัวเรื่องกับเนื้อความ (บรรทัดแรกของ text คือหัวเรื่อง) ไม่มีปุ่มคำตอบในตัว (ui_widget_mgr.c:1849-1880)
ทุกครั้งที่ออกแบบหน้าใหม่ ให้นับ widget บนกระดาษก่อนพิมพ์ ไม่ใช่ไปเจอ RuntimeError ตอนรัน
def show_status():
n = 0
for i in range(len(LED_IDX)):
if led_on[i]:
n += 1
lbl_count.text("ติดอยู่ " + str(n) + " จาก " + str(len(LED_IDX)) + " ดวง")
def set_led(i, on):
# i คือแถวของแผง (0 แดง 1 เขียว 2 น้ำเงิน) ดวงจริงอยู่ที่ LED_IDX[i]
led_on[i] = on # 1) จำไว้ก่อน
if on:
gpio.led(LED_IDX[i]).on() # 2) สั่งของจริง
lamps[i].value(1) # 3) ไฟบนจอสะท้อนของจริง
else:
gpio.led(LED_IDX[i]).off()
lamps[i].value(0) # หรี่ ไม่ใช่หาย
show_status() # 4) รายงานให้ครบ
for i in range(len(LED_IDX)):
set_led(i, False) # เริ่มจากที่รู้แน่
ลูปสามบรรทัดสุดท้ายมีความหมายมากกว่าที่เห็น — เราไม่ได้ สมมติ ว่าไฟดับตอนเริ่ม แต่เรา สั่งให้มันดับ แล้วให้จอรายงานตามนั้น ถ้าโปรแกรมก่อนหน้าทิ้งไฟติดค้างไว้ บรรทัดนี้จะเก็บกวาดให้เอง
โปรแกรมที่ดีไม่เชื่อสภาพเริ่มต้นที่ตัวเองไม่ได้เป็นคนกำหนด
while True:
for ev in ui.poll():
h = ev['handle']
t = ev['type']
if t != 'clicked':
continue
if h in on_ids:
set_led(on_ids.index(h), True)
elif h in off_ids:
set_led(off_ids.index(h), False)
elif h == btn_all_on.id():
for i in range(len(LED_IDX)):
set_led(i, True)
elif h == btn_all_off.id() and not asking:
asking = True
box.show()
btn_yes.show()
btn_no.show()
elif h == btn_yes.id() and asking:
asking = False
for i in range(len(LED_IDX)):
set_led(i, False)
box.hide()
btn_yes.hide()
btn_no.hide()
time.sleep_ms(50)
set_led(on_ids.index(h), True) สั่งเปิด ไม่ใช่สั่งสลับ — คำสั่งที่ระบุปลายทางแบบนี้เรียกว่า idempotent กดซ้ำกี่ครั้งก็ได้ผลเดิม ต่างจากคำสั่งสลับที่ผลขึ้นกับสถานะก่อนหน้า ซึ่งเป็นสิ่งที่คนกดมองไม่เห็น · ทุกปุ่มส่ง 'clicked' เหมือนกันหมด จึงกรอง type ทิ้งรอบเดียวข้างบน แล้วแยกด้วย handle อย่างเดียว — สั้นกว่าและพลาดยากกว่าการเช็กควบสองอย่างทุกกิ่ง
ปิดทั้งหมด เป็นกิ่งเดียวที่ไม่ลงมือทันที มันเปิดกล่องยืนยันก่อน เพราะแตะทีเดียวเปลี่ยนของจริงสามชิ้นพร้อมกัน และคำยืนยันบอก สิ่งที่จะเกิด ว่า "ไฟทุกสีจะดับพร้อมกัน" ไม่ใช่ถามลอย ๆ ว่า "แน่ใจไหม"
while Trueที่ไม่มีtime.sleep_ms()คือบั๊กที่ไม่แสดงตัวเป็น error แต่ทำให้ทั้งระบบช้าลง
ท่า 1 ล้างจอ + หัวเรื่อง พิสูจน์ว่าช่องทางถึงจอใช้ได้ ถ้าหัวเรื่องยังไม่ขึ้น อย่าเพิ่งเขียนอะไรต่อ
ท่า 2 แถวของแต่ละดวง มาก่อนตรรกะ เพราะ "มองเห็น" ง่ายที่สุด ตอนนี้กดยังไม่มีอะไรเกิด — และนั่นถูกต้องแล้ว
ท่า 3 คำสั่งทั้งชุดกับกล่องยืนยัน สร้างพร้อมกันตรงนี้แล้วซ่อนไว้ ไม่ใช่ไปสร้างตอนคนกด การสร้างของตอนคนกำลังรอคำตอบ คือการเพิ่มความหน่วงในจังหวะที่แย่ที่สุด
ท่า 4 set_led() มาก่อนลูป เพื่อให้ทดสอบตรง ๆ ได้ด้วย set_led(0, True) บรรทัดเดียว ไฟติดแปลว่าฝั่งฮาร์ดแวร์เรียบร้อย
ท่า 5 ตั้งต้นให้ดับ มาก่อนลูป เพราะ set_led() ต้องเรียกได้ตั้งแต่บรรทัดแรกที่ทำงาน และทุกการรันต้องเริ่มจากจุดเดียวกัน
ท่า 6 event loop มาสุดท้าย เพราะมันเป็นแค่ "คนเดินสาร" ถ้าห้าท่าบนถูกหมด ท่านี้จะสั้นและตรงไปตรงมา
ถ้าเขียน event loop ตั้งแต่แรกแล้วมันเงียบ เราจะไม่รู้เลยว่าพังที่จอ ที่ไฟ หรือที่ตรรกะ
คำถามคิดต่อ: ถ้าคำสั่งเปิดไฟมาจากอินเทอร์เน็ตแทนนิ้ว โค้ดส่วนไหนต้องเปลี่ยน · ส่วนไหนไม่ต้องเปลี่ยนเลย · แล้วเราจะรู้ได้อย่างไรว่าคำสั่งจากเน็ตมาถึงจริง



สามภาพนี้ตอบคำถามเดียวกับที่แผงของเราต้องตอบ: การตอบกลับผู้ใช้ไม่จำเป็นต้องเกิดบนหน้าจอเสมอไป และเมื่อของสำคัญเข้ามา มันต้องแซงทุกอย่างที่แสดงอยู่
ทั้งสี่มุมเจอโจทย์เดียวกันหมด: มีคนสั่งได้หลายทาง แต่ความจริงต้องมีชุดเดียว
ทำไมคลิปนี้เกี่ยวกับการบ้านข้อ 4
ข้อ 4 ให้เอาปุ่มผู้ใช้จริงบนบอร์ด (gpio.button(0) — เรียกด้วยชื่อจาก .name() ไม่ใช่ป้ายบนแผ่นวงจร) มาทำงานร่วมกับปุ่มบนจอในลูปเดียวกัน คลิปนี้ทบทวนว่าทำไมปุ่มกลไกกดครั้งเดียวถึงกลายเป็นหลายเหตุการณ์
ปุ่มบนจอไม่มีปัญหานี้เพราะ CM55 จัดการให้แล้ว — ปุ่มจริงต้องทำเอง
อ่านต่อสำหรับคนอยากรู้ลึก
time.ticks_ms() / ticks_diff() ที่ใช้จับจังหวะลูป — MicroPython docs: https://docs.micropython.org/en/latest/library/time.htmlปุ่มบนจอไม่ต้อง debounce เพราะมีคนทำให้แล้ว ปุ่มจริงต้องทำเอง — จำความต่างนี้ไว้ตอนทำข้อ 4

examples/s04/17_panel_to_broker.py↗ สร้างหลังแก้ TEAM เป็นเลขทีมแล้ว · สายและตัวนับบนภาพเป็นค่าแทนบนเครื่องโฮสต์ ไฟล์นี้ยังไม่เคยรันบนบอร์ดexamples/s04/17_panel_to_broker.py↗ เอาปุ่มกับแถบเลื่อนของวันนี้ไปต่อกับ mqtt ของคาบ 2 ชื่อ broker ทีม และหัวข้อเหมือนคาบ 2 และ 3 ทุกตัวอักษร · ค่าตั้งต้น TEAM = "teamXX" จงใจให้ไม่ยอมรัน แก้เป็นเลขทีมของเราก่อน
| ของบนจอ | ไปที่ไหน | เพราะ |
|---|---|---|
| แตะปุ่ม "ส่ง event" | event ทันที |
เรื่องที่เกิดครั้งหนึ่ง |
| ลากแถบเลื่อน | telemetry ทุก 2 วินาที |
ค่าที่เป็นอยู่ แถบยิง value_changed ถี่มากระหว่างลาก |
{"cmd":"set","v":80} จากเว็บ |
แถบกับตัวเลขบนจอขยับ | ผ่านฟังก์ชันเดียว set_value() แบบ set_led() ของไฟล์ 03 |
คำสั่ง set เป็นคำสั่งใหม่ของไฟล์นี้ พิมพ์ในช่อง "พิมพ์ JSON เอง" ของหน้าเว็บ · say กับ beep ใช้ปุ่มที่มีอยู่แล้ว · ข้อความ say ถูกกรองให้เหลือแค่ ASCII กับไทยก่อนขึ้นจอ และ v ที่แปลงเป็นเลขไม่ได้ (เช่น 1e999) ถูกทิ้ง ไม่ทำให้โปรแกรมล้ม เพราะใครก็ส่งเข้าหัวข้อนี้ได้ · เฟิร์มแวร์นี้ส่ง retain ไม่ได้ ไฟล์จึงส่ง telemetry ซ้ำเอง หน้าเว็บที่เปิดทีหลังเห็นภายในสองวินาที
ไม่อยู่ในเกณฑ์ผ่าน M4 · ต้องมี WiFi ที่ออกพอร์ต 1883 ได้ ซึ่งเครือข่ายในมหาวิทยาลัยยังไม่ได้ทดสอบ · Emulator ใน ide.tesaiot.dev ส่งถึง broker จริงได้แล้วผ่าน WebSocket
ข้อ 1 · โหมดไฟวิ่งสั่งจากจอ — เพิ่มปุ่ม START/STOP ที่เปิด-ปิดโหมดไฟวิ่ง (chaser) ของคาบ 3 ใช้ตัวแปร flag ในลูปเดียวกับ ui.poll() ห้ามใช้ลูปซ้อนที่ทำให้แตะปุ่มอื่นไม่ได้ระหว่างไฟวิ่ง
ข้อ 2 · ตัวนับการกดและปุ่มรีเซ็ต — เก็บจำนวนครั้งที่แต่ละสีถูกสั่งเปิด แสดงบน Label เพิ่มอีกบรรทัด และมีปุ่ม RESET ที่ล้างตัวนับกลับเป็นศูนย์ทั้งสามสี
ข้อ 3 · ล็อกแผงควบคุม — เพิ่ม ui.Switch ชื่อ LOCK เมื่อล็อกอยู่ ปุ่มทั้งหมดต้องกดไม่ได้ และบรรทัดสถานะต้องบอกให้รู้ — ทำอย่างไรให้ผู้ใช้เข้าใจว่าปุ่มถูกปิดการใช้งาน ไม่ใช่ค้าง
ข้อ 4 · ปุ่มจริงกับปุ่มบนจอทำงานร่วมกัน — อ่าน gpio.button(0).is_pressed() ในลูปเดียวกัน ให้ปุ่มผู้ใช้บนบอร์ด (ชื่อจาก btn.name() — บน Dev Kit ห้ามโยกสวิตช์บนฐาน หลายตัวคือสวิตช์ตัดไฟ) สลับไฟสีแดงได้ด้วย โดยสถานะบนจอยังตรงเสมอ (ระวัง debounce แบบที่ทำในคาบ 3)
ทั้งสี่ข้อเดินบน event loop เดิม ไม่ต้องเขียนลูปใหม่ — กติกาเดียวที่ใช้ได้กับทุกข้อคือ ห้ามให้จังหวะของงานหนึ่งไปหยุดการแตะปุ่มของอีกงานหนึ่ง ทุกอย่างที่ต้องรอ ให้จำเวลาไว้ในตัวแปรแล้วกลับมาดูรอบหน้า ไม่ใช่หลับรอด้วย sleep · ทีมที่ทำข้อ 2 ให้กลับไปดูแบบแผน "ฟังก์ชันเดียวที่มีสิทธิ์เปลี่ยนสถานะ" ใน examples/s04/03_switch_matches_led.py↗ เพราะตัวนับกับปุ่ม RESET คือสถานะชุดเดียวกันที่ถูกแตะจากสองทาง · ทีมที่อยากเพิ่มเสียงตอบรับตอนแตะ เปิด examples/s04/05_sound_feedback.py↗
เขียนคำตอบลง worksheet ข้อ 8 แล้วเอามาเล่าให้เพื่อนฟังต้นคาบหน้า

examples/s04/09_scale_led_spinbox.py↗ สร้าง| widget | ตอบคำถามอะไร | ทำไมหน้าจอควบคุมจริงต้องมี |
|---|---|---|
ui.Scale |
ค่านี้สูงไหมเมื่อเทียบกับพิสัย | เลข 72 ลอย ๆ ไม่บอกอะไร · 72 บนไม้บรรทัด 0–100 บอกทันที |
ui.Led |
ตอนนี้สถานะอะไร | ไฟแผงควบคุมที่คนมองปราดเดียวรู้ ไม่ต้องอ่านตัวหนังสือ |
ui.Spinbox |
ผู้ใช้ป้อนค่าที่ต้องการยังไง | แถบเลื่อนป้อน 23.75 ไม่ได้ ช่องนี้ได้ |
ui.Scale ไม่รับ .value() — มันคือไม้บรรทัด ไม่ใช่หน้าปัด ตัวที่ขยับคือสิ่งที่เราวางทับลงไปเอง ในไฟล์นี้คือ ui.Bar ที่วางเหนือมัน · นี่คือเรื่องที่คนเข้าใจผิดบ่อยที่สุดใน LVGL และโค้ด C ของเฟิร์มแวร์เราเองก็ทำแบบนี้ (ยกเว้นแบบวงกลม ที่มีเข็มจริงผ่าน PROP_SCALE_NEEDLE — fw 2026-08-20 ขึ้นไป คาบ 6 สอน)
ui.Led สั่ง .value(0) แล้วหรี่ ไม่ใช่หาย — ตั้งใจให้เป็นแบบนั้น ไฟแผงควบคุมที่หายไปตอนดับ แย่กว่าไฟที่หรี่ลง เพราะคนดูแยกไม่ออกว่าดับหรือจอเสีย
กติกาที่แผงจริงใช้ — ให้ติดทีละดวง ถ้าติดพร้อมกันสองดวงตอนคาบเกี่ยว คนอ่านไม่ออกว่าตกลงสถานะอะไร

BENTO_IDE/bento-emulator) ซึ่งรันโค้ด MicroPython ชุดเดียวกับที่ลงบอร์ด บนพื้นที่วาด 792x398 เท่ากับจอของทั้งสองบอร์ด · แสดงหน้าจอที่ตัวอย่างสร้าง — หน้าจอของ examples/s04/12_table_and_list.py↗ · ค่าที่เห็นในภาพมาจากเซนเซอร์จำลองบนเครื่องโฮสต์ ไม่ใช่ผลการวัดของบอร์ด| widget | ตอบคำถามอะไร |
|---|---|
ui.Table |
หลายค่าพร้อมกัน โดยคอลัมน์ไม่ขยับตามความยาวของค่า |
ui.List |
รายการที่แตะเลือกได้ มีไอคอนนำสายตา และเลื่อนเองเมื่อยาวเกินกรอบ |
ui.Line |
รูปร่างของค่าที่ผ่านมา วาดจากจุดที่เราป้อนเอง |
ui.Picture |
ไอคอนหนึ่งใบ กำหนดสีได้จากโค้ด |
.add_row() เขียนลงแถวถัดจากแถวที่ตัวมันเองเขียนล่าสุด ไม่ใช่แถวที่ยังว่าง — จะแก้ค่าในแถวเดิมต้องใช้ .cell(แถว, คอลัมน์, ข้อความ) ถ้าเผลอใช้ .add_row() ในลูป ตารางจะยาวลงไปเรื่อย ๆ จนเลยกรอบ
ป้ายหลายบรรทัดที่จัดเป็นตารางด้วยการนับพิกเซลเอง จะเลื่อนทันทีที่ค่าเปลี่ยนความยาว —
"27.4"กับ"8.0"กว้างไม่เท่ากัน ตารางจริงจัดคอลัมน์ให้

BENTO_IDE/bento-emulator) ซึ่งรันโค้ด MicroPython ชุดเดียวกับที่ลงบอร์ด บนพื้นที่วาด 792x398 เท่ากับจอของทั้งสองบอร์ด · แสดงหน้าจอที่ตัวอย่างสร้าง — หน้าจอของ examples/s04/13_confirm_before_acting.py↗ · ค่าที่เห็นในภาพมาจากเซนเซอร์จำลองบนเครื่องโฮสต์ ไม่ใช่ผลการวัดของบอร์ด| widget | ตอบคำถามอะไร |
|---|---|
ui.ButtonMatrix |
ปุ่มหลายใบเป็นตาราง ด้วย widget เดียวและโควตาเดียว |
ui.MsgBox |
กล่องยืนยันที่มีหัวเรื่อง เนื้อความ และปุ่มเป็นของตัวเอง |
ปุ่มเปิดกับปุ่มปิดต้องแยกกันคนละใบ — ปุ่มเดียวที่สลับสองสถานะดูประหยัดที่ แต่คนที่เพิ่งเดินมาถึงแผงไม่รู้ว่าตอนนี้อยู่สถานะไหน กดแล้วจะได้ผลตรงข้ามกับที่ตั้งใจ
คำยืนยันต้องบอกสิ่งที่จะเกิดขึ้น ไม่ใช่คำว่า "ยืนยันไหม" ซึ่งไม่ได้เพิ่มข้อมูลให้คนตัดสินใจเลยสักอย่าง
ทั้ง
examples/s04/12_table_and_list.py↗ และexamples/s04/13_confirm_before_acting.py↗ ถูกรันจริงในชุดทดสอบของ Emulator ทุกครั้งที่ไลบรารีเปลี่ยน — ถ้าวันไหน widget ตัวใดวาดไม่ออก ชุดทดสอบล้มก่อนที่ห้องเรียนจะเจอ

examples/s04/10_tabview_second_screen.py↗ สร้าง ตัวเลขอุณหภูมิบนจอเป็นค่าที่ไฟล์นั้นคำนวณขึ้นเอง ไม่ใช่ผลการวัดจากเซนเซอร์ในงานจริง หน้าจอเครื่องหนึ่งเครื่องมีของต้องโชว์มากกว่าที่ตาคนรับไหวเสมอ — ค่าปัจจุบัน ค่าย้อนหลัง เกณฑ์ที่ตั้งไว้ สถานะเครือข่าย ประวัติการแจ้งเตือน ทีมที่ยัดทุกอย่างลงหน้าเดียวจะได้จอที่ "มีข้อมูลครบและอ่านไม่ได้เลย" ซึ่งแย่กว่าจอที่มีของน้อยกว่า
| ทำอะไรได้ | ทำไมมันสำคัญกับหน้าจอควบคุม |
|---|---|
| แบ่งพื้นที่เดิมเป็นหลายหน้า สลับด้วยการแตะ | ของที่อยู่คนละแท็บไม่แย่งที่กัน เพราะไม่เคยอยู่บนจอพร้อมกัน |
| แถบแท็บบอกล่วงหน้าว่ามีอะไรอยู่บ้าง | ต่างจากปุ่ม "หน้าถัดไป" ที่ซ่อนรายการไว้ในหัวคนเขียน |
.add_tab("ชื่อ") คืนหน้า มาให้ใส่ใน parent= |
พิกัดของลูกนับจากมุมซ้ายบนของหน้า ไม่ใช่ของจอ |
ราคาที่ต้องจ่าย — แท็บสามใบกินโควตาไป 4 แฮนเดิล ตั้งแต่ยังไม่มีอะไรอยู่ในนั้น คือตัว Tabview เองหนึ่ง บวกหน้าที่ add_tab() คืนมาอีกใบละหนึ่ง เอาเลขนี้ไปบวกกับงบใน examples/s04/06_layout_budget.py↗ ก่อนวางผัง
value= ของ Tabview คือความสูงของแถบแท็บ ไม่ใช่แท็บที่เปิดอยู่ — ตั้ง value=88 เพราะแถบแท็บคือของที่ต้องแตะ และ 88 พิกเซลคือขั้นต่ำของเป้าสัมผัส ค่าปริยายคือ 40 ซึ่งเตี้ยเกินไป
กติกาข้อเดียวที่ห้ามลืม — สิ่งที่ต้องเห็นตลอดเวลาห้ามอยู่ในแท็บ สถานะเครือข่าย สัญญาณเตือน และปุ่มหยุดฉุกเฉิน ต้องอยู่นอกแท็บเสมอ ไม่งั้นมันจะหายไปตอนที่ผู้ใช้กำลังดูแท็บอื่น ซึ่งคือตอนที่มันสำคัญที่สุดพอดี

examples/s04/11_menu_settings_tree.py↗ สร้าง ค่าที่เห็นในเมนูมาจาก dict ในไฟล์ตัวอย่างเองแท็บเหมาะกับของที่เท่ากัน เมนูเหมาะกับของที่เป็นชั้น — "ตอนนี้ ทั้งวัน เกณฑ์" คือสามหัวข้อที่ใครมาก่อนมาหลังก็ได้ ส่วน "ตั้งค่า > เครือข่าย > วงที่ใช้" ไม่ใช่ ยัดโครงแบบหลังลงแท็บจะได้แท็บสิบใบที่ไม่มีใครหาของเจอ
| ชิ้นส่วน | ได้มาจาก | ทำหน้าที่ |
|---|---|---|
| หน้า | menu.add_page("ชื่อ") |
หน้าแรกที่สร้างคือหน้าที่เมนูเปิดให้เอง |
| กลุ่ม | page.section() |
กล่องจัดกลุ่มแถว — ไม่ใช่หน้า |
| แถว | section.row("ข้อความ") |
บรรทัดที่แตะได้ |
| เส้นเชื่อม | row.opens(page) |
หัวใจของ widget ตัวนี้ ขาดบรรทัดนี้เมนูจะแตะได้แต่ไม่ไปไหน |
ข้อจำกัดที่ต้องออกแบบรอบมัน — เมนูไม่ส่ง event กลับมาเลยสักตัว ทั้งตัวเมนูและแถวของมันไม่ได้ลงทะเบียน callback ไว้ใน ui_widget_mgr.c การแตะแถวถูกจัดการจบภายใน LVGL แปลว่า โปรแกรมเราไม่มีทางรู้ว่าผู้ใช้เปิดหน้าไหนอยู่
เมนูจึงเป็น "ที่ให้คนเดินดู" ไม่ใช่ "ที่ให้โปรแกรมรับคำสั่ง" — ของที่ต้องรับคำสั่งยังต้องเป็น Button Switch หรือ Spinbox ที่วางไว้ต่างหาก นี่คือเหตุผลที่หน้าจอในตัวอย่างมีการ์ดสรุปอยู่ข้าง ๆ เมนูเสมอ
product ต่างจาก demo ตรงนี้เอง — demo ฝังค่าไว้ในโค้ดแล้วแฟลชใหม่เมื่อต้องเปลี่ยน ส่วน product มีหน้าตั้งค่าให้คนหน้างานเปิดดูได้ว่าตอนนี้เครื่องใช้ค่าอะไรอยู่ โดยไม่ต้องมีใครเปิดซอร์สให้ดู



เอกสารของผู้ผลิตและซอฟต์แวร์
time (ticks_ms / ticks_diff): https://docs.micropython.org/en/latest/library/time.html — เอกสาร upstream มี machine.PWM/ADC/Timer ที่ พอร์ตของเรายังไม่มีวิดีโอ (ตรวจแล้วว่าเปิดได้ · บันทึกใน _BUILD/MEDIA_PLAN.md)
ภาพ — วงจร User LEDs มาจากคู่มือคิต รูปที่ 78 (หน้า 89) ใช้เพื่อการเรียนการสอน · ภาพถ่ายจากภายนอกทั้งเจ็ดภาพเป็น Wikimedia Commons ภายใต้ CC0 / CC BY / CC BY-SA / สาธารณสมบัติ เครดิตอยู่ใต้ภาพแต่ละใบบนสไลด์ · ภาพหน้าจอบอร์ดสี่ภาพเป็นของผู้สอนเอง ถ่ายจาก Eva Kit ขณะรันไฟล์ใน examples/s04/ · ไดอะแกรมอื่นทุกภาพวาดใหม่เป็น inline SVG ไม่ได้คัดลอกจากเอกสารผู้ผลิต
ตัวอย่างโค้ด — ชุดประจำคาบอยู่ที่ examples/s04/ (แปดไฟล์ · หมายเลข 07 กับ 08 เพิ่มเข้ามาเพื่อให้ทุกฟังก์ชันของ ui ฝั่งอินพุตมีที่ให้ลงมือจริง) · ตัวอย่างต่อยอดหนึ่งไฟล์คือ examples/usecase/07_hold_to_confirm.py↗ · ชุดเสียงสี่ไฟล์ที่เคยอยู่ใน examples/usecase/ หมายเลข 08 ถึง 11 ถูกถอดออกจากคลังเมื่อ 14 ส.ค.
ตัวเลขที่สืบกลับได้ — คิววงแหวน 16 ช่อง ใส่ได้จริง 15 และทิ้งของใหม่เมื่อเต็ม (ui_widget_mgr.c:2687-2690, UI_EVENT_RING_SIZE ใน ipc_ui_protocol.h:77) · หยิบได้ครั้งละ 8 (UI_MAX_EVENTS_PER_POLL) · เพดานเฟิร์มแวร์ 64 widget (UI_MAX_WIDGETS, ipc_ui_protocol.h:675; งบของคอร์ส 32 มาจาก SPEC S2.8) · พื้นที่ปริยาย 792 × 398 (ui_widget_defaults.h) · เพดานข้อความ 126 ไบต์ทั้งตอนสร้างและตอน .text() (IPC_DATA_MAX_LEN ลบสอง) · ui.tone รับตำแหน่ง 1-4 ตัว ปริยาย WAVE_SQUARE / 100 / 150 ms (modui.c:1741-1760) · เสียงสำเร็จรูป 21 ตัว รูปคลื่น 4 แบบ (ตารางค่าคงที่ modui.c:1928-1953)
พฤติกรรมของ ui, gpio และกฎเหล็กห้าข้อ ตรวจจากซอร์สโค้ด KIT_PSE84_EVAL_EPC2-MicroPython-BentoClaw และ BENTO-TESAIoT-libraries โดยตรง แล้วให้ทีมตรวจอิสระอีกชุดไล่หักล้างทีละข้อ
ทุกตัวเลขบนสไลด์ชุดนี้สืบกลับไปที่เอกสารต้นทางหรือซอร์สโค้ดได้ ถ้าเจอที่ไม่ตรง บอกผู้สอนได้เลย

KIT_PSE84_EVAL_EPC2-MicroPython-BentoClaw/sim) ซึ่งรัน ipc_ui.c กับ ui_widget_mgr.c ตัวจริงเดียวกับบอร์ด บนพื้นที่วาด 800x398 · แสดงหน้าจอที่ตัวอย่างสร้าง — หน้าจอของ examples/s04/14_container_coordinates.py↗ · ค่าที่เห็นในภาพมาจากเซนเซอร์จำลองบนเครื่องโฮสต์ ไม่ใช่ผลการวัดของบอร์ดparent= ทำสองอย่างพร้อมกัน และข้อที่สองคือข้อที่ทำให้คนงง
| ผลที่ได้ | |
|---|---|
| จัดกลุ่ม | ย้ายกล่อง ลูกย้ายตาม ลบกล่อง ลูกหายไปด้วย |
| เปลี่ยนระบบพิกัด | x=0, y=0 ของลูก คือมุมซ้ายบนของกล่อง ไม่ใช่ของจอ |
ป้ายสามใบในภาพเขียนข้อความเดียวกันและสั่งพิกัดเดียวกันทั้งสามใบ แต่ไปโผล่คนละที่ เพราะอยู่คนละกล่อง
กล่องกินแฮนเดิลของตัวเอง จากโควตา (งบคอร์ส 32 · เพดานเฟิร์มแวร์ 64) — กล่องเปล่าที่ทำหน้าที่แค่จัดกลุ่ม ยังต้องจ่ายหนึ่งใบ
tools/check_overlap.pyข้าม widget ที่มีparent=แล้วนับให้เห็นว่าข้ามไปกี่ตัว เพราะมันอ่านพิกัดจากซอร์สและไม่รู้ว่าลูกอยู่คนละระบบพิกัด — จอที่ทำด้วยกล่อง ต้องเปิดภาพดูเอง

KIT_PSE84_EVAL_EPC2-MicroPython-BentoClaw/sim) ซึ่งรัน ipc_ui.c กับ ui_widget_mgr.c ตัวจริงเดียวกับบอร์ด บนพื้นที่วาด 800x398 · แสดงหน้าจอที่ตัวอย่างสร้าง — หน้าจอของ examples/s04/15_press_and_hold.py↗ · ค่าที่เห็นในภาพมาจากเซนเซอร์จำลองบนเครื่องโฮสต์ ไม่ใช่ผลการวัดของบอร์ดclicked มาถึงตอน ปล่อยนิ้วแล้ว ปุ่มเร่งที่ต้องเดินขึ้นเรื่อย ๆ ระหว่างกดค้าง จึงทำด้วย clicked ไม่ได้เลย
| ชนิด | มาถึงเมื่อไร |
|---|---|
pressed |
นิ้วแตะลง — เริ่มทำงานได้ตรงนี้ |
long_pressed |
กดค้างเกิน 400 มิลลิวินาที — มาครั้งเดียว |
long_pressed_repeat |
ยังกดอยู่ ย้ำทุก 100 มิลลิวินาที · value คือจำนวนครั้งที่ย้ำมาตั้งแต่ poll() รอบที่แล้ว |
press_lost |
ยังกดอยู่ แต่เลื่อนนิ้วออกนอกปุ่มแล้ว |
released |
ปล่อยแล้ว มาถึงเสมอ แม้ปล่อยนอกปุ่ม |
ทั้งห้าชนิดต้องขอก่อนด้วย .listen() ค่าตั้งต้นคือเงียบ — ปุ่มที่ไม่มีใครขอรับ จะไม่ส่งอะไรลงคิวเลยแม้แต่ใบเดียว
value ของ long_pressed_repeat ให้อ่านว่า "ไปไกลแค่ไหน" ไม่ใช่ "มากี่ข้อความ" — ปุ่มที่กดค้างยาวแค่ไหนก็กินคิวแค่ ช่องเดียว เพราะการย้ำที่ติดกันจะถูกรวมเข้ากับใบเดิมแล้วบวกเลขใน value ขึ้นไป ที่ทำแบบนี้เพราะถ้าปล่อยให้ย้ำกินช่องละใบ คิวจะเต็มไปด้วยการย้ำ แล้วใบที่ตกคิวจะกลายเป็น released — ซึ่งคือใบที่สั่งให้หยุด
ถามตัวเองว่า "ต้องรู้ตอนไหน" ก่อนเลือกชนิด — ตอนเริ่ม ตอนกำลังทำ หรือตอนจบ

KIT_PSE84_EVAL_EPC2-MicroPython-BentoClaw/sim) ซึ่งรัน ipc_ui.c กับ ui_widget_mgr.c ตัวจริงเดียวกับบอร์ด บนพื้นที่วาด 800x398 · แสดงหน้าจอที่ตัวอย่างสร้าง — หน้าจอของ examples/s04/16_swipe_scroll_focus.py↗ · ค่าที่เห็นในภาพมาจากเซนเซอร์จำลองบนเครื่องโฮสต์ ไม่ใช่ผลการวัดของบอร์ด| ชนิด | ใครส่ง | value บอกอะไร |
|---|---|---|
gesture |
ของที่ไม่เลื่อนตัวเอง เช่น ui.Panel |
ทิศที่ปัด ui.DIR_LEFT และพวกเดียวกัน |
scroll_begin / scroll_end |
ของที่เลื่อนตัวเองได้จริง เช่น ui.List |
— |
focused / defocused |
widget ที่รับสัมผัส | — |
gesture จะไม่ถูกส่งระหว่างที่มีการเลื่อนเกิดขึ้น — พอ LVGL ตัดสินใจว่านิ้วนี้กำลังเลื่อนเนื้อหาของ widget ตัวใดตัวหนึ่ง มันจะถือว่านิ้วนั้น "ถูกจองแล้ว" และไม่แปลงเป็นการปัดสั่งงาน แผ่นสำหรับปัดจึงต้องเป็นของที่ไม่เลื่อนตัวเอง อย่าง ui.Panel ส่วน scroll_begin / scroll_end ต้องขอจากของที่เลื่อนได้จริง — ในตัวอย่างนี้คือ ui.List ที่มีรายชื่อยาวกว่ากรอบ
ข้อยกเว้นที่ต้องรู้ — ui.Roller ไม่ส่ง scroll_* เลย วงล้อดู "เลื่อนได้" แต่ในสายตา LVGL มันไม่ใช่ของที่เลื่อนได้ ตอนสร้างมันสั่ง lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE) ใส่ตัวเอง แล้วขยับป้ายข้างในด้วยมือ scroll_begin / scroll_end จึงไม่มีวันมาถึงมัน สิ่งที่วงล้อรายงานคือ value_changed ตอนตัวเลือกเปลี่ยน
focused ใช้ตอบว่า "ตอนนี้ผู้ใช้อยู่ที่ช่องไหน" — ฟอร์มหลายช่องผูกแป้นพิมพ์ตามช่องที่ถูกแตะได้ด้วยเหตุการณ์นี้ตัวเดียว โดยไม่ต้องมี lv_group และไม่ต้องมีคีย์บอร์ดจริง
LVGL เห็นนิ้ว → CM55 เรียก callback → คิว 16 ช่อง → ui.poll() ตักได้ 8 → ลูปของเรา
สี่ขั้นนี้อธิบายเกือบทุกอาการที่ผู้เรียนจะเจอในคาบนี้
| อาการ | ขั้นที่เป็นเหตุ |
|---|---|
| กดปุ่มแล้วไม่มีอะไรเกิดขึ้น | ลูปไม่ได้เรียก ui.poll() — คิวเต็มแล้วของใหม่ถูกทิ้ง |
| ปุ่มหน่วง ๆ กดไม่ค่อยติด | time.sleep_ms() นาน ระหว่างหลับไม่มีใครตักคิว |
| แตะรัว ๆ แล้วบางครั้งหาย | ตักได้ครั้งละ 8 ที่เหลือรอรอบหน้า ถ้าลูปช้าก็ไล่ไม่ทัน |
คิวมี 16 ช่อง และของใหม่คือตัวที่ถูกทิ้งเมื่อเต็ม ไม่ใช่ของเก่า — เพราะของเก่าอาจเป็นเหตุการณ์ที่โปรแกรมกำลังจะประมวลผลอยู่พอดี
ui.poll()ไม่ใช่การ "ถามว่ามีอะไรไหม" แต่เป็นการ "ตักออกจากคิว" — ไม่ตัก คิวก็เต็ม
.listen()เหตุการณ์สามชนิดมาถึงเสมอโดยไม่ต้องขอ ส่วนอีกสิบสองชนิด เงียบจนกว่าจะขอ
| ชนิด | ต้องขอไหม | |
|---|---|---|
| มาเอง | clicked value_changed toggled |
ไม่ต้อง |
| ต้องขอ | pressed released press_lost long_pressed long_pressed_repeat ready cancel focused defocused scroll_begin scroll_end gesture |
.listen("...") |
btn.listen("pressed", "long_pressed_repeat", "released")
เหตุผลคือคิว 16 ช่องนั้นเอง — นิ้วที่แตะจอหนึ่งครั้ง ทำให้เกิดได้ทั้ง pressed focused defocused released และถ้ามีการเลื่อนด้วยก็ scroll_begin scroll_end ตามมาอีก นิ้วเดียว หลายใบ ถ้าทุก widget บนจอส่งทุกชนิดตลอดเวลาโดยไม่มีใครอ่าน คิวสิบหกช่องเต็มได้ในไม่กี่การแตะ แล้วเหตุการณ์ที่เราสนใจจริงจะถูกทิ้งตั้งแต่ยังไม่ถึงมือเรา
ค่าตั้งต้นจึงเป็นเงียบ แล้วให้เราบอกว่าจะอ่านอะไร — widget ที่ไม่เคยเรียก .listen() เลย จะไม่ถูกติดตัวดักเหตุการณ์ให้ด้วยซ้ำ
ขอเท่าที่จะอ่านจริง ไม่ขอเผื่อไว้ — ทุกชนิดที่ขอเพิ่มคือช่องในคิวที่ถูกใช้จริง
clicked มาถึงตอน ปล่อยนิ้วแล้ว ปุ่มเร่งที่ต้องเดินขึ้นระหว่างกดค้าง จึงทำด้วย clicked ไม่ได้เลย
| อยากรู้ว่า | ใช้ชนิด |
|---|---|
| เริ่มแตะแล้ว | pressed |
| ยังกดค้างอยู่ | long_pressed_repeat — value คือจำนวนครั้งที่ย้ำมาตั้งแต่ poll() รอบที่แล้ว |
| นิ้วเลื่อนออกนอกปุ่มแล้ว | press_lost — สั่งหยุดตรงนี้ |
| จบแล้ว | released — มาถึงเสมอ แม้ปล่อยนอกปุ่ม |
| ผู้ใช้พิมพ์เสร็จ | ready |
| ตอนนี้อยู่ที่ช่องไหน | focused |
ถามตัวเองว่า "ต้องรู้ตอนไหน" ก่อนเลือกชนิด — ตอนเริ่ม ตอนกำลังทำ หรือตอนจบ
gestureไม่ถูกส่งระหว่างที่มีการเลื่อนเกิดขึ้น และui.Rollerไม่ส่งscroll_*เลยเพราะ LVGL ไม่ถือว่ามันเป็นของที่เลื่อนได้ — สองข้อนี้เจอจากการปัดจอจริงก่อน แล้วจึงไปอ่านlv_roller.cเพื่อยืนยันว่าทำไม
fit-css
VIDEO-SLOT: คลิป 40-60 วินาที ถ่ายจอบอร์ด Eva Kit — เปิดเมนู Controls ของเฟิร์มแวร์ (Dev Kit ไม่มีการ์ดนี้) แตะวงกลมสีให้ LED จริงบนบอร์ดติดทีละดวง แล้วแพนกล้องลงไปที่หลอด LED จริง ให้เห็นว่าจอกับหลอดเปลี่ยนพร้อมกัน
ภาพหน้าจอ examples/s04/04_seg7_takes_text.py (img/board/examples__s04__04_seg7_takes_text_a.png) ย้ายออกจากสไลด์นี้เพราะไม่มีที่ — ยังอยู่ในสไลด์ "หน้าจอของทุกไฟล์ในคาบนี้ (2/3)" · หมายเหตุเดิม: ภาพบันทึกก่อน 2026-09-05 บรรทัดสีส้มเป็นหลักฐานของพฤติกรรมเก่า ตอนนั้น seg.value(88) ไม่ทำอะไรเลยและจอค้างที่ 0.0 ปัจจุบันคำสั่งเดียวกันขึ้น 88 ทันที ที่ยังจริงคือทศนิยมต้องส่งเป็นข้อความ
VIDEO-SLOT: คลิป 20-30 วินาที ถ่ายตอนผ่าน MVP โดยให้เห็น "จอกับหลอดไฟในเฟรมเดียวกัน" — แตะ "เปิด" แถวแดงแล้วหลอดสีแดงติดพร้อมไฟสถานะบนจอ กด "เปิดทั้งหมด" ให้ติดครบสามสี แล้วปิดท้ายด้วยการกด "ปิดทั้งหมด" ให้เห็นกล่องยืนยันโผล่ก่อน แตะยืนยันแล้วไฟดับหมดพร้อมตัวเลข "ติดอยู่ 0 จาก 3" ใช้เป็นตัวอย่าง "หน้าตาของคำว่าผ่าน" ให้ทุกทีมเทียบ
ชุดตัวอย่างเรื่องเสียง 4 ไฟล์ที่เคยอยู่ตรงนี้ ถูกถอดออกจากคลังเมื่อ 14 ส.ค. เพราะเสียงไม่อยู่ในผลลัพธ์ของคาบใดในหลักสูตรนี้ · เรื่องเสียงที่ยังเหลือ อยู่ในไฟล์เดียวคือ 05_sound_feedback.py