From 6ca186903e96cd379c369a6a8f5de4b4424a2d81 Mon Sep 17 00:00:00 2001 From: qsethuk Date: Wed, 7 Jan 2026 03:29:58 +0300 Subject: [PATCH] TEST: add custom logging --- can_sniffer/src/logger.py | 386 +++++++++++++++++- .../src/socket_can/message_processor.py | 11 +- 2 files changed, 386 insertions(+), 11 deletions(-) diff --git a/can_sniffer/src/logger.py b/can_sniffer/src/logger.py index ceadf33..d05b92f 100644 --- a/can_sniffer/src/logger.py +++ b/can_sniffer/src/logger.py @@ -6,19 +6,382 @@ - Ротации логов - Записи в файл и консоль - Интеграции с модулем config +- Красивого форматирования CAN фреймов и dict объектов """ +import json import logging import sys from logging.handlers import RotatingFileHandler from pathlib import Path -from typing import Optional +from typing import Optional, Dict, Any +from datetime import datetime from config import get_config +class PrettyFormatter(logging.Formatter): + """ + Красивый форматтер для логирования с блочным выводом. + + Форматирует dict объекты и CAN фреймы в читаемые блоки. + """ + + # ANSI цвета для консоли + COLORS = { + 'DEBUG': '\033[36m', # Cyan + 'INFO': '\033[32m', # Green + 'WARNING': '\033[33m', # Yellow + 'ERROR': '\033[31m', # Red + 'CRITICAL': '\033[35m', # Magenta + 'RESET': '\033[0m', # Reset + 'BOLD': '\033[1m', + 'DIM': '\033[2m', + } + + # Символы для блоков + BOX_CHARS = { + 'top_left': '┌', + 'top_right': '┐', + 'bottom_left': '└', + 'bottom_right': '┘', + 'horizontal': '─', + 'vertical': '│', + 'cross': '┼', + 'top_tee': '┬', + 'bottom_tee': '┴', + } + + def __init__(self, fmt: Optional[str] = None, use_colors: bool = True): + """ + Инициализация форматтера. + + Args: + fmt: Формат строки (если None, используется стандартный) + use_colors: Использовать ли цвета (только для консоли) + """ + super().__init__(fmt) + self.use_colors = use_colors and sys.stdout.isatty() + + def _format_dict_block(self, data: Dict[str, Any], title: Optional[str] = None) -> str: + """ + Форматирование словаря в красивый блок. + + Args: + data: Словарь для форматирования + title: Заголовок блока + + Returns: + Отформатированная строка + """ + if not data: + return "" + + lines = [] + + # Определяем максимальную ширину ключа + max_key_width = max(len(str(k)) for k in data.keys()) if data else 0 + max_key_width = max(max_key_width, 20) # Минимум 20 символов + + # Заголовок + if title: + title_line = f" {title} " + border_width = max(len(title_line), max_key_width + 20) + lines.append( + f"{self.BOX_CHARS['top_left']}" + f"{self.BOX_CHARS['horizontal'] * (border_width - 2)}" + f"{self.BOX_CHARS['top_right']}" + ) + lines.append( + f"{self.BOX_CHARS['vertical']}" + f"{title_line.center(border_width - 2)}" + f"{self.BOX_CHARS['vertical']}" + ) + lines.append( + f"{self.BOX_CHARS['top_tee']}" + f"{self.BOX_CHARS['horizontal'] * (border_width - 2)}" + f"{self.BOX_CHARS['top_right']}" + ) + else: + border_width = max_key_width + 20 + lines.append( + f"{self.BOX_CHARS['top_left']}" + f"{self.BOX_CHARS['horizontal'] * (border_width - 2)}" + f"{self.BOX_CHARS['top_right']}" + ) + + # Содержимое + for i, (key, value) in enumerate(data.items()): + # Форматируем значение + formatted_value = self._format_value(value) + + # Разбиваем длинные значения на несколько строк + value_lines = formatted_value.split('\n') + + for j, value_line in enumerate(value_lines): + if j == 0: + # Первая строка с ключом + key_str = str(key).ljust(max_key_width) + lines.append( + f"{self.BOX_CHARS['vertical']} " + f"{key_str}: {value_line}" + f"{' ' * (border_width - len(key_str) - len(value_line) - 4)}" + f" {self.BOX_CHARS['vertical']}" + ) + else: + # Продолжение значения + lines.append( + f"{self.BOX_CHARS['vertical']} " + f"{' ' * (max_key_width + 2)}{value_line}" + f"{' ' * (border_width - max_key_width - len(value_line) - 4)}" + f" {self.BOX_CHARS['vertical']}" + ) + + # Разделитель между элементами (кроме последнего) + if i < len(data) - 1: + lines.append( + f"{self.BOX_CHARS['vertical']}" + f"{' ' * (border_width - 2)}" + f"{self.BOX_CHARS['vertical']}" + ) + + # Нижняя граница + lines.append( + f"{self.BOX_CHARS['bottom_left']}" + f"{self.BOX_CHARS['horizontal'] * (border_width - 2)}" + f"{self.BOX_CHARS['bottom_right']}" + ) + + return '\n' + '\n'.join(lines) + + def _format_value(self, value: Any) -> str: + """ + Форматирование значения для вывода. + + Args: + value: Значение для форматирования + + Returns: + Отформатированная строка + """ + if isinstance(value, dict): + # Вложенные словари - форматируем как JSON с отступами + return json.dumps(value, indent=2, ensure_ascii=False) + elif isinstance(value, list): + # Списки - форматируем как JSON + if len(value) > 5: + return json.dumps(value[:3], ensure_ascii=False) + f" ... ({len(value)} items)" + return json.dumps(value, ensure_ascii=False) + elif isinstance(value, (int, float)): + # Числа - добавляем форматирование для больших чисел + if isinstance(value, float): + return f"{value:.6f}" if abs(value) < 1000 else f"{value:.2f}" + return f"{value:,}" if abs(value) >= 1000 else str(value) + elif isinstance(value, bytes): + # Байты - показываем hex + return value.hex().upper() if len(value) <= 16 else value.hex().upper()[:32] + "..." + elif isinstance(value, bool): + return "✓" if value else "✗" + else: + return str(value) + + def _format_can_frame(self, data: Dict[str, Any]) -> str: + """ + Специальное форматирование для CAN фреймов. + + Args: + data: Данные CAN фрейма + + Returns: + Отформатированная строка + """ + # Определяем, это CAN фрейм или нет + can_fields = {'can_id', 'interface', 'bus', 'dlc', 'data', 'data_hex', 'timestamp', 'ts_ns'} + if not any(field in data for field in can_fields): + return self._format_dict_block(data) + + # Создаем специальный блок для CAN фрейма + lines = [] + border_width = 60 + + # Заголовок + lines.append( + f"{self.BOX_CHARS['top_left']}" + f"{self.BOX_CHARS['horizontal'] * (border_width - 2)}" + f"{self.BOX_CHARS['top_right']}" + ) + lines.append( + f"{self.BOX_CHARS['vertical']}" + f" {'🚗 CAN FRAME'.center(border_width - 4)} " + f"{self.BOX_CHARS['vertical']}" + ) + lines.append( + f"{self.BOX_CHARS['top_tee']}" + f"{self.BOX_CHARS['horizontal'] * (border_width - 2)}" + f"{self.BOX_CHARS['top_right']}" + ) + + # CAN ID (выделяем особо) + can_id = data.get('can_id') or data.get('can_id_int') + can_id_hex = data.get('can_id_hex') or (hex(can_id) if can_id is not None else 'N/A') + interface = data.get('interface') or data.get('bus', 'N/A') + + lines.append( + f"{self.BOX_CHARS['vertical']} " + f"CAN ID: {self._colorize(can_id_hex, 'BOLD') if self.use_colors else can_id_hex}" + f"{' ' * (border_width - len(can_id_hex) - 10)}" + f" {self.BOX_CHARS['vertical']}" + ) + lines.append( + f"{self.BOX_CHARS['vertical']} " + f"Interface: {interface}" + f"{' ' * (border_width - len(interface) - 13)}" + f" {self.BOX_CHARS['vertical']}" + ) + lines.append( + f"{self.BOX_CHARS['vertical']}" + f"{' ' * (border_width - 2)}" + f"{self.BOX_CHARS['vertical']}" + ) + + # Данные + dlc = data.get('dlc', 'N/A') + data_hex = data.get('data_hex') or data.get('data', '') + if isinstance(data_hex, bytes): + data_hex = data_hex.hex().upper() + elif not isinstance(data_hex, str): + data_hex = str(data_hex) + + # Форматируем данные по байтам + if data_hex and data_hex != 'N/A': + data_formatted = ' '.join(data_hex[i:i+2] for i in range(0, min(len(data_hex), 32), 2)) + if len(data_hex) > 32: + data_formatted += " ..." + else: + data_formatted = 'N/A' + + lines.append( + f"{self.BOX_CHARS['vertical']} " + f"DLC: {dlc} | Data: {data_formatted}" + f"{' ' * (border_width - len(f'DLC: {dlc} | Data: {data_formatted}') - 3)}" + f" {self.BOX_CHARS['vertical']}" + ) + + # Timestamp + timestamp = data.get('timestamp') or data.get('ts_ns') + if timestamp: + if isinstance(timestamp, (int, float)) and timestamp > 1e10: + # Наносекунды + ts_sec = timestamp / 1e9 + dt = datetime.fromtimestamp(ts_sec) + timestamp_str = dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] + elif isinstance(timestamp, (int, float)): + # Секунды + dt = datetime.fromtimestamp(timestamp) + timestamp_str = dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] + else: + timestamp_str = str(timestamp) + else: + timestamp_str = 'N/A' + + lines.append( + f"{self.BOX_CHARS['vertical']} " + f"Timestamp: {timestamp_str}" + f"{' ' * (border_width - len(timestamp_str) - 13)}" + f" {self.BOX_CHARS['vertical']}" + ) + + # Дополнительные поля + extra_fields = {k: v for k, v in data.items() + if k not in {'can_id', 'can_id_int', 'can_id_hex', 'interface', 'bus', + 'dlc', 'data', 'data_hex', 'timestamp', 'ts_ns'}} + if extra_fields: + lines.append( + f"{self.BOX_CHARS['vertical']}" + f"{' ' * (border_width - 2)}" + f"{self.BOX_CHARS['vertical']}" + ) + for key, value in extra_fields.items(): + formatted_value = self._format_value(value) + value_line = formatted_value.split('\n')[0] + if len(value_line) > border_width - 20: + value_line = value_line[:border_width - 23] + "..." + lines.append( + f"{self.BOX_CHARS['vertical']} " + f"{key}: {value_line}" + f"{' ' * (border_width - len(key) - len(value_line) - 4)}" + f" {self.BOX_CHARS['vertical']}" + ) + + # Нижняя граница + lines.append( + f"{self.BOX_CHARS['bottom_left']}" + f"{self.BOX_CHARS['horizontal'] * (border_width - 2)}" + f"{self.BOX_CHARS['bottom_right']}" + ) + + return '\n' + '\n'.join(lines) + + def _colorize(self, text: str, color: str) -> str: + """Добавление цвета к тексту.""" + if not self.use_colors: + return text + color_code = self.COLORS.get(color, '') + reset = self.COLORS['RESET'] + return f"{color_code}{text}{reset}" + + def format(self, record: logging.LogRecord) -> str: + """Форматирование записи лога.""" + # Извлекаем дополнительные поля + extra_fields = {} + for key, value in record.__dict__.items(): + if key not in [ + 'name', 'msg', 'args', 'created', 'filename', 'funcName', + 'levelname', 'levelno', 'lineno', 'module', 'msecs', + 'message', 'pathname', 'process', 'processName', 'relativeCreated', + 'thread', 'threadName', 'exc_info', 'exc_text', 'stack_info' + ]: + extra_fields[key] = value + + # Форматируем основное сообщение + base_message = super().format(record) + + # Определяем уровень логирования для цвета + level_color = self.COLORS.get(record.levelname, '') + reset_color = self.COLORS['RESET'] if self.use_colors else '' + + # Форматируем уровень + level_str = f"{level_color}{record.levelname:8s}{reset_color}" if self.use_colors else f"{record.levelname:8s}" + + # Основная строка + timestamp = datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] + formatted = f"{timestamp} | {level_str} | {record.name} | {record.getMessage()}" + + # Добавляем дополнительные поля + if extra_fields: + # Проверяем, это CAN фрейм или обычный dict + if any(field in extra_fields for field in ['can_id', 'can_id_int', 'can_id_hex', 'interface', 'bus']): + # CAN фрейм - специальное форматирование + formatted += self._format_can_frame(extra_fields) + elif 'first_message' in extra_fields and isinstance(extra_fields['first_message'], dict): + # Батч с первым сообщением + batch_info = {k: v for k, v in extra_fields.items() if k != 'first_message'} + if batch_info: + formatted += self._format_dict_block(batch_info, "Batch Info") + formatted += self._format_can_frame(extra_fields['first_message']) + else: + # Обычный dict - блочное форматирование + formatted += self._format_dict_block(extra_fields) + + # Добавляем информацию об исключении, если есть + if record.exc_info: + formatted += '\n' + self.formatException(record.exc_info) + + return formatted + + class StructuredFormatter(logging.Formatter): - """Форматтер для structured logging с дополнительными полями.""" + """Форматтер для structured logging с дополнительными полями (старый формат).""" def format(self, record: logging.LogRecord) -> str: """Форматирование записи лога с поддержкой дополнительных полей.""" @@ -71,14 +434,24 @@ class Logger: # Очищаем существующие обработчики self.root_logger.handlers.clear() - # Создаем форматтер - formatter = StructuredFormatter(self.log_config.format) + # Создаем форматтеры + # Для консоли - красивый форматтер + console_formatter = PrettyFormatter( + fmt='%(message)s', + use_colors=True + ) + + # Для файла - структурированный форматтер (без цветов) + file_formatter = PrettyFormatter( + fmt='%(message)s', + use_colors=False + ) # Обработчик для файла с ротацией - self._setup_file_handler(formatter) + self._setup_file_handler(file_formatter) # Обработчик для консоли - self._setup_console_handler(formatter) + self._setup_console_handler(console_formatter) # Предотвращаем распространение на корневой логгер self.root_logger.propagate = False @@ -204,4 +577,3 @@ def get_logger(name: Optional[str] = None) -> logging.Logger: # Для удобства - корневой логгер logger = get_logger() - diff --git a/can_sniffer/src/socket_can/message_processor.py b/can_sniffer/src/socket_can/message_processor.py index 12c3e43..e0b180d 100644 --- a/can_sniffer/src/socket_can/message_processor.py +++ b/can_sniffer/src/socket_can/message_processor.py @@ -230,11 +230,14 @@ class MessageProcessor: extra={ "batch_size": len(batch), "first_message": { - "interface": first_frame.bus, - "can_id": first_frame.can_id_hex, + "bus": first_frame.bus, + "can_id": first_frame.can_id, + "can_id_hex": first_frame.can_id_hex, "dlc": first_frame.dlc, - "data": first_frame.data_hex, - "timestamp": first_frame.timestamp + "data_hex": first_frame.data_hex, + "ts_ns": first_frame.ts_ns, + "timestamp": first_frame.timestamp, + "is_extended": first_frame.is_extended } } )