14.3.1. التسجيل (Logging)

لا يمكن لمنتج تم شحنه أن يتصل بالمنزل عبر print(). تكتب print() إلى مخرج USB القياسي (stdout)، وهو موجود فقط عندما تكون الكاميرا على طاولة المطور مع فتح طرفية. أما في الميدان فلا شيء يقرأه؛ فكل سطر يُلقى ويُهدر. مكتبة logging هي البديل -- مرشّح مستوى، ووجهة من اختيار التطبيق، وتنسيق يوضّح ما حدث ومتى.

وحدة logging على الكاميرا هي نسخة مبسّطة من نسخة CPython -- النموذج الذهني نفسه، بسطح أصغر، مع بعض الاختلافات التي لها أهميتها عند إعداد بيئة الإنتاج.

14.3.1.1. النموذج الذهني

يُبنى التسجيل من أربعة أجزاء. لكل جزء مهمة واحدة؛ وهذا الفصل هو ما يتيح لمسجّل واحد أن يتفرّع إلى وجهات متعددة، لكلٍّ منها تنسيقه ومستواه الخاص:

  • المسجّل (Logger) هو ما يستدعيه التطبيق. تقول الشيفرة log.info("frame %d", n)؛ والمسجّل هو الكائن الذي يصل إليه ذلك الاستدعاء. تُسترجع المسجّلات بالاسم عبر logging.getLogger().

  • المعالِج (Handler) يقرّر أين يذهب السجل. يكتب StreamHandler إلى تدفّق (sys.stderr افتراضيًا)؛ ويُلحق FileHandler بملف على القرص. ويمكن للمسجّل أن يملك أي عدد من المعالِجات.

    ملاحظة

    على الكاميرا، يكون sys.stdout و sys.stderr موصولين بأنبوب USB CDC نفسه -- فالكتابة إلى أيٍّ منهما تظهر على الطرفية نفسها التي فتحها المطور عبر USB. والمعالِج الذي يكتب إلى sys.stderr هو عمليًا معالِج يكتب إلى المكان نفسه الذي تكتب إليه print(). لا يزال تجريد المعالِج يوفّر لك ترشيحًا وتنسيقًا لكل وجهة؛ لكنه لا يمنحك قناة منفصلة فيزيائيًا.

  • المنسّق (Formatter) يقرّر كيف يُعرض السجل كنص. يأخذ سجلًا ويعيد السطر الذي يُكتب. سلسلة تنسيق واحدة لكل منسّق؛ ومنسّق واحد لكل معالِج.

  • مرشّح المستوى يجلس على كل مسجّل وكل معالِج. تحمل السجلات مستوى (DEBUG / INFO / WARNING / ERROR / CRITICAL). ولا يمر سوى السجلات التي تكون عند مستوى المرشّح أو أعلى منه.

هذا الفصل مهم لأن إعداد الإنتاج النموذجي يملك أكثر من وجهة واحدة: ملف على بطاقة SD يحتفظ بكل شيء حتى مستوى DEBUG لتحليل ما بعد الفشل، وتدفّق إلى USB يُظهر فقط مستوى WARNING وما هو أسوأ منه كي يرى المطور الموصول بالكاميرا أهم النقاط دون أن يغرق في التفاصيل. الشيفرة نفسها، ووجهتان، ومرشّحان.

14.3.1.2. المستويات ومعنى كلٍّ منها

المستويات الخمسة هي مقياس مرتّب. تحمل السجلات مستوى كي يتمكّن المرشّح على كل معالِج من إسقاط تلك التي لا يكترث لها.

  • DEBUG -- التتبّع، والعدّادات لكل إطار، وتفريغ الحالة الداخلية. هو المستوى الأدنى؛ وحجمه مرتفع.

  • INFO -- الأحداث التشغيلية الطبيعية. اتصال Wi-Fi، وتحميل نموذج، وبدء مراقب التشغيل، ودوران ملف سجل جديد.

  • WARNING -- شيء غير متوقع لكن التطبيق تعامل معه. إطار مُسقَط، أو طلب شبكة أُعيدت محاولته.

  • ERROR -- فشلت عملية ولم يتمكّن التطبيق من إتمامها. ملف نموذج مفقود، أو رفض كتابة على بطاقة SD.

  • CRITICAL -- لا يمكن للتطبيق المتابعة على الإطلاق. نفاد الذاكرة، أو نقطة وصل إلزامية مفقودة.

إعداد افتراضي مهم يجب تذكّره: تبدأ وحدة logging في الكاميرا كل مسجّل عند مستوى WARNING. وتُسقَط السجلات عند مستويي DEBUG و INFO بصمت ما لم تُستدعَ Logger.setLevel() -- عادةً كجزء من استدعاء basicConfig() أدناه. ومن أوّل الأعراض الشائعة لإعداد تسجيل "لا يعمل" أن التطبيق أصدر عند مستوى INFO فابتلع المرشّح الافتراضي السجل.

ملاحظة

المستوى هو المرشّح الوحيد الذي تقدّمه logging في الكاميرا. ولا توجد كائنات Filter لقواعد أغنى لكل سجل؛ فإن اجتاز مستوى السجل، صدر.

14.3.1.3. basicConfig: البدء السريع

تُهيّئ logging.basicConfig() المسجّل الجذري في استدعاء واحد. وتظهر صيغتان أكثر من غيرهما:

إعداد للتطوير، كل شيء إلى مخرج USB القياسي للأخطاء (stderr) عند مستوى INFO

import logging

logging.basicConfig(level=logging.INFO)

إعداد للإنتاج، كل شيء إلى ملف على بطاقة SD بتنسيق يحمل طابعًا زمنيًا:

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. الحقول التي يدعمها منسّق الكاميرا:

  • %(asctime)s -- طابع زمني منسّق من time.localtime(). التنسيق الافتراضي هو %Y-%m-%d %H:%M:%S؛ مرّر datefmt= لتجاوزه.

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

  • %(name)s -- اسم المسجّل (انظر القسم التالي).

  • %(message)s -- رسالة السجل المنسّقة.

  • %(msecs)d -- الجزء بالميلي ثانية من الطابع الزمني للسجل.

التنسيق الافتراضي إن لم يُعطَ أي تنسيق هو %(levelname)s:%(name)s:%(message)s -- وهو ملائم لإعداد التطوير وغير كافٍ لسجل ميداني، حيث يكون الطابع الزمني هو ما يجعل الملف مفيدًا بعد أسابيع.

basicConfig() لا تفعل شيئًا عند الاستدعاءات اللاحقة ما لم يُمرَّر force=True. هيّئ مرة واحدة عند بدء التشغيل؛ ولا تستدعِها مجددًا "لتبديل الوجهات" في منتصف التشغيل.

ملاحظة

لا تملك logging في الكاميرا dictConfig() ولا fileConfig(). التهيئة برمجية دائمًا -- والعُرف هو دالة مساعِدة setup_logging() واحدة تُستدعى مرة واحدة من main.py.

14.3.1.4. مسجّلات مسمّاة لكل وحدة

لا ينبغي لشيفرة التطبيق أن تستدعي الاختصارات على مستوى الوحدة (logging.info() و logging.warning() وهكذا). فكلها تمر عبر المسجّل الجذري، وتحمل سجلات النتيجة الاسم root -- وهو عديم الفائدة في معرفة مصدر السجل.

العُرف هو مسجّل واحد لكل وحدة، مسمّى باسم الوحدة:

# 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 ويقول سطر السجل من الذي أصدره.

تختلف logging في الكاميرا عن CPython باختلاف مهم واحد: فضاء أسماء المسجّلات مسطّح. فـ getLogger('app') و getLogger('app.detector') مسجّلان مستقلان دون علاقة أب/ابن بينهما -- وضبط مستوى على app لا ينتشر إلى app.detector. الآلية التي تعمل فعلًا: مسجّل مسمّى لا يملك معالِجات خاصة به يستعير معالِجات المسجّل الجذري ومستواه. وهكذا يُهيّئ استدعاء basicConfig() واحد على الجذر كل استدعاء getLogger() في أي مكان آخر من التطبيق.

14.3.1.5. تنسيق وسائط %- الكسول

اكتب:

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

وليس:

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

تتيح صيغة وسيط % للمسجّل أن يُدرج الوسائط بعد أن يقرّر مرشّح المستوى ما إذا كان سيصدر السجل. فاستدعاء DEBUG مُرشَّح خارجًا في حلقة ساخنة لا يدفع شيئًا مقابل سلسلة تنسيقه. أما سلسلة f-string فتُقيَّم أولًا، في كل مرة، قبل أن يصل الاستدعاء حتى إلى المسجّل.

الكلمة المفتاحية extra= في CPython للحقول المهيكلة غير مدعومة على الكاميرا؛ مرّر القيم كوسائط للرسالة بدلًا من ذلك.

14.3.1.6. تسجيل الاستثناءات

داخل كتلة except، تسجّل Logger.exception() الرسالة عند مستوى ERROR و تُلحق تتبّع المكدّس للاستثناء الحالي بالسجل:

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

يُلتقط تتبّع المكدّس عبر sys.print_exception()، وهي ما يمنح سجل الاستثناء كتلة Traceback (most recent call last): متعددة الأسطر. وهذه هي الأداة الصحيحة لمعالجة الاستثناءات على المستوى الأعلى -- التقاط، وتسجيل، ومتابعة.

14.3.1.7. معالِجات متعددة

التقسيم الإنتاجي المذكور في الأعلى -- كل شيء إلى ملف عند DEBUG، وأهم النقاط إلى stderr عند WARNING -- هو معالِجان مرفقان بالمسجّل نفسه، لكلٍّ منهما مستواه ومنسّقه الخاص:

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)

مستوى المسجّل الجذري هو أول مرشّح يصطدم به كل سجل. اضبطه على أدنى مستوى يريد أي معالِج رؤيته -- DEBUG هنا -- كي لا يُحرَم أيٌّ من المعالِجَين من قِبَل المسجّل نفسه. ثم تقرّر مستويات كل معالِج أي السجلات تصدر فعلًا إلى أي وجهة.

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() مرة واحدة لكل تكرار للحلقة الرئيسية؛ فحص المسار رخيص ولا يحدث التبديل إلا عند حدود الساعة. يجب أن تكون شجرة الأدلة موجودة قبل أن يتمكّن FileHandler من فتح الملف.

14.3.1.9. الإفراغ في عمليات النشر الحسّاسة للطاقة

تمر كتابات FileHandler عبر التخزين المؤقت في بايثون لكائن الملف الأساسي. وفقدان الطاقة بين كتابة وإفراغ يفقد السجلات الأخيرة. وفي عمليات النشر التي تعمل بالبطارية أو التي يُنزع فيها القابس، استدعِ flush() على تدفّق المعالِج بعد السجلات الحرجة، أو بمؤقّت.

دالة مساعِدة صغيرة تُفرغ كل معالِج مرفق بالمسجّل الجذري:

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()

للأمان في الخلفية، استدعِها من الحلقة الرئيسية بأي وتيرة توازن بين حداثة السجل وتآكل ذاكرة الفلاش -- مرة في الثانية عادةً ما تكون كافية تمامًا. لا تُطلق Logger.critical() إفراغًا بمفردها.

14.3.1.10. تشخيصات وقت الإقلاع

سجل ميداني بلا سياق عديم الفائدة تقريبًا. فالسجلات الأولى عند كل إقلاع بارد ينبغي أن تحدّد أيّ كاميرا، وأيّ إصدار يعمل، وكيف وصلت الكاميرا إلى هذا الإقلاع. وثلاثة مصادر على الجهاز تغطّي كل ذلك فيما بينها:

  • omv -- إصدار البرنامج الثابت لـ OpenMV.

  • os.uname() -- إصدار MicroPython، واسم اللوحة + وحدة المعالجة الدقيقة (MCU)، ووسم git وتاريخ بناء المصدر الذي بُني منه البرنامج الثابت.

  • machine -- معرّف السيليكون الفريد للـ MCU وسبب إعادة الضبط الذي أطلق هذا الإقلاع.

  • os.listdir() على كل نقطة وصل -- أنظمة الملفات التي ظهرت فعلًا.

دالة مساعِدة تسحب كل واحد من هذه إلى السجلات الأولى من السجل:

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

بعد ثمانية أسطر في كل ملف سجل، يعرف المشغّل الوحدة الفيزيائية، وسلالة البرنامج الثابت، وسبب إقلاع الكاميرا، وأي وحدة تخزين ظهرت. unique id هو الرقم التسلسلي السيليكوني المبرمج في المصنع للـ MCU؛ وهو نفسه عبر عمليات إعادة الفلاش وعبر عمليات تبديل بطاقة SD. build هو وسم git وتاريخ شجرة البرنامج الثابت التي بُنيت منها الصورة -- الحقل الوحيد الذي يقول "هذه بالضبط الثنائية التي شُحنت إلى هذه الوحدة في هذه اللحظة الزمنية."

14.3.1.11. تجميع كل ذلك معًا

إعداد تسجيل إنتاجي كامل، مُجمّع في دالة مساعِدة يستدعيها 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__)

وتحصل على المخرج المهيّأ مجانًا -- ملف بتفاصيل كاملة، وتدفّق بالتحذيرات، وسجلات مسمّاة، ومنسّق يحمل طابعًا زمنيًا، وإقلاع موثّق في كل بدء تشغيل بارد.