Add source
This commit is contained in:
1
.cursorignore
Normal file
1
.cursorignore
Normal file
@@ -0,0 +1 @@
|
||||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
||||
89
.gitignore
vendored
Normal file
89
.gitignore
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual Environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.venv/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Database files (SQLite)
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.hypothesis/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# Project specific
|
||||
can_offline.db*
|
||||
can_edge.log*
|
||||
config.json
|
||||
|
||||
.cursor/
|
||||
4
can_sniffer/requirements.txt
Normal file
4
can_sniffer/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-can>=4.0.0
|
||||
|
||||
357
can_sniffer/src/config.py
Normal file
357
can_sniffer/src/config.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Модуль конфигурации для CAN Sniffer проекта.
|
||||
|
||||
Использует pydantic-settings для типобезопасной конфигурации с валидацией
|
||||
и поддержкой загрузки из файла и переменных окружения.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class CanConfig(BaseModel):
|
||||
"""Конфигурация CAN интерфейсов."""
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
interfaces: List[str] = Field(
|
||||
default=["can0", "can1"],
|
||||
description="Список CAN интерфейсов для мониторинга"
|
||||
)
|
||||
listen_only: bool = Field(
|
||||
default=True,
|
||||
description="Режим только чтения (listen-only mode)"
|
||||
)
|
||||
bitrate: int = Field(
|
||||
default=500000,
|
||||
description="Скорость передачи CAN (бит/с)"
|
||||
)
|
||||
filters: List[dict] = Field(
|
||||
default_factory=list,
|
||||
description="Список фильтров SocketCAN: [{'can_id': 0x123, 'can_mask': 0x7FF}, ...]"
|
||||
)
|
||||
|
||||
@field_validator('interfaces', mode='before')
|
||||
@classmethod
|
||||
def parse_interfaces(cls, v):
|
||||
"""Парсинг интерфейсов из строки (для env переменных)."""
|
||||
if isinstance(v, str):
|
||||
return [item.strip() for item in v.split(',')]
|
||||
return v
|
||||
|
||||
|
||||
class StorageConfig(BaseModel):
|
||||
"""Конфигурация локального хранилища (SQLite)."""
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
type: str = Field(
|
||||
default="sqlite",
|
||||
description="Тип хранилища"
|
||||
)
|
||||
database_path: str = Field(
|
||||
default="can_offline.db",
|
||||
description="Путь к файлу базы данных SQLite"
|
||||
)
|
||||
wal_mode: bool = Field(
|
||||
default=True,
|
||||
description="Включить режим WAL (Write-Ahead Logging)"
|
||||
)
|
||||
sync_mode: str = Field(
|
||||
default="NORMAL",
|
||||
description="Режим синхронизации: NORMAL, FULL, OFF"
|
||||
)
|
||||
|
||||
|
||||
class InfluxDBConfig(BaseModel):
|
||||
"""Конфигурация InfluxDB."""
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
enabled: bool = Field(
|
||||
default=True,
|
||||
description="Включить отправку данных в InfluxDB"
|
||||
)
|
||||
url: str = Field(
|
||||
default="http://localhost:8086",
|
||||
description="URL сервера InfluxDB"
|
||||
)
|
||||
token: str = Field(
|
||||
default="",
|
||||
description="Токен аутентификации InfluxDB"
|
||||
)
|
||||
org: str = Field(
|
||||
default="",
|
||||
description="Организация InfluxDB"
|
||||
)
|
||||
bucket: str = Field(
|
||||
default="can_data",
|
||||
description="Имя bucket для данных"
|
||||
)
|
||||
batch_size: int = Field(
|
||||
default=1000,
|
||||
description="Размер батча для отправки данных"
|
||||
)
|
||||
flush_interval: int = Field(
|
||||
default=5,
|
||||
description="Интервал отправки батча (секунды)"
|
||||
)
|
||||
timeout: int = Field(
|
||||
default=10,
|
||||
description="Таймаут подключения (секунды)"
|
||||
)
|
||||
|
||||
|
||||
class LoggingConfig(BaseModel):
|
||||
"""Конфигурация логирования."""
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
level: str = Field(
|
||||
default="INFO",
|
||||
description="Уровень логирования: DEBUG, INFO, WARNING, ERROR, CRITICAL"
|
||||
)
|
||||
format: str = Field(
|
||||
default="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
description="Формат логов"
|
||||
)
|
||||
file: str = Field(
|
||||
default="can_edge.log",
|
||||
description="Имя файла для логов"
|
||||
)
|
||||
max_bytes: int = Field(
|
||||
default=10485760,
|
||||
description="Максимальный размер файла лога (байты)"
|
||||
)
|
||||
backup_count: int = Field(
|
||||
default=5,
|
||||
description="Количество резервных копий логов"
|
||||
)
|
||||
|
||||
|
||||
class GeneralConfig(BaseModel):
|
||||
"""Общие настройки."""
|
||||
|
||||
model_config = {"extra": "ignore"}
|
||||
|
||||
buffer_size: int = Field(
|
||||
default=10000,
|
||||
description="Размер буфера для данных"
|
||||
)
|
||||
max_retries: int = Field(
|
||||
default=3,
|
||||
description="Максимальное количество попыток повтора"
|
||||
)
|
||||
retry_delay: float = Field(
|
||||
default=1.0,
|
||||
description="Задержка между попытками (секунды)"
|
||||
)
|
||||
|
||||
|
||||
class Config(BaseSettings):
|
||||
"""Главный класс конфигурации проекта."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CAN_SNIFFER_",
|
||||
env_nested_delimiter="__",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
can: CanConfig = Field(default_factory=CanConfig)
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
influxdb: InfluxDBConfig = Field(default_factory=InfluxDBConfig)
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
general: GeneralConfig = Field(default_factory=GeneralConfig)
|
||||
|
||||
@classmethod
|
||||
def _find_config_file(cls) -> Optional[Path]:
|
||||
"""Поиск конфигурационного файла."""
|
||||
# Определяем правильный путь к корню проекта can_sniffer
|
||||
# __file__ = can_sniffer/src/config.py
|
||||
# parent = can_sniffer/src
|
||||
# parent.parent = can_sniffer
|
||||
project_root = Path(__file__).parent.parent
|
||||
|
||||
config_paths = [
|
||||
project_root / "config.json", # can_sniffer/config.json
|
||||
Path(__file__).parent / "config.json", # can_sniffer/src/config.json
|
||||
Path.home() / ".can_sniffer" / "config.json",
|
||||
]
|
||||
|
||||
for config_path in config_paths:
|
||||
if config_path.exists():
|
||||
return config_path
|
||||
return None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""Инициализация конфигурации с загрузкой из JSON файла."""
|
||||
# Если kwargs пусты, пытаемся загрузить из файла
|
||||
if not kwargs:
|
||||
config_file = self._find_config_file()
|
||||
if config_file:
|
||||
import json
|
||||
try:
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
json_data = json.load(f)
|
||||
|
||||
# Передаем данные из JSON в super().__init__()
|
||||
# Pydantic автоматически создаст вложенные объекты CanConfig, StorageConfig и т.д.
|
||||
super().__init__(**json_data)
|
||||
return
|
||||
except Exception as e:
|
||||
# Если не удалось загрузить JSON, выводим предупреждение
|
||||
import warnings
|
||||
import traceback
|
||||
warnings.warn(
|
||||
f"Failed to load config from {config_file}: {e}\n"
|
||||
f"Traceback: {traceback.format_exc()}\n"
|
||||
f"Using defaults."
|
||||
)
|
||||
|
||||
# Инициализация с переданными kwargs или defaults
|
||||
super().__init__(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def load_from_file(cls, file_path: Optional[Path] = None) -> 'Config':
|
||||
"""Загрузка конфигурации из указанного файла или поиск автоматически.
|
||||
|
||||
Args:
|
||||
file_path: Путь к конфигурационному файлу. Если None, выполняется поиск.
|
||||
|
||||
Returns:
|
||||
Экземпляр Config
|
||||
"""
|
||||
if file_path is None:
|
||||
file_path = cls._find_config_file()
|
||||
|
||||
if file_path and file_path.exists():
|
||||
import json
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
json_data = json.load(f)
|
||||
return cls.model_validate(json_data)
|
||||
except Exception as e:
|
||||
import warnings
|
||||
warnings.warn(f"Failed to load config from {file_path}: {e}")
|
||||
|
||||
return cls()
|
||||
|
||||
def get(self, key_path: str, default=None):
|
||||
"""Получение значения конфигурации по пути через точку.
|
||||
|
||||
Args:
|
||||
key_path: Путь к значению через точку, например 'can.interfaces'
|
||||
default: Значение по умолчанию, если ключ не найден
|
||||
|
||||
Returns:
|
||||
Значение конфигурации или default
|
||||
|
||||
Example:
|
||||
>>> config.get('can.interfaces')
|
||||
['can0', 'can1']
|
||||
"""
|
||||
keys = key_path.split('.')
|
||||
current = self
|
||||
|
||||
for key in keys:
|
||||
if hasattr(current, key):
|
||||
current = getattr(current, key)
|
||||
elif isinstance(current, dict) and key in current:
|
||||
current = current[key]
|
||||
else:
|
||||
return default
|
||||
|
||||
return current
|
||||
|
||||
def get_section(self, section: str):
|
||||
"""Получение всей секции конфигурации.
|
||||
|
||||
Args:
|
||||
section: Имя секции, например 'can', 'influxdb'
|
||||
|
||||
Returns:
|
||||
Объект конфигурации секции
|
||||
|
||||
Example:
|
||||
>>> can_config = config.get_section('can')
|
||||
>>> print(can_config.interfaces)
|
||||
"""
|
||||
return getattr(self, section, None)
|
||||
|
||||
|
||||
# Глобальный экземпляр конфигурации (singleton)
|
||||
_config_instance: Optional[Config] = None
|
||||
|
||||
|
||||
def get_config(reload: bool = False) -> Config:
|
||||
"""Получение глобального экземпляра конфигурации.
|
||||
|
||||
Args:
|
||||
reload: Если True, перезагружает конфигурацию из файла
|
||||
|
||||
Returns:
|
||||
Экземпляр Config
|
||||
|
||||
Example:
|
||||
>>> from config import get_config
|
||||
>>> config = get_config()
|
||||
>>> interfaces = config.can.interfaces
|
||||
>>> # Перезагрузить конфигурацию после изменения файла
|
||||
>>> config = get_config(reload=True)
|
||||
"""
|
||||
global _config_instance
|
||||
if _config_instance is None or reload:
|
||||
_config_instance = Config()
|
||||
return _config_instance
|
||||
|
||||
|
||||
def reload_config() -> Config:
|
||||
"""Перезагрузка конфигурации из файла.
|
||||
|
||||
Returns:
|
||||
Перезагруженный экземпляр Config
|
||||
|
||||
Example:
|
||||
>>> from config import reload_config
|
||||
>>> config = reload_config()
|
||||
"""
|
||||
return get_config(reload=True)
|
||||
|
||||
|
||||
# Для обратной совместимости и удобства
|
||||
# Используем прокси для автоматического доступа к актуальной конфигурации
|
||||
class _ConfigProxy:
|
||||
"""Прокси для глобального доступа к конфигурации с поддержкой перезагрузки."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Делегирование доступа к атрибутам конфигурации."""
|
||||
# Всегда получаем актуальный экземпляр конфигурации
|
||||
return getattr(get_config(), name)
|
||||
|
||||
def reload(self):
|
||||
"""Перезагрузка конфигурации из файла."""
|
||||
global _config_instance
|
||||
_config_instance = None # Сбрасываем singleton
|
||||
return reload_config()
|
||||
|
||||
def __repr__(self):
|
||||
"""Строковое представление прокси."""
|
||||
return f"ConfigProxy({get_config()})"
|
||||
|
||||
# Поддержка прямого доступа к методам Config
|
||||
def get(self, key_path: str, default=None):
|
||||
"""Получение значения по пути."""
|
||||
return get_config().get(key_path, default)
|
||||
|
||||
def get_section(self, section: str):
|
||||
"""Получение секции конфигурации."""
|
||||
return get_config().get_section(section)
|
||||
|
||||
|
||||
# Глобальный прокси для удобного доступа
|
||||
# ВАЖНО: После изменения config.json нужно вызвать config.reload() или перезапустить приложение
|
||||
config = _ConfigProxy()
|
||||
207
can_sniffer/src/logger.py
Normal file
207
can_sniffer/src/logger.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Модуль логирования для CAN Sniffer проекта.
|
||||
|
||||
Предоставляет централизованную систему логирования с поддержкой:
|
||||
- Structured logging
|
||||
- Ротации логов
|
||||
- Записи в файл и консоль
|
||||
- Интеграции с модулем config
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from config import get_config
|
||||
|
||||
|
||||
class StructuredFormatter(logging.Formatter):
|
||||
"""Форматтер для structured logging с дополнительными полями."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Форматирование записи лога с поддержкой дополнительных полей."""
|
||||
# Добавляем дополнительные поля из extra, если они есть
|
||||
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
|
||||
|
||||
# Форматируем основное сообщение
|
||||
message = super().format(record)
|
||||
|
||||
# Добавляем дополнительные поля в конец сообщения
|
||||
if extra_fields:
|
||||
extra_str = ' | '.join(f'{k}={v}' for k, v in extra_fields.items())
|
||||
message = f'{message} | {extra_str}'
|
||||
|
||||
return message
|
||||
|
||||
|
||||
class Logger:
|
||||
"""Класс для управления логированием проекта."""
|
||||
|
||||
_instance: Optional['Logger'] = None
|
||||
_initialized: bool = False
|
||||
|
||||
def __new__(cls):
|
||||
"""Singleton паттерн для единого экземпляра логгера."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализация системы логирования."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self.config = get_config()
|
||||
self.log_config = self.config.logging
|
||||
|
||||
# Настройка корневого логгера
|
||||
self.root_logger = logging.getLogger('can_sniffer')
|
||||
self.root_logger.setLevel(self._get_log_level())
|
||||
|
||||
# Очищаем существующие обработчики
|
||||
self.root_logger.handlers.clear()
|
||||
|
||||
# Создаем форматтер
|
||||
formatter = StructuredFormatter(self.log_config.format)
|
||||
|
||||
# Обработчик для файла с ротацией
|
||||
self._setup_file_handler(formatter)
|
||||
|
||||
# Обработчик для консоли
|
||||
self._setup_console_handler(formatter)
|
||||
|
||||
# Предотвращаем распространение на корневой логгер
|
||||
self.root_logger.propagate = False
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def _get_log_level(self) -> int:
|
||||
"""Преобразование строкового уровня в числовой."""
|
||||
level_map = {
|
||||
'DEBUG': logging.DEBUG,
|
||||
'INFO': logging.INFO,
|
||||
'WARNING': logging.WARNING,
|
||||
'ERROR': logging.ERROR,
|
||||
'CRITICAL': logging.CRITICAL,
|
||||
}
|
||||
return level_map.get(self.log_config.level.upper(), logging.INFO)
|
||||
|
||||
def _setup_file_handler(self, formatter: logging.Formatter) -> None:
|
||||
"""Настройка обработчика для записи в файл с ротацией."""
|
||||
log_file = Path(self.log_config.file)
|
||||
|
||||
# Создаем директорию для логов, если её нет
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Создаем RotatingFileHandler
|
||||
file_handler = RotatingFileHandler(
|
||||
filename=str(log_file),
|
||||
maxBytes=self.log_config.max_bytes,
|
||||
backupCount=self.log_config.backup_count,
|
||||
encoding='utf-8',
|
||||
)
|
||||
file_handler.setLevel(self._get_log_level())
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
self.root_logger.addHandler(file_handler)
|
||||
|
||||
def _setup_console_handler(self, formatter: logging.Formatter) -> None:
|
||||
"""Настройка обработчика для записи в консоль."""
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(self._get_log_level())
|
||||
console_handler.setFormatter(formatter)
|
||||
|
||||
self.root_logger.addHandler(console_handler)
|
||||
|
||||
def get_logger(self, name: Optional[str] = None) -> logging.Logger:
|
||||
"""Получение логгера для конкретного модуля.
|
||||
|
||||
Args:
|
||||
name: Имя модуля (обычно __name__). Если None, возвращается корневой логгер.
|
||||
|
||||
Returns:
|
||||
Экземпляр logging.Logger
|
||||
|
||||
Example:
|
||||
>>> logger = Logger().get_logger(__name__)
|
||||
>>> logger.info("Message")
|
||||
"""
|
||||
if name is None:
|
||||
return self.root_logger
|
||||
|
||||
# Создаем дочерний логгер с именем модуля
|
||||
child_logger = self.root_logger.getChild(name)
|
||||
return child_logger
|
||||
|
||||
def set_level(self, level: str) -> None:
|
||||
"""Изменение уровня логирования во время выполнения.
|
||||
|
||||
Args:
|
||||
level: Новый уровень логирования (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
"""
|
||||
level_map = {
|
||||
'DEBUG': logging.DEBUG,
|
||||
'INFO': logging.INFO,
|
||||
'WARNING': logging.WARNING,
|
||||
'ERROR': logging.ERROR,
|
||||
'CRITICAL': logging.CRITICAL,
|
||||
}
|
||||
|
||||
numeric_level = level_map.get(level.upper(), logging.INFO)
|
||||
self.root_logger.setLevel(numeric_level)
|
||||
|
||||
# Обновляем уровень для всех обработчиков
|
||||
for handler in self.root_logger.handlers:
|
||||
handler.setLevel(numeric_level)
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Перезагрузка конфигурации логирования.
|
||||
|
||||
Переинициализирует систему логирования с текущими настройками из config.
|
||||
"""
|
||||
self._initialized = False
|
||||
# Обновляем ссылку на конфигурацию
|
||||
self.config = get_config()
|
||||
self.log_config = self.config.logging
|
||||
# Переинициализируем
|
||||
self.__init__()
|
||||
|
||||
|
||||
# Глобальный экземпляр логгера
|
||||
_logger_instance: Optional[Logger] = None
|
||||
|
||||
|
||||
def get_logger(name: Optional[str] = None) -> logging.Logger:
|
||||
"""Получение логгера для использования в модулях.
|
||||
|
||||
Args:
|
||||
name: Имя модуля (обычно __name__). Если None, возвращается корневой логгер.
|
||||
|
||||
Returns:
|
||||
Экземпляр logging.Logger
|
||||
|
||||
Example:
|
||||
>>> from logger import get_logger
|
||||
>>> logger = get_logger(__name__)
|
||||
>>> logger.info("Application started")
|
||||
>>> logger.info("CAN frame received", extra={"can_id": 0x123, "interface": "can0"})
|
||||
"""
|
||||
global _logger_instance
|
||||
if _logger_instance is None:
|
||||
_logger_instance = Logger()
|
||||
return _logger_instance.get_logger(name)
|
||||
|
||||
|
||||
# Для удобства - корневой логгер
|
||||
logger = get_logger()
|
||||
|
||||
77
can_sniffer/src/main.py
Normal file
77
can_sniffer/src/main.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Главный модуль CAN Sniffer приложения.
|
||||
|
||||
Только код запуска приложения. Вся логика обработки сообщений
|
||||
автоматически применяется в модуле socket_can.
|
||||
"""
|
||||
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from config import config
|
||||
from logger import get_logger
|
||||
from socket_can import CANSniffer
|
||||
|
||||
# Инициализация логгера
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Глобальная переменная для graceful shutdown
|
||||
sniffer: CANSniffer = None
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
"""Обработчик сигналов для graceful shutdown."""
|
||||
logger.info("Received shutdown signal, stopping gracefully...")
|
||||
if sniffer:
|
||||
sniffer.stop()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
"""Главная функция приложения - только запуск."""
|
||||
global sniffer
|
||||
|
||||
# Регистрируем обработчики сигналов для graceful shutdown
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
logger.info("CAN Sniffer application starting", extra={
|
||||
"interfaces": config.can.interfaces,
|
||||
"bitrate": config.can.bitrate,
|
||||
"listen_only": config.can.listen_only
|
||||
})
|
||||
|
||||
logger.info("Configuration loaded", extra={
|
||||
"influxdb_enabled": config.influxdb.enabled,
|
||||
"influxdb_url": config.influxdb.url if config.influxdb.enabled else None,
|
||||
"storage_path": config.storage.database_path
|
||||
})
|
||||
|
||||
try:
|
||||
# Создаем и запускаем CAN Sniffer
|
||||
# MessageProcessor автоматически инициализируется и используется внутри CANSniffer
|
||||
sniffer = CANSniffer()
|
||||
sniffer.start()
|
||||
|
||||
logger.info("Application initialized successfully. Reading CAN messages...")
|
||||
logger.info("Press Ctrl+C to stop")
|
||||
|
||||
# Основной цикл - периодически выводим статистику
|
||||
while True:
|
||||
time.sleep(10) # Выводим статистику каждые 10 секунд
|
||||
|
||||
stats = sniffer.get_stats()
|
||||
logger.info("Statistics", extra=stats)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Keyboard interrupt received")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}", exc_info=True)
|
||||
finally:
|
||||
if sniffer:
|
||||
sniffer.stop()
|
||||
logger.info("Application stopped")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
7
can_sniffer/src/socket_can/__init__.py
Normal file
7
can_sniffer/src/socket_can/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Модуль для работы с SocketCAN интерфейсами."""
|
||||
|
||||
from .src import CANSniffer, CANBusHandler
|
||||
from .message_processor import MessageProcessor
|
||||
|
||||
__all__ = ['CANSniffer', 'CANBusHandler', 'MessageProcessor']
|
||||
|
||||
155
can_sniffer/src/socket_can/message_processor.py
Normal file
155
can_sniffer/src/socket_can/message_processor.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Модуль для обработки CAN сообщений.
|
||||
|
||||
Обрабатывает входящие CAN сообщения и сохраняет их в SQLite и InfluxDB.
|
||||
"""
|
||||
|
||||
import can
|
||||
from typing import Optional
|
||||
from logger import get_logger
|
||||
from config import config
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MessageProcessor:
|
||||
"""Класс для обработки и сохранения CAN сообщений."""
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализация процессора сообщений."""
|
||||
self.logger = logger
|
||||
self.storage = None
|
||||
self.influxdb_client = None
|
||||
|
||||
# Инициализируем хранилища
|
||||
self._init_storage()
|
||||
self._init_influxdb()
|
||||
|
||||
def _init_storage(self) -> None:
|
||||
"""Инициализация локального хранилища (SQLite)."""
|
||||
# TODO: Инициализация SQLite хранилища
|
||||
# from storage import Storage
|
||||
# self.storage = Storage(config.storage)
|
||||
self.logger.info(
|
||||
"Storage initialization",
|
||||
extra={
|
||||
"type": config.storage.type,
|
||||
"path": config.storage.database_path
|
||||
}
|
||||
)
|
||||
|
||||
def _init_influxdb(self) -> None:
|
||||
"""Инициализация клиента InfluxDB."""
|
||||
if not config.influxdb.enabled:
|
||||
self.logger.info("InfluxDB is disabled in configuration")
|
||||
return
|
||||
|
||||
# TODO: Инициализация InfluxDB клиента
|
||||
# from influxdb_client import InfluxDBClient
|
||||
# self.influxdb_client = InfluxDBClient(config.influxdb)
|
||||
self.logger.info(
|
||||
"InfluxDB initialization",
|
||||
extra={
|
||||
"url": config.influxdb.url,
|
||||
"bucket": config.influxdb.bucket
|
||||
}
|
||||
)
|
||||
|
||||
def process(self, interface: str, message: can.Message) -> None:
|
||||
"""
|
||||
Обработка CAN сообщения.
|
||||
|
||||
Args:
|
||||
interface: Имя интерфейса (например, 'can0')
|
||||
message: CAN сообщение
|
||||
"""
|
||||
try:
|
||||
# Логируем сообщение
|
||||
self.logger.debug(
|
||||
"CAN message received",
|
||||
extra={
|
||||
"interface": interface,
|
||||
"can_id": hex(message.arbitration_id),
|
||||
"dlc": message.dlc,
|
||||
"data": message.data.hex() if message.data else "",
|
||||
"timestamp": message.timestamp
|
||||
}
|
||||
)
|
||||
|
||||
# Сохраняем в локальное хранилище (SQLite)
|
||||
self._save_to_storage(interface, message)
|
||||
|
||||
# Отправляем в InfluxDB (если включено)
|
||||
if self.influxdb_client:
|
||||
self._send_to_influxdb(interface, message)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error processing message: {e}",
|
||||
exc_info=True,
|
||||
extra={
|
||||
"interface": interface,
|
||||
"can_id": hex(message.arbitration_id) if message else None
|
||||
}
|
||||
)
|
||||
|
||||
def _save_to_storage(self, interface: str, message: can.Message) -> None:
|
||||
"""
|
||||
Сохранение сообщения в локальное хранилище (SQLite).
|
||||
|
||||
Args:
|
||||
interface: Имя интерфейса
|
||||
message: CAN сообщение
|
||||
"""
|
||||
if not self.storage:
|
||||
# TODO: Реализовать сохранение в SQLite
|
||||
# self.storage.save_message(interface, message)
|
||||
return
|
||||
|
||||
try:
|
||||
# self.storage.save_message(interface, message)
|
||||
pass
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to save message to storage: {e}",
|
||||
exc_info=True,
|
||||
extra={"interface": interface}
|
||||
)
|
||||
|
||||
def _send_to_influxdb(self, interface: str, message: can.Message) -> None:
|
||||
"""
|
||||
Отправка сообщения в InfluxDB.
|
||||
|
||||
Args:
|
||||
interface: Имя интерфейса
|
||||
message: CAN сообщение
|
||||
"""
|
||||
if not self.influxdb_client:
|
||||
return
|
||||
|
||||
try:
|
||||
# TODO: Реализовать отправку в InfluxDB
|
||||
# self.influxdb_client.write_message(interface, message)
|
||||
pass
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to send message to InfluxDB: {e}",
|
||||
exc_info=True,
|
||||
extra={"interface": interface}
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Корректное завершение работы процессора."""
|
||||
self.logger.info("Shutting down message processor...")
|
||||
|
||||
# Закрываем соединения
|
||||
if self.storage:
|
||||
# self.storage.close()
|
||||
pass
|
||||
|
||||
if self.influxdb_client:
|
||||
# self.influxdb_client.close()
|
||||
pass
|
||||
|
||||
self.logger.info("Message processor stopped")
|
||||
|
||||
324
can_sniffer/src/socket_can/src.py
Normal file
324
can_sniffer/src/socket_can/src.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Модуль для работы с SocketCAN интерфейсами.
|
||||
|
||||
Предоставляет параллельное чтение CAN сообщений с нескольких интерфейсов
|
||||
с поддержкой обработки ошибок, логирования и graceful shutdown.
|
||||
"""
|
||||
|
||||
import can
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Dict, List, Optional
|
||||
from queue import Queue, Empty
|
||||
|
||||
from config import config
|
||||
from logger import get_logger
|
||||
from .message_processor import MessageProcessor
|
||||
|
||||
|
||||
class CANBusHandler:
|
||||
"""Обработчик для одной CAN шины."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
interface: str,
|
||||
bus: can.Bus,
|
||||
message_callback: Callable,
|
||||
logger,
|
||||
filters: Optional[List[dict]] = None
|
||||
):
|
||||
"""
|
||||
Инициализация обработчика CAN шины.
|
||||
|
||||
Args:
|
||||
interface: Имя интерфейса (например, 'can0')
|
||||
bus: Экземпляр can.Bus
|
||||
message_callback: Функция для обработки CAN сообщений
|
||||
logger: Логгер для данного интерфейса
|
||||
filters: Список фильтров SocketCAN
|
||||
"""
|
||||
self.interface = interface
|
||||
self.bus = bus
|
||||
self.message_callback = message_callback
|
||||
self.logger = logger
|
||||
self.filters = filters or []
|
||||
self.running = False
|
||||
self.thread: Optional[threading.Thread] = None
|
||||
self.message_count = 0
|
||||
self.error_count = 0
|
||||
self.last_message_time: Optional[float] = None
|
||||
|
||||
# Применяем фильтры, если они есть
|
||||
if self.filters:
|
||||
self._apply_filters()
|
||||
|
||||
def _apply_filters(self) -> None:
|
||||
"""Применение фильтров SocketCAN к шине."""
|
||||
try:
|
||||
# SocketCAN фильтры применяются через set_filters
|
||||
# Формат: [{"can_id": 0x123, "can_mask": 0x7FF}, ...]
|
||||
self.bus.set_filters(self.filters)
|
||||
self.logger.info(
|
||||
f"Applied {len(self.filters)} filters to {self.interface}",
|
||||
extra={"filters": self.filters}
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f"Failed to apply filters to {self.interface}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
"""Основной цикл чтения сообщений с шины."""
|
||||
self.logger.info(f"Starting read loop for {self.interface}")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Читаем сообщение с таймаутом для возможности проверки running
|
||||
message = self.bus.recv(timeout=0.1)
|
||||
|
||||
if message is not None:
|
||||
self.message_count += 1
|
||||
self.last_message_time = time.time()
|
||||
|
||||
# Вызываем callback для обработки сообщения
|
||||
try:
|
||||
self.message_callback(self.interface, message)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Error in message callback for {self.interface}: {e}",
|
||||
exc_info=True,
|
||||
extra={"can_id": hex(message.arbitration_id)}
|
||||
)
|
||||
self.error_count += 1
|
||||
|
||||
except can.CanError as e:
|
||||
self.logger.error(
|
||||
f"CAN error on {self.interface}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
self.error_count += 1
|
||||
# Небольшая задержка перед повторной попыткой
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Unexpected error on {self.interface}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
self.error_count += 1
|
||||
time.sleep(0.1)
|
||||
|
||||
self.logger.info(
|
||||
f"Read loop stopped for {self.interface}",
|
||||
extra={
|
||||
"total_messages": self.message_count,
|
||||
"total_errors": self.error_count
|
||||
}
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Запуск чтения сообщений в отдельном потоке."""
|
||||
if self.running:
|
||||
self.logger.warning(f"{self.interface} is already running")
|
||||
return
|
||||
|
||||
self.running = True
|
||||
self.thread = threading.Thread(
|
||||
target=self._read_loop,
|
||||
name=f"CAN-{self.interface}",
|
||||
daemon=True
|
||||
)
|
||||
self.thread.start()
|
||||
self.logger.info(f"Started reading from {self.interface}")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Остановка чтения сообщений."""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
self.logger.info(f"Stopping {self.interface}...")
|
||||
self.running = False
|
||||
|
||||
if self.thread and self.thread.is_alive():
|
||||
self.thread.join(timeout=2.0)
|
||||
if self.thread.is_alive():
|
||||
self.logger.warning(f"Thread for {self.interface} did not stop gracefully")
|
||||
|
||||
# Закрываем шину
|
||||
try:
|
||||
self.bus.shutdown()
|
||||
self.logger.info(f"Bus {self.interface} closed")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error closing bus {self.interface}: {e}", exc_info=True)
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""Получение статистики по обработке сообщений."""
|
||||
return {
|
||||
"interface": self.interface,
|
||||
"message_count": self.message_count,
|
||||
"error_count": self.error_count,
|
||||
"last_message_time": self.last_message_time,
|
||||
"running": self.running
|
||||
}
|
||||
|
||||
|
||||
class CANSniffer:
|
||||
"""Класс для параллельного чтения CAN сообщений с нескольких интерфейсов."""
|
||||
|
||||
def __init__(self, message_callback: Optional[Callable] = None):
|
||||
"""
|
||||
Инициализация CAN Sniffer.
|
||||
|
||||
Args:
|
||||
message_callback: Функция для обработки CAN сообщений.
|
||||
Должна принимать (interface: str, message: can.Message)
|
||||
"""
|
||||
self.config = config.can
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
# Инициализируем MessageProcessor для автоматической обработки сообщений
|
||||
self.message_processor = MessageProcessor()
|
||||
|
||||
# Используем переданный callback или процессор по умолчанию
|
||||
if message_callback:
|
||||
self.message_callback = message_callback
|
||||
else:
|
||||
# Автоматически используем MessageProcessor
|
||||
self.message_callback = self.message_processor.process
|
||||
|
||||
self.bus_handlers: Dict[str, CANBusHandler] = {}
|
||||
self.running = False
|
||||
|
||||
self._init_buses()
|
||||
|
||||
def _init_buses(self) -> None:
|
||||
"""Инициализация CAN шин из конфигурации."""
|
||||
self.logger.info(
|
||||
"Initializing CAN buses",
|
||||
extra={
|
||||
"interfaces": self.config.interfaces,
|
||||
"listen_only": self.config.listen_only,
|
||||
"bitrate": self.config.bitrate
|
||||
}
|
||||
)
|
||||
|
||||
for interface in self.config.interfaces:
|
||||
try:
|
||||
bus = self._create_bus(interface)
|
||||
handler = CANBusHandler(
|
||||
interface=interface,
|
||||
bus=bus,
|
||||
message_callback=self.message_callback,
|
||||
logger=self.logger.getChild(f"bus.{interface}"),
|
||||
filters=self.config.filters
|
||||
)
|
||||
self.bus_handlers[interface] = handler
|
||||
self.logger.info(f"Initialized bus: {interface}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to initialize bus {interface}: {e}",
|
||||
exc_info=True,
|
||||
extra={"interface": interface}
|
||||
)
|
||||
|
||||
def _create_bus(self, interface: str) -> can.Bus:
|
||||
"""
|
||||
Создание CAN шины для интерфейса.
|
||||
|
||||
Args:
|
||||
interface: Имя интерфейса (например, 'can0')
|
||||
|
||||
Returns:
|
||||
Экземпляр can.Bus
|
||||
"""
|
||||
bus_kwargs = {
|
||||
"channel": interface,
|
||||
"bustype": "socketcan",
|
||||
"receive_own_messages": False,
|
||||
}
|
||||
|
||||
# Добавляем listen-only режим, если указан в конфигурации
|
||||
if self.config.listen_only:
|
||||
# Для SocketCAN listen-only режим устанавливается через параметр
|
||||
# В некоторых версиях python-can это может быть через receive_own_messages=False
|
||||
# и отдельным параметром, но для SocketCAN обычно достаточно receive_own_messages=False
|
||||
pass
|
||||
|
||||
try:
|
||||
bus = can.interface.Bus(**bus_kwargs)
|
||||
self.logger.debug(
|
||||
f"Created bus for {interface}",
|
||||
extra={"kwargs": bus_kwargs}
|
||||
)
|
||||
return bus
|
||||
except can.CanError as e:
|
||||
self.logger.error(
|
||||
f"CAN error creating bus for {interface}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Unexpected error creating bus for {interface}: {e}",
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def start(self) -> None:
|
||||
"""Запуск чтения со всех шин."""
|
||||
if self.running:
|
||||
self.logger.warning("CANSniffer is already running")
|
||||
return
|
||||
|
||||
self.logger.info(
|
||||
f"Starting CANSniffer with {len(self.bus_handlers)} buses",
|
||||
extra={"interfaces": list(self.bus_handlers.keys())}
|
||||
)
|
||||
|
||||
self.running = True
|
||||
|
||||
# Запускаем все обработчики параллельно
|
||||
for handler in self.bus_handlers.values():
|
||||
handler.start()
|
||||
|
||||
self.logger.info("CANSniffer started successfully")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Остановка чтения со всех шин."""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
self.logger.info("Stopping CANSniffer...")
|
||||
self.running = False
|
||||
|
||||
# Останавливаем все обработчики
|
||||
for handler in self.bus_handlers.values():
|
||||
handler.stop()
|
||||
|
||||
# Останавливаем процессор сообщений
|
||||
self.message_processor.shutdown()
|
||||
|
||||
self.logger.info("CANSniffer stopped")
|
||||
|
||||
def get_stats(self) -> Dict:
|
||||
"""Получение статистики по всем шинам."""
|
||||
return {
|
||||
"running": self.running,
|
||||
"buses": {
|
||||
interface: handler.get_stats()
|
||||
for interface, handler in self.bus_handlers.items()
|
||||
}
|
||||
}
|
||||
|
||||
def __enter__(self):
|
||||
"""Поддержка context manager."""
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Поддержка context manager."""
|
||||
self.stop()
|
||||
return False
|
||||
94
can_sniffer/src/socket_can_example.py
Normal file
94
can_sniffer/src/socket_can_example.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Пример использования модуля socket_can.
|
||||
"""
|
||||
|
||||
import time
|
||||
import signal
|
||||
import sys
|
||||
from socket_can import CANSniffer
|
||||
from logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Глобальная переменная для graceful shutdown
|
||||
sniffer = None
|
||||
|
||||
|
||||
def message_handler(interface: str, message):
|
||||
"""
|
||||
Обработчик CAN сообщений.
|
||||
|
||||
Args:
|
||||
interface: Имя интерфейса (например, 'can0')
|
||||
message: CAN сообщение (can.Message)
|
||||
"""
|
||||
# Здесь можно добавить логику сохранения в SQLite/InfluxDB
|
||||
logger.info(
|
||||
"CAN message",
|
||||
extra={
|
||||
"interface": interface,
|
||||
"can_id": hex(message.arbitration_id),
|
||||
"dlc": message.dlc,
|
||||
"data": message.data.hex() if message.data else "",
|
||||
"timestamp": message.timestamp
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
"""Обработчик сигналов для graceful shutdown."""
|
||||
logger.info("Received shutdown signal, stopping...")
|
||||
if sniffer:
|
||||
sniffer.stop()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
"""Пример использования CANSniffer."""
|
||||
global sniffer
|
||||
|
||||
# Регистрируем обработчики сигналов
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Создаем sniffer с callback функцией
|
||||
sniffer = CANSniffer(message_callback=message_handler)
|
||||
|
||||
try:
|
||||
# Запускаем чтение (можно использовать context manager)
|
||||
sniffer.start()
|
||||
|
||||
logger.info("CANSniffer started. Press Ctrl+C to stop.")
|
||||
|
||||
# Основной цикл - просто ждем
|
||||
while True:
|
||||
time.sleep(1)
|
||||
|
||||
# Периодически выводим статистику
|
||||
stats = sniffer.get_stats()
|
||||
logger.debug("Statistics", extra=stats)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Keyboard interrupt received")
|
||||
finally:
|
||||
if sniffer:
|
||||
sniffer.stop()
|
||||
logger.info("Application stopped")
|
||||
|
||||
|
||||
def example_with_context_manager():
|
||||
"""Пример использования с context manager."""
|
||||
def message_handler(interface: str, message):
|
||||
print(f"[{interface}] ID: {hex(message.arbitration_id)}, Data: {message.data.hex()}")
|
||||
|
||||
# Использование с context manager (автоматический start/stop)
|
||||
with CANSniffer(message_callback=message_handler) as sniffer:
|
||||
# Читаем сообщения в течение 10 секунд
|
||||
time.sleep(10)
|
||||
|
||||
# После выхода из блока with, sniffer автоматически остановится
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
55
chart.md
Normal file
55
chart.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
id: 421a4a02-025a-4ab5-8ae3-fd52e2c738f0
|
||||
---
|
||||
flowchart LR
|
||||
subgraph Vehicle["Автомобиль"]
|
||||
OBD["OBD-II"]
|
||||
CANBUS["HS-CAN\n500 kbps"]
|
||||
OBD --> CANBUS
|
||||
end
|
||||
|
||||
subgraph CANBoard["CAN-плата"]
|
||||
PHY0["CAN PHY"]
|
||||
PHY1["CAN PHY"]
|
||||
MCP0["MCP2515\ncan0"]
|
||||
MCP1["MCP2515\ncan1"]
|
||||
ISO["Isolation"]
|
||||
|
||||
CANBUS --> PHY0
|
||||
CANBUS --> PHY1
|
||||
PHY0 --> MCP0
|
||||
PHY1 --> MCP1
|
||||
MCP0 --> ISO
|
||||
MCP1 --> ISO
|
||||
end
|
||||
|
||||
subgraph Edge["Raspberry Pi 5 (Edge)"]
|
||||
SPI["SPI"]
|
||||
SocketCAN["SocketCAN\nlisten-only"]
|
||||
EdgeApp["Edge CAN Logger"]
|
||||
LocalStore["SQLite WAL\nOffline Buffer"]
|
||||
Forwarder["Store-and-Forward"]
|
||||
|
||||
ISO --> SPI
|
||||
SPI --> SocketCAN
|
||||
SocketCAN --> EdgeApp
|
||||
EdgeApp --> LocalStore
|
||||
LocalStore --> Forwarder
|
||||
end
|
||||
|
||||
subgraph BackendHost["Backend Host"]
|
||||
Influx["InfluxDB"]
|
||||
Flask["Flask Backend"]
|
||||
WS["WebSocket Server"]
|
||||
end
|
||||
|
||||
subgraph UI["Web UI"]
|
||||
Browser["Browser"]
|
||||
Charts["Real-time Charts"]
|
||||
end
|
||||
|
||||
Forwarder --> Influx
|
||||
Influx --> Flask
|
||||
Flask --> WS
|
||||
WS --> Browser
|
||||
Browser --> Charts
|
||||
165
web/task_webui.md
Normal file
165
web/task_webui.md
Normal file
@@ -0,0 +1,165 @@
|
||||
1.1 Цель
|
||||
|
||||
Разработать web-интерфейс реального времени для отображения CAN-данных, собираемых edge-устройством и сохраняемых в InfluxDB.
|
||||
|
||||
Система должна:
|
||||
|
||||
отображать данные в реальном времени,
|
||||
|
||||
работать на одном хосте с InfluxDB,
|
||||
|
||||
использовать Python / Flask,
|
||||
|
||||
использовать WebSocket для push-обновлений,
|
||||
|
||||
быть расширяемой под future-аналитику.
|
||||
|
||||
1.2 Общая архитектура
|
||||
Edge (Raspberry Pi)
|
||||
→ InfluxDB (write)
|
||||
→ Flask Backend (read + stream)
|
||||
→ Web UI (WebSocket)
|
||||
|
||||
|
||||
InfluxDB — единый источник истины для UI
|
||||
(не читаем данные напрямую с Edge).
|
||||
|
||||
1.3 Компоненты системы
|
||||
1.3.1 Backend (Flask)
|
||||
|
||||
Назначение
|
||||
|
||||
HTTP API
|
||||
|
||||
WebSocket сервер
|
||||
|
||||
агрегация данных из InfluxDB
|
||||
|
||||
доставка данных в UI
|
||||
|
||||
Технологии
|
||||
|
||||
Python 3.11+
|
||||
|
||||
Flask
|
||||
|
||||
Flask-SocketIO (или websockets + ASGI)
|
||||
|
||||
InfluxDB Python Client
|
||||
|
||||
1.3.2 InfluxDB
|
||||
|
||||
Роль
|
||||
|
||||
time-series storage
|
||||
|
||||
буфер между Edge и UI
|
||||
|
||||
исторические запросы
|
||||
|
||||
Типы данных
|
||||
|
||||
CAN frames (raw)
|
||||
|
||||
декодированные сигналы (future)
|
||||
|
||||
1.3.3 Frontend (Web UI)
|
||||
|
||||
Назначение
|
||||
|
||||
визуализация CAN-данных
|
||||
|
||||
real-time обновление
|
||||
|
||||
базовая аналитика
|
||||
|
||||
Минимальный стек
|
||||
|
||||
HTML + JS
|
||||
|
||||
WebSocket клиент
|
||||
|
||||
Chart.js / ECharts (на ваш выбор)
|
||||
|
||||
1.4 Функциональные требования (MVP)
|
||||
1.4.1 Real-time отображение
|
||||
|
||||
Обновление ≤ 500 мс
|
||||
|
||||
Push-модель (WebSocket)
|
||||
|
||||
Без polling
|
||||
|
||||
Отображать:
|
||||
|
||||
timestamp
|
||||
|
||||
CAN interface (can0 / can1)
|
||||
|
||||
CAN ID
|
||||
|
||||
DLC
|
||||
|
||||
DATA (hex)
|
||||
|
||||
frequency (msg/sec)
|
||||
|
||||
1.4.2 Исторический просмотр
|
||||
|
||||
выбор временного окна:
|
||||
|
||||
last 1 min / 5 min / 1 h
|
||||
|
||||
график:
|
||||
|
||||
частота сообщений
|
||||
|
||||
значение байтов (raw)
|
||||
|
||||
1.4.3 Фильтрация (UI)
|
||||
|
||||
по CAN ID
|
||||
|
||||
по интерфейсу
|
||||
|
||||
включение / выключение ID
|
||||
|
||||
Фильтрация не влияет на ingestion, только на отображение.
|
||||
|
||||
1.5 Нефункциональные требования
|
||||
Производительность
|
||||
|
||||
≥ 5–10k msg/sec без деградации UI
|
||||
|
||||
batch-чтение из InfluxDB
|
||||
|
||||
Надёжность
|
||||
|
||||
UI не зависит от Edge availability
|
||||
|
||||
UI не ломается при временном отсутствии новых данных
|
||||
|
||||
Безопасность (минимум)
|
||||
|
||||
UI доступен только из доверенной сети
|
||||
|
||||
без write-доступа к InfluxDB
|
||||
|
||||
1.6 Поток данных (важно)
|
||||
Write path
|
||||
Edge → InfluxDB
|
||||
|
||||
Read / Stream path
|
||||
InfluxDB → Flask → WebSocket → Browser
|
||||
|
||||
|
||||
❗ Flask не принимает CAN напрямую
|
||||
❗ WebSocket не ходит в InfluxDB
|
||||
|
||||
1.7 Ограничения (осознанные)
|
||||
|
||||
Flask — single logical service
|
||||
|
||||
Без auth (на MVP)
|
||||
|
||||
Без декодирования DBC (пока raw)
|
||||
Reference in New Issue
Block a user