14.3.1. Journalisation¶
Un produit livré ne peut pas communiquer avec print(). print() écrit sur la sortie standard USB, qui n’existe que lorsque la caméra est sur le banc d’un développeur avec un terminal ouvert. Sur le terrain, rien ne le lit ; chaque ligne tombe dans le vide. La bibliothèque logging est la solution de remplacement – un filtre de niveau, une destination au choix de l’application, et un format qui indique ce qui s’est passé et quand.
Le module logging de la caméra est un portage allégé de celui de CPython – même modèle mental, surface plus réduite, quelques différences qui comptent pour la configuration en production.
14.3.1.1. Le modèle mental¶
La journalisation se construit à partir de quatre éléments. Chaque élément a un seul rôle ; c’est cette séparation qui permet à un même logger de se répartir vers plusieurs destinations, chacune avec son propre format et son propre niveau :
Un Logger est ce que l’application appelle. Le code écrit
log.info("frame %d", n); le logger est l’objet sur lequel cet appel atterrit. Les loggers sont récupérés par nom aveclogging.getLogger().Un Handler décide où va un enregistrement. Un
StreamHandlerécrit dans un flux (sys.stderrpar défaut) ; unFileHandlerajoute à un fichier sur le disque. Un logger peut avoir un nombre quelconque de handlers.Note
Sur la caméra,
sys.stdoutetsys.stderrsont reliés au même canal USB CDC – les écritures sur l’un ou l’autre apparaissent sur le même terminal ouvert par le développeur via USB. Un handler qui écrit danssys.stderrest, en pratique, un handler qui écrit au même endroit queprint(). L’abstraction du handler vous offre toujours un filtrage et un formatage par destination ; elle ne vous donne simplement pas de canal physiquement distinct.Un Formatter décide comment un enregistrement est rendu en texte. Il prend un enregistrement et renvoie la ligne qui sera écrite. Une chaîne de format par formatter ; un formatter par handler.
Un filtre de niveau est présent sur chaque logger et chaque handler. Les enregistrements portent un niveau (
DEBUG/INFO/WARNING/ERROR/CRITICAL). Seuls les enregistrements au niveau du filtre ou au-dessus passent.
Cette séparation compte parce qu’une configuration de production typique a plus d’une destination : un fichier sur la carte SD qui conserve tout jusqu’à DEBUG pour l’analyse post-mortem, et un flux vers l’USB qui ne fait remonter que WARNING et pire, afin qu’un développeur connecté à la caméra voie les points saillants sans se noyer dans les détails. Même code, deux destinations, deux filtres.
14.3.1.2. Les niveaux et ce que chacun signifie¶
Les cinq niveaux forment une échelle ordonnée. Les enregistrements portent un niveau afin que le filtre de chaque handler puisse écarter ceux qui ne l’intéressent pas.
DEBUG– traçage, compteurs par trame, vidages de l’état interne. Le niveau le plus bas ; le volume est élevé.INFO– événements opérationnels normaux. Wi-Fi connecté, un modèle chargé, le chien de garde démarré, un nouveau fichier de journal mis en rotation.WARNING– quelque chose d’inattendu, mais l’application l’a géré. Une trame perdue, une requête réseau réessayée.ERROR– une opération a échoué et l’application n’a pas pu la mener à bien. Un fichier de modèle manquant, une écriture sur carte SD refusée.CRITICAL– l’application ne peut plus du tout continuer. Mémoire épuisée, montage obligatoire manquant.
Une valeur par défaut importante à retenir : le module logging de la caméra démarre chaque logger à WARNING. Les enregistrements à DEBUG et INFO sont silencieusement écartés à moins que Logger.setLevel() ne soit appelé – généralement dans le cadre de l’appel à basicConfig() ci-dessous. Un premier symptôme courant d’une configuration de journalisation qui « ne fonctionne pas » est que l’application a émis à INFO et que le filtre par défaut a mangé l’enregistrement.
Note
Le niveau est le seul filtre que propose le module logging de la caméra. Il n’y a pas d’objets Filter pour des règles par enregistrement plus riches ; si le niveau d’un enregistrement passe, il est émis.
14.3.1.3. basicConfig : le démarrage rapide¶
logging.basicConfig() configure le logger racine en un seul appel. Deux formes apparaissent le plus souvent :
Une configuration de développement, tout vers stderr USB à INFO
import logging
logging.basicConfig(level=logging.INFO)
Une configuration de production, tout vers un fichier sur la carte SD avec un format horodaté
import logging
logging.basicConfig(
filename='/sdcard/logs/app.log',
level=logging.INFO,
format='%(asctime)s %(levelname)s %(name)s: %(message)s',
)
Passez soit filename= pour un FileHandler, soit stream= pour un StreamHandler ; les deux sont mutuellement exclusifs dans basicConfig().
La chaîne de format est un modèle de style %(field)s. Les champs pris en charge par le formatter de la caméra :
%(asctime)s– horodatage formaté à partir detime.localtime(). Le format par défaut est%Y-%m-%d %H:%M:%S; passezdatefmt=pour le remplacer.%(levelname)s–DEBUG/INFO/WARNING/ERROR/CRITICAL.%(name)s– le nom du logger (voir la section suivante).%(message)s– le message formaté de l’enregistrement.%(msecs)d– la fraction de milliseconde de l’horodatage de l’enregistrement.
Le format par défaut, si aucun n’est fourni, est %(levelname)s:%(name)s:%(message)s – ce qui convient pour la configuration de développement et est inadéquat pour un journal de terrain, où c’est l’horodatage qui rend le fichier utile des semaines plus tard.
basicConfig() est sans effet lors des appels suivants à moins que force=True ne soit passé. Configurez une fois au démarrage ; ne le rappelez pas pour « changer de destination » en cours d’exécution.
Note
Le module logging de la caméra n’a pas de dictConfig() ni de fileConfig(). La configuration est toujours programmatique – la convention est une seule fonction d’aide setup_logging() appelée une fois depuis main.py.
14.3.1.4. Loggers nommés par module¶
Le code applicatif ne devrait pas appeler les raccourcis au niveau du module (logging.info(), logging.warning(), et ainsi de suite). Ceux-ci passent tous par le logger racine, et les enregistrements de journal résultants portent le nom root – inutile pour savoir d’où vient l’enregistrement.
La convention est un logger par module, nommé d’après le module
# in app/detector.py
import logging
log = logging.getLogger(__name__)
def detect(frame):
log.info("detect on %dx%d frame", frame.width(), frame.height())
Chaque enregistrement porte alors app.detector dans %(name)s et la ligne de journal indique qui l’a émis.
Le module logging de la caméra diffère de CPython sur un point important : l’espace de noms des loggers est plat. getLogger('app') et getLogger('app.detector') sont des loggers indépendants sans relation parent / enfant – définir un niveau sur app ne se propage pas à app.detector. Le mécanisme qui fonctionne : un logger nommé sans handlers propres emprunte les handlers et le niveau du logger racine. C’est ainsi qu’un seul appel à basicConfig() sur le logger racine configure chaque appel à getLogger() ailleurs dans l’application.
14.3.1.5. Formatage paresseux des arguments %¶
Écrivez
log.info("processed %d frames in %d ms", count, dt)
Pas
log.info(f"processed {count} frames in {dt} ms")
La forme avec arguments % permet au logger d’interpoler les arguments après que le filtre de niveau a décidé d’émettre ou non l’enregistrement. Un appel DEBUG filtré dans une boucle critique ne paie rien pour sa chaîne de format. Une f-string est évaluée d’abord, à chaque fois, avant même que l’appel n’atteigne le logger.
Le mot-clé extra= de CPython pour les champs structurés n’est pas pris en charge sur la caméra ; passez plutôt les valeurs comme arguments du message.
14.3.1.6. Journaliser les exceptions¶
À l’intérieur d’un bloc except, Logger.exception() journalise le message au niveau ERROR et ajoute à l’enregistrement la trace de l’exception courante
try:
frame = csi0.snapshot()
process(frame)
except Exception:
log.exception("frame loop iteration failed")
La trace est capturée via sys.print_exception(), ce qui donne au journal d’une exception son bloc multiligne Traceback (most recent call last):. C’est le bon outil pour la gestion d’exceptions au niveau supérieur – capturer, journaliser et poursuivre.
14.3.1.7. Handlers multiples¶
La répartition de production mentionnée en haut – tout vers un fichier à DEBUG, les points saillants vers stderr à WARNING – correspond à deux handlers attachés au même logger, chacun avec son propre niveau et son propre 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)
Le niveau du logger racine est le premier filtre que rencontre chaque enregistrement. Réglez-le sur le niveau le plus bas que n’importe quel handler souhaite voir – DEBUG ici – afin qu’aucun handler ne soit privé par le logger lui-même. Les niveaux par handler décident ensuite quels enregistrements sont réellement émis vers quelle destination.
14.3.1.8. Rotation des fichiers de journal¶
Le module logging de la caméra n’a pas de RotatingFileHandler ni de TimedRotatingFileHandler. La rotation est le travail de l’application.
Le schéma consiste à conserver le FileHandler courant à un endroit connu, à le remplacer par un nouveau lorsque le critère de bascule se déclenche, et à laisser un chemin daté fournir la séparation naturelle entre fichiers. Pour une bascule horaire vers /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
Appelez rotate_if_needed() une fois par itération de la boucle principale ; la vérification du chemin est peu coûteuse et la bascule ne se produit qu’à la limite de l’heure. L’arborescence de répertoires doit exister avant que FileHandler puisse ouvrir le fichier.
14.3.1.9. Vidage du tampon pour les déploiements sensibles à l’alimentation¶
Les écritures de FileHandler passent par la mise en tampon Python de l’objet fichier sous-jacent. Une coupure de courant entre une écriture et un vidage perd les derniers enregistrements. Pour les déploiements alimentés par batterie ou susceptibles d’être débranchés, appelez flush() sur le flux du handler après les enregistrements critiques, ou sur un minuteur.
Une petite fonction d’aide qui vide chaque handler attaché au logger racine
import logging
def flush_handlers():
for handler in logging.getLogger().handlers:
if hasattr(handler, 'stream'):
handler.stream.flush()
Appelez flush_handlers() juste après un enregistrement que l’application ne peut se permettre de perdre
log.critical("memory low: restarting")
flush_handlers()
Pour une sécurité en arrière-plan, appelez-la depuis la boucle principale à la cadence qui équilibre la fraîcheur du journal et l’usure de la mémoire flash – une fois par seconde suffit généralement. Logger.critical() ne déclenche pas de vidage par lui-même.
14.3.1.10. Diagnostics au démarrage¶
Un journal de terrain sans contexte est quasiment inutile. Les premiers enregistrements à chaque démarrage à froid devraient identifier quelle caméra, quelle version est en cours d’exécution, et comment la caméra est arrivée à ce démarrage. Trois sources sur l’appareil couvrent à elles trois tout cela :
omv– la version du micrologiciel OpenMV.os.uname()– la version de MicroPython, le nom de la carte + le MCU, ainsi que l’étiquette git et la date de compilation des sources à partir desquelles le micrologiciel a été construit.machine– l’identifiant silicium unique du MCU et la cause de réinitialisation qui a déclenché ce démarrage.os.listdir()sur chaque point de montage – les systèmes de fichiers qui se sont effectivement montés.
Une fonction d’aide qui rassemble chacun de ces éléments dans les premiers enregistrements du journal
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)
Un journal typique s’ouvre avec quelque chose comme
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
Huit lignes au début de chaque fichier de journal, l’opérateur connaît l’unité physique, la lignée du micrologiciel, la raison du démarrage de la caméra, et quel stockage s’est monté. unique id est le numéro de série silicium programmé en usine dans le MCU ; il est identique d’un reflashage à l’autre et d’un échange de carte SD à l’autre. build est l’étiquette git et la date de l’arbre du micrologiciel à partir duquel l’image a été construite – le seul champ qui dit « ceci est exactement le binaire livré à cette unité à ce moment précis ».
14.3.1.11. Tout assembler¶
Une configuration complète de journalisation en production, factorisée dans une fonction d’aide que main.py appelle une fois au démarrage
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)
Puis en haut de main.py
from app.logging_setup import setup_logging, log_boot_diagnostics
setup_logging('/sdcard/logs/app.log')
log_boot_diagnostics()
Chaque autre module de l’application n’a plus qu’à faire
import logging
log = logging.getLogger(__name__)
et obtient gratuitement la sortie configurée – fichier avec le détail complet, flux avec les avertissements, enregistrements nommés, formatter horodaté, et un démarrage documenté à chaque démarrage à froid.