คาบ 4 — สร้าง Touch UI ควบคุมฮาร์ดแวร์

แผงควบคุมของทีมเราเอง: แตะบนจอแล้วไฟจริงติด

คาถาประจำคาบ: จอไม่ใช่ของจริง จอคือรายงานของจริง — สองอย่างนี้ต้องตรงกันเสมอ

ย้อนกลับไปที่เมนู Controls อีกครั้ง (Eva Kit)

จอบอร์ด Eva Kit · เมนู Controls แดง เขียว น้ำเงิน แตะวงกลมบนจอ = สั่งไฟจริง สถานะบนจอต้องตรงกับหลอดเสมอ IPC หลอดจริงบน Eva Kit D3 D4 D5 คาบนี้เราสร้างฝั่งซ้ายเอง

คาบ 1 เราแตะหน้านี้เล่น (บน Eva Kit — Dev Kit ไม่มีการ์ด Controls จึงไม่เคยเห็นหน้านี้ และนั่นยิ่งเป็นเหตุผลให้สร้างเอง) คาบ 3 เราสั่งไฟด้วยโค้ด gpio.led(i).on() ที่ไม่มีหน้าจอเลย

คาบนี้เราจะสร้างหน้าจอแบบนี้เอง — ปุ่มบนจอที่ทีมเราวางเอง แตะแล้วหลอดไฟจริงบนบอร์ดติดจริง และรันได้ทั้งสองบอร์ด

สังเกตสามอย่างที่เดี๋ยวเราต้องทำให้ได้เอง: ปุ่มรู้ว่าถูกแตะ · ไฟจริงเปลี่ยนสถานะ · ตัวหนังสือบนจอเปลี่ยนตาม

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

ทำไม · คืออะไร · ทำยังไง — แผนที่ของคาบนี้

คำถาม คำตอบของคาบนี้ อยู่ช่วงไหน
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 เราเป็นคนสั่งไฟเอง · คาบนี้เราเปิดให้คนอื่นสั่งได้ โดยที่เรายังรับผิดชอบว่าจอพูดความจริง

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

  1. สร้าง ui.Button, ui.Led, ui.Label, ui.MsgBox วางตำแหน่งเองได้ และรู้ว่าเมื่อไรควรปล่อยให้จอจัดวางให้
  2. เข้าใจว่า handle คืออะไร และทำไมต้องเก็บ .id() ไว้
  3. เขียน event loop ด้วย ui.poll() ที่แยกได้ว่าเหตุการณ์ไหนมาจาก widget ตัวไหน
  4. ต่อเหตุการณ์บนจอเข้ากับ gpio.led() แล้วทำให้ สถานะบนจอตรงกับหลอดไฟจริงตลอดเวลา

ปลายทางของวันนี้: แผงควบคุม LED สามสี แถวละ ไฟสถานะ + เปิด + ปิด และคำสั่งทั้งชุดที่ยืนยันก่อน ใช้งานได้จริง และมีตัวเลขสรุปกับบรรทัดสถานะที่ไม่เคยโกหก

คาบนี้ยากกว่าคาบ 3 ตรงที่ของสองฝั่ง (จอกับหลอด) ต้องพูดตรงกัน ไม่ใช่แค่ทำงานได้

ปลายทางของคาบนี้ — แผงควบคุมที่เรากดเองได้

หน้าจอจริงจากการรันโค้ดเฉลยบน BENTO Emulator ที่ 800x480 เท่าจอของทั้งสองบอร์ด — ไม่ใช่ภาพวาด ไม่ใช่ mock-up และไม่ใช่ภาพถ่ายจากบอร์ด · ภาพนี้เก่า (13 ส.ค.) ถ่ายตอนเฉลยยังเป็นสามปุ่มสลับ + Switch "ทั้งหมด" + ALL OFF — เฉลยปัจจุบันเป็นสามแถว แถวละ ไฟสถานะ + เปิด + ปิด และการ์ดคำสั่งทั้งชุดทางขวา (สไลด์เฉลยส่วนที่สอง) รอถ่ายใหม่
  • ปุ่มบนจอ แดง / เขียว / น้ำเงิน ตรงกับ LED สามสีบนบอร์ดหนึ่งต่อหนึ่ง — แผงนี้คุม "สามสี" ไม่ใช่ "ทุกดวง" (Dev Kit มีห้าดวง ดวง LED1/LED2 บนโมดูลไม่อยู่ในแผงนี้) ดัชนีดวงของแต่ละสีถามจากชื่อที่บอร์ดรายงาน
  • ในภาพเก่า สีละหนึ่งปุ่มสลับ + Switch "ทั้งหมด" + ALL OFF · เฉลยวันนี้แยก ปุ่มเปิด กับ ปุ่มปิด สีละคู่ และ "ปิดทั้งหมด" เปิดกล่องยืนยันก่อน — เหตุผลอยู่ในสไลด์เฉลยส่วนที่สอง
  • บรรทัดสถานะในภาพคือ "แดง:OFF เขียว:OFF น้ำเงิน:OFF" · เฉลยวันนี้แยกเป็นไฟสถานะสีละดวง + ตัวเลข "ติดอยู่ n จาก 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" ไม่ใช่ป้ายบนแผ่นวงจร

ภาพ: KIT_PSE84_EVAL PSOC™ Edge E84 Evaluation Kit guide, Infineon 002-39007 Rev.*B, รูปที่ 78 (หน้า 89) — ใช้เพื่อการเรียนการสอน
ภาพ: Stefan Riepl (Quark48), Wikimedia Commons, สาธารณสมบัติ — ช่องนำกระแส drain-source ก่อตัวตามแรงดันที่ gate ดูตอนมันเคลื่อน
ปลายทางของทุกคำสั่ง 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()

นิ้วแตะกระจก ตัวตรวจจับสัมผัส CM55 · LVGL รู้ว่าโดน widget ไหน เขียนลงคิวเหตุการณ์ คิวเหตุการณ์ รอ ไม่หายไปไหน CM33 · โค้ดของเรา ui.poll() มาถามเป็นรอบ ๆ แล้วสั่ง gpio.led() นิ้วเราไปถึงโค้ด Python ผ่านสี่ทอด ไม่ใช่ทอดเดียว ถ้าเราไม่ถาม คิวก็ค้าง — เหตุการณ์ไม่หาย แต่ก็ไม่มีอะไรเกิดขึ้น

ปุ่มบนบอร์ดเราไป "อ่าน" เอง ส่วนปุ่มบนจอเราต้องไป "รับของที่ฝากไว้"

เข้าใจฮาร์ดแวร์ · ปุ่มบนจอไม่ใช่ปุ่มบนบอร์ด (ต่อ)

ซ้าย: ห้องโดยสารที่เหลือแต่จอสัมผัส — ของจริงที่แลกปุ่มกดทิ้งไปหมด ดูแล้วเถียงกันก่อนว่าได้อะไรและเสียอะไร — ภาพ: Oq10pass / Wikimedia Commons — CC0 1.0  |  ขวา: แผงปุ่มกดจริงของเครื่องจักร นิ้วรู้ตำแหน่งได้โดยไม่ต้องมอง นี่คือสิ่งที่ปุ่มบนจอไม่มี และเป็นเหตุผลที่บอร์ดยังเหลือปุ่มจริงไว้ — ภาพ: Elmschrat / Wikimedia Commons — CC BY-SA 3.0

ดูเพิ่ม · กระจกแผ่นนั้นรู้ได้อย่างไรว่านิ้วมาแตะ

How do touchscreens work? · Khan Academy India
นิ้วคนคือตัวนำ พอเข้าใกล้กระจกมันเพิ่มความจุไฟฟ้าให้จุดนั้น วงจรจึงรู้ว่ามีคนแตะโดยไม่ต้องมีสวิตช์กล
Projected Capacitive Touch · Zytronic Displays
แอนิเมชันที่เห็นชัดที่สุดว่านิ้ว "ขโมย" เส้นสนามไฟฟ้าระหว่างอิเล็กโทรดสองชุดไปอย่างไร

แผงปุ่มสัมผัสหลังกระจกจริง — ไม่มีชิ้นส่วนขยับเลยสักชิ้น มองแล้วเห็นว่าสิ่งที่อยู่ใต้กระจกคือลายทองแดง ไม่ใช่สวิตช์ — ภาพ: Zeroping / Wikimedia Commons — CC BY 4.0

ทั้งสองคลิปเป็นของนอกเวลา (ต้องมีอินเทอร์เน็ต) ไม่ต้องเปิดในคาบ — เนื้อหาวันนี้เข้าใจได้ครบโดยไม่ต้องดู หลักการเดียวกันนี้กลับมาอีกครั้งในคาบ 5 ตอนที่เราอ่านปุ่ม CapSense บนบอร์ดโดยตรง

จอสัมผัสไม่ได้วัด "แรงกด" มันวัด ความใกล้ของตัวนำ — จำประโยคนี้ไว้ คาบหน้าใช้ซ้ำ

ห้าขั้นของการสร้าง widget หนึ่งตัว

แพทเทิร์นนี้ยกมาจากคอร์สภาษา C ที่เราเคยสอน — บน MicroPython โค้ดสั้นลงมาก แต่ ลำดับความคิดเหมือนเดิมทุกขั้น

1 · สร้าง ui.Button(...) 2 · วางที่ x, y, w, h 3 · หน้าตา text, color 4 · จำเบอร์ btn.id() 5 · กรอง type == 'clicked' ห้าขั้นนี้เรียงตายตัว ข้ามขั้นไหนก็พังคนละแบบ ขั้น 1-3 คือ "ของที่เห็น" · ขั้น 4-5 คือ "ของที่ตอบสนอง"
ขั้น ในภาษา 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) ใช้ลำดับเดียวกันหมด

สร้าง widget: พารามิเตอร์ที่ใช้บ่อย

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)
(0,0) มุมซ้ายบนของ Playground x = 20 → ขวา y = 110 → ลง แดง w = 170 h = 80 กำหนดเอง = ออกแบบไว้ x = -1 · ปล่อยให้จอเรียงให้ เรียงตามลำดับที่สร้าง คุมตำแหน่งไม่ได้
  • x, y มุมซ้ายบนเป็นพิกเซล · w, h ถ้าไม่ใส่ จอเลือกขนาดพอดีข้อความให้เอง
  • color สีเป็นเลขฐานสิบหก 0xRRGGBB แบบเดียวกับ CSS
  • x=-1 (ค่าเริ่มต้น) = auto-layout — ปล่อยให้จอเรียงให้ เหมาะกับตอนลองของเร็ว ๆ ไม่เหมาะกับแผงควบคุม

แผงควบคุมจริงต้องกำหนด x, y เอง เพราะ "ปุ่มอยู่ตรงไหน" เป็นส่วนหนึ่งของการออกแบบ ไม่ใช่เรื่องบังเอิญ

กับดักที่คนพลาดกันทุกรุ่น: value= คือขนาดฟอนต์

ui.Label("อุณหภูมิ", value=24)      # 24 = ตัวอักษรสูง 24 พิกเซล ไม่ใช่ค่า 24 องศา
ui.Button("แดง", value=20)          # ตัวหนังสือบนปุ่มขนาด 20
Label · Button · Dropdown · Textarea value=14 ข้อความตัวอย่าง value=16 ข้อความตัวอย่าง value=20 ข้อความตัวอย่าง value=24 ข้อความตัวอย่าง value=28 · เกินห้าค่านี้ไม่รับ Slider · Arc · Bar value คือ "ค่าจริง" ตามชื่อ min=0 max=100 value=45 ตัวนี้เท่านั้นที่ value = ตัวเลขที่วัดได้

สำหรับ Label, Button, Dropdown, Textarea พารามิเตอร์ value= หมายถึง ขนาดฟอนต์ และรับได้แค่ห้าค่า: 14 / 16 / 20 / 24 / 28

ส่วน min, max, value ที่เป็น "ค่าจริง" ตามชื่อ ใช้กับ Slider, Arc, Bar เท่านั้น (คาบ 5 กับ 6 เราจะได้ใช้)

อยากเปลี่ยนข้อความของ Label ให้ใช้เมธอด .text("ข้อความใหม่") เสมอ ไม่ใช่ value=

เห็น value= ที่ Label เมื่อไร ให้อ่านในใจว่า "ขนาดตัวอักษร" ทันที จะไม่พลาดอีกเลย

handle — บัตรประจำตัวของ widget

ตอนสร้าง widget สิ่งที่เราได้กลับมาคือ ตัวจัดการ (handle) ที่ผูกกับ widget จริงบน CM55 อีกฝั่งหนึ่ง

btn_red = ui.Button("แดง", x=20, y=110, w=170, h=80)
red_id  = btn_red.id()          # หมายเลขประจำตัว ใช้เทียบตอนรับเหตุการณ์
แดง เขียว น้ำเงิน .id() = 3 .id() = 4 .id() = 5 สิ่งที่ ui.poll() คืนมา {'handle': 4, 'type': 'clicked'} เหตุการณ์ไม่ได้บอกชื่อปุ่ม มันบอกแค่เบอร์ ไม่มีชื่อ ไม่มีสี ไม่มีข้อความ — มีแต่เบอร์

เหตุการณ์ที่ ui.poll() คืนมาไม่ได้บอกว่า "ปุ่มแดงถูกกด" มันบอกแค่ หมายเลข ว่า widget เบอร์นี้ถูกกด งานของเราคือจำไว้ว่าเบอร์ไหนคือใคร

วิธีที่สะอาดที่สุดเมื่อมีปุ่มหลายตัวคือเก็บเป็น list แล้วใช้ .index() หาลำดับ

on_ids = [b.id() for b in btn_on]
i = on_ids.index(ev['handle'])     # ได้ 0, 1 หรือ 2 = แถวไหน (แดง เขียว น้ำเงิน)

ถ้าไม่เก็บ .id() ไว้ ตอนเหตุการณ์เข้ามาเราจะแยกไม่ออกเลยว่าใครเป็นคนส่ง

วงจรของ event loop

1 · poll ui.poll() ถามว่ามีอะไรใหม่ 2 · dispatch เทียบ handle + type ว่าใครส่งอะไรมา 3 · act gpio.led().on() + อัปเดต Label 4 · sleep time.sleep_ms(50) คืนเวลาให้ระบบ วนแบบนี้ประมาณ 20 รอบต่อวินาที ตลอดเวลาที่โปรแกรมยังอยู่ โปรแกรม UI ไม่ใช่โค้ดที่ไหลจากบนลงล่าง แต่เป็นวงกลมที่หมุนไม่หยุด

หน้าจอจริงตอนรัน examples/s04/05_sound_feedback.py↗ — ทุกครั้งที่แตะ จอเปลี่ยนและมีเสียงตอบกลับในรอบเดียวกัน คือหลักฐานว่าวงกลมสี่ขั้นนี้ปิดวงครบ · ภาพหน้าจอจริงจากบอร์ด Eva Kit บันทึกโดยผู้สอน

ขั้นที่ 4 ไม่ใช่ของฟุ่มเฟือย ถ้าไม่มี ระบบจะไม่มีเวลาไปวาดจอให้เราเลย

หน้าตาของเหตุการณ์ และชนิดของมัน

for ev in ui.poll():
    print(ev)     # {'handle': 3, 'type': 'clicked', 'value': 0}
คิวเหตุการณ์ที่ CM55 จดไว้ให้ คิวลึก 16 ช่อง · หยิบได้ครั้งละไม่เกิน 8 ui.poll() list ของ dict ว่างได้ ไม่ใช่ None หยิบออกแล้วคิวว่างลง

ทุก dict มีสามช่องเสมอ ไม่มีช่องไหนหายไปบางครั้ง จึงเขียน ev['value'] ได้เลยโดยไม่ต้องเช็กก่อน

คิวเป็นวงแหวน 16 ช่อง ใส่ได้จริง 15 และหยิบได้ครั้งละไม่เกิน 8 — ที่ใส่ได้ 15 ไม่ใช่ 16 เพราะวงแหวนต้องเว้นหนึ่งช่องไว้แยกว่า "เต็ม" กับ "ว่าง" · และถ้าเต็มจริง เฟิร์มแวร์ ทิ้งเหตุการณ์ใหม่ที่เพิ่งเข้ามา ไม่ได้ทิ้งของเก่า และไม่ได้ให้รอ (ui_widget_mgr.c:2687-2690)

แปลว่านิ้วที่แตะรัว ๆ ตอนลูปเราหลับยาว คือการแตะที่ หายไปเลย ไม่ใช่แตะที่มาถึงช้า และไม่มี error ให้จับสักตัว · นี่คือเหตุผลจริง ๆ ที่กฎข้อ 2 บอกให้หลับ 50 ms ไม่ใช่ 500

เหตุการณ์ที่หายไปเงียบ ๆ คือบั๊กที่หาไม่เจอด้วย print — หาเจอด้วยการนับ

ชนิดของเหตุการณ์ — widget ไหนส่งอะไร และ 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 เสมอ

กฎเหล็ก 5 ข้อของโมดูล ui

ข้อ 1 poll ทุกรอบ ข้อ 2 sleep ≥ 50 ms ข้อ 3 งบ 32 · เพดาน 64 ข้อ 4 ui หยุด auto-task ข้อ 5 เร็วไป เฟรมหาย ห้าข้อนี้คือกติกาของบอร์ด ไม่ใช่คำแนะนำ ข้อ 1 กับ 2 คือสองข้อที่ทำให้ทีมส่วนใหญ่เสียเวลาไปครึ่งคาบ

ข้อ 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)" รอถ่ายใหม่

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

ฝั่งอินพุตของโมดูล ui มีอะไรบ้าง — ทั้งหมด ไม่ใช่แค่ที่วันนี้ใช้

คาบ 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 ของเฟิร์มแวร์) ใกล้เต็ม มีแค่ตัวหลังที่ช่วยได้

widget ที่ "ใส่ของอื่นไว้ข้างใน" ได้ — อีกกลุ่มหนึ่งที่ยังไม่ได้พูดถึง

ทุกตัวข้างบนเป็น ของชิ้นเดียว วางเรียงกันบนจอ พอของเกินสิบชิ้น จอ 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/ ชื่อเดียวกัน

ทั้งโมดูล ui มีอยู่เท่านี้ (1/3) — หนึ่งร้อยสามสิบสองชื่อบน Eva Kit (Dev Kit มี Sprite เพิ่ม)

สไลด์ก่อนหน้าเปิดเฉพาะฝั่งอินพุต สไลด์นี้เปิดทั้งใบ — พิมพ์ 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

ทั้งโมดูล ui มีอยู่เท่านี้ (2/3) — ฟังก์ชัน เสียง และค่าคงที่

ฟังก์ชันระดับโมดูล — แปดตัว สี่ตัวแรกอยู่ในสไลด์ฝั่งอินพุตครบแล้ว

ชื่อ ทำอะไร คาบนี้ใช้ไหม
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() เท่านั้น

ทั้งโมดูล ui มีอยู่เท่านี้ (3/3) — เมธอดของ Widget สามสิบแปดตัว

เมธอดของ 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 สมัยใหม่ถึงเป็น event loop กันหมด

แบบเก่า · โปรแกรมไปนั่งรอที่จุดเดียว บล็อกอยู่ที่ input() — ทำอย่างอื่นไม่ได้เลย เวลา → แตะปุ่มอื่น · ไม่มีใครฟัง แบบ event loop · เก็บใส่คิวแล้ววนมาหยิบ poll poll poll poll poll เหตุการณ์ตกลงมาได้ทุกจังหวะ

ยุคแรกของโปรแกรมมีหน้าจอ โค้ดจะนั่ง "รอ" อยู่ตรงจุดเดียว เช่นรอให้คนพิมพ์คำตอบ ระหว่างนั้นทำอย่างอื่นไม่ได้เลย พอ UI มีปุ่มหลายตัวพร้อมกัน วิธีนี้ก็ตัน เพราะโปรแกรมไม่รู้ว่าจะไปรอที่ปุ่มไหน

ทางออกที่วงการเลือกใช้ตั้งแต่ยุค 1980 จนถึงเว็บและมือถือทุกวันนี้เหมือนกันหมด: ให้ระบบ เก็บเหตุการณ์ใส่คิว แล้วให้โปรแกรมวนลูปมาหยิบไปจัดการ — addEventListener ของ JavaScript, lv_obj_add_event_cb ของ LVGL และ ui.poll() ของเรา คือความคิดเดียวกันในสามภาษา

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

แผงไฟเตือนของยานอะพอลโล — เหตุการณ์เข้ามาเมื่อไรก็ติดเมื่อนั้น ไม่มีใครนั่งวนถามทีละดวง นี่คือ event loop ก่อนจะมีคำนี้ — ภาพ: Steve Jurvetson / Wikimedia Commons — CC BY 2.0

สถานะบนจอ กับ สถานะจริง — สองอย่างนี้ไม่ใช่อันเดียวกัน

หัวใจของ MVP วันนี้คือ สถานะบนจอต้องตรงกับ LED เสมอ ไปถามหลอดไฟแล้วเชื่อคำตอบไม่ได้ — gpio.led(2).value() อ่านกลับได้จริง แต่สิ่งที่มันตอบคือระดับของขา ณ วินาทีที่ถาม และ hold() จบด้วยขาต่ำเสมอ (brightness() ค่ากลางก็เช่นกันบนดวงที่ไม่มีเส้น PWM) อ่านตามหลังไปจึงได้ 0 ทั้งที่ผู้ใช้เพิ่งเห็นหลอดสว่างอยู่ วิธีที่ถูกคือเก็บความจริงไว้ในตัวแปรของเราเอง แล้วให้ ทุกการเปลี่ยนแปลงผ่านฟังก์ชันเดียว

คำสั่งเข้ามาสามทาง เปิด/ปิด รายสี · เปิด/ปิดทั้งหมด set_led(i, on) จำ → สั่ง → ไฟบนจอ → รายงาน ประตูเดียวที่เปลี่ยนสถานะได้ หลอด LED จริงบนบอร์ด ไฟสถานะบนจอ (lamps) ตัวเลข "ติดอยู่ n จาก 3" เรียก gpio.led() ตรง ๆ ที่อื่น = ข้ามประตูนี้ แล้วจอจะโกหกทันที
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% น้อง ๆ เขียน 30%

เฟิร์มแวร์ทำให้แล้ว 70% งานของเรา 30% วาด · ตรวจจับนิ้ว · ทำ event · ข้าม IPC · คุมขา GPIO ออกแบบปฏิสัมพันธ์ ส่วนนี้เราแตะไม่ได้ และไม่ต้องแตะ ส่วนนี้เครื่องมือช่วยแทนไม่ได้

สิ่งที่เฟิร์มแวร์ทำให้แล้ว (70%)
วาดปุ่มและสวิตช์ให้สวยตามธีม · ตรวจจับนิ้วบนกระจก · แปลงการแตะเป็นเหตุการณ์พร้อม handle · ส่งข้ามคอร์ผ่าน IPC · จัดการหน่วยความจำของ widget ทั้ง 64 ช่องของเฟิร์มแวร์ (คอร์สใช้ไม่เกิน 32) · ควบคุมขา GPIO ของหลอดไฟ

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

30% ก้อนนี้คือ "การออกแบบปฏิสัมพันธ์" ซึ่งเป็นงานที่เครื่องมือช่วยแทนไม่ได้

เฟิร์มแวร์วาดปุ่มให้ได้ แต่ไม่มีทางรู้ว่าปุ่มนั้นควรทำอะไรกับโรงงานของเรา

แกะโค้ดจริง — ท่าที่ 1 เตรียมหน้าจอและหัวเรื่อง

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↗ ตรง ๆ — บรรทัด # เติม: คือช่องว่างที่ทีมต้องเติมเอง

ผลบนจอหลังท่านี้ แผงควบคุม LED ของทีม ที่เหลือยังว่าง และเรารู้ว่าว่างจริง widget ที่ใช้: 2 จากงบคอร์ส 32 (เพดาน 64)
หน้าจอจริงตอนรัน 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

แกะโค้ดจริง — ท่าที่ 2 สามแถว แถวละหนึ่งสี และ 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]
i = 0, 1, 2 กลายเป็นแถว y = 52 + i·120 แดง เขียว น้ำเงิน เปิดปิดเปิดปิดเปิดปิด ไฟหรี่ = ยังไม่ติด กดตอนนี้ยังไม่เกิดอะไร ถูกแล้ว

แถวละ 120 px = ปุ่มสูง 88 (เป้าสัมผัสตามระบบออกแบบ) + ช่องว่าง 32 · ปุ่มเปิดกับปุ่มปิดแยกกันคนละปุ่ม เพราะปุ่มสลับปุ่มเดียวบอกไม่ได้ว่าตอนนี้อยู่สถานะไหน คนกดต้องเดา · ไฟสถานะ lamps แยกจากปุ่ม — ไฟตอบว่า "ตอนนี้เป็นยังไง" ปุ่มตอบว่า "สั่งอะไรได้"

LED_IDX คือดวงจริงของแต่ละสี ถามจากชื่อที่บอร์ดรายงาน — บอร์ดที่รายงาน RGB_RED/GREEN/BLUE ครบ (Dev Kit) ใช้ชื่อ · ที่ไม่ครบ (Eva Kit: ดวง 0 1 2 คือ แดง เขียว น้ำเงิน แม้ดวง 2 จะชื่อ RGB_RED) ใช้เลขตรง ๆ · แผงนี้คุม "สามสี" ไม่ใช่ "ทุกดวงบนบอร์ด"

เขียนเป็นลูปตั้งแต่แรก พอต้องเพิ่มสีที่สี่ในการบ้าน จะแก้แค่สาม list บนสุด (BTN_TEXT COL_ON RGB_NAMES)

แกะโค้ดจริง — ท่าที่ 4 ฟังก์ชันเดียวที่เปลี่ยนสถานะได้

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) รายงานขึ้นจอทุกครั้ง ไม่มีข้อยกเว้น
set_led(i, on) 1 · จำ — led_on[i] = on 2 · สั่ง — หลอดจริง + ไฟบนจอ 3 · รายงาน — show_status() สามอย่างนี้เกิดพร้อมกันเสมอ หรือไม่เกิดเลย

ทุกคำสั่งที่ทำให้ไฟเปลี่ยนต้องผ่านประตูนี้ประตูเดียว ไม่ว่าจะมาจากปุ่มเปิด/ปิดรายสี จากปุ่ม "เปิดทั้งหมด" หรือจากกล่องยืนยันของ "ปิดทั้งหมด" · lamps[i].value(0) ทำให้ไฟบนจอ หรี่ ไม่ใช่หาย — ดวงที่หายไปตอนดับ ทำให้คนดูแยกไม่ออกว่าดับจริงหรือจอเสีย

ทำไมถึงคุ้ม — ถ้าเราเรียก gpio.led(LED_IDX[1]).on() ตรง ๆ ที่ไหนสักแห่ง โค้ดจะยังทำงาน ไฟยังติด แต่ led_on[1] กับไฟสถานะบนจอจะไม่รู้เรื่องด้วย แล้ววันหนึ่งจอจะโกหกโดยที่เราหาสาเหตุไม่เจอ

ในงานจริงเราเรียกวิธีนี้ว่า single source of truth — ความจริงต้องมีที่อยู่ที่เดียว

แกะโค้ดจริง — ท่าที่ 4 (ต่อ) ตัวเลขสรุปที่ไม่โกหก

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)) + " ดวง")
ความจริงอยู่ที่ list เท่านั้น True False False led_on[0..2] ติดอยู่ 1 จาก 3 ดวง ไม่เคยไปถาม gpio.led(2).value() เพราะขาตอบระดับ ไม่ตอบความตั้งใจ

show_status() อ่านจาก led_on อย่างเดียว ไม่เคยไปถามฮาร์ดแวร์ — และนั่นคือเหตุผลที่มันเชื่อถือได้ เพราะค่าที่อ่านกลับจากขาบอกแค่ระดับของขา ณ วินาทีที่ถาม ไม่ได้บอกความตั้งใจของโปรแกรม ตามที่คุยกันไปแล้ว · ตัวเลขมาพร้อมพิสัยเสมอ "2 จาก 3" บอกทันทีว่าเหลืออีกดวง

สังเกตว่าเราไม่เคย "อ่านข้อความเดิมบน Label" กลับมาคำนวณต่อ แม้ .text() แบบไม่ใส่อาร์กิวเมนต์จะอ่านสตริงคืนได้แล้ว (modui.c:352-383) — ข้อความบนจอคือ ผลลัพธ์ ของความจริง ไม่ใช่ตัวความจริง ความจริงอยู่ที่ led_on ที่เดียว

Label เป็นกระดาษให้เราเขียนทับ ไม่ใช่สมุดบัญชีที่โปรแกรมควรไปเปิดอ่านย้อนหลัง

แกะโค้ดจริง — ท่าที่ 6 event loop ตัวจริง

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
ev หนึ่งรายการ handle อยู่ใน on_ids → set_led(แถว, True) handle อยู่ใน off_ids → set_led(แถว, False) btn_all_off → เปิดกล่องยืนยันก่อน ไม่ดับทันที กรอง type ครั้งเดียว แล้วแยกด้วย handle เพราะทุกปุ่มส่ง clicked เหมือนกันหมด

set_led(on_ids.index(h), True) สั่งเปิด ไม่ใช่สั่งสลับ — กดซ้ำสิบครั้งได้ผลเท่ากดครั้งเดียว · asking คือธงกันกดซ้ำ: ระหว่างกล่องยืนยันเปิดอยู่ "ปิดทั้งหมด" จะไม่เปิดกล่องซ้อน และคำตอบมาจาก btn_yes / btn_no ซึ่งเป็น ui.Button จริง (กิ่งของสองปุ่มนี้อยู่ในไฟล์ต่อจากตรง ...) · status.hide() ตอนกล่องเปิด แล้ว status.show() ทุกกิ่งที่ปิดกล่อง — ลืมข้างใดข้างหนึ่ง จอโกหกทันที

ทุกครั้งที่สถานะถูกเปลี่ยนจากทางอื่น อย่าลืมดึง widget ที่ค้างอยู่ให้กลับมาตรงด้วย — และ while True ที่ไม่มี time.sleep_ms() คือช่องว่างข้อสุดท้ายที่ห้ามลืม

ข้อมูลไหลไปทางไหน — จากปลายนิ้วถึงหลอดไฟ

นิ้วแตะ ปุ่ม "แดง" CM55 คิว: handle 3 type clicked ui.poll() CM33 มารับของ set_led(0, True) จำ · สั่ง · รายงาน หลอด LED จริงติด gpio.led(LED_IDX[0]).on() ปุ่มเปลี่ยนสี + สถานะ .color() · .text() การแตะหนึ่งครั้ง แตกออกเป็นผลลัพธ์สองทางที่ต้องเกิดพร้อมกัน ถ้าไฟติดแต่จอไม่เปลี่ยน แปลว่าขาดขา "รายงาน" — ไม่ใช่ฮาร์ดแวร์เสีย

เวลาไล่บั๊ก ให้ถามว่าขาดตอนที่ทอดไหน อย่าเดาว่าทั้งเส้นพัง

เอาต์พุตอีกทางที่ ui มีให้ — เสียง

แผงควบคุมที่ตอบกลับเฉพาะทางตา บังคับให้คนต้องจ้องจอ · 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()

เอาต์พุตอีกทางที่ ui มีให้ — เสียง (ต่อ)

เสียงสำเร็จรูปมี 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 คำสั่งถูกส่งออกไปตามปกติ แล้วเงียบหายไปที่ปลายทาง ไม่มีใครฟ้อง

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

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

จอบอร์ด เปิด Playground ค้างไว้ BENTO IDE เติม pass ทีละจุด Program to Device ปุ่มโผล่พร้อมกันทั้งชุด มองสองที่ จอ + หลอดจริง ลำดับการรัน — ข้ามขั้นแรกแล้วจะไม่เห็นอะไรเลย ปัดออกจาก Playground เมื่อไร widget ทั้งหมดถูกทำลาย ต้องส่งโค้ดใหม่
  1. บนจอบอร์ด แตะการ์ด BENTO Playground ค้างหน้านี้ไว้ — widget ทุกตัวเกิดบนหน้านี้เท่านั้น
  2. บนคอม เปิด BENTO IDE เชื่อมต่อบอร์ด
  3. เปิด practise_codes/s04_touch_panel.py↗ เติมช่องว่าง pass ให้ครบตามคำใบ้ # เติม:
  4. กด Program to Device แล้วมองจอบอร์ด รอให้ปุ่มทั้งชุดโผล่พร้อมกัน (ไม่ใช่ทีละตัว)
  5. แตะปุ่มแต่ละดวง แล้ว มองหลอดไฟจริงบนบอร์ดควบคู่กับจอเสมอ

ข้อควรรู้: ถ้าน้อง ๆ ปัดออกจากหน้า Playground ไปเมนูอื่น widget ทั้งหมดจะถูกทำลาย กลับเข้ามาแล้วจอจะว่าง ต้องส่งโค้ดใหม่ — ไม่ใช่ความผิดพลาด แต่เป็นวิธีที่บอร์ดคืนหน่วยความจำ

ทดสอบทุกครั้งโดยมองสองที่พร้อมกัน จอกับหลอด ถ้าดูแค่จอ จะไม่มีวันรู้ว่ามันโกหกหรือเปล่า

MVP checkpoint — ผ่านคาบนี้เมื่อ

แผงควบคุม LED สามสี (แดง เขียว น้ำเงิน — ดัชนีดวงถามจากชื่อที่บอร์ดรายงาน) แถวละ ไฟสถานะ + ปุ่มเปิด + ปุ่มปิด พร้อมปุ่มเปิดทั้งหมด / ปิดทั้งหมด โดยสถานะบนจอตรงกับ LED เสมอ

แปลเป็นสิ่งที่ตรวจได้จริง (ตรงกับหกช่องว่างใน practise_codes/s04_touch_panel.py↗):

  • [ ] จอมีสามแถว แถวละ ไฟสถานะ + ปุ่ม "เปิด" + ปุ่ม "ปิด" · การ์ดขวามีตัวเลข "ติดอยู่ n จาก 3 ดวง" + ปุ่ม "เปิดทั้งหมด" + "ปิดทั้งหมด" · บรรทัดสถานะข้างหัวเรื่อง
  • [ ] แตะ "เปิด" แถวไหน หลอดไฟจริงบนบอร์ด ติดสีนั้น แตะ "ปิด" แล้วดับ — กด "เปิด" ซ้ำสิบครั้ง ผลเท่ากดครั้งเดียว
  • [ ] "เปิดทั้งหมด" ติดครบสามสี · "ปิดทั้งหมด" เปิดกล่องยืนยันก่อน ยืนยันแล้วดับหมด · "ไม่ปิด" แล้วไฟไม่เปลี่ยน
  • [ ] ไฟสถานะบนจอ ตัวเลข "ติดอยู่ n จาก 3" และบรรทัดสถานะ ตรงกับหลอดจริงในทุกกรณีที่ทดสอบ รวมทั้งหลัง "ปิดทั้งหมด"
  • [ ] ถ่ายรูปหรือคลิปที่เห็นทั้งจอและหลอดไฟในเฟรมเดียวกัน

เกณฑ์ข้อที่สี่คือข้อที่ตกกันมากที่สุด ทดสอบมันเป็นข้อสุดท้ายเสมอ

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

อาการ สาเหตุที่แท้จริง วิธีแก้
จอว่างราว 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
เติมแล้วต้องเห็นอะไร ท่า 2 เสร็จ เห็นปุ่มเปิดครบสามแถว กดยังไม่ทำงาน ท่า 4 เสร็จ เรียก set_led(0, True) แล้วไฟติด ท่า 6 เสร็จ แตะจอแล้วทั้งแผงมีชีวิต

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

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

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

ต้องทำในคาบ · เปิดตามลำดับนี้ ทั้งชุดราว 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 สองคอร์ ทรัพยากรมีเพดานตายตัว Python และ CS list · dict · comprehension ลูปที่คืนเวลาให้ระบบ ออกแบบระบบ single source of truth แยกสถานะออกจากการแสดงผล สามเสาที่วันนี้แตะพร้อมกัน เสาที่สามคือเสาที่จะยังใช้ได้แม้เปลี่ยนภาษาและเปลี่ยนบอร์ด

ฝั่งระบบสมองกลฝังตัว
สถาปัตยกรรม event-driven บนระบบสองคอร์ · คิวเหตุการณ์และการ poll ข้ามคอร์ · ข้อจำกัดของทรัพยากรที่มีเพดานตายตัว (64 widgets) · ขา GPIO ที่เขียนได้แต่อ่านไม่ได้

ฝั่ง Python และวิทยาการคอมพิวเตอร์
list ของอ็อบเจกต์และ list comprehension · dict กับการเข้าถึงด้วยคีย์ · การนิยามฟังก์ชันเพื่อรวมงานที่ต้องทำพร้อมกัน · .index() เพื่อ map จาก handle กลับเป็นลำดับ · ลูปที่ไม่มีวันจบกับการคืนเวลาให้ระบบ

ฝั่งการออกแบบระบบ
single source of truth · การแยก "สถานะ" ออกจาก "การแสดงผล" · การออกแบบให้ทุกเส้นทางการเปลี่ยนแปลงผ่านประตูเดียว · การทดสอบด้วยการมองสองฝั่งพร้อมกัน

แนวคิด single source of truth จะกลับมาอีกในคาบ 10 ตอนที่คำสั่งมาจาก MQTT แทนที่จะมาจากนิ้ว

งานทำเอง 30% + สรุปคาบ

สร้าง widget วางตำแหน่งเอง handle .id() เก็บไว้เทียบ event loop poll · dispatch · act จอตรงกับของจริง ประตูเดียว คาบหน้า pot + CapSense สี่อย่างที่ติดมือไปคาบหน้า คาบหน้าเปลี่ยนแค่ต้นทางของข้อมูล โครงลูปยังเป็นตัวเดิม

วันนี้เราได้:
สร้าง 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)
สี่ list ที่เดินด้วยดัชนีเดียว "แดง" "เขียว" "น้ำเงิน" BTN_TEXT COL_ON Eva 0 · Dev 2 Eva 1 · Dev 4 Eva 2 · Dev 3 LED_IDX · ถามจากบอร์ด False False False led_on · i เดียวใช้ได้ทั้งสามแถว

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 อยู่บนโมดูล)

ข้อมูลที่คู่กันให้เรียงให้ตรงกัน แล้วดัชนีเดียวจะพาเราไปได้ทั้งโปรแกรม

เฉลย — ส่วนที่สอง (1/2): สามแถวของแผง

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]
หน้าตาจริงหลังท่านี้ แผงควบคุม LED ของทีม พร้อมรับคำสั่ง แดง เขียว น้ำเงิน เปิด ปิด เปิด ปิด เปิด ปิด การ์ดขวา (2/2) หัวเรื่อง 2 + ไฟสถานะ 3 + ป้าย 3 + ปุ่มสีละคู่ 6 = 14 widget หลังท่านี้

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

ไฟสถานะแยกจากปุ่ม ไม่ใช่ให้ปุ่มเปลี่ยนสีเอง เพราะสองอย่างนี้คนละหน้าที่ — ไฟตอบว่า "ตอนนี้เป็นยังไง" ปุ่มตอบว่า "สั่งอะไรได้" และไฟผ่านการทดสอบขาวดำ ส่วนปุ่มที่เปลี่ยนสีไม่ผ่าน · ปุ่มสูง 88 และห่างกัน 32 ตามระยะนิ้วจริง ปุ่มเปิดใช้สีสถานะ ok 0x30A46C ปุ่มปิดใช้สีปุ่มรอง 0x3A4150

ทุกอย่างในแถวอ้างด้วย i ตัวเดียว — ข้อความ สี ไฟ ปุ่ม และดวงจริง LED_IDX[i]

เฉลย — ส่วนที่สอง (2/2): การ์ดคำสั่งทั้งชุด และกล่องยืนยันที่สร้างแล้วซ่อน

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)           # เริ่มจากที่รู้แน่
ทำไมต้องสั่งดับตอนเริ่ม สภาพที่เจอตอนโปรแกรมเริ่ม ทีมก่อนหน้าทิ้งไฟติดค้างไว้ 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)
ทำไมไม่ใช้ปุ่มเดียวสลับ ปุ่มเดียวเขียนว่า "สลับ" คนกดต้องรู้ก่อนว่าตอนนี้เปิดหรือปิด เดาผิดเมื่อไร ก็สั่งตรงข้ามกับที่ตั้งใจ สองปุ่มแยกกัน เปิด กับ ปิด กดซ้ำสิบครั้ง ได้ผลเท่ากดครั้งเดียว ไม่ต้องรู้สถานะก่อนกด ทุกปุ่มส่ง 'clicked' เหมือนกันหมด จึงต้องเทียบ handle ควบไปด้วยเสมอ

set_led(on_ids.index(h), True) สั่งเปิด ไม่ใช่สั่งสลับ — คำสั่งที่ระบุปลายทางแบบนี้เรียกว่า idempotent กดซ้ำกี่ครั้งก็ได้ผลเดิม ต่างจากคำสั่งสลับที่ผลขึ้นกับสถานะก่อนหน้า ซึ่งเป็นสิ่งที่คนกดมองไม่เห็น · ทุกปุ่มส่ง 'clicked' เหมือนกันหมด จึงกรอง type ทิ้งรอบเดียวข้างบน แล้วแยกด้วย handle อย่างเดียว — สั้นกว่าและพลาดยากกว่าการเช็กควบสองอย่างทุกกิ่ง

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

while True ที่ไม่มี time.sleep_ms() คือบั๊กที่ไม่แสดงตัวเป็น error แต่ทำให้ทั้งระบบช้าลง

เฉลย · ทำไมเรียงหกท่าแบบนี้ ไม่ใช่สุ่มเรียง

1 ล้างจอ 2-3 แผงกับปุ่ม 4 set_led() 5 ตั้งต้นให้ดับ 6 event loop สิ่งที่มองเห็น สิ่งที่ควบคุมได้ สิ่งที่ตอบสนอง ลำดับดีบักมาตรฐานของงาน UI ทุกแพลตฟอร์ม — ล้มตรงไหนก็รู้ทันทีว่าตรงไหน

ท่า 1 ล้างจอ + หัวเรื่อง พิสูจน์ว่าช่องทางถึงจอใช้ได้ ถ้าหัวเรื่องยังไม่ขึ้น อย่าเพิ่งเขียนอะไรต่อ

ท่า 2 แถวของแต่ละดวง มาก่อนตรรกะ เพราะ "มองเห็น" ง่ายที่สุด ตอนนี้กดยังไม่มีอะไรเกิด — และนั่นถูกต้องแล้ว

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

ท่า 4 set_led() มาก่อนลูป เพื่อให้ทดสอบตรง ๆ ได้ด้วย set_led(0, True) บรรทัดเดียว ไฟติดแปลว่าฝั่งฮาร์ดแวร์เรียบร้อย

ท่า 5 ตั้งต้นให้ดับ มาก่อนลูป เพราะ set_led() ต้องเรียกได้ตั้งแต่บรรทัดแรกที่ทำงาน และทุกการรันต้องเริ่มจากจุดเดียวกัน

ท่า 6 event loop มาสุดท้าย เพราะมันเป็นแค่ "คนเดินสาร" ถ้าห้าท่าบนถูกหมด ท่านี้จะสั้นและตรงไปตรงมา

ถ้าเขียน event loop ตั้งแต่แรกแล้วมันเงียบ เราจะไม่รู้เลยว่าพังที่จอ ที่ไฟ หรือที่ตรรกะ

เชื่อมจุดให้เห็นภาพ — วันนี้อยู่ตรงไหนของเส้นทาง

คาบ 3 · ที่ผ่านมา สั่งไฟด้วยโค้ดล้วน ยังไม่มีคนมาสั่ง คาบ 4 · วันนี้ คนแตะจอ → ไฟจริงเปลี่ยน จอกับของจริงตรงกัน คาบ 5-8 · ถัดไป เซนเซอร์ขับหน้าจอ Arc · Chart · Dashboard คาบ 9-12 คำสั่งมาจากเน็ต แทนที่จะมาจากนิ้ว event loop วันนี้ คือโครงเดียวกับที่รับคำสั่ง MQTT ในคาบ 10

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

ใช้จริงที่ไหน — สี่มุมของแผงควบคุมสัมผัสในสนามจริง

โรงงาน · HMI หน้าเครื่องจักร จอสัมผัสสั่งเดินเครื่อง หยุด และรีเซ็ตความผิดพลาด จอต้องสะท้อนสถานะรีเลย์จริง ไม่ใช่คำสั่งล่าสุด แพทเทิร์นเดียวกับ set_led() ของเราวันนี้ อาคาร · แผงคุมไฟและแอร์ แผงที่ผนังห้องประชุม เปิด-ปิดเป็นโซน "ปิดทั้งหมด" คือปุ่มที่คนใช้บ่อยที่สุดตอนออกจากห้อง ไฟสถานะต้องกลับมาตรงเมื่อโซนถูกปิดจากทางอื่น การแพทย์ · แผงคุมเตียงและปั๊ม ปุ่มบนจอที่สั่งของจริงซึ่งพลาดไม่ได้ ต้องยืนยันจากอุปกรณ์ก่อนจึงเปลี่ยนสถานะบนจอ อย่าให้จอรายงานสิ่งที่ยังไม่เกิดขึ้นจริง เกษตร · ตู้คุมปั๊มน้ำและวาล์ว แผงหน้าตู้ควบคุมโซนรดน้ำทีละแปลง คำสั่งเข้ามาได้สองทาง: หน้าตู้ และจากมือถือ สองทางเข้าหนึ่งความจริง — เหมือน set_led() วันนี้
ซ้าย: หน้าจอมอนิเตอร์ผู้ป่วยจริงขณะมีสัญญาณเตือน TACHY แถบสีแดงพาดบนสุด — เมื่อของสำคัญ ระบบจะยกมันขึ้นเหนือทุกอย่างบนจอ — ภาพ: US Navy / Wikimedia Commons — สาธารณสมบัติ  |  กลาง: เครื่องนับรังสีที่รายงานด้วยเสียงคลิก ส่วนติดต่อผู้ใช้ที่ไม่มีจอเลย — ภาพ: TimVickers / Wikimedia Commons — สาธารณสมบัติ  |  ขวา: เครื่องวัดอัตราไต่ของนักร่มร่อน มือทั้งสองข้างไม่ว่าง จอจึงไม่ใช่ทางออก ระบบนี้พูดออกมาเป็นเสียงแทน — ภาพ: Flyout / Wikimedia Commons — CC BY-SA 3.0

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

ทั้งสี่มุมเจอโจทย์เดียวกันหมด: มีคนสั่งได้หลายทาง แต่ความจริงต้องมีชุดเดียว

ดูเพิ่มเติมนอกเวลา + อ่านต่อ

What is Switch Bounce and How to Debounce · DigiKey
ของนอกเวลา ต้องมีอินเทอร์เน็ต

ทำไมคลิปนี้เกี่ยวกับการบ้านข้อ 4

ข้อ 4 ให้เอาปุ่มผู้ใช้จริงบนบอร์ด (gpio.button(0) — เรียกด้วยชื่อจาก .name() ไม่ใช่ป้ายบนแผ่นวงจร) มาทำงานร่วมกับปุ่มบนจอในลูปเดียวกัน คลิปนี้ทบทวนว่าทำไมปุ่มกลไกกดครั้งเดียวถึงกลายเป็นหลายเหตุการณ์

ปุ่มบนจอไม่มีปัญหานี้เพราะ CM55 จัดการให้แล้ว — ปุ่มจริงต้องทำเอง

อ่านต่อสำหรับคนอยากรู้ลึก

  • time.ticks_ms() / ticks_diff() ที่ใช้จับจังหวะลูป — MicroPython docs: https://docs.micropython.org/en/latest/library/time.html
  • จอ MIPI-DSI และ backlight ของ Eva Kit — KIT_PSE84_EVAL user guide §3.2.2.8 (หน้า 75–81)

ปุ่มบนจอไม่ต้อง debounce เพราะมีคนทำให้แล้ว ปุ่มจริงต้องทำเอง — จำความต่างนี้ไว้ตอนทำข้อ 4

ส่วนขยาย (ไม่บังคับ) — แผงของเราบน broker

ภาพจากตัวจำลอง bento_sim — โค้ด CM55 ชุดเดียวกับที่รันบนบอร์ด ที่ 800x480 เท่าจอของทั้งสองบอร์ด · แสดงหน้าจอที่ 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 ข้อ)

1 · ไฟวิ่ง START / STOP ใช้ flag ในลูปเดียว 2 · ตัวนับ นับครั้งที่กด + ปุ่ม RESET 3 · ล็อกแผง Switch LOCK บอกให้รู้ว่าปิดอยู่ 4 · ปุ่มจริง ปุ่มบนบอร์ดร่วมกับจอ ระวัง debounce ทั้งสี่ข้อใช้ event loop เดิม ไม่ต้องเขียนลูปใหม่

ข้อ 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 แล้วเอามาเล่าให้เพื่อนฟังต้นคาบหน้า

สาม widget ที่แยกหน้าจอ HMI ออกจากหน้าจอเล่น ๆ

ภาพจากตัวจำลอง bento_sim — โค้ด CM55 ชุดเดียวกับที่รันบนบอร์ด ที่ 800x480 เท่าจอของทั้งสองบอร์ด · แสดงหน้าจอที่ 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) แล้วหรี่ ไม่ใช่หาย — ตั้งใจให้เป็นแบบนั้น ไฟแผงควบคุมที่หายไปตอนดับ แย่กว่าไฟที่หรี่ลง เพราะคนดูแยกไม่ออกว่าดับหรือจอเสีย

กติกาที่แผงจริงใช้ — ให้ติดทีละดวง ถ้าติดพร้อมกันสองดวงตอนคาบเกี่ยว คนอ่านไม่ออกว่าตกลงสถานะอะไร

ตารางกับรายการ แทนป้ายที่จัดคอลัมน์เอง

ภาพจาก Emulator ของหลักสูตร (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" กว้างไม่เท่ากัน ตารางจริงจัดคอลัมน์ให้

สั่งของจริง ต้องถามก่อนหนึ่งครั้ง

ภาพจาก Emulator ของหลักสูตร (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 ตัวใดวาดไม่ออก ชุดทดสอบล้มก่อนที่ห้องเรียนจะเจอ

หน้าจอเดียวไม่พอเมื่อไร และจอที่สองราคาเท่าไร

ภาพจากตัวจำลอง bento_sim ซึ่งเรนเดอร์ด้วยโค้ด CM55 ชุดเดียวกับที่รันบนบอร์ด ที่ 800x480 เท่าจอของทั้งสองบอร์ด — แสดงหน้าจอที่ 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 ซึ่งเตี้ยเกินไป

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

เมื่อของไม่ได้เท่ากันทุกหัวข้อ — เมนูแบบเป็นชั้น

ภาพจากตัวจำลอง bento_sim ซึ่งเรนเดอร์ด้วยโค้ด CM55 ชุดเดียวกับที่รันบนบอร์ด — แสดงหน้าจอที่ 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 มีหน้าตั้งค่าให้คนหน้างานเปิดดูได้ว่าตอนนี้เครื่องใช้ค่าอะไรอยู่ โดยไม่ต้องมีใครเปิดซอร์สให้ดู

หน้าจอของทุกไฟล์ในคาบนี้ (1/3)

01 widget ตัวแรก และเหตุผลที่ต้องใส่ x กับ y ทุกครั้ง · 02 เหตุการณ์หน้าตาเป็นอย่างไร และใครส่งอะไร · 03 จอกับไฟจริงต้องพูดตรงกันเสมอ
ภาพจากตัวจำลอง bento_sim ซึ่งเรนเดอร์ด้วยโค้ด CM55 ชุดเดียวกับที่รันบนบอร์ด ที่ 800x480 เท่าจอของทั้งสองบอร์ด — แสดงหน้าจอที่ตัวอย่างสร้าง ค่าจากเซนเซอร์ WiFi และไมค์เป็นค่าแทนบนเครื่องโฮสต์ ไม่ใช่ผลการวัดของบอร์ด

หน้าจอของทุกไฟล์ในคาบนี้ (2/3)

04 Seg7 กินข้อความ ไม่ใช่กินตัวเลข · 05 เสียงตอบรับตอนแตะปุ่ม · 06 พื้นที่ 792x398 และงบของคอร์ส 32 ตัว (เพดานเฟิร์มแวร์ 64) · ภาพ 06 เก่า ถ่ายตอนไฟล์ยังพิมพ์ "เหลือโควตา 32 ตัว" และตารางยังไม่เต็ม — ปัจจุบันไฟล์ใช้พอดี 32 พิมพ์ "งบ 32 (เพดาน 64)" และลองสร้างตัวที่ 33 ให้ดู รอถ่ายใหม่
ภาพจากตัวจำลอง bento_sim ซึ่งเรนเดอร์ด้วยโค้ด CM55 ชุดเดียวกับที่รันบนบอร์ด ที่ 800x480 เท่าจอของทั้งสองบอร์ด — แสดงหน้าจอที่ตัวอย่างสร้าง ค่าจากเซนเซอร์ WiFi และไมค์เป็นค่าแทนบนเครื่องโฮสต์ ไม่ใช่ผลการวัดของบอร์ด

หน้าจอของทุกไฟล์ในคาบนี้ (3/3)

07 จัดการ widget ที่สร้างไปแล้ว · 08 อีกสามชนิดที่รับอินพุตได้ และค่าที่ถามกลับได้จริง
ภาพจากตัวจำลอง bento_sim ซึ่งเรนเดอร์ด้วยโค้ด CM55 ชุดเดียวกับที่รันบนบอร์ด ที่ 800x480 เท่าจอของทั้งสองบอร์ด — แสดงหน้าจอที่ตัวอย่างสร้าง ค่าจากเซนเซอร์ WiFi และไมค์เป็นค่าแทนบนเครื่องโฮสต์ ไม่ใช่ผลการวัดของบอร์ด

อ้างอิงและเครดิต

เอกสารของผู้ผลิตและซอฟต์แวร์

วิดีโอ (ตรวจแล้วว่าเปิดได้ · บันทึกใน _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 โดยตรง แล้วให้ทีมตรวจอิสระอีกชุดไล่หักล้างทีละข้อ

ทุกตัวเลขบนสไลด์ชุดนี้สืบกลับไปที่เอกสารต้นทางหรือซอร์สโค้ดได้ ถ้าเจอที่ไม่ตรง บอกผู้สอนได้เลย

กล่องคือที่อยู่ ไม่ใช่ของที่ตอบได้

ภาพจากตัวจำลอง Eva Kit (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= แล้วนับให้เห็นว่าข้ามไปกี่ตัว เพราะมันอ่านพิกัดจากซอร์สและไม่รู้ว่าลูกอยู่คนละระบบพิกัด — จอที่ทำด้วยกล่อง ต้องเปิดภาพดูเอง

กดค้าง — สิ่งที่ clicked บอกไม่ได้

ภาพจากตัวจำลอง Eva Kit (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 — ซึ่งคือใบที่สั่งให้หยุด

ถามตัวเองว่า "ต้องรู้ตอนไหน" ก่อนเลือกชนิด — ตอนเริ่ม ตอนกำลังทำ หรือตอนจบ

ปัดนิ้ว เลื่อนรายการ และช่องที่ถูกเลือก

ภาพจากตัวจำลอง Eva Kit (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

☰ สารบัญ