14.3.1. การบันทึกข้อมูล (Logging)#

ผลิตภัณฑ์ที่จัดส่งออกไปไม่สามารถส่งข้อมูลกลับบ้านด้วย print() ได้ print() เขียนไปยัง USB stdout ซึ่งมีอยู่เฉพาะเมื่อกล้องอยู่บนโต๊ะของนักพัฒนาที่เปิดเทอร์มินัลไว้ ในภาคสนามไม่มีสิ่งใดมาอ่านข้อมูลนั้น ทุกบรรทัดจะถูกทิ้งไป ไลบรารี logging คือตัวแทนที่แท้จริง -- มีตัวกรองระดับ ปลายทางตามที่แอปพลิเคชันเลือก และรูปแบบที่บอกว่าเกิดอะไรขึ้นและเมื่อใด

โมดูล logging บนกล้องเป็นพอร์ตที่ลดขนาดมาจาก CPython -- มีแนวคิดเดียวกัน พื้นผิวเล็กลง และความแตกต่างบางประการที่สำคัญสำหรับการตั้งค่าในการใช้งานจริง

14.3.1.1. แนวคิดหลัก#

ระบบ logging ถูกสร้างขึ้นจากสี่ส่วน แต่ละส่วนมีหน้าที่เดียว การแยกกันนี้ทำให้ logger หนึ่งตัวส่งออกไปยังหลายปลายทางได้ แต่ละปลายทางมีรูปแบบและระดับของตัวเอง:

  • Logger คือสิ่งที่แอปพลิเคชันเรียกใช้ โค้ดเขียนว่า log.info("frame %d", n) logger คือออบเจ็กต์ที่รับการเรียกนั้น Logger ถูกค้นหาด้วยชื่อผ่าน logging.getLogger()

  • Handler ตัดสินใจว่าเรคอร์ดจะไปที่ ไหน StreamHandler เขียนไปยัง stream (sys.stderr ตามค่าเริ่มต้น) ส่วน FileHandler ต่อท้ายไฟล์บนดิสก์ Logger หนึ่งตัวสามารถมี handler ได้หลายตัว

    Note

    บนกล้อง sys.stdout และ sys.stderr เชื่อมต่อกับ USB CDC pipe เดียวกัน -- การเขียนไปยังทั้งสองจะปรากฏบนเทอร์มินัลเดียวกันที่นักพัฒนาเปิดผ่าน USB handler ที่เขียนไปยัง sys.stderr ในทางปฏิบัติ คือ handler ที่เขียนไปยังที่เดียวกับ print() การแยก handler ยังคงให้การกรองและการจัดรูปแบบแต่ละปลายทาง แต่ไม่ได้ให้ช่องทางทางกายภาพที่แยกต่างหาก

  • Formatter ตัดสินใจว่าเรคอร์ดจะแสดงเป็นข้อความอย่าง ไร มันรับเรคอร์ดและส่งคืนบรรทัดที่จะถูกเขียน หนึ่งสตริงรูปแบบต่อหนึ่ง formatter หนึ่ง formatter ต่อหนึ่ง handler

  • ตัวกรองระดับ อยู่บน logger ทุกตัวและ handler ทุกตัว เรคอร์ดมีระดับ (DEBUG / INFO / WARNING / ERROR / CRITICAL) เฉพาะเรคอร์ดที่ระดับเท่ากับหรือสูงกว่าระดับตัวกรองเท่านั้นที่จะผ่านไปได้

การแยกนั้นสำคัญเพราะการตั้งค่าในการผลิตทั่วไปมีปลายทางมากกว่าหนึ่งแห่ง: ไฟล์บนการ์ด SD ที่เก็บทุกอย่างลงถึงระดับ DEBUG สำหรับการวิเคราะห์หลังเหตุการณ์ และ stream ไปยัง USB ที่แสดงเฉพาะ WARNING ขึ้นไปเพื่อให้นักพัฒนาที่เชื่อมต่อกับกล้องเห็นสิ่งสำคัญโดยไม่จมกับรายละเอียด โค้ดเดียวกัน สองปลายทาง สองตัวกรอง

14.3.1.2. ระดับและความหมายของแต่ละระดับ#

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

  • DEBUG -- การติดตาม, ตัวนับแต่ละเฟรม, การดัมพ์สถานะภายใน ระดับต่ำสุด ปริมาณสูง

  • INFO -- เหตุการณ์การดำเนินงานปกติ Wi-Fi เชื่อมต่อแล้ว, โมเดลโหลดแล้ว, watchdog เริ่มแล้ว, ไฟล์บันทึกใหม่หมุนเวียนเข้ามา

  • WARNING -- บางอย่างไม่คาดคิดแต่แอปพลิเคชันจัดการได้ เฟรมที่หลุดหาย, คำขอเครือข่ายที่ลองใหม่

  • ERROR -- การดำเนินการล้มเหลวและแอปพลิเคชันไม่สามารถดำเนินการให้เสร็จสมบูรณ์ได้ ไฟล์โมเดลหายไป, การเขียนการ์ด SD ถูกปฏิเสธ

  • CRITICAL -- แอปพลิเคชันไม่สามารถดำเนินต่อไปได้เลย หน่วยความจำหมด, mount ที่จำเป็นขาดหายไป

ค่าเริ่มต้นที่สำคัญที่ต้องจำ: โมดูล logging ของกล้องเริ่มต้น logger ทุกตัวที่ระดับ WARNING เรคอร์ดที่ระดับ DEBUG และ INFO จะถูกทิ้งอย่างเงียบๆ เว้นแต่จะเรียก Logger.setLevel() -- โดยปกติเป็นส่วนหนึ่งของการเรียก basicConfig() ด้านล่าง อาการแรกที่พบบ่อยของการตั้งค่า logging ที่ "ไม่ทำงาน" คือแอปพลิเคชันส่งออกที่ระดับ INFO และตัวกรองเริ่มต้นกิน record นั้นทิ้ง

Note

ระดับเป็นตัวกรองเดียวที่ logging ของกล้องมีให้ ไม่มีออบเจ็กต์ Filter สำหรับกฎต่อเรคอร์ดที่ซับซ้อนกว่านั้น ถ้าระดับของเรคอร์ดผ่าน มันก็จะถูกส่งออก

14.3.1.3. basicConfig: การเริ่มต้นอย่างรวดเร็ว#

logging.basicConfig() กำหนดค่า root logger ในการเรียกเดียว รูปแบบที่พบบ่อยสองแบบ:

การตั้งค่าสำหรับพัฒนา ทุกอย่างไปยัง USB stderr ที่ระดับ INFO

import logging

logging.basicConfig(level=logging.INFO)

การตั้งค่าสำหรับการผลิต ทุกอย่างไปยังไฟล์บนการ์ด SD พร้อมรูปแบบที่มี timestamp:

import logging

logging.basicConfig(
    filename='/sdcard/logs/app.log',
    level=logging.INFO,
    format='%(asctime)s %(levelname)s %(name)s: %(message)s',
)

ส่งผ่าน filename= สำหรับ FileHandler หรือ stream= สำหรับ StreamHandler ทั้งสองใช้ร่วมกันไม่ได้ใน basicConfig()

สตริงรูปแบบเป็นแม่แบบแบบ %(field)s ฟิลด์ที่ formatter ของกล้องรองรับ:

  • %(asctime)s -- timestamp ที่จัดรูปแบบจาก time.localtime() รูปแบบเริ่มต้นคือ %Y-%m-%d %H:%M:%S ส่ง datefmt= เพื่อแทนที่

  • %(levelname)s -- DEBUG / INFO / WARNING / ERROR / CRITICAL

  • %(name)s -- ชื่อของ logger (ดูส่วนถัดไป)

  • %(message)s -- ข้อความที่จัดรูปแบบแล้วของเรคอร์ด

  • %(msecs)d -- ส่วนมิลลิวินาทีของ timestamp ของเรคอร์ด

รูปแบบเริ่มต้นหากไม่มีการกำหนดคือ %(levelname)s:%(name)s:%(message)s -- ซึ่งเพียงพอสำหรับการตั้งค่าพัฒนาแต่ไม่เพียงพอสำหรับบันทึกในภาคสนาม ซึ่ง timestamp คือสิ่งที่ทำให้ไฟล์มีประโยชน์หลายสัปดาห์ต่อมา

basicConfig() ไม่มีผลกระทบใดในการเรียกครั้งถัดไป เว้นแต่จะส่ง force=True กำหนดค่าครั้งเดียวในการเริ่มต้น อย่าเรียกมันอีกเพื่อ "สลับปลายทาง" ระหว่างการทำงาน

Note

logging ของกล้องไม่มี dictConfig() หรือ fileConfig() การกำหนดค่าเป็นแบบโปรแกรมเสมอ -- helper setup_logging() หนึ่งตัวที่เรียกครั้งเดียวจาก main.py คือแนวทางปฏิบัติ

14.3.1.4. Named logger แยกตามโมดูล#

โค้ดแอปพลิเคชันไม่ควรเรียกใช้ shortcut ระดับโมดูล (logging.info(), logging.warning(), และอื่นๆ) เนื่องจากทั้งหมดถูกส่งผ่าน root logger และเรคอร์ด log ที่ได้มีชื่อว่า root -- ไม่มีประโยชน์ในการบอกว่าเรคอร์ดมาจาก ที่ไหน

แนวทางคือหนึ่ง logger ต่อหนึ่งโมดูล ตั้งชื่อตามโมดูล:

# in app/detector.py
import logging

log = logging.getLogger(__name__)

def detect(frame):
    log.info("detect on %dx%d frame", frame.width(), frame.height())

ทุกเรคอร์ดจะมี app.detector ใน %(name)s และบรรทัด log บอกว่าใครส่งออกมา

logging ของกล้องแตกต่างจาก CPython ในด้านสำคัญหนึ่งประการ: namespace ของ logger เป็น แบบแบน getLogger('app') และ getLogger('app.detector') เป็น logger อิสระที่ไม่มีความสัมพันธ์ parent / child -- การตั้งระดับบน app ไม่ ส่งต่อไปยัง app.detector กลไกที่ใช้งานได้: logger ที่มีชื่อแต่ไม่มี handler ของตัวเองจะยืม handler และระดับของ root logger นั่นคือวิธีที่การเรียก basicConfig() ครั้งเดียวบน root ตั้งค่าการเรียก getLogger() ทุกครั้งในแอปพลิเคชัน

14.3.1.5. การจัดรูปแบบอาร์กิวเมนต์แบบ Lazy %-arg#

เขียนว่า:

log.info("processed %d frames in %d ms", count, dt)

ไม่ใช่:

log.info(f"processed {count} frames in {dt} ms")

รูปแบบ %-arg ให้ logger แทรกค่าอาร์กิวเมนต์ หลังจาก ตัวกรองระดับตัดสินใจว่าจะส่งออกเรคอร์ดหรือไม่ การเรียก DEBUG ที่ถูกกรองออกในลูปที่รันถี่ไม่เสียค่าใช้จ่ายใดๆ สำหรับสตริงรูปแบบ f-string จะถูกประเมินก่อน ทุกครั้ง ก่อนที่การเรียกจะไปถึง logger

คีย์เวิร์ด extra= ของ CPython สำหรับฟิลด์ที่มีโครงสร้างไม่ได้รับการสนับสนุนบนกล้อง ส่งค่าเป็นอาร์กิวเมนต์ข้อความแทน

14.3.1.6. การบันทึก exception#

ภายในบล็อก except Logger.exception() บันทึกข้อความที่ระดับ ERROR และ ต่อท้าย traceback ของ exception ปัจจุบันไปยังเรคอร์ด:

try:
    frame = csi0.snapshot()
    process(frame)
except Exception:
    log.exception("frame loop iteration failed")

traceback ถูกจับภาพผ่าน sys.print_exception() ซึ่งเป็นสิ่งที่ให้บล็อก Traceback (most recent call last): หลายบรรทัดในบันทึก exception นี่คือเครื่องมือที่เหมาะสมสำหรับการจัดการ exception ระดับสูงสุด -- จับ, บันทึก, และดำเนินต่อ

14.3.1.7. หลาย handler#

การแยกในการผลิตที่กล่าวถึงตอนต้น -- ทุกอย่างไปยังไฟล์ที่ระดับ DEBUG ส่วนสำคัญไปยัง stderr ที่ระดับ WARNING -- คือ handler สองตัวที่ติดกับ logger เดียวกัน แต่ละตัวมีระดับและ formatter ของตัวเอง:

import logging

fmt = '%(asctime)s %(levelname)s %(name)s: %(message)s'

file_handler = logging.FileHandler('/sdcard/logs/app.log')
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter(fmt))

stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.WARNING)
stream_handler.setFormatter(logging.Formatter(fmt))

root = logging.getLogger()
root.setLevel(logging.DEBUG)          # admit everything to the filters
root.addHandler(file_handler)
root.addHandler(stream_handler)

ระดับของ root logger คือตัวกรอง แรก ที่ทุกเรคอร์ดผ่าน ตั้งไว้ที่ระดับต่ำสุดที่ handler ใดต้องการเห็น -- DEBUG ในที่นี้ -- เพื่อไม่ให้ handler ใดถูกตัดโดย logger เอง ระดับของ handler แต่ละตัวจึงตัดสินใจว่าเรคอร์ดใดจะถูกส่งออกไปยังปลายทางใด

14.3.1.8. การหมุนเวียนไฟล์บันทึก#

logging ของกล้องไม่มี RotatingFileHandler หรือ TimedRotatingFileHandler การหมุนเวียนเป็นหน้าที่ของแอปพลิเคชัน

รูปแบบคือการเก็บ FileHandler ปัจจุบันในตำแหน่งที่ทราบ สลับไปยังตัวใหม่เมื่อเกณฑ์การหมุนเวียนเกิดขึ้น และให้พาธที่มีวันที่ทำหน้าที่เป็นขอบเขตไฟล์ตามธรรมชาติ สำหรับการหมุนเวียนรายชั่วโมงไปยัง /sdcard/logs/<year>/<month>/<day>/<hour>.log

import logging
import time

_LOG_FMT = '%(asctime)s %(levelname)s %(name)s: %(message)s'
_current_path = None
_current_handler = None

def _hourly_path(now):
    return '/sdcard/logs/{:04d}/{:02d}/{:02d}/{:02d}.log'.format(
        now[0], now[1], now[2], now[3])

def rotate_if_needed():
    global _current_path, _current_handler

    path = _hourly_path(time.localtime())
    if path == _current_path:
        return

    root = logging.getLogger()
    if _current_handler is not None:
        root.removeHandler(_current_handler)
        _current_handler.close()

    _current_handler = logging.FileHandler(path)
    _current_handler.setFormatter(logging.Formatter(_LOG_FMT))
    root.addHandler(_current_handler)
    _current_path = path

เรียก rotate_if_needed() หนึ่งครั้งต่อการวนซ้ำของ main loop การตรวจสอบพาธมีต้นทุนต่ำและการสลับจะเกิดขึ้นเฉพาะที่ขอบเขตชั่วโมง ต้องมีไดเรกทอรีก่อนที่ FileHandler จะเปิดไฟล์ได้

14.3.1.9. การ flush สำหรับการใช้งานที่ไวต่อไฟฟ้า#

การเขียนของ FileHandler ผ่านการบัฟเฟอร์ Python ของออบเจ็กต์ไฟล์พื้นฐาน การสูญเสียไฟฟ้าระหว่างการเขียนและการ flush จะทำให้เรคอร์ดท้ายสูญหาย สำหรับการใช้งานที่ใช้แบตเตอรี่หรือถอดปลั๊กโดยตรง ให้เรียก flush() บน stream ของ handler หลังเรคอร์ดที่สำคัญ หรือตาม timer

helper เล็กๆ ที่ flush ทุก handler ที่ติดกับ root logger:

import logging

def flush_handlers():
    for handler in logging.getLogger().handlers:
        if hasattr(handler, 'stream'):
            handler.stream.flush()

เรียก flush_handlers() ทันทีหลังเรคอร์ดที่แอปพลิเคชันไม่สามารถสูญเสียได้:

log.critical("memory low: restarting")
flush_handlers()

เพื่อความปลอดภัยในเบื้องหลัง ให้เรียกจาก main loop ตามความถี่ที่สมดุลระหว่างความสดของบันทึกกับการสึกหรอของแฟลช -- หนึ่งครั้งต่อวินาทีโดยปกติเพียงพอ Logger.critical() ไม่ ทริกเกอร์การ flush เอง

14.3.1.10. การวินิจฉัยเมื่อบูต#

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

  • omv -- เวอร์ชัน firmware ของ OpenMV

  • os.uname() -- เวอร์ชัน MicroPython, ชื่อบอร์ด + MCU และแท็ก git และวันที่บิลด์ของซอร์สที่สร้าง firmware

  • machine -- ID ซิลิกอนที่ไม่ซ้ำกันของ MCU และสาเหตุการรีเซ็ตที่ทำให้เกิดการบูตนี้

  • os.listdir() กับแต่ละจุด mount -- ระบบไฟล์ที่เริ่มต้นทำงานได้จริง

helper ที่ดึงข้อมูลทั้งหมดนั้นไปยังเรคอร์ดแรกของบันทึก:

import binascii
import logging
import machine
import omv
import os

log = logging.getLogger(__name__)

_RESET_NAMES = {
    machine.PWRON_RESET: "power-on",
    machine.HARD_RESET: "hard reset",
    machine.WDT_RESET: "watchdog timeout",
    machine.DEEPSLEEP_RESET: "wake from deep sleep",
    machine.SOFT_RESET: "soft reset",
}

def log_boot_diagnostics():
    uname = os.uname()

    log.info("machine: %s", uname.machine)
    log.info("unique id: %s",
             binascii.hexlify(machine.unique_id()).decode())
    log.info("firmware: openmv %s, micropython %s",
             omv.version_string(), uname.release)
    log.info("build: %s", uname.version)
    log.info("reset cause: %s",
             _RESET_NAMES.get(machine.reset_cause(), "unknown"))

    for mount in ('/flash', '/sdcard', '/rom'):
        try:
            os.listdir(mount)
            log.info("mount %s: ok", mount)
        except OSError as e:
            log.warning("mount %s: %s", mount, e)

บันทึกทั่วไปเปิดด้วยข้อความเช่น:

INFO machine: OPENMV4 with STM32H743
INFO unique id: 002C00543235501020373835
INFO firmware: openmv 5.0.0, micropython 1.28.0
INFO build: v1.28.0-101-gabc1234 on 2026-06-09
INFO reset cause: watchdog timeout
INFO mount /flash: ok
INFO mount /sdcard: ok
INFO mount /rom: ok

แปดบรรทัดเข้าสู่ทุกไฟล์บันทึก ผู้ปฏิบัติงานทราบหน่วยทางกายภาพ, สายพันธุ์ firmware, สาเหตุที่กล้องบูต และพื้นที่จัดเก็บที่เริ่มทำงาน unique id คือหมายเลขซีเรียลซิลิกอนที่โรงงานโปรแกรมไว้ของ MCU มันเหมือนกันแม้จะ reflash หรือเปลี่ยนการ์ด SD build คือแท็ก git และวันที่ของ firmware tree ที่สร้าง image -- ฟิลด์เดียวที่บอกว่า "นี่คือไบนารีที่ส่งไปยังหน่วยนี้ ณ จุดในเวลานั้นอย่างแน่นอน"

14.3.1.11. รวมทุกอย่างเข้าด้วยกัน#

การตั้งค่า logging สำหรับการผลิตที่สมบูรณ์ แบ่งเป็น helper ที่ main.py เรียกครั้งเดียวในการเริ่มต้น:

import logging

_LOG_FMT = '%(asctime)s %(levelname)s %(name)s: %(message)s'

def setup_logging(log_path):
    fh = logging.FileHandler(log_path)
    fh.setLevel(logging.DEBUG)
    fh.setFormatter(logging.Formatter(_LOG_FMT))

    sh = logging.StreamHandler()
    sh.setLevel(logging.WARNING)
    sh.setFormatter(logging.Formatter(_LOG_FMT))

    root = logging.getLogger()
    root.setLevel(logging.DEBUG)
    root.addHandler(fh)
    root.addHandler(sh)

จากนั้นที่ด้านบนของ main.py

from app.logging_setup import setup_logging, log_boot_diagnostics

setup_logging('/sdcard/logs/app.log')
log_boot_diagnostics()

ทุกโมดูลอื่นๆ ในแอปพลิเคชันเพียงแค่ทำ:

import logging

log = logging.getLogger(__name__)

และได้รับผลลัพธ์ที่กำหนดค่าไว้โดยไม่ต้องทำอะไรเพิ่ม -- ไฟล์พร้อมรายละเอียดครบถ้วน, stream พร้อมคำเตือน, เรคอร์ดที่มีชื่อ, formatter ที่มี timestamp และบันทึกการบูตทุกครั้งที่บูตเย็น