diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..4240f2c --- /dev/null +++ b/src/README.md @@ -0,0 +1,39 @@ +# ComConfigCopy + +Приложение для копирования конфигураций на сетевое оборудование через последовательный порт и TFTP. + +## Структура проекта + +``` +src/ +├── core/ # Ядро приложения +├── communication/ # Коммуникация с устройствами +├── filesystem/ # Работа с файловой системой +├── network/ # Сетевые компоненты +├── ui/ # Пользовательский интерфейс +└── utils/ # Утилиты +``` + +## Требования + +- Python 3.8+ +- pyserial>=3.5 +- tftpy>=0.8.0 +- requests>=2.31.0 +- watchdog>=3.0.0 + +## Установка + +1. Клонируйте репозиторий +2. Создайте виртуальное окружение: `python -m venv .venv` +3. Активируйте виртуальное окружение: + - Windows: `.venv\Scripts\activate` + - Linux/Mac: `source .venv/bin/activate` +4. Установите зависимости: `pip install -r requirements.txt` + +## Использование + +Запустите приложение: +```bash +python -m src.core.app +``` \ No newline at end of file diff --git a/src/communication/__init__.py b/src/communication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/communication/command_handler.py b/src/communication/command_handler.py new file mode 100644 index 0000000..ee9f5e4 --- /dev/null +++ b/src/communication/command_handler.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import re +from typing import List, Optional, Dict, Any +import logging + +from core.exceptions import ValidationError +from .serial_manager import SerialManager + +class CommandHandler: + """Обработчик команд для работы с устройством.""" + + def __init__(self, serial_manager: SerialManager): + self._serial = serial_manager + self._logger = logging.getLogger(__name__) + self._command_cache: Dict[str, str] = {} + + def validate_command(self, command: str) -> None: + """ + Валидация команды перед отправкой. + + Args: + command: Команда для проверки + + Raises: + ValidationError: Если команда не прошла валидацию + """ + # Проверяем на пустую команду + if not command or not command.strip(): + raise ValidationError("Пустая команда") + + # Проверяем на недопустимые символы + if re.search(r'[\x00-\x1F\x7F]', command): + raise ValidationError("Команда содержит недопустимые символы") + + # Проверяем максимальную длину + if len(command) > 1024: + raise ValidationError("Превышена максимальная длина команды (1024 символа)") + + def validate_config_commands(self, commands: List[str]) -> None: + """ + Валидация списка команд конфигурации. + + Args: + commands: Список команд для проверки + + Raises: + ValidationError: Если команды не прошли валидацию + """ + if not commands: + raise ValidationError("Пустой список команд") + + for command in commands: + self.validate_command(command) + + def execute_command(self, command: str, timeout: Optional[int] = None, + use_cache: bool = False) -> str: + """ + Выполнение команды на устройстве. + + Args: + command: Команда для выполнения + timeout: Таймаут ожидания ответа + use_cache: Использовать кэш команд + + Returns: + str: Ответ устройства + + Raises: + ValidationError: При ошибке валидации + ConnectionError: При ошибке соединения + """ + # Проверяем команду + self.validate_command(command) + + # Проверяем кэш + if use_cache and command in self._command_cache: + self._logger.debug(f"Использован кэш для команды: {command}") + return self._command_cache[command] + + # Выполняем команду + response = self._serial.send_command(command, timeout) + + # Сохраняем в кэш + if use_cache: + self._command_cache[command] = response + + return response + + def execute_config(self, commands: List[str], timeout: Optional[int] = None) -> str: + """ + Выполнение конфигурационных команд на устройстве. + + Args: + commands: Список команд для выполнения + timeout: Таймаут ожидания ответа + + Returns: + str: Ответ устройства + + Raises: + ValidationError: При ошибке валидации + ConnectionError: При ошибке соединения + """ + # Проверяем команды + self.validate_config_commands(commands) + + # Выполняем команды + return self._serial.send_config(commands, timeout) + + def clear_cache(self) -> None: + """Очистка кэша команд.""" + self._command_cache.clear() + self._logger.debug("Кэш команд очищен") + + def get_device_info(self) -> Dict[str, str]: + """ + Получение информации об устройстве. + + Returns: + Dict[str, str]: Словарь с информацией об устройстве + + Raises: + ConnectionError: При ошибке соединения + """ + info = {} + + try: + # Получаем версию ПО + version = self.execute_command("show version", use_cache=True) + info["version"] = self._parse_version(version) + + # Получаем hostname + hostname = self.execute_command("show hostname", use_cache=True) + info["hostname"] = hostname.strip() + + # Получаем модель + model = self.execute_command("show model", use_cache=True) + info["model"] = self._parse_model(model) + + except Exception as e: + self._logger.error(f"Ошибка получения информации об устройстве: {e}") + raise + + return info + + def _parse_version(self, version_output: str) -> str: + """ + Парсинг версии из вывода команды. + + Args: + version_output: Вывод команды show version + + Returns: + str: Версия ПО + """ + # Здесь должна быть реализация парсинга версии + # в зависимости от формата вывода конкретного устройства + return version_output.strip() + + def _parse_model(self, model_output: str) -> str: + """ + Парсинг модели из вывода команды. + + Args: + model_output: Вывод команды show model + + Returns: + str: Модель устройства + """ + # Здесь должна быть реализация парсинга модели + # в зависимости от формата вывода конкретного устройства + return model_output.strip() diff --git a/src/communication/protocols/base.py b/src/communication/protocols/base.py new file mode 100644 index 0000000..af96745 --- /dev/null +++ b/src/communication/protocols/base.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional +from core.events import event_bus, Event, EventTypes +from core.exceptions import ConnectionError, AuthenticationError + +class BaseProtocol(ABC): + """Базовый класс для всех протоколов связи.""" + + def __init__(self): + self._connected = False + self._authenticated = False + self._config: Dict[str, Any] = {} + + @property + def is_connected(self) -> bool: + """Проверка состояния подключения.""" + return self._connected + + @property + def is_authenticated(self) -> bool: + """Проверка состояния аутентификации.""" + return self._authenticated + + @abstractmethod + def connect(self) -> None: + """ + Установка соединения. + + Raises: + ConnectionError: При ошибке подключения + """ + pass + + @abstractmethod + def disconnect(self) -> None: + """Разрыв соединения.""" + pass + + @abstractmethod + def authenticate(self, username: str, password: str) -> None: + """ + Аутентификация на устройстве. + + Args: + username: Имя пользователя + password: Пароль + + Raises: + AuthenticationError: При ошибке аутентификации + """ + pass + + @abstractmethod + def send_command(self, command: str, timeout: Optional[int] = None) -> str: + """ + Отправка команды на устройство. + + Args: + command: Команда для отправки + timeout: Таймаут ожидания ответа + + Returns: + str: Ответ устройства + + Raises: + ConnectionError: При ошибке отправки команды + """ + pass + + @abstractmethod + def send_config(self, config_commands: list[str], timeout: Optional[int] = None) -> str: + """ + Отправка конфигурационных команд на устройство. + + Args: + config_commands: Список команд конфигурации + timeout: Таймаут ожидания ответа + + Returns: + str: Ответ устройства + + Raises: + ConnectionError: При ошибке отправки команд + """ + pass + + def configure(self, **kwargs) -> None: + """ + Конфигурация протокола. + + Args: + **kwargs: Параметры конфигурации + """ + self._config.update(kwargs) + + def _notify_connection_established(self) -> None: + """Уведомление об установке соединения.""" + self._connected = True + event_bus.publish(Event(EventTypes.CONNECTION_ESTABLISHED, None)) + + def _notify_connection_lost(self) -> None: + """Уведомление о потере соединения.""" + self._connected = False + self._authenticated = False + event_bus.publish(Event(EventTypes.CONNECTION_LOST, None)) + + def _notify_connection_error(self, error: Exception) -> None: + """ + Уведомление об ошибке соединения. + + Args: + error: Объект ошибки + """ + self._connected = False + self._authenticated = False + event_bus.publish(Event(EventTypes.CONNECTION_ERROR, str(error))) \ No newline at end of file diff --git a/src/communication/protocols/serial.py b/src/communication/protocols/serial.py new file mode 100644 index 0000000..1a7476e --- /dev/null +++ b/src/communication/protocols/serial.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import time +import re +from typing import Optional, List +import serial +from serial.serialutil import SerialException + +from core.config import AppConfig +from core.exceptions import ConnectionError, AuthenticationError +from .base import BaseProtocol + +class SerialProtocol(BaseProtocol): + """Протокол для работы с последовательным портом.""" + + def __init__(self): + super().__init__() + self._serial: Optional[serial.Serial] = None + self._prompt = AppConfig.DEFAULT_PROMPT + self._timeout = AppConfig.DEFAULT_TIMEOUT + self._baudrate = AppConfig.DEFAULT_BAUDRATE + self._port: Optional[str] = None + + def configure(self, port: str, baudrate: int = None, timeout: int = None, + prompt: str = None) -> None: + """ + Конфигурация последовательного порта. + + Args: + port: COM-порт + baudrate: Скорость передачи + timeout: Таймаут операций + prompt: Приглашение командной строки + """ + self._port = port + if baudrate is not None: + self._baudrate = baudrate + if timeout is not None: + self._timeout = timeout + if prompt is not None: + self._prompt = prompt + + def connect(self) -> None: + """ + Установка соединения с устройством. + + Raises: + ConnectionError: При ошибке подключения + """ + if not self._port: + raise ConnectionError("Не указан COM-порт") + + try: + self._serial = serial.Serial( + port=self._port, + baudrate=self._baudrate, + timeout=1 + ) + time.sleep(1) # Ждем инициализации порта + self._notify_connection_established() + + except SerialException as e: + self._notify_connection_error(e) + raise ConnectionError(f"Ошибка подключения к порту {self._port}: {e}") + + def disconnect(self) -> None: + """Закрытие соединения.""" + if self._serial and self._serial.is_open: + self._serial.close() + self._notify_connection_lost() + + def authenticate(self, username: str, password: str) -> None: + """ + Аутентификация на устройстве. + + Args: + username: Имя пользователя + password: Пароль + + Raises: + AuthenticationError: При ошибке аутентификации + ConnectionError: При ошибке соединения + """ + if not self.is_connected: + raise ConnectionError("Нет подключения к устройству") + + try: + # Очищаем буфер + self._serial.reset_input_buffer() + self._serial.reset_output_buffer() + + # Отправляем Enter для получения приглашения + self._serial.write(b"\n") + time.sleep(0.5) + + # Ожидаем запрос логина или пароля + response = self._read_until(["login:", "username:", "password:", self._prompt]) + + if "login:" in response.lower() or "username:" in response.lower(): + self._serial.write(f"{username}\n".encode()) + time.sleep(0.5) + response = self._read_until(["password:", self._prompt]) + + if "password:" in response.lower(): + self._serial.write(f"{password}\n".encode()) + time.sleep(0.5) + response = self._read_until([self._prompt]) + + if self._prompt not in response: + raise AuthenticationError("Неверные учетные данные") + + self._authenticated = True + + except SerialException as e: + self._notify_connection_error(e) + raise ConnectionError(f"Ошибка при аутентификации: {e}") + + def send_command(self, command: str, timeout: Optional[int] = None) -> str: + """ + Отправка команды на устройство. + + Args: + command: Команда для отправки + timeout: Таймаут ожидания ответа + + Returns: + str: Ответ устройства + + Raises: + ConnectionError: При ошибке отправки команды + """ + if not self.is_connected: + raise ConnectionError("Нет подключения к устройству") + + if not self.is_authenticated: + raise ConnectionError("Требуется аутентификация") + + try: + # Очищаем буфер перед отправкой + self._serial.reset_input_buffer() + + # Отправляем команду + self._serial.write(f"{command}\n".encode()) + + # Ожидаем ответ + response = self._read_until([self._prompt], timeout or self._timeout) + + # Удаляем отправленную команду из ответа + lines = response.splitlines() + if lines and command in lines[0]: + lines.pop(0) + + return "\n".join(lines).strip() + + except SerialException as e: + self._notify_connection_error(e) + raise ConnectionError(f"Ошибка при отправке команды: {e}") + + def send_config(self, config_commands: List[str], timeout: Optional[int] = None) -> str: + """ + Отправка конфигурационных команд на устройство. + + Args: + config_commands: Список команд конфигурации + timeout: Таймаут ожидания ответа + + Returns: + str: Ответ устройства + + Raises: + ConnectionError: При ошибке отправки команд + """ + responses = [] + for command in config_commands: + response = self.send_command(command, timeout) + responses.append(response) + return "\n".join(responses) + + def _read_until(self, patterns: List[str], timeout: Optional[int] = None) -> str: + """ + Чтение данных из порта до появления одного из паттернов. + + Args: + patterns: Список паттернов для поиска + timeout: Таймаут операции + + Returns: + str: Прочитанные данные + + Raises: + ConnectionError: При ошибке чтения или таймауте + """ + if not patterns: + raise ValueError("Не указаны паттерны для поиска") + + timeout = timeout or self._timeout + end_time = time.time() + timeout + buffer = "" + + while time.time() < end_time: + if self._serial.in_waiting: + chunk = self._serial.read(self._serial.in_waiting).decode(errors="ignore") + buffer += chunk + + # Проверяем наличие паттернов + for pattern in patterns: + if pattern in buffer: + return buffer + + # Небольшая задержка для снижения нагрузки на CPU + time.sleep(0.1) + + raise ConnectionError(f"Таймаут операции ({timeout} сек)") + + @staticmethod + def list_ports() -> List[str]: + """ + Получение списка доступных COM-портов. + + Returns: + List[str]: Список доступных портов + """ + return [port.device for port in serial.tools.list_ports.comports()] \ No newline at end of file diff --git a/src/communication/protocols/tftp.py b/src/communication/protocols/tftp.py new file mode 100644 index 0000000..bb637fa --- /dev/null +++ b/src/communication/protocols/tftp.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +from typing import Optional, Callable +import tftpy + +from core.config import AppConfig +from core.exceptions import TFTPError +from core.events import event_bus, Event, EventTypes + +class TFTPProtocol: + """Протокол для работы с TFTP сервером.""" + + def __init__(self): + self._server: Optional[tftpy.TftpServer] = None + self._client: Optional[tftpy.TftpClient] = None + self._server_running = False + self._root_dir = AppConfig.CONFIGS_DIR + self._port = AppConfig.TFTP_PORT + self._timeout = AppConfig.TFTP_TIMEOUT + self._retries = AppConfig.TFTP_RETRIES + + def configure(self, root_dir: Optional[str] = None, port: Optional[int] = None, + timeout: Optional[int] = None, retries: Optional[int] = None) -> None: + """ + Конфигурация TFTP сервера/клиента. + + Args: + root_dir: Корневая директория для файлов + port: Порт TFTP сервера + timeout: Таймаут операций + retries: Количество попыток + """ + if root_dir is not None: + self._root_dir = root_dir + if port is not None: + self._port = port + if timeout is not None: + self._timeout = timeout + if retries is not None: + self._retries = retries + + def start_server(self, host: str = "0.0.0.0") -> None: + """ + Запуск TFTP сервера. + + Args: + host: IP-адрес для прослушивания + + Raises: + TFTPError: При ошибке запуска сервера + """ + if self._server_running: + return + + try: + # Создаем серверный объект + self._server = tftpy.TftpServer(self._root_dir) + + # Запускаем сервер в отдельном потоке + self._server.listen(host, self._port, timeout=self._timeout) + self._server_running = True + + event_bus.publish(Event(EventTypes.TFTP_SERVER_STARTED, { + "host": host, + "port": self._port, + "root_dir": self._root_dir + })) + + except Exception as e: + raise TFTPError(f"Ошибка запуска TFTP сервера: {e}") + + def stop_server(self) -> None: + """Остановка TFTP сервера.""" + if self._server and self._server_running: + self._server.stop() + self._server = None + self._server_running = False + event_bus.publish(Event(EventTypes.TFTP_SERVER_STOPPED, None)) + + def upload_file(self, filename: str, host: str, remote_filename: Optional[str] = None, + progress_callback: Optional[Callable[[int], None]] = None) -> None: + """ + Загрузка файла на удаленное устройство. + + Args: + filename: Путь к локальному файлу + host: IP-адрес устройства + remote_filename: Имя файла на устройстве + progress_callback: Функция обратного вызова для отслеживания прогресса + + Raises: + TFTPError: При ошибке загрузки файла + """ + if not os.path.exists(filename): + raise TFTPError(f"Файл не найден: {filename}") + + try: + # Создаем клиентский объект + self._client = tftpy.TftpClient( + host, + self._port, + options={"timeout": self._timeout, "retries": self._retries} + ) + + # Определяем имя удаленного файла + if not remote_filename: + remote_filename = os.path.basename(filename) + + event_bus.publish(Event(EventTypes.TFTP_TRANSFER_STARTED, { + "operation": "upload", + "local_file": filename, + "remote_file": remote_filename, + "host": host + })) + + # Загружаем файл + self._client.upload( + remote_filename, + filename, + progress_callback + ) + + event_bus.publish(Event(EventTypes.TFTP_TRANSFER_COMPLETED, { + "operation": "upload", + "local_file": filename, + "remote_file": remote_filename, + "host": host + })) + + except Exception as e: + event_bus.publish(Event(EventTypes.TFTP_ERROR, str(e))) + raise TFTPError(f"Ошибка загрузки файла: {e}") + + def download_file(self, remote_filename: str, host: str, local_filename: Optional[str] = None, + progress_callback: Optional[Callable[[int], None]] = None) -> None: + """ + Загрузка файла с удаленного устройства. + + Args: + remote_filename: Имя файла на устройстве + host: IP-адрес устройства + local_filename: Путь для сохранения файла + progress_callback: Функция обратного вызова для отслеживания прогресса + + Raises: + TFTPError: При ошибке загрузки файла + """ + try: + # Создаем клиентский объект + self._client = tftpy.TftpClient( + host, + self._port, + options={"timeout": self._timeout, "retries": self._retries} + ) + + # Определяем имя локального файла + if not local_filename: + local_filename = os.path.join(self._root_dir, remote_filename) + + event_bus.publish(Event(EventTypes.TFTP_TRANSFER_STARTED, { + "operation": "download", + "local_file": local_filename, + "remote_file": remote_filename, + "host": host + })) + + # Загружаем файл + self._client.download( + remote_filename, + local_filename, + progress_callback + ) + + event_bus.publish(Event(EventTypes.TFTP_TRANSFER_COMPLETED, { + "operation": "download", + "local_file": local_filename, + "remote_file": remote_filename, + "host": host + })) + + except Exception as e: + event_bus.publish(Event(EventTypes.TFTP_ERROR, str(e))) + raise TFTPError(f"Ошибка загрузки файла: {e}") + + @property + def is_server_running(self) -> bool: + """Проверка состояния сервера.""" + return self._server_running \ No newline at end of file diff --git a/src/communication/response_parser.py b/src/communication/response_parser.py new file mode 100644 index 0000000..51dbfd1 --- /dev/null +++ b/src/communication/response_parser.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import re +from typing import List, Dict, Any, Optional +from dataclasses import dataclass +import logging + +@dataclass +class ParsedResponse: + """Структура разобранного ответа.""" + success: bool + data: Any + error: Optional[str] = None + raw_response: Optional[str] = None + +class ResponseParser: + """Парсер ответов от сетевого устройства.""" + + def __init__(self): + self._logger = logging.getLogger(__name__) + + # Регулярные выражения для парсинга + self._patterns = { + "error": r"(?i)error|invalid|failed|denied|rejected", + "success": r"(?i)success|completed|done|ok", + "version": r"(?i)version\s+(\S+)", + "model": r"(?i)model\s*:\s*(\S+)", + "hostname": r"(?i)hostname\s*:\s*(\S+)", + "interface": r"(?i)interface\s+(\S+)", + "ip_address": r"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})", + "mac_address": r"([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})", + } + + def parse_command_response(self, response: str) -> ParsedResponse: + """ + Парсинг ответа на команду. + + Args: + response: Ответ устройства + + Returns: + ParsedResponse: Структура с результатами парсинга + """ + # Проверяем на наличие ошибок + if re.search(self._patterns["error"], response, re.MULTILINE): + error_msg = self._extract_error_message(response) + return ParsedResponse( + success=False, + data=None, + error=error_msg, + raw_response=response + ) + + # Проверяем на успешное выполнение + success = bool(re.search(self._patterns["success"], response, re.MULTILINE)) + + return ParsedResponse( + success=success, + data=response.strip(), + raw_response=response + ) + + def parse_version(self, response: str) -> ParsedResponse: + """ + Парсинг версии ПО. + + Args: + response: Ответ на команду show version + + Returns: + ParsedResponse: Структура с результатами парсинга + """ + match = re.search(self._patterns["version"], response) + if match: + return ParsedResponse( + success=True, + data=match.group(1), + raw_response=response + ) + return ParsedResponse( + success=False, + data=None, + error="Версия не найдена", + raw_response=response + ) + + def parse_model(self, response: str) -> ParsedResponse: + """ + Парсинг модели устройства. + + Args: + response: Ответ на команду show model + + Returns: + ParsedResponse: Структура с результатами парсинга + """ + match = re.search(self._patterns["model"], response) + if match: + return ParsedResponse( + success=True, + data=match.group(1), + raw_response=response + ) + return ParsedResponse( + success=False, + data=None, + error="Модель не найдена", + raw_response=response + ) + + def parse_interfaces(self, response: str) -> ParsedResponse: + """ + Парсинг информации об интерфейсах. + + Args: + response: Ответ на команду show interfaces + + Returns: + ParsedResponse: Структура с результатами парсинга + """ + interfaces = [] + + # Ищем все интерфейсы + for line in response.splitlines(): + if_match = re.search(self._patterns["interface"], line) + if if_match: + interface = { + "name": if_match.group(1), + "ip": None, + "mac": None + } + + # Ищем IP-адрес + ip_match = re.search(self._patterns["ip_address"], line) + if ip_match: + interface["ip"] = ip_match.group(1) + + # Ищем MAC-адрес + mac_match = re.search(self._patterns["mac_address"], line) + if mac_match: + interface["mac"] = mac_match.group(0) + + interfaces.append(interface) + + if interfaces: + return ParsedResponse( + success=True, + data=interfaces, + raw_response=response + ) + return ParsedResponse( + success=False, + data=None, + error="Интерфейсы не найдены", + raw_response=response + ) + + def parse_config_result(self, response: str) -> ParsedResponse: + """ + Парсинг результата применения конфигурации. + + Args: + response: Ответ на команды конфигурации + + Returns: + ParsedResponse: Структура с результатами парсинга + """ + # Проверяем на наличие ошибок + if re.search(self._patterns["error"], response, re.MULTILINE): + error_msg = self._extract_error_message(response) + return ParsedResponse( + success=False, + data=None, + error=error_msg, + raw_response=response + ) + + # Если нет ошибок, считаем что конфигурация применена успешно + return ParsedResponse( + success=True, + data=response.strip(), + raw_response=response + ) + + def _extract_error_message(self, response: str) -> str: + """ + Извлечение сообщения об ошибке из ответа. + + Args: + response: Ответ устройства + + Returns: + str: Сообщение об ошибке + """ + # Ищем строку с ошибкой + for line in response.splitlines(): + if re.search(self._patterns["error"], line, re.IGNORECASE): + return line.strip() + return "Неизвестная ошибка" + + def add_pattern(self, name: str, pattern: str) -> None: + """ + Добавление нового шаблона для парсинга. + + Args: + name: Имя шаблона + pattern: Регулярное выражение + """ + self._patterns[name] = pattern + self._logger.debug(f"Добавлен новый шаблон: {name} = {pattern}") + + def get_pattern(self, name: str) -> Optional[str]: + """ + Получение шаблона по имени. + + Args: + name: Имя шаблона + + Returns: + Optional[str]: Регулярное выражение или None + """ + return self._patterns.get(name) diff --git a/src/communication/serial_manager.py b/src/communication/serial_manager.py new file mode 100644 index 0000000..a4c3cbd --- /dev/null +++ b/src/communication/serial_manager.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from typing import Optional, List, Dict, Any +import logging + +from core.events import event_bus, Event, EventTypes +from core.exceptions import ConnectionError, AuthenticationError +from core.config import AppConfig +from .protocols.serial import SerialProtocol + +class SerialManager: + """Менеджер для работы с последовательным портом.""" + + def __init__(self): + self._protocol = SerialProtocol() + self._logger = logging.getLogger(__name__) + self._connected = False + self._authenticated = False + + def configure(self, settings: Dict[str, Any]) -> None: + """ + Конфигурация последовательного порта. + + Args: + settings: Словарь с настройками + """ + self._protocol.configure( + port=settings.get("port"), + baudrate=settings.get("baudrate", AppConfig.DEFAULT_BAUDRATE), + timeout=settings.get("timeout", AppConfig.DEFAULT_TIMEOUT), + prompt=settings.get("prompt", AppConfig.DEFAULT_PROMPT) + ) + + def connect(self) -> None: + """ + Установка соединения с устройством. + + Raises: + ConnectionError: При ошибке подключения + """ + try: + self._protocol.connect() + self._connected = True + self._logger.info("Соединение установлено") + + except ConnectionError as e: + self._connected = False + self._authenticated = False + self._logger.error(f"Ошибка подключения: {e}") + raise + + def disconnect(self) -> None: + """Разрыв соединения.""" + if self._connected: + self._protocol.disconnect() + self._connected = False + self._authenticated = False + self._logger.info("Соединение разорвано") + + def authenticate(self, username: str, password: str) -> None: + """ + Аутентификация на устройстве. + + Args: + username: Имя пользователя + password: Пароль + + Raises: + ConnectionError: При ошибке соединения + AuthenticationError: При ошибке аутентификации + """ + try: + self._protocol.authenticate(username, password) + self._authenticated = True + self._logger.info("Аутентификация успешна") + + except (ConnectionError, AuthenticationError) as e: + self._authenticated = False + self._logger.error(f"Ошибка аутентификации: {e}") + raise + + def send_command(self, command: str, timeout: Optional[int] = None) -> str: + """ + Отправка команды на устройство. + + Args: + command: Команда для отправки + timeout: Таймаут ожидания ответа + + Returns: + str: Ответ устройства + + Raises: + ConnectionError: При ошибке отправки команды + """ + try: + response = self._protocol.send_command(command, timeout) + self._logger.debug(f"Отправлена команда: {command}") + self._logger.debug(f"Получен ответ: {response}") + return response + + except ConnectionError as e: + self._logger.error(f"Ошибка отправки команды: {e}") + raise + + def send_config(self, config_commands: List[str], timeout: Optional[int] = None) -> str: + """ + Отправка конфигурационных команд на устройство. + + Args: + config_commands: Список команд конфигурации + timeout: Таймаут ожидания ответа + + Returns: + str: Ответ устройства + + Raises: + ConnectionError: При ошибке отправки команд + """ + try: + response = self._protocol.send_config(config_commands, timeout) + self._logger.info(f"Отправлено {len(config_commands)} команд конфигурации") + return response + + except ConnectionError as e: + self._logger.error(f"Ошибка отправки конфигурации: {e}") + raise + + @property + def is_connected(self) -> bool: + """Проверка состояния подключения.""" + return self._connected + + @property + def is_authenticated(self) -> bool: + """Проверка состояния аутентификации.""" + return self._authenticated + + @staticmethod + def list_ports() -> List[str]: + """ + Получение списка доступных COM-портов. + + Returns: + List[str]: Список доступных портов + """ + return SerialProtocol.list_ports() diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/app.py b/src/core/app.py new file mode 100644 index 0000000..c331eb2 --- /dev/null +++ b/src/core/app.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import sys +import os +import logging +from typing import Optional, Dict, Any + +# Добавляем корневую директорию в PYTHONPATH +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from ui.main_window import MainWindow +from core.config import AppConfig +from core.state import StateManager +from core.events import event_bus, Event, EventTypes +from filesystem.logger import setup_logging +from filesystem.settings import Settings +from filesystem.config_manager import ConfigManager +from filesystem.watchers.config_watcher import ConfigWatcher +from communication.serial_manager import SerialManager +from communication.command_handler import CommandHandler +from network.servers.tftp_server import TFTPServer +from network.transfer.transfer_manager import TransferManager + +class App: + """Основной класс приложения.""" + + def __init__(self): + self._logger = logging.getLogger(__name__) + self._window: Optional[MainWindow] = None + + # Инициализируем компоненты + self._init_components() + + # Подписываемся на события + self._setup_event_handlers() + + def _init_components(self) -> None: + """Инициализация компонентов приложения.""" + try: + # Создаем необходимые директории + AppConfig.create_directories() + + # Инициализируем логирование + setup_logging() + + # Загружаем настройки + self._settings = Settings() + self._settings.load() + + # Создаем менеджеры + self._state = StateManager() + self._config_manager = ConfigManager() + self._config_watcher = ConfigWatcher() + self._serial = SerialManager() + self._command_handler = CommandHandler(self._serial) + self._tftp = TFTPServer() + self._transfer = TransferManager(self._serial, self._tftp) + + self._logger.info("Компоненты приложения инициализированы") + + except Exception as e: + self._logger.error(f"Ошибка инициализации компонентов: {e}") + raise + + def _setup_event_handlers(self) -> None: + """Настройка обработчиков событий.""" + # Обработка изменения настроек + event_bus.subscribe( + EventTypes.UI_SETTINGS_CHANGED, + self._handle_settings_changed + ) + + # Обработка событий подключения + event_bus.subscribe( + EventTypes.CONNECTION_ESTABLISHED, + self._handle_connection_established + ) + event_bus.subscribe( + EventTypes.CONNECTION_LOST, + self._handle_connection_lost + ) + event_bus.subscribe( + EventTypes.CONNECTION_ERROR, + self._handle_connection_error + ) + + # Обработка событий передачи + event_bus.subscribe( + EventTypes.TRANSFER_COMPLETED, + self._handle_transfer_completed + ) + event_bus.subscribe( + EventTypes.TRANSFER_ERROR, + self._handle_transfer_error + ) + + # Обработка событий конфигурации + event_bus.subscribe( + EventTypes.CONFIG_MODIFIED, + self._handle_config_modified + ) + event_bus.subscribe( + EventTypes.CONFIG_DELETED, + self._handle_config_deleted + ) + + def _handle_settings_changed(self, event: Event) -> None: + """ + Обработка изменения настроек. + + Args: + event: Событие изменения настроек + """ + try: + settings = event.data + + # Применяем настройки к компонентам + self._serial.configure(settings) + self._tftp.configure( + port=settings.get("tftp_port"), + root_dir=AppConfig.CONFIGS_DIR + ) + + # Сохраняем настройки + self._settings.update(settings) + + self._logger.info("Настройки обновлены") + + except Exception as e: + self._logger.error(f"Ошибка применения настроек: {e}") + + def _handle_connection_established(self, event: Event) -> None: + """ + Обработка установки соединения. + + Args: + event: Событие установки соединения + """ + try: + # Получаем информацию об устройстве + device_info = self._command_handler.get_device_info() + + # Обновляем состояние + self._state.update_device_info(device_info) + + self._logger.info("Соединение установлено") + + except Exception as e: + self._logger.error(f"Ошибка получения информации об устройстве: {e}") + + def _handle_connection_lost(self, event: Event) -> None: + """ + Обработка потери соединения. + + Args: + event: Событие потери соединения + """ + # Отменяем все активные передачи + self._transfer.cancel_all_transfers() + + # Обновляем состояние + self._state.clear_device_info() + + self._logger.info("Соединение потеряно") + + def _handle_connection_error(self, event: Event) -> None: + """ + Обработка ошибки соединения. + + Args: + event: Событие ошибки соединения + """ + error = event.data + self._logger.error(f"Ошибка соединения: {error}") + + def _handle_transfer_completed(self, event: Event) -> None: + """ + Обработка завершения передачи. + + Args: + event: Событие завершения передачи + """ + try: + transfer_info = event.data + + # Очищаем завершенные передачи + self._transfer.cleanup_transfers() + + self._logger.info(f"Передача завершена: {transfer_info}") + + except Exception as e: + self._logger.error(f"Ошибка обработки завершения передачи: {e}") + + def _handle_transfer_error(self, event: Event) -> None: + """ + Обработка ошибки передачи. + + Args: + event: Событие ошибки передачи + """ + error = event.data + self._logger.error(f"Ошибка передачи: {error}") + + def _handle_config_modified(self, event: Event) -> None: + """ + Обработка изменения конфигурации. + + Args: + event: Событие изменения конфигурации + """ + try: + file_info = event.data + + # Обновляем информацию о файле + self._state.update_config_info(file_info) + + self._logger.info(f"Конфигурация изменена: {file_info['name']}") + + except Exception as e: + self._logger.error(f"Ошибка обработки изменения конфигурации: {e}") + + def _handle_config_deleted(self, event: Event) -> None: + """ + Обработка удаления конфигурации. + + Args: + event: Событие удаления конфигурации + """ + try: + file_info = event.data + + # Удаляем информацию о файле + self._state.remove_config_info(file_info["name"]) + + # Удаляем файл из отслеживания + self._config_watcher.remove_watch(file_info["name"]) + + self._logger.info(f"Конфигурация удалена: {file_info['name']}") + + except Exception as e: + self._logger.error(f"Ошибка обработки удаления конфигурации: {e}") + + def start(self) -> None: + """Запуск приложения.""" + try: + # Запускаем наблюдатель за конфигурациями + self._config_watcher.start() + self._config_watcher.watch_all_configs() + + # Создаем и запускаем главное окно + self._window = MainWindow( + self._settings, + self._state, + self._serial, + self._transfer, + self._config_manager + ) + self._window.mainloop() + + except Exception as e: + self._logger.error(f"Ошибка запуска приложения: {e}") + raise + + finally: + self.cleanup() + + def cleanup(self) -> None: + """Очистка ресурсов приложения.""" + try: + # Останавливаем компоненты + if self._serial.is_connected: + self._serial.disconnect() + + if self._tftp.is_running: + self._tftp.stop() + + if self._config_watcher.is_running: + self._config_watcher.stop() + + # Отменяем все передачи + self._transfer.cancel_all_transfers() + + # Сохраняем настройки + self._settings.save() + + self._logger.info("Ресурсы приложения освобождены") + + except Exception as e: + self._logger.error(f"Ошибка очистки ресурсов: {e}") + +def main(): + """Точка входа в приложение.""" + app = App() + app.start() + +if __name__ == "__main__": + main() diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..07f3cca --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +from typing import Dict, List, Optional + +class AppConfig: + """Конфигурация приложения.""" + + # Версия приложения + VERSION: str = "1.0.0" + + # Директории + LOGS_DIR: str = "Logs" + CONFIGS_DIR: str = "Configs" + SETTINGS_DIR: str = "Settings" + FIRMWARE_DIR: str = "Firmware" + DOCS_DIR: str = "docs" + + # Файлы + LOG_FILE: str = "app.log" + SETTINGS_FILE: str = "settings.json" + + # Настройки логирования + LOG_MAX_BYTES: int = 5 * 1024 * 1024 # 5 MB + LOG_BACKUP_COUNT: int = 3 + + # Настройки TFTP + TFTP_PORT: int = 69 + TFTP_TIMEOUT: int = 5 + TFTP_RETRIES: int = 3 + + # Настройки последовательного порта по умолчанию + DEFAULT_BAUDRATE: int = 9600 + DEFAULT_TIMEOUT: int = 10 + DEFAULT_PROMPT: str = ">" + + # Настройки передачи конфигурации + DEFAULT_BLOCK_SIZE: int = 15 + DEFAULT_COPY_MODE: str = "line" + + # Поддерживаемые скорости передачи + SUPPORTED_BAUDRATES: List[int] = [ + 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200 + ] + + # Поддерживаемые режимы копирования + SUPPORTED_COPY_MODES: List[str] = [ + "line", # Построчный режим + "block", # Блочный режим + "tftp" # Через TFTP + ] + + @classmethod + def create_directories(cls) -> None: + """Создание необходимых директорий.""" + directories = [ + cls.LOGS_DIR, + cls.CONFIGS_DIR, + cls.SETTINGS_DIR, + cls.FIRMWARE_DIR, + cls.DOCS_DIR + ] + + for directory in directories: + os.makedirs(directory, exist_ok=True) + + @classmethod + def get_log_path(cls) -> str: + """Получение пути к файлу лога.""" + return os.path.join(cls.LOGS_DIR, cls.LOG_FILE) + + @classmethod + def get_settings_path(cls) -> str: + """Получение пути к файлу настроек.""" + return os.path.join(cls.SETTINGS_DIR, cls.SETTINGS_FILE) + + @classmethod + def get_config_path(cls, config_name: str) -> str: + """ + Получение пути к файлу конфигурации. + + Args: + config_name: Имя файла конфигурации + + Returns: + str: Полный путь к файлу конфигурации + """ + return os.path.join(cls.CONFIGS_DIR, config_name) + + @classmethod + def get_firmware_path(cls, firmware_name: str) -> str: + """ + Получение пути к файлу прошивки. + + Args: + firmware_name: Имя файла прошивки + + Returns: + str: Полный путь к файлу прошивки + """ + return os.path.join(cls.FIRMWARE_DIR, firmware_name) diff --git a/src/core/events.py b/src/core/events.py new file mode 100644 index 0000000..371229d --- /dev/null +++ b/src/core/events.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from typing import Any, Callable, Dict, List, Optional +from dataclasses import dataclass +from datetime import datetime + +@dataclass +class Event: + """Базовый класс для событий.""" + type: str + data: Any + timestamp: datetime = datetime.now() + +class EventBus: + """Шина событий для асинхронного взаимодействия компонентов.""" + + def __init__(self): + self._subscribers: Dict[str, List[Callable[[Event], None]]] = {} + + def subscribe(self, event_type: str, callback: Callable[[Event], None]) -> None: + """ + Подписка на событие. + + Args: + event_type: Тип события + callback: Функция обратного вызова + """ + if event_type not in self._subscribers: + self._subscribers[event_type] = [] + self._subscribers[event_type].append(callback) + + def unsubscribe(self, event_type: str, callback: Callable[[Event], None]) -> None: + """ + Отписка от события. + + Args: + event_type: Тип события + callback: Функция обратного вызова + """ + if event_type in self._subscribers: + self._subscribers[event_type].remove(callback) + + def publish(self, event: Event) -> None: + """ + Публикация события. + + Args: + event: Объект события + """ + if event.type in self._subscribers: + for callback in self._subscribers[event.type]: + callback(event) + +# Создаем глобальный экземпляр шины событий +event_bus = EventBus() + +# Определяем типы событий +class EventTypes: + """Константы для типов событий.""" + + # События подключения + CONNECTION_ESTABLISHED = "connection_established" + CONNECTION_LOST = "connection_lost" + CONNECTION_ERROR = "connection_error" + + # События передачи данных + TRANSFER_STARTED = "transfer_started" + TRANSFER_PROGRESS = "transfer_progress" + TRANSFER_COMPLETED = "transfer_completed" + TRANSFER_ERROR = "transfer_error" + + # События конфигурации + CONFIG_LOADED = "config_loaded" + CONFIG_SAVED = "config_saved" + CONFIG_ERROR = "config_error" + CONFIG_MODIFIED = "config_modified" + CONFIG_DELETED = "config_deleted" + CONFIG_CREATED = "config_created" + + # События TFTP + TFTP_SERVER_STARTED = "tftp_server_started" + TFTP_SERVER_STOPPED = "tftp_server_stopped" + TFTP_TRANSFER_STARTED = "tftp_transfer_started" + TFTP_TRANSFER_COMPLETED = "tftp_transfer_completed" + TFTP_ERROR = "tftp_error" + + # События UI + UI_SETTINGS_CHANGED = "ui_settings_changed" + UI_STATUS_CHANGED = "ui_status_changed" diff --git a/src/core/exceptions.py b/src/core/exceptions.py new file mode 100644 index 0000000..3e8c31c --- /dev/null +++ b/src/core/exceptions.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +class ComConfigError(Exception): + """Базовый класс для всех исключений приложения.""" + pass + +class ConnectionError(ComConfigError): + """Ошибка подключения к устройству.""" + pass + +class AuthenticationError(ConnectionError): + """Ошибка аутентификации.""" + pass + +class ConfigError(ComConfigError): + """Ошибка работы с конфигурацией.""" + pass + +class TransferError(ComConfigError): + """Ошибка передачи данных.""" + pass + +class TFTPError(ComConfigError): + """Ошибка TFTP сервера.""" + pass + +class ValidationError(ComConfigError): + """Ошибка валидации данных.""" + pass + +class SettingsError(ComConfigError): + """Ошибка работы с настройками.""" + pass + +class FileSystemError(ComConfigError): + """Ошибка работы с файловой системой.""" + pass diff --git a/src/core/state.py b/src/core/state.py new file mode 100644 index 0000000..afee082 --- /dev/null +++ b/src/core/state.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from typing import Any, Dict, Optional, List +from dataclasses import dataclass, field +from enum import Enum, auto +import logging +from datetime import datetime +from .events import event_bus, Event, EventTypes + +class ConnectionState(Enum): + """Состояния подключения.""" + DISCONNECTED = auto() + CONNECTING = auto() + CONNECTED = auto() + ERROR = auto() + +class TransferState(Enum): + """Состояния передачи данных.""" + IDLE = auto() + PREPARING = auto() + TRANSFERRING = auto() + COMPLETED = auto() + ERROR = auto() + +@dataclass +class DeviceInfo: + """Информация об устройстве.""" + version: Optional[str] = None + model: Optional[str] = None + hostname: Optional[str] = None + interfaces: List[Dict[str, Any]] = field(default_factory=list) + last_update: Optional[datetime] = None + +@dataclass +class ConfigInfo: + """Информация о файле конфигурации.""" + name: str + path: str + size: int + modified: datetime + created: datetime + is_watched: bool = False + +@dataclass +class ApplicationState: + """Состояние приложения.""" + connection_state: ConnectionState = ConnectionState.DISCONNECTED + transfer_state: TransferState = TransferState.IDLE + device_info: DeviceInfo = field(default_factory=DeviceInfo) + configs: Dict[str, ConfigInfo] = field(default_factory=dict) + current_config: Optional[str] = None + transfer_progress: float = 0.0 + status_message: str = "" + error_message: Optional[str] = None + is_tftp_server_running: bool = False + custom_data: Dict[str, Any] = field(default_factory=dict) + +class StateManager: + """Менеджер состояния приложения.""" + + def __init__(self): + self._state = ApplicationState() + self._logger = logging.getLogger(__name__) + self._setup_event_handlers() + + def _setup_event_handlers(self) -> None: + """Настройка обработчиков событий.""" + event_bus.subscribe(EventTypes.CONNECTION_ESTABLISHED, self._handle_connection_established) + event_bus.subscribe(EventTypes.CONNECTION_LOST, self._handle_connection_lost) + event_bus.subscribe(EventTypes.CONNECTION_ERROR, self._handle_connection_error) + event_bus.subscribe(EventTypes.TRANSFER_STARTED, self._handle_transfer_started) + event_bus.subscribe(EventTypes.TRANSFER_PROGRESS, self._handle_transfer_progress) + event_bus.subscribe(EventTypes.TRANSFER_COMPLETED, self._handle_transfer_completed) + event_bus.subscribe(EventTypes.TRANSFER_ERROR, self._handle_transfer_error) + event_bus.subscribe(EventTypes.TFTP_SERVER_STARTED, self._handle_tftp_server_started) + event_bus.subscribe(EventTypes.TFTP_SERVER_STOPPED, self._handle_tftp_server_stopped) + + # Подписка на события конфигурации + event_bus.subscribe(EventTypes.CONFIG_CREATED, self._handle_config_created) + event_bus.subscribe(EventTypes.CONFIG_MODIFIED, self._handle_config_modified) + event_bus.subscribe(EventTypes.CONFIG_DELETED, self._handle_config_deleted) + + def _handle_connection_established(self, event: Event) -> None: + self._state.connection_state = ConnectionState.CONNECTED + self._state.error_message = None + self._update_status("Подключение установлено") + + def _handle_connection_lost(self, event: Event) -> None: + self._state.connection_state = ConnectionState.DISCONNECTED + self._update_status("Подключение потеряно") + + def _handle_connection_error(self, event: Event) -> None: + self._state.connection_state = ConnectionState.ERROR + self._state.error_message = str(event.data) + self._update_status(f"Ошибка подключения: {event.data}") + + def _handle_transfer_started(self, event: Event) -> None: + self._state.transfer_state = TransferState.TRANSFERRING + self._state.transfer_progress = 0.0 + self._update_status("Передача данных начата") + + def _handle_transfer_progress(self, event: Event) -> None: + self._state.transfer_progress = float(event.data) + self._update_status(f"Прогресс передачи: {self._state.transfer_progress:.1f}%") + + def _handle_transfer_completed(self, event: Event) -> None: + self._state.transfer_state = TransferState.COMPLETED + self._state.transfer_progress = 100.0 + self._update_status("Передача данных завершена") + + def _handle_transfer_error(self, event: Event) -> None: + self._state.transfer_state = TransferState.ERROR + self._state.error_message = str(event.data) + self._update_status(f"Ошибка передачи: {event.data}") + + def _handle_tftp_server_started(self, event: Event) -> None: + self._state.is_tftp_server_running = True + self._update_status("TFTP сервер запущен") + + def _handle_tftp_server_stopped(self, event: Event) -> None: + self._state.is_tftp_server_running = False + self._update_status("TFTP сервер остановлен") + + def _handle_config_created(self, event: Event) -> None: + """ + Обработка создания конфигурации. + + Args: + event: Событие создания конфигурации + """ + try: + self.update_config_info(event.data) + self._update_status(f"Конфигурация создана: {event.data['name']}") + except Exception as e: + self._logger.error(f"Ошибка обработки создания конфигурации: {e}") + + def _handle_config_modified(self, event: Event) -> None: + """ + Обработка изменения конфигурации. + + Args: + event: Событие изменения конфигурации + """ + try: + self.update_config_info(event.data) + self._update_status(f"Конфигурация изменена: {event.data['name']}") + except Exception as e: + self._logger.error(f"Ошибка обработки изменения конфигурации: {e}") + + def _handle_config_deleted(self, event: Event) -> None: + """ + Обработка удаления конфигурации. + + Args: + event: Событие удаления конфигурации + """ + try: + config_name = event.data['name'] + self.remove_config_info(config_name) + self._update_status(f"Конфигурация удалена: {config_name}") + except Exception as e: + self._logger.error(f"Ошибка обработки удаления конфигурации: {e}") + + def _update_status(self, message: str) -> None: + """Обновление статусного сообщения.""" + self._state.status_message = message + event_bus.publish(Event(EventTypes.UI_STATUS_CHANGED, message)) + + def update_device_info(self, info: Dict[str, Any]) -> None: + """ + Обновление информации об устройстве. + + Args: + info: Словарь с информацией об устройстве + """ + try: + self._state.device_info = DeviceInfo( + version=info.get("version"), + model=info.get("model"), + hostname=info.get("hostname"), + interfaces=info.get("interfaces", []), + last_update=datetime.now() + ) + + # Обновляем состояние подключения + self._state.connection_state = ConnectionState.CONNECTED + + # Уведомляем об изменении состояния + event_bus.publish(Event(EventTypes.UI_STATUS_CHANGED, { + "message": f"Подключено к {self._state.device_info.hostname}" + })) + + self._logger.info("Информация об устройстве обновлена") + + except Exception as e: + self._logger.error(f"Ошибка обновления информации об устройстве: {e}") + self._state.connection_state = ConnectionState.ERROR + self._state.error_message = str(e) + + def clear_device_info(self) -> None: + """Очистка информации об устройстве.""" + self._state.device_info = DeviceInfo() + self._state.connection_state = ConnectionState.DISCONNECTED + self._state.error_message = None + + # Уведомляем об изменении состояния + event_bus.publish(Event(EventTypes.UI_STATUS_CHANGED, { + "message": "Отключено от устройства" + })) + + self._logger.info("Информация об устройстве очищена") + + def update_config_info(self, info: Dict[str, Any]) -> None: + """ + Обновление информации о файле конфигурации. + + Args: + info: Словарь с информацией о файле + """ + try: + config = ConfigInfo( + name=info["name"], + path=info["path"], + size=info["size"], + modified=info["modified"], + created=info["created"], + is_watched=info.get("is_watched", False) + ) + + self._state.configs[config.name] = config + + # Уведомляем об изменении состояния + event_bus.publish(Event(EventTypes.UI_STATUS_CHANGED, { + "message": f"Конфигурация обновлена: {config.name}" + })) + + self._logger.debug(f"Обновлена информация о конфигурации: {config.name}") + + except Exception as e: + self._logger.error(f"Ошибка обновления информации о конфигурации: {e}") + + def remove_config_info(self, config_name: str) -> None: + """ + Удаление информации о файле конфигурации. + + Args: + config_name: Имя файла конфигурации + """ + if config_name in self._state.configs: + self._state.configs.pop(config_name) + + # Если удаляется текущая конфигурация, очищаем её + if self._state.current_config == config_name: + self._state.current_config = None + + # Уведомляем об изменении состояния + event_bus.publish(Event(EventTypes.UI_STATUS_CHANGED, { + "message": f"Конфигурация удалена: {config_name}" + })) + + self._logger.debug(f"Удалена информация о конфигурации: {config_name}") + + def set_current_config(self, config_name: Optional[str]) -> None: + """ + Установка текущей конфигурации. + + Args: + config_name: Имя файла конфигурации + """ + if config_name is None or config_name in self._state.configs: + self._state.current_config = config_name + + if config_name: + self._logger.debug(f"Установлена текущая конфигурация: {config_name}") + else: + self._logger.debug("Текущая конфигурация очищена") + + def update_transfer_state(self, state: TransferState, progress: float = 0.0, + error: Optional[str] = None) -> None: + """ + Обновление состояния передачи. + + Args: + state: Новое состояние + progress: Прогресс передачи + error: Сообщение об ошибке + """ + self._state.transfer_state = state + self._state.transfer_progress = progress + self._state.error_message = error + + # Формируем сообщение о состоянии + if state == TransferState.IDLE: + message = "Готов к передаче" + elif state == TransferState.PREPARING: + message = "Подготовка к передаче..." + elif state == TransferState.TRANSFERRING: + message = f"Передача данных: {progress:.1f}%" + elif state == TransferState.COMPLETED: + message = "Передача завершена" + elif state == TransferState.ERROR: + message = f"Ошибка передачи: {error}" + else: + message = "Неизвестное состояние" + + # Уведомляем об изменении состояния + event_bus.publish(Event(EventTypes.UI_STATUS_CHANGED, { + "message": message + })) + + self._logger.debug(f"Состояние передачи обновлено: {state.name}") + + def set_tftp_server_state(self, is_running: bool) -> None: + """ + Установка состояния TFTP сервера. + + Args: + is_running: Флаг работы сервера + """ + self._state.is_tftp_server_running = is_running + + # Уведомляем об изменении состояния + event_bus.publish(Event(EventTypes.UI_STATUS_CHANGED, { + "message": "TFTP сервер запущен" if is_running else "TFTP сервер остановлен" + })) + + self._logger.debug(f"Состояние TFTP сервера: {'запущен' if is_running else 'остановлен'}") + + def set_status_message(self, message: str) -> None: + """ + Установка сообщения о состоянии. + + Args: + message: Сообщение + """ + self._state.status_message = message + + # Уведомляем об изменении состояния + event_bus.publish(Event(EventTypes.UI_STATUS_CHANGED, { + "message": message + })) + + def set_error_message(self, error: Optional[str]) -> None: + """ + Установка сообщения об ошибке. + + Args: + error: Сообщение об ошибке + """ + self._state.error_message = error + + if error: + # Уведомляем об ошибке + event_bus.publish(Event(EventTypes.UI_STATUS_CHANGED, { + "message": f"Ошибка: {error}" + })) + + def get_device_info(self) -> DeviceInfo: + """ + Получение информации об устройстве. + + Returns: + DeviceInfo: Информация об устройстве + """ + return self._state.device_info + + def get_config_info(self, config_name: str) -> Optional[ConfigInfo]: + """ + Получение информации о конфигурации. + + Args: + config_name: Имя файла конфигурации + + Returns: + Optional[ConfigInfo]: Информация о конфигурации или None + """ + return self._state.configs.get(config_name) + + def get_configs(self) -> Dict[str, ConfigInfo]: + """ + Получение списка всех конфигураций. + + Returns: + Dict[str, ConfigInfo]: Словарь с информацией о конфигурациях + """ + return self._state.configs.copy() + + def get_current_config(self) -> Optional[str]: + """ + Получение текущей конфигурации. + + Returns: + Optional[str]: Имя текущей конфигурации или None + """ + return self._state.current_config + + @property + def state(self) -> ApplicationState: + """Получение текущего состояния.""" + return self._state + + def set_custom_data(self, key: str, value: Any) -> None: + """Установка пользовательских данных.""" + self._state.custom_data[key] = value + + def get_custom_data(self, key: str, default: Any = None) -> Any: + """Получение пользовательских данных.""" + return self._state.custom_data.get(key, default) + +# Создаем глобальный экземпляр менеджера состояния +state_manager = StateManager() diff --git a/src/filesystem/__init__.py b/src/filesystem/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/filesystem/config_manager.py b/src/filesystem/config_manager.py new file mode 100644 index 0000000..0effc45 --- /dev/null +++ b/src/filesystem/config_manager.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import shutil +from typing import List, Optional, Dict, Any +import logging +from datetime import datetime + +from core.config import AppConfig +from core.exceptions import FileSystemError +from core.events import event_bus, Event, EventTypes + +class ConfigManager: + """Менеджер для работы с файлами конфигураций.""" + + def __init__(self): + self._logger = logging.getLogger(__name__) + self._config_dir = AppConfig.CONFIGS_DIR + + # Создаем директорию, если её нет + os.makedirs(self._config_dir, exist_ok=True) + + def save_config(self, config_name: str, content: str, + make_backup: bool = True) -> str: + """ + Сохранение конфигурации в файл. + + Args: + config_name: Имя файла конфигурации + content: Содержимое конфигурации + make_backup: Создавать ли резервную копию + + Returns: + str: Путь к сохраненному файлу + + Raises: + FileSystemError: При ошибке сохранения + """ + try: + # Формируем путь к файлу + config_path = os.path.join(self._config_dir, config_name) + + # Создаем резервную копию, если файл существует + if make_backup and os.path.exists(config_path): + self._create_backup(config_path) + + # Сохраняем конфигурацию + with open(config_path, "w", encoding="utf-8") as f: + f.write(content) + + event_bus.publish(Event(EventTypes.CONFIG_SAVED, { + "file": config_path, + "size": len(content) + })) + + self._logger.info(f"Конфигурация сохранена: {config_name}") + return config_path + + except Exception as e: + self._logger.error(f"Ошибка сохранения конфигурации: {e}") + raise FileSystemError(f"Ошибка сохранения конфигурации: {e}") + + def load_config(self, config_name: str) -> str: + """ + Загрузка конфигурации из файла. + + Args: + config_name: Имя файла конфигурации + + Returns: + str: Содержимое конфигурации + + Raises: + FileSystemError: При ошибке загрузки + """ + try: + config_path = os.path.join(self._config_dir, config_name) + + if not os.path.exists(config_path): + raise FileSystemError(f"Файл не найден: {config_name}") + + with open(config_path, "r", encoding="utf-8") as f: + content = f.read() + + event_bus.publish(Event(EventTypes.CONFIG_LOADED, { + "file": config_path, + "size": len(content) + })) + + self._logger.info(f"Конфигурация загружена: {config_name}") + return content + + except Exception as e: + self._logger.error(f"Ошибка загрузки конфигурации: {e}") + raise FileSystemError(f"Ошибка загрузки конфигурации: {e}") + + def delete_config(self, config_name: str, keep_backup: bool = True) -> None: + """ + Удаление файла конфигурации. + + Args: + config_name: Имя файла конфигурации + keep_backup: Сохранить ли резервную копию + + Raises: + FileSystemError: При ошибке удаления + """ + try: + config_path = os.path.join(self._config_dir, config_name) + + if not os.path.exists(config_path): + return + + # Создаем резервную копию перед удалением + if keep_backup: + self._create_backup(config_path) + + # Удаляем файл + os.remove(config_path) + + event_bus.publish(Event(EventTypes.CONFIG_DELETED, { + "file": config_path + })) + + self._logger.info(f"Конфигурация удалена: {config_name}") + + except Exception as e: + self._logger.error(f"Ошибка удаления конфигурации: {e}") + raise FileSystemError(f"Ошибка удаления конфигурации: {e}") + + def list_configs(self) -> List[Dict[str, Any]]: + """ + Получение списка файлов конфигураций. + + Returns: + List[Dict[str, Any]]: Список с информацией о файлах + """ + configs = [] + + try: + for filename in os.listdir(self._config_dir): + file_path = os.path.join(self._config_dir, filename) + if os.path.isfile(file_path): + stat = os.stat(file_path) + configs.append({ + "name": filename, + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime), + "created": datetime.fromtimestamp(stat.st_ctime) + }) + + return sorted(configs, key=lambda x: x["modified"], reverse=True) + + except Exception as e: + self._logger.error(f"Ошибка получения списка конфигураций: {e}") + return [] + + def get_config_info(self, config_name: str) -> Optional[Dict[str, Any]]: + """ + Получение информации о файле конфигурации. + + Args: + config_name: Имя файла конфигурации + + Returns: + Optional[Dict[str, Any]]: Информация о файле или None + """ + try: + config_path = os.path.join(self._config_dir, config_name) + + if not os.path.exists(config_path): + return None + + stat = os.stat(config_path) + return { + "name": config_name, + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime), + "created": datetime.fromtimestamp(stat.st_ctime), + "path": config_path + } + + except Exception as e: + self._logger.error(f"Ошибка получения информации о конфигурации: {e}") + return None + + def _create_backup(self, file_path: str) -> None: + """ + Создание резервной копии файла. + + Args: + file_path: Путь к файлу + + Raises: + FileSystemError: При ошибке создания копии + """ + try: + # Формируем имя для резервной копии + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_name = f"{os.path.basename(file_path)}.{timestamp}.bak" + backup_path = os.path.join(self._config_dir, backup_name) + + # Копируем файл + shutil.copy2(file_path, backup_path) + + self._logger.info(f"Создана резервная копия: {backup_name}") + + except Exception as e: + self._logger.error(f"Ошибка создания резервной копии: {e}") + raise FileSystemError(f"Ошибка создания резервной копии: {e}") diff --git a/src/filesystem/logger.py b/src/filesystem/logger.py new file mode 100644 index 0000000..ea42864 --- /dev/null +++ b/src/filesystem/logger.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import logging +from logging.handlers import RotatingFileHandler +from typing import Optional + +def setup_logging( + log_dir: str = "Logs", + log_file: str = "app.log", + max_bytes: int = 5 * 1024 * 1024, # 5 MB + backup_count: int = 3, + log_level: int = logging.DEBUG, +) -> None: + """ + Настройка системы логирования. + + Args: + log_dir: Директория для хранения логов + log_file: Имя файла лога + max_bytes: Максимальный размер файла лога + backup_count: Количество файлов ротации + log_level: Уровень логирования + """ + # Создаем директорию для логов, если её нет + os.makedirs(log_dir, exist_ok=True) + + # Настраиваем корневой логгер + logger = logging.getLogger() + logger.setLevel(log_level) + + # Очищаем существующие обработчики + logger.handlers = [] + + # Создаем форматтер + formatter = logging.Formatter( + "%(asctime)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + + # Добавляем обработчик файла с ротацией + log_path = os.path.join(log_dir, log_file) + file_handler = RotatingFileHandler( + log_path, + maxBytes=max_bytes, + backupCount=backup_count, + encoding="utf-8" + ) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + # Добавляем обработчик консоли для отладки + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + logging.info("Система логирования инициализирована") + +def get_logger(name: Optional[str] = None) -> logging.Logger: + """ + Получение логгера с указанным именем. + + Args: + name: Имя логгера + + Returns: + logging.Logger: Настроенный логгер + """ + return logging.getLogger(name) diff --git a/src/filesystem/settings.py b/src/filesystem/settings.py new file mode 100644 index 0000000..36dc573 --- /dev/null +++ b/src/filesystem/settings.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import json +import os +import logging +from typing import Dict, Any + +class Settings: + """Класс для работы с настройками приложения.""" + + def __init__(self): + self.settings_dir = "Settings" + self.settings_file = os.path.join(self.settings_dir, "settings.json") + self._settings = self._get_default_settings() + + # Создаем директорию для настроек, если её нет + os.makedirs(self.settings_dir, exist_ok=True) + + def _get_default_settings(self) -> Dict[str, Any]: + """Возвращает настройки по умолчанию.""" + return { + "port": None, # Порт для подключения + "baudrate": 9600, # Скорость передачи данных + "config_file": None, # Файл конфигурации + "login": None, # Логин для подключения + "password": None, # Пароль для подключения + "timeout": 10, # Таймаут подключения + "copy_mode": "line", # Режим копирования + "block_size": 15, # Размер блока команд + "prompt": ">", # Используется для определения приглашения + } + + def load(self) -> None: + """Загружает настройки из файла.""" + if not os.path.exists(self.settings_file): + self.save() + return + + try: + with open(self.settings_file, "r", encoding="utf-8") as f: + loaded_settings = json.load(f) + + # Проверяем наличие всех необходимых параметров + default_settings = self._get_default_settings() + for key, value in default_settings.items(): + if key not in loaded_settings: + loaded_settings[key] = value + + self._settings = loaded_settings + self.save() # Сохраняем обновленные настройки + + except Exception as e: + logging.error(f"Ошибка при загрузке настроек: {e}", exc_info=True) + self._settings = self._get_default_settings() + self.save() + + def save(self) -> None: + """Сохраняет настройки в файл.""" + try: + with open(self.settings_file, "w", encoding="utf-8") as f: + json.dump(self._settings, f, indent=4, ensure_ascii=False) + logging.info("Настройки успешно сохранены") + except Exception as e: + logging.error(f"Ошибка при сохранении настроек: {e}", exc_info=True) + + def get(self, key: str, default: Any = None) -> Any: + """Возвращает значение настройки по ключу.""" + return self._settings.get(key, default) + + def set(self, key: str, value: Any) -> None: + """Устанавливает значение настройки.""" + self._settings[key] = value + + def update(self, settings: Dict[str, Any]) -> None: + """Обновляет несколько настроек одновременно.""" + self._settings.update(settings) + self.save() diff --git a/src/filesystem/watchers/config_watcher.py b/src/filesystem/watchers/config_watcher.py new file mode 100644 index 0000000..1ffa163 --- /dev/null +++ b/src/filesystem/watchers/config_watcher.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import time +from typing import Optional, Dict, Set, Callable, Any +import logging +from datetime import datetime +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler, FileSystemEvent + +from core.config import AppConfig +from core.exceptions import FileSystemError +from core.events import event_bus, Event, EventTypes + +class ConfigEventHandler(FileSystemEventHandler): + """Обработчик событий файловой системы для конфигураций.""" + + def __init__(self, callback: Callable[[str, str], None]): + self._callback = callback + + def on_created(self, event: FileSystemEvent) -> None: + """ + Обработка создания файла. + + Args: + event: Событие файловой системы + """ + if not event.is_directory: + self._callback(event.src_path, "created") + + def on_modified(self, event: FileSystemEvent) -> None: + """ + Обработка изменения файла. + + Args: + event: Событие файловой системы + """ + if not event.is_directory: + self._callback(event.src_path, "modified") + + def on_deleted(self, event: FileSystemEvent) -> None: + """ + Обработка удаления файла. + + Args: + event: Событие файловой системы + """ + if not event.is_directory: + self._callback(event.src_path, "deleted") + +class ConfigWatcher: + """Наблюдатель за изменениями файлов конфигурации.""" + + def __init__(self): + self._logger = logging.getLogger(__name__) + self._observer: Optional[Observer] = None + self._watched_files: Set[str] = set() + self._config_dir = AppConfig.CONFIGS_DIR + + # Создаем директорию, если её нет + os.makedirs(self._config_dir, exist_ok=True) + + def get_file_info(self, filename: str) -> Optional[Dict[str, Any]]: + """ + Получение информации о файле. + + Args: + filename: Имя файла + + Returns: + Optional[Dict[str, Any]]: Информация о файле или None + """ + file_path = os.path.join(self._config_dir, filename) + + if not os.path.exists(file_path): + return None + + try: + stat = os.stat(file_path) + return { + "name": filename, + "path": file_path, + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime), + "created": datetime.fromtimestamp(stat.st_ctime), + "is_watched": file_path in self._watched_files + } + + except Exception as e: + self._logger.error(f"Ошибка получения информации о файле: {e}") + return None + + def start(self) -> None: + """ + Запуск наблюдателя. + + Raises: + FileSystemError: При ошибке запуска + """ + if self._observer is not None: + return + + try: + self._observer = Observer() + handler = ConfigEventHandler(self._handle_event) + + # Начинаем наблюдение за директорией + self._observer.schedule(handler, self._config_dir, recursive=False) + self._observer.start() + + self._logger.info(f"Наблюдатель запущен: {self._config_dir}") + + except Exception as e: + self._logger.error(f"Неожиданная ошибка: {e}") + raise FileSystemError(f"Неожиданная ошибка: {e}") + + def stop(self) -> None: + """ + Остановка наблюдателя. + + Raises: + FileSystemError: При ошибке остановки + """ + if self._observer is not None: + try: + self._observer.stop() + self._observer.join() + self._observer = None + self._watched_files.clear() + + self._logger.info("Наблюдатель остановлен") + + except Exception as e: + self._logger.error(f"Ошибка остановки наблюдателя: {e}") + raise FileSystemError(f"Ошибка остановки наблюдателя: {e}") + + def add_watch(self, filename: str) -> None: + """ + Добавление файла для отслеживания. + + Args: + filename: Имя файла + + Raises: + FileSystemError: При ошибке добавления файла + """ + try: + file_path = os.path.join(self._config_dir, filename) + + if not os.path.exists(file_path): + raise FileSystemError(f"Файл не найден: {filename}") + + self._watched_files.add(file_path) + self._logger.debug(f"Добавлен файл для отслеживания: {filename}") + + except Exception as e: + self._logger.error(f"Ошибка добавления файла для отслеживания: {e}") + raise FileSystemError(f"Ошибка добавления файла для отслеживания: {e}") + + def remove_watch(self, filename: str) -> None: + """ + Удаление файла из отслеживания. + + Args: + filename: Имя файла + + Raises: + FileSystemError: При ошибке удаления файла + """ + try: + file_path = os.path.join(self._config_dir, filename) + self._watched_files.discard(file_path) + self._logger.debug(f"Удален файл из отслеживания: {filename}") + + except Exception as e: + self._logger.error(f"Ошибка удаления файла из отслеживания: {e}") + raise FileSystemError(f"Ошибка удаления файла из отслеживания: {e}") + + def _handle_event(self, file_path: str, event_type: str) -> None: + """ + Обработка события файловой системы. + + Args: + file_path: Путь к файлу + event_type: Тип события + """ + # Проверяем, отслеживается ли файл + if file_path not in self._watched_files: + return + + try: + # Получаем информацию о файле + filename = os.path.basename(file_path) + file_info = self.get_file_info(filename) + + if not file_info: + return + + if event_type == "created": + event_bus.publish(Event(EventTypes.CONFIG_CREATED, file_info)) + self._logger.info(f"Создан файл конфигурации: {filename}") + + elif event_type == "modified": + event_bus.publish(Event(EventTypes.CONFIG_MODIFIED, file_info)) + self._logger.info(f"Изменен файл конфигурации: {filename}") + + elif event_type == "deleted": + event_bus.publish(Event(EventTypes.CONFIG_DELETED, file_info)) + self._logger.info(f"Удален файл конфигурации: {filename}") + + except Exception as e: + self._logger.error(f"Ошибка обработки события: {e}") + + def watch_all_configs(self) -> None: + """ + Добавление всех существующих конфигураций для отслеживания. + + Raises: + FileSystemError: При ошибке добавления файлов + """ + try: + for filename in os.listdir(self._config_dir): + file_path = os.path.join(self._config_dir, filename) + if os.path.isfile(file_path): + self._watched_files.add(file_path) + + self._logger.info(f"Добавлено {len(self._watched_files)} файлов для отслеживания") + + except Exception as e: + self._logger.error(f"Ошибка добавления файлов для отслеживания: {e}") + raise FileSystemError(f"Ошибка добавления файлов для отслеживания: {e}") + + def clear_watches(self) -> None: + """Удаление всех файлов из отслеживания.""" + self._watched_files.clear() + self._logger.info("Очищен список отслеживаемых файлов") + + @property + def is_running(self) -> bool: + """Проверка состояния наблюдателя.""" + return self._observer is not None and self._observer.is_alive() + + @property + def watched_files(self) -> Set[str]: + """Получение списка отслеживаемых файлов.""" + return {os.path.basename(path) for path in self._watched_files} \ No newline at end of file diff --git a/src/network/servers/__init__.py b/src/network/servers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/network/servers/base_server.py b/src/network/servers/base_server.py new file mode 100644 index 0000000..ffbb1eb --- /dev/null +++ b/src/network/servers/base_server.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from abc import ABC, abstractmethod +import threading +from typing import Optional, Dict, Any +import logging + +from core.events import event_bus, Event, EventTypes + +class BaseServer(ABC): + """Базовый класс для всех серверов.""" + + def __init__(self): + self._running = False + self._thread: Optional[threading.Thread] = None + self._logger = logging.getLogger(__name__) + self._config: Dict[str, Any] = {} + + @property + def is_running(self) -> bool: + """Проверка состояния сервера.""" + return self._running + + @abstractmethod + def start(self) -> None: + """ + Запуск сервера. + + Raises: + Exception: При ошибке запуска + """ + pass + + @abstractmethod + def stop(self) -> None: + """Остановка сервера.""" + pass + + def configure(self, **kwargs) -> None: + """ + Конфигурация сервера. + + Args: + **kwargs: Параметры конфигурации + """ + self._config.update(kwargs) + + def _start_in_thread(self, target) -> None: + """ + Запуск сервера в отдельном потоке. + + Args: + target: Функция для выполнения в потоке + """ + if self._running: + self._logger.warning("Сервер уже запущен") + return + + self._thread = threading.Thread(target=target) + self._thread.daemon = True + self._thread.start() + self._running = True + + def _stop_thread(self) -> None: + """Остановка потока сервера.""" + self._running = False + if self._thread and self._thread.is_alive(): + self._thread.join() + self._thread = None + + def _notify_started(self, data: Optional[Dict[str, Any]] = None) -> None: + """ + Уведомление о запуске сервера. + + Args: + data: Дополнительные данные + """ + event_bus.publish(Event(EventTypes.TFTP_SERVER_STARTED, data)) + + def _notify_stopped(self) -> None: + """Уведомление об остановке сервера.""" + event_bus.publish(Event(EventTypes.TFTP_SERVER_STOPPED, None)) + + def _notify_error(self, error: Exception) -> None: + """ + Уведомление об ошибке сервера. + + Args: + error: Объект ошибки + """ + event_bus.publish(Event(EventTypes.TFTP_ERROR, str(error))) diff --git a/src/network/servers/tftp_server.py b/src/network/servers/tftp_server.py new file mode 100644 index 0000000..f3dae2c --- /dev/null +++ b/src/network/servers/tftp_server.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import socket +import threading +from typing import Optional, Dict, Any, Callable +import tftpy + +from core.config import AppConfig +from core.exceptions import TFTPError +from .base_server import BaseServer + +class TFTPServer(BaseServer): + """TFTP сервер для передачи файлов.""" + + def __init__(self): + super().__init__() + self._server: Optional[tftpy.TftpServer] = None + self._root_dir = AppConfig.CONFIGS_DIR + self._host = "0.0.0.0" + self._port = AppConfig.TFTP_PORT + self._timeout = AppConfig.TFTP_TIMEOUT + + # Создаем директорию, если её нет + os.makedirs(self._root_dir, exist_ok=True) + + def configure(self, root_dir: Optional[str] = None, host: Optional[str] = None, + port: Optional[int] = None, timeout: Optional[int] = None) -> None: + """ + Конфигурация TFTP сервера. + + Args: + root_dir: Корневая директория для файлов + host: IP-адрес для прослушивания + port: Порт сервера + timeout: Таймаут операций + """ + if root_dir is not None: + self._root_dir = root_dir + os.makedirs(self._root_dir, exist_ok=True) + if host is not None: + self._host = host + if port is not None: + self._port = port + if timeout is not None: + self._timeout = timeout + + def start(self) -> None: + """ + Запуск TFTP сервера. + + Raises: + TFTPError: При ошибке запуска сервера + """ + if self.is_running: + self._logger.warning("TFTP сервер уже запущен") + return + + try: + # Создаем серверный объект + self._server = tftpy.TftpServer(self._root_dir) + + # Запускаем сервер в отдельном потоке + self._start_in_thread(self._serve) + + self._notify_started({ + "host": self._host, + "port": self._port, + "root_dir": self._root_dir + }) + + self._logger.info(f"TFTP сервер запущен на {self._host}:{self._port}") + + except Exception as e: + self._notify_error(e) + raise TFTPError(f"Ошибка запуска TFTP сервера: {e}") + + def stop(self) -> None: + """Остановка TFTP сервера.""" + if not self.is_running: + return + + try: + if self._server: + self._server.stop() + self._server = None + + self._stop_thread() + self._notify_stopped() + + self._logger.info("TFTP сервер остановлен") + + except Exception as e: + self._logger.error(f"Ошибка при остановке TFTP сервера: {e}") + + def _serve(self) -> None: + """Основной цикл сервера.""" + try: + self._server.listen(self._host, self._port, timeout=self._timeout) + except Exception as e: + self._notify_error(e) + self._logger.error(f"Ошибка в работе TFTP сервера: {e}") + self.stop() + + def get_server_info(self) -> Dict[str, Any]: + """ + Получение информации о сервере. + + Returns: + Dict[str, Any]: Информация о сервере + """ + return { + "running": self.is_running, + "host": self._host, + "port": self._port, + "root_dir": self._root_dir, + "timeout": self._timeout + } + + def list_files(self) -> list[str]: + """ + Получение списка файлов в корневой директории. + + Returns: + list[str]: Список файлов + """ + try: + return os.listdir(self._root_dir) + except Exception as e: + self._logger.error(f"Ошибка при получении списка файлов: {e}") + return [] + + def get_file_info(self, filename: str) -> Optional[Dict[str, Any]]: + """ + Получение информации о файле. + + Args: + filename: Имя файла + + Returns: + Optional[Dict[str, Any]]: Информация о файле или None + """ + file_path = os.path.join(self._root_dir, filename) + if not os.path.exists(file_path): + return None + + try: + stat = os.stat(file_path) + return { + "name": filename, + "size": stat.st_size, + "modified": stat.st_mtime, + "created": stat.st_ctime + } + except Exception as e: + self._logger.error(f"Ошибка при получении информации о файле: {e}") + return None + + def delete_file(self, filename: str) -> bool: + """ + Удаление файла из корневой директории. + + Args: + filename: Имя файла + + Returns: + bool: True если файл удален успешно + """ + file_path = os.path.join(self._root_dir, filename) + if not os.path.exists(file_path): + return False + + try: + os.remove(file_path) + self._logger.info(f"Файл удален: {filename}") + return True + except Exception as e: + self._logger.error(f"Ошибка при удалении файла: {e}") + return False diff --git a/src/network/transfer/__init__.py b/src/network/transfer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/network/transfer/progress_tracker.py b/src/network/transfer/progress_tracker.py new file mode 100644 index 0000000..56b576f --- /dev/null +++ b/src/network/transfer/progress_tracker.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import time +from typing import Optional, Dict, Any, Callable +from dataclasses import dataclass +import logging + +from core.events import event_bus, Event, EventTypes + +@dataclass +class TransferProgress: + """Информация о прогрессе передачи.""" + total_bytes: int + transferred_bytes: int = 0 + start_time: float = 0.0 + end_time: float = 0.0 + status: str = "pending" + error: Optional[str] = None + + @property + def progress(self) -> float: + """Процент выполнения.""" + if self.total_bytes == 0: + return 0.0 + return (self.transferred_bytes / self.total_bytes) * 100 + + @property + def speed(self) -> float: + """Скорость передачи в байтах в секунду.""" + if self.start_time == 0 or self.transferred_bytes == 0: + return 0.0 + + duration = (self.end_time or time.time()) - self.start_time + if duration == 0: + return 0.0 + + return self.transferred_bytes / duration + + @property + def elapsed_time(self) -> float: + """Прошедшее время в секундах.""" + if self.start_time == 0: + return 0.0 + return (self.end_time or time.time()) - self.start_time + + @property + def estimated_time(self) -> float: + """Оценка оставшегося времени в секундах.""" + if self.speed == 0 or self.transferred_bytes == 0: + return 0.0 + + remaining_bytes = self.total_bytes - self.transferred_bytes + return remaining_bytes / self.speed + +class ProgressTracker: + """Трекер прогресса передачи файлов.""" + + def __init__(self): + self._logger = logging.getLogger(__name__) + self._transfers: Dict[str, TransferProgress] = {} + self._callbacks: Dict[str, Callable[[TransferProgress], None]] = {} + + def start_transfer(self, transfer_id: str, total_bytes: int, + callback: Optional[Callable[[TransferProgress], None]] = None) -> None: + """ + Начало отслеживания передачи. + + Args: + transfer_id: Идентификатор передачи + total_bytes: Общий размер в байтах + callback: Функция обратного вызова + """ + progress = TransferProgress( + total_bytes=total_bytes, + start_time=time.time() + ) + + self._transfers[transfer_id] = progress + if callback: + self._callbacks[transfer_id] = callback + + event_bus.publish(Event(EventTypes.TRANSFER_STARTED, { + "transfer_id": transfer_id, + "total_bytes": total_bytes + })) + + self._logger.info(f"Начата передача {transfer_id}: {total_bytes} байт") + + def update_progress(self, transfer_id: str, transferred_bytes: int) -> None: + """ + Обновление прогресса передачи. + + Args: + transfer_id: Идентификатор передачи + transferred_bytes: Количество переданных байт + """ + if transfer_id not in self._transfers: + self._logger.warning(f"Передача {transfer_id} не найдена") + return + + progress = self._transfers[transfer_id] + progress.transferred_bytes = transferred_bytes + + # Вызываем callback, если есть + if transfer_id in self._callbacks: + self._callbacks[transfer_id](progress) + + event_bus.publish(Event(EventTypes.TRANSFER_PROGRESS, { + "transfer_id": transfer_id, + "progress": progress.progress, + "speed": progress.speed, + "elapsed_time": progress.elapsed_time, + "estimated_time": progress.estimated_time + })) + + def complete_transfer(self, transfer_id: str) -> None: + """ + Завершение передачи. + + Args: + transfer_id: Идентификатор передачи + """ + if transfer_id not in self._transfers: + return + + progress = self._transfers[transfer_id] + progress.end_time = time.time() + progress.status = "completed" + + event_bus.publish(Event(EventTypes.TRANSFER_COMPLETED, { + "transfer_id": transfer_id, + "total_time": progress.elapsed_time, + "average_speed": progress.speed + })) + + self._logger.info( + f"Завершена передача {transfer_id}: " + f"{progress.transferred_bytes}/{progress.total_bytes} байт " + f"за {progress.elapsed_time:.1f} сек " + f"({progress.speed:.1f} байт/сек)" + ) + + self._cleanup_transfer(transfer_id) + + def fail_transfer(self, transfer_id: str, error: str) -> None: + """ + Отметка передачи как неудачной. + + Args: + transfer_id: Идентификатор передачи + error: Сообщение об ошибке + """ + if transfer_id not in self._transfers: + return + + progress = self._transfers[transfer_id] + progress.end_time = time.time() + progress.status = "failed" + progress.error = error + + event_bus.publish(Event(EventTypes.TRANSFER_ERROR, { + "transfer_id": transfer_id, + "error": error + })) + + self._logger.error(f"Ошибка передачи {transfer_id}: {error}") + + self._cleanup_transfer(transfer_id) + + def get_progress(self, transfer_id: str) -> Optional[TransferProgress]: + """ + Получение информации о прогрессе передачи. + + Args: + transfer_id: Идентификатор передачи + + Returns: + Optional[TransferProgress]: Информация о прогрессе или None + """ + return self._transfers.get(transfer_id) + + def list_transfers(self) -> Dict[str, TransferProgress]: + """ + Получение списка всех передач. + + Returns: + Dict[str, TransferProgress]: Словарь с информацией о передачах + """ + return self._transfers.copy() + + def _cleanup_transfer(self, transfer_id: str) -> None: + """ + Очистка данных завершенной передачи. + + Args: + transfer_id: Идентификатор передачи + """ + self._transfers.pop(transfer_id, None) + self._callbacks.pop(transfer_id, None) diff --git a/src/network/transfer/transfer_manager.py b/src/network/transfer/transfer_manager.py new file mode 100644 index 0000000..97b291a --- /dev/null +++ b/src/network/transfer/transfer_manager.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import uuid +from typing import Optional, Dict, Any, List, Callable +import logging + +from core.config import AppConfig +from core.exceptions import TransferError, ValidationError +from core.events import event_bus, Event, EventTypes +from communication.serial_manager import SerialManager +from network.servers.tftp_server import TFTPServer +from .progress_tracker import ProgressTracker + +class TransferManager: + """Менеджер передачи файлов.""" + + def __init__(self, serial_manager: SerialManager, tftp_server: TFTPServer): + self._serial = serial_manager + self._tftp = tftp_server + self._progress = ProgressTracker() + self._logger = logging.getLogger(__name__) + self._active_transfers: Dict[str, Dict[str, Any]] = {} + + def transfer_config(self, config_path: str, mode: str = "line", + block_size: int = AppConfig.DEFAULT_BLOCK_SIZE, + callback: Optional[Callable[[float], None]] = None) -> str: + """ + Передача конфигурации на устройство. + + Args: + config_path: Путь к файлу конфигурации + mode: Режим передачи (line/block/tftp) + block_size: Размер блока команд + callback: Функция обратного вызова для отслеживания прогресса + + Returns: + str: Идентификатор передачи + + Raises: + ValidationError: При ошибке валидации параметров + TransferError: При ошибке передачи + """ + # Проверяем файл + if not os.path.exists(config_path): + raise ValidationError(f"Файл не найден: {config_path}") + + # Проверяем режим передачи + if mode not in AppConfig.SUPPORTED_COPY_MODES: + raise ValidationError(f"Неподдерживаемый режим передачи: {mode}") + + try: + # Создаем идентификатор передачи + transfer_id = str(uuid.uuid4()) + + # Читаем конфигурацию + with open(config_path, "r", encoding="utf-8") as f: + config_data = f.read() + + # Разбиваем на команды + commands = [cmd.strip() for cmd in config_data.splitlines() if cmd.strip()] + total_commands = len(commands) + + if not commands: + raise ValidationError("Пустой файл конфигурации") + + # Сохраняем информацию о передаче + self._active_transfers[transfer_id] = { + "mode": mode, + "file": config_path, + "total_commands": total_commands, + "current_command": 0 + } + + # Запускаем передачу в зависимости от режима + if mode == "line": + self._transfer_line_by_line(transfer_id, commands, callback) + elif mode == "block": + self._transfer_by_blocks(transfer_id, commands, block_size, callback) + elif mode == "tftp": + self._transfer_by_tftp(transfer_id, config_path, callback) + + return transfer_id + + except Exception as e: + self._logger.error(f"Ошибка передачи конфигурации: {e}") + raise TransferError(f"Ошибка передачи конфигурации: {e}") + + def _transfer_line_by_line(self, transfer_id: str, commands: List[str], + callback: Optional[Callable[[float], None]] = None) -> None: + """ + Построчная передача команд. + + Args: + transfer_id: Идентификатор передачи + commands: Список команд + callback: Функция обратного вызова + + Raises: + TransferError: При ошибке передачи + """ + try: + total_commands = len(commands) + + # Начинаем отслеживание прогресса + self._progress.start_transfer(transfer_id, total_commands, callback) + + # Отправляем команды по одной + for i, command in enumerate(commands, 1): + response = self._serial.send_command(command) + + # Проверяем ответ на ошибки + if "error" in response.lower() or "invalid" in response.lower(): + raise TransferError(f"Ошибка выполнения команды: {response}") + + # Обновляем прогресс + self._progress.update_progress(transfer_id, i) + + # Обновляем информацию о передаче + self._active_transfers[transfer_id]["current_command"] = i + + # Завершаем передачу + self._progress.complete_transfer(transfer_id) + + except Exception as e: + self._progress.fail_transfer(transfer_id, str(e)) + raise TransferError(f"Ошибка построчной передачи: {e}") + + def _transfer_by_blocks(self, transfer_id: str, commands: List[str], + block_size: int, + callback: Optional[Callable[[float], None]] = None) -> None: + """ + Блочная передача команд. + + Args: + transfer_id: Идентификатор передачи + commands: Список команд + block_size: Размер блока + callback: Функция обратного вызова + + Raises: + TransferError: При ошибке передачи + """ + try: + total_commands = len(commands) + + # Начинаем отслеживание прогресса + self._progress.start_transfer(transfer_id, total_commands, callback) + + # Разбиваем команды на блоки + for i in range(0, total_commands, block_size): + block = commands[i:i + block_size] + + # Отправляем блок команд + response = self._serial.send_config(block) + + # Проверяем ответ на ошибки + if "error" in response.lower() or "invalid" in response.lower(): + raise TransferError(f"Ошибка выполнения блока команд: {response}") + + # Обновляем прогресс + self._progress.update_progress(transfer_id, i + len(block)) + + # Обновляем информацию о передаче + self._active_transfers[transfer_id]["current_command"] = i + len(block) + + # Завершаем передачу + self._progress.complete_transfer(transfer_id) + + except Exception as e: + self._progress.fail_transfer(transfer_id, str(e)) + raise TransferError(f"Ошибка блочной передачи: {e}") + + def _transfer_by_tftp(self, transfer_id: str, config_path: str, + callback: Optional[Callable[[float], None]] = None) -> None: + """ + Передача через TFTP. + + Args: + transfer_id: Идентификатор передачи + config_path: Путь к файлу конфигурации + callback: Функция обратного вызова + + Raises: + TransferError: При ошибке передачи + """ + try: + # Получаем размер файла + file_size = os.path.getsize(config_path) + + # Начинаем отслеживание прогресса + self._progress.start_transfer(transfer_id, file_size, callback) + + # Запускаем TFTP сервер, если не запущен + if not self._tftp.is_running: + self._tftp.start() + + # Копируем файл в директорию TFTP сервера + filename = os.path.basename(config_path) + tftp_path = os.path.join(self._tftp.root_dir, filename) + + with open(config_path, "rb") as src, open(tftp_path, "wb") as dst: + dst.write(src.read()) + + # Отправляем команду на загрузку файла через TFTP + command = f"copy tftp://{self._tftp.host}/{filename} startup-config" + response = self._serial.send_command(command) + + # Проверяем ответ на ошибки + if "error" in response.lower() or "invalid" in response.lower(): + raise TransferError(f"Ошибка загрузки через TFTP: {response}") + + # Завершаем передачу + self._progress.complete_transfer(transfer_id) + + except Exception as e: + self._progress.fail_transfer(transfer_id, str(e)) + raise TransferError(f"Ошибка передачи через TFTP: {e}") + + finally: + # Удаляем временный файл + if os.path.exists(tftp_path): + os.remove(tftp_path) + + def get_transfer_info(self, transfer_id: str) -> Optional[Dict[str, Any]]: + """ + Получение информации о передаче. + + Args: + transfer_id: Идентификатор передачи + + Returns: + Optional[Dict[str, Any]]: Информация о передаче или None + """ + if transfer_id not in self._active_transfers: + return None + + transfer = self._active_transfers[transfer_id] + progress = self._progress.get_progress(transfer_id) + + if not progress: + return None + + return { + **transfer, + "progress": progress.progress, + "speed": progress.speed, + "elapsed_time": progress.elapsed_time, + "estimated_time": progress.estimated_time, + "status": progress.status, + "error": progress.error + } + + def list_transfers(self) -> List[Dict[str, Any]]: + """ + Получение списка активных передач. + + Returns: + List[Dict[str, Any]]: Список с информацией о передачах + """ + transfers = [] + + for transfer_id, transfer in self._active_transfers.items(): + info = self.get_transfer_info(transfer_id) + if info: + transfers.append(info) + + return transfers + + def cancel_transfer(self, transfer_id: str) -> None: + """ + Отмена передачи. + + Args: + transfer_id: Идентификатор передачи + """ + if transfer_id in self._active_transfers: + self._progress.fail_transfer(transfer_id, "Передача отменена пользователем") + self._active_transfers.pop(transfer_id) + + def is_transfer_active(self, transfer_id: str) -> bool: + """ + Проверка активности передачи. + + Args: + transfer_id: Идентификатор передачи + + Returns: + bool: True если передача активна + """ + if transfer_id not in self._active_transfers: + return False + + progress = self._progress.get_progress(transfer_id) + if not progress: + return False + + return progress.status not in ["completed", "failed"] + + def cleanup_transfers(self) -> None: + """Очистка завершенных передач.""" + completed_transfers = [ + transfer_id for transfer_id in self._active_transfers + if not self.is_transfer_active(transfer_id) + ] + + for transfer_id in completed_transfers: + self._active_transfers.pop(transfer_id) + + self._logger.debug(f"Очищено {len(completed_transfers)} завершенных передач") + + def get_active_transfer_count(self) -> int: + """ + Получение количества активных передач. + + Returns: + int: Количество активных передач + """ + return len([ + transfer_id for transfer_id in self._active_transfers + if self.is_transfer_active(transfer_id) + ]) + + def cancel_all_transfers(self) -> None: + """Отмена всех активных передач.""" + active_transfers = [ + transfer_id for transfer_id in self._active_transfers + if self.is_transfer_active(transfer_id) + ] + + for transfer_id in active_transfers: + self.cancel_transfer(transfer_id) + + self._logger.info(f"Отменено {len(active_transfers)} активных передач") diff --git a/src/network/utils/__init__.py b/src/network/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/network/utils/network_scanner.py b/src/network/utils/network_scanner.py new file mode 100644 index 0000000..e69de29 diff --git a/src/network/utils/port_checker.py b/src/network/utils/port_checker.py new file mode 100644 index 0000000..e69de29 diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..c9c31c8 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,4 @@ +pyserial>=3.5 +tftpy>=0.8.0 +requests>=2.31.0 +watchdog>=3.0.0 \ No newline at end of file diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/dialogs/__init__.py b/src/ui/dialogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/dialogs/about_dialog.py b/src/ui/dialogs/about_dialog.py new file mode 100644 index 0000000..b181c6b --- /dev/null +++ b/src/ui/dialogs/about_dialog.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import tkinter as tk +from tkinter import ttk +import webbrowser +from typing import Optional +import platform +import sys + +from core.config import AppConfig + +class AboutDialog(tk.Toplevel): + """Диалог с информацией о программе.""" + + def __init__(self, parent): + super().__init__(parent) + + self.title("О программе") + self.resizable(False, False) + self.transient(parent) + + # Создаем элементы интерфейса + self._create_widgets() + + # Делаем диалог модальным + self.grab_set() + self.focus_set() + + # Центрируем окно + self._center_window() + + def _create_widgets(self) -> None: + """Создание элементов интерфейса.""" + # Основной контейнер + main_frame = ttk.Frame(self, padding="20") + main_frame.pack(fill=tk.BOTH, expand=True) + + # Логотип + try: + logo = tk.PhotoImage(file="Icon.ico") + logo_label = ttk.Label(main_frame, image=logo) + logo_label.image = logo # Сохраняем ссылку + logo_label.pack(pady=(0, 10)) + except Exception: + pass + + # Название программы + ttk.Label( + main_frame, + text="ComConfigCopy", + font=("Arial", 14, "bold") + ).pack() + + # Версия + ttk.Label( + main_frame, + text=f"Версия {AppConfig.VERSION}", + font=("Arial", 10) + ).pack() + + # Описание + description = ( + "Программа для копирования конфигураций\n" + "на сетевое оборудование через последовательный порт и TFTP" + ) + ttk.Label( + main_frame, + text=description, + justify=tk.CENTER, + wraplength=300 + ).pack(pady=10) + + # Системная информация + system_frame = ttk.LabelFrame(main_frame, text="Системная информация", padding="5") + system_frame.pack(fill=tk.X, pady=10) + + # Python + ttk.Label( + system_frame, + text=f"Python: {sys.version.split()[0]}" + ).pack(anchor=tk.W) + + # ОС + ttk.Label( + system_frame, + text=f"ОС: {platform.system()} {platform.release()}" + ).pack(anchor=tk.W) + + # Процессор + ttk.Label( + system_frame, + text=f"Процессор: {platform.processor()}" + ).pack(anchor=tk.W) + + # Зависимости + deps_frame = ttk.LabelFrame(main_frame, text="Зависимости", padding="5") + deps_frame.pack(fill=tk.X, pady=10) + + # Создаем список зависимостей + self._add_dependency(deps_frame, "pyserial", ">=3.5") + self._add_dependency(deps_frame, "tftpy", ">=0.8.0") + self._add_dependency(deps_frame, "requests", ">=2.31.0") + self._add_dependency(deps_frame, "watchdog", ">=3.0.0") + + # Ссылки + links_frame = ttk.Frame(main_frame) + links_frame.pack(fill=tk.X, pady=10) + + # GitHub + github_link = ttk.Label( + links_frame, + text="GitHub", + cursor="hand2", + foreground="blue" + ) + github_link.pack(side=tk.LEFT, padx=5) + github_link.bind( + "", + lambda e: webbrowser.open("https://github.com/yourusername/ComConfigCopy") + ) + + # Документация + docs_link = ttk.Label( + links_frame, + text="Документация", + cursor="hand2", + foreground="blue" + ) + docs_link.pack(side=tk.LEFT, padx=5) + docs_link.bind( + "", + lambda e: webbrowser.open("https://yourusername.github.io/ComConfigCopy") + ) + + # Лицензия + license_link = ttk.Label( + links_frame, + text="Лицензия", + cursor="hand2", + foreground="blue" + ) + license_link.pack(side=tk.LEFT, padx=5) + license_link.bind( + "", + lambda e: self._show_license() + ) + + # Кнопка закрытия + ttk.Button( + main_frame, + text="Закрыть", + command=self.destroy + ).pack(pady=(10, 0)) + + def _add_dependency(self, parent: ttk.Frame, name: str, version: str) -> None: + """ + Добавление зависимости в список. + + Args: + parent: Родительский виджет + name: Название пакета + version: Версия пакета + """ + frame = ttk.Frame(parent) + frame.pack(fill=tk.X) + + ttk.Label(frame, text=name).pack(side=tk.LEFT) + ttk.Label(frame, text=version).pack(side=tk.RIGHT) + + def _show_license(self) -> None: + """Отображение текста лицензии.""" + license_text = """ +MIT License + +Copyright (c) 2024 Your Name + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + # Создаем диалог с текстом лицензии + dialog = tk.Toplevel(self) + dialog.title("Лицензия") + dialog.transient(self) + dialog.grab_set() + + # Добавляем текстовое поле + text = tk.Text( + dialog, + wrap=tk.WORD, + width=60, + height=20, + padx=10, + pady=10 + ) + text.pack(padx=10, pady=10) + + # Вставляем текст лицензии + text.insert("1.0", license_text.strip()) + text.configure(state=tk.DISABLED) + + # Добавляем кнопку закрытия + ttk.Button( + dialog, + text="Закрыть", + command=dialog.destroy + ).pack(pady=(0, 10)) + + # Центрируем диалог + self._center_window(dialog) + + def _center_window(self, window: Optional[tk.Toplevel] = None) -> None: + """ + Центрирование окна на экране. + + Args: + window: Окно для центрирования + """ + window = window or self + window.update_idletasks() + + # Получаем размеры экрана + screen_width = window.winfo_screenwidth() + screen_height = window.winfo_screenheight() + + # Получаем размеры окна + window_width = window.winfo_width() + window_height = window.winfo_height() + + # Вычисляем координаты + x = (screen_width - window_width) // 2 + y = (screen_height - window_height) // 2 + + # Устанавливаем позицию + window.geometry(f"+{x}+{y}") diff --git a/src/ui/dialogs/settings_dialog.py b/src/ui/dialogs/settings_dialog.py new file mode 100644 index 0000000..5cee101 --- /dev/null +++ b/src/ui/dialogs/settings_dialog.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import tkinter as tk +from tkinter import ttk +from typing import Dict, Any, Optional +import logging + +from core.config import AppConfig +from core.events import event_bus, Event, EventTypes +from ..widgets.custom_entry import CustomEntry + +class SettingsDialog(tk.Toplevel): + """Диалог настроек приложения.""" + + def __init__(self, parent, settings: Dict[str, Any]): + super().__init__(parent) + + self.title("Настройки") + self.resizable(False, False) + self.transient(parent) + + # Сохраняем текущие настройки + self._settings = settings.copy() + self._logger = logging.getLogger(__name__) + + # Создаем элементы интерфейса + self._create_widgets() + + # Заполняем поля текущими значениями + self._load_settings() + + # Делаем диалог модальным + self.grab_set() + self.focus_set() + + def _create_widgets(self) -> None: + """Создание элементов интерфейса.""" + # Основной контейнер + main_frame = ttk.Frame(self, padding="10") + main_frame.pack(fill=tk.BOTH, expand=True) + + # Настройки последовательного порта + port_frame = ttk.LabelFrame(main_frame, text="Последовательный порт", padding="5") + port_frame.pack(fill=tk.X, padx=5, pady=5) + + # COM-порт + ttk.Label(port_frame, text="COM-порт:").grid(row=0, column=0, sticky=tk.W) + self.port_entry = CustomEntry(port_frame) + self.port_entry.grid(row=0, column=1, sticky=tk.EW, padx=5) + + # Скорость + ttk.Label(port_frame, text="Скорость:").grid(row=1, column=0, sticky=tk.W) + self.baudrate_combo = ttk.Combobox( + port_frame, + values=AppConfig.SUPPORTED_BAUDRATES, + state="readonly" + ) + self.baudrate_combo.grid(row=1, column=1, sticky=tk.EW, padx=5, pady=5) + + # Таймаут + ttk.Label(port_frame, text="Таймаут (сек):").grid(row=2, column=0, sticky=tk.W) + self.timeout_entry = CustomEntry( + port_frame, + validator=lambda x: x.isdigit() and 1 <= int(x) <= 60 + ) + self.timeout_entry.grid(row=2, column=1, sticky=tk.EW, padx=5) + + # Настройки передачи + transfer_frame = ttk.LabelFrame(main_frame, text="Передача данных", padding="5") + transfer_frame.pack(fill=tk.X, padx=5, pady=5) + + # Режим копирования + ttk.Label(transfer_frame, text="Режим:").grid(row=0, column=0, sticky=tk.W) + self.copy_mode_combo = ttk.Combobox( + transfer_frame, + values=AppConfig.SUPPORTED_COPY_MODES, + state="readonly" + ) + self.copy_mode_combo.grid(row=0, column=1, sticky=tk.EW, padx=5) + + # Размер блока + ttk.Label(transfer_frame, text="Размер блока:").grid(row=1, column=0, sticky=tk.W) + self.block_size_entry = CustomEntry( + transfer_frame, + validator=lambda x: x.isdigit() and 1 <= int(x) <= 100 + ) + self.block_size_entry.grid(row=1, column=1, sticky=tk.EW, padx=5, pady=5) + + # Приглашение командной строки + ttk.Label(transfer_frame, text="Приглашение:").grid(row=2, column=0, sticky=tk.W) + self.prompt_entry = CustomEntry(transfer_frame) + self.prompt_entry.grid(row=2, column=1, sticky=tk.EW, padx=5) + + # Настройки TFTP + tftp_frame = ttk.LabelFrame(main_frame, text="TFTP сервер", padding="5") + tftp_frame.pack(fill=tk.X, padx=5, pady=5) + + # Порт TFTP + ttk.Label(tftp_frame, text="Порт:").grid(row=0, column=0, sticky=tk.W) + self.tftp_port_entry = CustomEntry( + tftp_frame, + validator=lambda x: x.isdigit() and 1 <= int(x) <= 65535 + ) + self.tftp_port_entry.grid(row=0, column=1, sticky=tk.EW, padx=5) + + # Кнопки + button_frame = ttk.Frame(main_frame) + button_frame.pack(fill=tk.X, padx=5, pady=10) + + ttk.Button( + button_frame, + text="Сохранить", + command=self._save_settings + ).pack(side=tk.RIGHT, padx=5) + + ttk.Button( + button_frame, + text="Отмена", + command=self.destroy + ).pack(side=tk.RIGHT) + + # Настраиваем растяжение колонок + port_frame.columnconfigure(1, weight=1) + transfer_frame.columnconfigure(1, weight=1) + tftp_frame.columnconfigure(1, weight=1) + + def _load_settings(self) -> None: + """Загрузка текущих настроек в поля ввода.""" + # COM-порт + self.port_entry.set(self._settings.get("port", "")) + + # Скорость + baudrate = str(self._settings.get("baudrate", AppConfig.DEFAULT_BAUDRATE)) + self.baudrate_combo.set(baudrate) + + # Таймаут + timeout = str(self._settings.get("timeout", AppConfig.DEFAULT_TIMEOUT)) + self.timeout_entry.set(timeout) + + # Режим копирования + copy_mode = self._settings.get("copy_mode", AppConfig.DEFAULT_COPY_MODE) + self.copy_mode_combo.set(copy_mode) + + # Размер блока + block_size = str(self._settings.get("block_size", AppConfig.DEFAULT_BLOCK_SIZE)) + self.block_size_entry.set(block_size) + + # Приглашение + prompt = self._settings.get("prompt", AppConfig.DEFAULT_PROMPT) + self.prompt_entry.set(prompt) + + # Порт TFTP + tftp_port = str(self._settings.get("tftp_port", AppConfig.TFTP_PORT)) + self.tftp_port_entry.set(tftp_port) + + def _validate_settings(self) -> Optional[str]: + """ + Валидация введенных настроек. + + Returns: + Optional[str]: Сообщение об ошибке или None + """ + # Проверяем COM-порт + if not self.port_entry.get(): + return "Не указан COM-порт" + + # Проверяем таймаут + try: + timeout = int(self.timeout_entry.get()) + if not 1 <= timeout <= 60: + return "Таймаут должен быть от 1 до 60 секунд" + except ValueError: + return "Некорректное значение таймаута" + + # Проверяем размер блока + try: + block_size = int(self.block_size_entry.get()) + if not 1 <= block_size <= 100: + return "Размер блока должен быть от 1 до 100" + except ValueError: + return "Некорректное значение размера блока" + + # Проверяем порт TFTP + try: + tftp_port = int(self.tftp_port_entry.get()) + if not 1 <= tftp_port <= 65535: + return "Порт TFTP должен быть от 1 до 65535" + except ValueError: + return "Некорректное значение порта TFTP" + + return None + + def _save_settings(self) -> None: + """Сохранение настроек.""" + # Проверяем настройки + error = self._validate_settings() + if error: + tk.messagebox.showerror("Ошибка", error) + return + + try: + # Обновляем настройки + self._settings.update({ + "port": self.port_entry.get(), + "baudrate": int(self.baudrate_combo.get()), + "timeout": int(self.timeout_entry.get()), + "copy_mode": self.copy_mode_combo.get(), + "block_size": int(self.block_size_entry.get()), + "prompt": self.prompt_entry.get(), + "tftp_port": int(self.tftp_port_entry.get()) + }) + + # Уведомляем об изменении настроек + event_bus.publish(Event(EventTypes.UI_SETTINGS_CHANGED, self._settings)) + + self._logger.info("Настройки сохранены") + self.destroy() + + except Exception as e: + self._logger.error(f"Ошибка сохранения настроек: {e}") + tk.messagebox.showerror("Ошибка", "Не удалось сохранить настройки") + + @property + def settings(self) -> Dict[str, Any]: + """Получение текущих настроек.""" + return self._settings diff --git a/src/ui/main_window.py b/src/ui/main_window.py new file mode 100644 index 0000000..3e06c02 --- /dev/null +++ b/src/ui/main_window.py @@ -0,0 +1,735 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import tkinter as tk +from tkinter import ttk, messagebox +from typing import Optional, Dict, Any +import logging +import serial.tools.list_ports +import os + +from core.config import AppConfig +from core.events import event_bus, Event, EventTypes +from core.state import ConnectionState, TransferState, StateManager +from communication.serial_manager import SerialManager +from network.servers.tftp_server import TFTPServer +from network.transfer.transfer_manager import TransferManager +from filesystem.config_manager import ConfigManager +from filesystem.watchers.config_watcher import ConfigWatcher +from .widgets.custom_text import CustomText +from .widgets.custom_entry import CustomEntry +from .widgets.transfer_view import TransferView +from .dialogs.settings_dialog import SettingsDialog +from .dialogs.about_dialog import AboutDialog + +class MainWindow(tk.Tk): + """Главное окно приложения.""" + + def __init__(self, settings, state, serial, transfer, config_manager): + super().__init__() + + # Настройка окна + self.title(f"ComConfigCopy v{AppConfig.VERSION}") + self.geometry("800x600") + self.minsize(800, 600) + + # Флаг несохраненных изменений + self._has_unsaved_changes = False + + # Обработка закрытия окна + self.protocol("WM_DELETE_WINDOW", self._on_close) + + # Инициализация компонентов + self._logger = logging.getLogger(__name__) + self._settings = settings + self._state_manager = state + self._serial_manager = serial + self._transfer_manager = transfer + self._config_manager = config_manager + self._tftp_server = TFTPServer() + self._config_watcher = ConfigWatcher() + + # Создаем интерфейс + self._create_widgets() + self._setup_event_handlers() + + # Загружаем конфигурации + self._load_configs() + + # Запускаем наблюдатель за конфигурациями + self._config_watcher.start() + self._config_watcher.watch_all_configs() + + def _create_widgets(self) -> None: + """Создание элементов интерфейса.""" + # Создаем главное меню + self._create_menu() + + # Создаем панель инструментов + self._create_toolbar() + + # Создаем основной контейнер + main_frame = ttk.Frame(self) + main_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Создаем разделитель для левой и правой панели + paned = ttk.PanedWindow(main_frame, orient=tk.HORIZONTAL) + paned.pack(fill=tk.BOTH, expand=True) + + # Левая панель + left_frame = ttk.Frame(paned) + paned.add(left_frame, weight=1) + + # Список конфигураций + configs_frame = ttk.LabelFrame(left_frame, text="Конфигурации") + configs_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Создаем список с прокруткой + self._configs_list = tk.Listbox( + configs_frame, + selectmode=tk.SINGLE, + activestyle=tk.NONE + ) + self._configs_list.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + scrollbar = ttk.Scrollbar(configs_frame, orient=tk.VERTICAL) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + self._configs_list.configure(yscrollcommand=scrollbar.set) + scrollbar.configure(command=self._configs_list.yview) + + # Кнопки управления конфигурациями + config_buttons = ttk.Frame(left_frame) + config_buttons.pack(fill=tk.X, padx=5, pady=5) + + ttk.Button( + config_buttons, + text="Добавить", + command=self._add_config + ).pack(side=tk.LEFT, padx=2) + + ttk.Button( + config_buttons, + text="Удалить", + command=self._remove_config + ).pack(side=tk.LEFT, padx=2) + + ttk.Button( + config_buttons, + text="Обновить", + command=self._load_configs + ).pack(side=tk.LEFT, padx=2) + + # Правая панель + right_frame = ttk.Frame(paned) + paned.add(right_frame, weight=2) + + # Информация об устройстве + device_frame = ttk.LabelFrame(right_frame, text="Информация об устройстве") + device_frame.pack(fill=tk.X, padx=5, pady=5) + + # Создаем сетку для информации + self._device_info = {} + + info_grid = ttk.Frame(device_frame) + info_grid.pack(fill=tk.X, padx=5, pady=5) + + # Hostname + ttk.Label(info_grid, text="Hostname:").grid(row=0, column=0, sticky=tk.W) + self._device_info["hostname"] = ttk.Label(info_grid, text="-") + self._device_info["hostname"].grid(row=0, column=1, sticky=tk.W, padx=5) + + # Модель + ttk.Label(info_grid, text="Модель:").grid(row=1, column=0, sticky=tk.W) + self._device_info["model"] = ttk.Label(info_grid, text="-") + self._device_info["model"].grid(row=1, column=1, sticky=tk.W, padx=5) + + # Версия + ttk.Label(info_grid, text="Версия:").grid(row=2, column=0, sticky=tk.W) + self._device_info["version"] = ttk.Label(info_grid, text="-") + self._device_info["version"].grid(row=2, column=1, sticky=tk.W, padx=5) + + # Редактор конфигурации + editor_frame = ttk.LabelFrame(right_frame, text="Конфигурация") + editor_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + self._editor = CustomText(editor_frame) + self._editor.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Виджет прогресса передачи + self._transfer_view = TransferView(right_frame) + self._transfer_view.pack(fill=tk.X, padx=5, pady=5) + + # Статусная строка + self._status_bar = ttk.Label( + self, + text="Готов к работе", + anchor=tk.W, + padding=5 + ) + self._status_bar.pack(fill=tk.X) + + def _create_menu(self) -> None: + """Создание главного меню.""" + menubar = tk.Menu(self) + + # Меню "Файл" + file_menu = tk.Menu(menubar, tearoff=0) + file_menu.add_command(label="Сохранить", command=self._save_config_changes, accelerator="Ctrl+S") + file_menu.add_separator() + file_menu.add_command(label="Настройки", command=self._show_settings) + file_menu.add_separator() + file_menu.add_command(label="Выход", command=self.quit) + menubar.add_cascade(label="Файл", menu=file_menu) + + # Меню "Устройство" + device_menu = tk.Menu(menubar, tearoff=0) + device_menu.add_command(label="Подключить", command=self._connect_device) + device_menu.add_command(label="Отключить", command=self._disconnect_device) + device_menu.add_separator() + device_menu.add_command(label="Обновить информацию", command=self._update_device_info) + menubar.add_cascade(label="Устройство", menu=device_menu) + + # Меню "Передача" + transfer_menu = tk.Menu(menubar, tearoff=0) + transfer_menu.add_command(label="Передать конфигурацию", command=self._transfer_config) + transfer_menu.add_command(label="Отменить передачу", command=self._cancel_transfer) + transfer_menu.add_separator() + transfer_menu.add_command(label="Запустить TFTP сервер", command=self._start_tftp_server) + transfer_menu.add_command(label="Остановить TFTP сервер", command=self._stop_tftp_server) + menubar.add_cascade(label="Передача", menu=transfer_menu) + + # Меню "Справка" + help_menu = tk.Menu(menubar, tearoff=0) + help_menu.add_command(label="О программе", command=self._show_about) + menubar.add_cascade(label="Справка", menu=help_menu) + + self.config(menu=menubar) + + def _create_toolbar(self) -> None: + """Создание панели инструментов.""" + toolbar = ttk.Frame(self) + toolbar.pack(fill=tk.X, padx=5, pady=2) + + # Кнопка сохранения + ttk.Button( + toolbar, + text="Сохранить", + command=self._save_config_changes + ).pack(side=tk.LEFT, padx=2) + + ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, padx=5, fill=tk.Y) + + # Кнопки подключения + ttk.Button( + toolbar, + text="Подключить", + command=self._connect_device + ).pack(side=tk.LEFT, padx=2) + + ttk.Button( + toolbar, + text="Отключить", + command=self._disconnect_device + ).pack(side=tk.LEFT, padx=2) + + ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, padx=5, fill=tk.Y) + + # Кнопки передачи + ttk.Button( + toolbar, + text="Передать", + command=self._transfer_config + ).pack(side=tk.LEFT, padx=2) + + ttk.Button( + toolbar, + text="Отменить", + command=self._cancel_transfer + ).pack(side=tk.LEFT, padx=2) + + ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, padx=5, fill=tk.Y) + + # Кнопки TFTP сервера + ttk.Button( + toolbar, + text="Запустить TFTP", + command=self._start_tftp_server + ).pack(side=tk.LEFT, padx=2) + + ttk.Button( + toolbar, + text="Остановить TFTP", + command=self._stop_tftp_server + ).pack(side=tk.LEFT, padx=2) + + def _setup_event_handlers(self) -> None: + """Настройка обработчиков событий.""" + # Привязка сохранения к Ctrl+S + self.bind("", lambda e: self._save_config_changes()) + + # Привязка отслеживания изменений в редакторе + self._editor.bind("<>", self._on_editor_change) + + # События подключения + event_bus.subscribe(EventTypes.CONNECTION_ESTABLISHED, self._on_connection_established) + event_bus.subscribe(EventTypes.CONNECTION_LOST, self._on_connection_lost) + event_bus.subscribe(EventTypes.CONNECTION_ERROR, self._on_connection_error) + + # События передачи + event_bus.subscribe(EventTypes.TRANSFER_STARTED, self._on_transfer_started) + event_bus.subscribe(EventTypes.TRANSFER_COMPLETED, self._on_transfer_completed) + event_bus.subscribe(EventTypes.TRANSFER_ERROR, self._on_transfer_error) + + # События конфигурации + event_bus.subscribe(EventTypes.CONFIG_LOADED, self._on_config_loaded) + event_bus.subscribe(EventTypes.CONFIG_SAVED, self._on_config_saved) + event_bus.subscribe(EventTypes.CONFIG_DELETED, self._on_config_deleted) + + # События UI + event_bus.subscribe(EventTypes.UI_STATUS_CHANGED, self._on_status_changed) + + # События списка конфигураций + self._configs_list.bind("<>", self._on_config_selected) + + def _load_configs(self) -> None: + """Загрузка списка конфигураций.""" + try: + # Получаем список конфигураций + configs = self._config_manager.list_configs() + + # Очищаем список + self._configs_list.delete(0, tk.END) + + # Добавляем конфигурации в список + for config in configs: + self._configs_list.insert(tk.END, config["name"]) + + self._logger.debug(f"Загружено {len(configs)} конфигураций") + + except Exception as e: + self._logger.error(f"Ошибка загрузки конфигураций: {e}") + messagebox.showerror("Ошибка", "Не удалось загрузить список конфигураций") + + def _add_config(self) -> None: + """Добавление новой конфигурации.""" + try: + # Показываем диалог выбора файла + file_path = tk.filedialog.askopenfilename( + title="Выберите файл конфигурации", + filetypes=[ + ("Текстовые файлы", "*.txt"), + ("Все файлы", "*.*") + ] + ) + + if not file_path: + return + + # Получаем содержимое файла + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # Сохраняем в директорию конфигураций + config_name = os.path.basename(file_path) + self._config_manager.save_config(config_name, content) + + # Обновляем список конфигураций + self._load_configs() + + # Выбираем добавленную конфигурацию + configs = self._configs_list.get(0, tk.END) + if config_name in configs: + index = configs.index(config_name) + self._configs_list.selection_set(index) + self._configs_list.see(index) + + self._logger.info(f"Добавлена конфигурация: {config_name}") + + except Exception as e: + self._logger.error(f"Ошибка добавления конфигурации: {e}") + messagebox.showerror( + "Ошибка", + f"Не удалось добавить конфигурацию:\n{str(e)}" + ) + + def _remove_config(self) -> None: + """Удаление выбранной конфигурации.""" + selection = self._configs_list.curselection() + if not selection: + return + + config_name = self._configs_list.get(selection[0]) + + if messagebox.askyesno( + "Подтверждение", + f"Удалить конфигурацию {config_name}?" + ): + try: + self._config_manager.delete_config(config_name) + self._load_configs() + + except Exception as e: + self._logger.error(f"Ошибка удаления конфигурации: {e}") + messagebox.showerror("Ошибка", "Не удалось удалить конфигурацию") + + def _show_settings(self) -> None: + """Отображение диалога настроек.""" + try: + # Получаем текущие настройки + settings = { + "port": self._serial_manager._protocol._port, + "baudrate": self._serial_manager._protocol._baudrate, + "timeout": self._serial_manager._protocol._timeout, + "prompt": self._serial_manager._protocol._prompt, + "copy_mode": self._state_manager.get_custom_data("copy_mode", AppConfig.DEFAULT_COPY_MODE), + "block_size": self._state_manager.get_custom_data("block_size", AppConfig.DEFAULT_BLOCK_SIZE), + "tftp_port": self._tftp_server._port + } + + # Создаем и показываем диалог + dialog = SettingsDialog(self, settings) + self.wait_window(dialog) + + # Если настройки были изменены, применяем их + if dialog.settings != settings: + self._apply_settings(dialog.settings) + + except Exception as e: + self._logger.error(f"Ошибка отображения настроек: {e}") + messagebox.showerror( + "Ошибка", + f"Не удалось открыть настройки:\n{str(e)}" + ) + + def _show_about(self) -> None: + """Отображение диалога 'О программе'.""" + AboutDialog(self) + + def _connect_device(self) -> None: + """Подключение к устройству.""" + try: + self._serial_manager.connect() + + except Exception as e: + self._logger.error(f"Ошибка подключения: {e}") + messagebox.showerror("Ошибка", f"Не удалось подключиться к устройству: {e}") + + def _disconnect_device(self) -> None: + """Отключение от устройства.""" + try: + self._serial_manager.disconnect() + + except Exception as e: + self._logger.error(f"Ошибка отключения: {e}") + messagebox.showerror("Ошибка", f"Не удалось отключиться от устройства: {e}") + + def _update_device_info(self) -> None: + """Обновление информации об устройстве.""" + if not self._serial_manager.is_connected: + messagebox.showwarning("Предупреждение", "Нет подключения к устройству") + return + + try: + # Получаем информацию об устройстве + device_info = self._serial_manager.get_device_info() + + # Обновляем состояние + self._state_manager.update_device_info(device_info) + + # Обновляем отображение + self._device_info["hostname"].configure(text=device_info.get("hostname", "-")) + self._device_info["model"].configure(text=device_info.get("model", "-")) + self._device_info["version"].configure(text=device_info.get("version", "-")) + + except Exception as e: + self._logger.error(f"Ошибка получения информации об устройстве: {e}") + messagebox.showerror("Ошибка", "Не удалось получить информацию об устройстве") + + def _transfer_config(self) -> None: + """Передача конфигурации на устройство.""" + if not self._serial_manager.is_connected: + messagebox.showwarning("Предупреждение", "Нет подключения к устройству") + return + + selection = self._configs_list.curselection() + if not selection: + messagebox.showwarning("Предупреждение", "Не выбрана конфигурация") + return + + config_name = self._configs_list.get(selection[0]) + + try: + # Получаем путь к файлу конфигурации + config_info = self._config_manager.get_config_info(config_name) + if not config_info: + raise ValueError("Конфигурация не найдена") + + # Запускаем передачу + self._transfer_manager.transfer_config(config_info["path"]) + + except Exception as e: + self._logger.error(f"Ошибка передачи конфигурации: {e}") + messagebox.showerror("Ошибка", "Не удалось передать конфигурацию") + + def _cancel_transfer(self) -> None: + """Отмена передачи конфигурации.""" + try: + self._transfer_manager.cancel_all_transfers() + + except Exception as e: + self._logger.error(f"Ошибка отмены передачи: {e}") + messagebox.showerror("Ошибка", "Не удалось отменить передачу") + + def _start_tftp_server(self) -> None: + """Запуск TFTP сервера.""" + try: + self._tftp_server.start() + + except Exception as e: + self._logger.error(f"Ошибка запуска TFTP сервера: {e}") + messagebox.showerror("Ошибка", "Не удалось запустить TFTP сервер") + + def _stop_tftp_server(self) -> None: + """Остановка TFTP сервера.""" + try: + self._tftp_server.stop() + + except Exception as e: + self._logger.error(f"Ошибка остановки TFTP сервера: {e}") + messagebox.showerror("Ошибка", "Не удалось остановить TFTP сервер") + + def _on_config_selected(self, event) -> None: + """ + Обработка выбора конфигурации в списке. + + Args: + event: Событие выбора + """ + if self._has_unsaved_changes: + if messagebox.askyesno( + "Несохраненные изменения", + "Есть несохраненные изменения. Сохранить?" + ): + self._save_config_changes() + + selection = self._configs_list.curselection() + if not selection: + return + + config_name = self._configs_list.get(selection[0]) + + try: + # Загружаем содержимое конфигурации + content = self._config_manager.load_config(config_name) + + # Обновляем редактор + self._editor.set_text(content) + + # Обновляем состояние + self._state_manager.set_current_config(config_name) + + except Exception as e: + self._logger.error(f"Ошибка загрузки конфигурации: {e}") + messagebox.showerror("Ошибка", "Не удалось загрузить конфигурацию") + + def _on_connection_established(self, event: Event) -> None: + """ + Обработка установки соединения. + + Args: + event: Событие подключения + """ + self._update_device_info() + + def _on_connection_lost(self, event: Event) -> None: + """ + Обработка потери соединения. + + Args: + event: Событие отключения + """ + # Очищаем информацию об устройстве + self._device_info["hostname"].configure(text="-") + self._device_info["model"].configure(text="-") + self._device_info["version"].configure(text="-") + + # Очищаем состояние + self._state_manager.clear_device_info() + + def _on_connection_error(self, event: Event) -> None: + """ + Обработка ошибки соединения. + + Args: + event: Событие ошибки + """ + messagebox.showerror("Ошибка", str(event.data)) + + def _on_transfer_started(self, event: Event) -> None: + """ + Обработка начала передачи. + + Args: + event: Событие начала передачи + """ + # Блокируем редактор + self._editor.set_readonly(True) + + def _on_transfer_completed(self, event: Event) -> None: + """ + Обработка завершения передачи. + + Args: + event: Событие завершения передачи + """ + # Разблокируем редактор + self._editor.set_readonly(False) + + def _on_transfer_error(self, event: Event) -> None: + """ + Обработка ошибки передачи. + + Args: + event: Событие ошибки + """ + # Разблокируем редактор + self._editor.set_readonly(False) + + messagebox.showerror("Ошибка", str(event.data)) + + def _on_config_loaded(self, event: Event) -> None: + """ + Обработка загрузки конфигурации. + + Args: + event: Событие загрузки + """ + self._state_manager.update_config_info(event.data) + + def _on_config_saved(self, event: Event) -> None: + """ + Обработка сохранения конфигурации. + + Args: + event: Событие сохранения + """ + self._state_manager.update_config_info(event.data) + self._load_configs() + + def _on_config_deleted(self, event: Event) -> None: + """ + Обработка удаления конфигурации. + + Args: + event: Событие удаления + """ + self._state_manager.remove_config_info(event.data["name"]) + self._load_configs() + + def _on_status_changed(self, event: Event) -> None: + """ + Обработка изменения статуса. + + Args: + event: Событие изменения статуса + """ + if isinstance(event.data, dict): + message = event.data.get("message", "") + else: + message = str(event.data) + + self._status_bar.configure(text=message) + + def _on_editor_change(self, event=None) -> None: + """ + Обработка изменений в редакторе. + + Args: + event: Событие изменения + """ + if not self._has_unsaved_changes: + self._has_unsaved_changes = True + self.title(f"*ComConfigCopy v{AppConfig.VERSION}") + + def _save_config_changes(self) -> None: + """Сохранение изменений в редакторе конфигурации.""" + try: + # Получаем текущую конфигурацию + selection = self._configs_list.curselection() + if not selection: + return + + config_name = self._configs_list.get(selection[0]) + + # Получаем содержимое редактора + content = self._editor.get_text() + + # Сохраняем изменения + self._config_manager.save_config(config_name, content) + + # Сбрасываем флаг изменений + self._has_unsaved_changes = False + self.title(f"ComConfigCopy v{AppConfig.VERSION}") + + self._logger.info(f"Сохранены изменения в конфигурации: {config_name}") + + except Exception as e: + self._logger.error(f"Ошибка сохранения изменений: {e}") + messagebox.showerror( + "Ошибка", + f"Не удалось сохранить изменения:\n{str(e)}" + ) + + def _apply_settings(self, settings: Dict[str, Any]) -> None: + """ + Применение новых настроек. + + Args: + settings: Словарь с настройками + """ + try: + # Настраиваем последовательный порт + self._serial_manager._protocol.configure( + port=settings["port"], + baudrate=settings["baudrate"], + timeout=settings["timeout"], + prompt=settings["prompt"] + ) + + # Настраиваем TFTP сервер + self._tftp_server.configure(port=settings["tftp_port"]) + + # Сохраняем настройки передачи + self._state_manager.set_custom_data("copy_mode", settings["copy_mode"]) + self._state_manager.set_custom_data("block_size", settings["block_size"]) + + self._logger.info("Настройки применены") + + except Exception as e: + self._logger.error(f"Ошибка применения настроек: {e}") + messagebox.showerror( + "Ошибка", + f"Не удалось применить настройки:\n{str(e)}" + ) + + def _on_close(self) -> None: + """Обработка закрытия окна.""" + if self._has_unsaved_changes: + answer = messagebox.askyesnocancel( + "Несохраненные изменения", + "Есть несохраненные изменения. Сохранить перед выходом?" + ) + + if answer is None: # Отмена + return + elif answer: # Да + self._save_config_changes() + + # Останавливаем компоненты + self._config_watcher.stop() + self._tftp_server.stop() + self._serial_manager.disconnect() + + # Закрываем окно + self.quit() diff --git a/src/ui/widgets/__init__.py b/src/ui/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/widgets/custom_entry.py b/src/ui/widgets/custom_entry.py new file mode 100644 index 0000000..78779cc --- /dev/null +++ b/src/ui/widgets/custom_entry.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable, Any +import re + +class CustomEntry(ttk.Frame): + """Пользовательское поле ввода с дополнительными возможностями.""" + + def __init__(self, master, validator: Optional[Callable[[str], bool]] = None, + placeholder: Optional[str] = None, **kwargs): + super().__init__(master) + + # Создаем поле ввода + self.entry = ttk.Entry(self, **kwargs) + self.entry.pack(fill=tk.X, expand=True) + + # Функция валидации + self._validator = validator + + # Текст подсказки + self._placeholder = placeholder + self._placeholder_color = "gray" + self._default_fg = self.entry["foreground"] + + # Флаг показа пароля + self._show_password = False + self._default_show = self.entry.cget("show") + + # Привязываем события + self.entry.bind("", self._on_focus_in) + self.entry.bind("", self._on_focus_out) + self.entry.bind("", self._on_key_release) + + # Добавляем контекстное меню + self._create_context_menu() + self.entry.bind("", self._show_context_menu) + + # Устанавливаем подсказку, если указана + if placeholder: + self._show_placeholder() + + def _create_context_menu(self) -> None: + """Создание контекстного меню.""" + self.context_menu = tk.Menu(self, tearoff=0) + self.context_menu.add_command(label="Копировать", command=self.copy) + self.context_menu.add_command(label="Вставить", command=self.paste) + self.context_menu.add_command(label="Вырезать", command=self.cut) + self.context_menu.add_separator() + self.context_menu.add_command(label="Выделить всё", command=self.select_all) + + def _show_context_menu(self, event) -> None: + """ + Отображение контекстного меню. + + Args: + event: Событие мыши + """ + state = tk.NORMAL if not self.entry.cget("state") == tk.DISABLED else tk.DISABLED + self.context_menu.entryconfig("Вставить", state=state) + self.context_menu.entryconfig("Вырезать", state=state) + + self.context_menu.tk_popup(event.x_root, event.y_root) + + def _on_focus_in(self, event) -> None: + """ + Обработка получения фокуса. + + Args: + event: Событие фокуса + """ + if self._placeholder and self.entry.get() == self._placeholder: + self.entry.delete(0, tk.END) + self.entry.configure(foreground=self._default_fg) + + def _on_focus_out(self, event) -> None: + """ + Обработка потери фокуса. + + Args: + event: Событие фокуса + """ + if self._placeholder and not self.entry.get(): + self._show_placeholder() + + def _on_key_release(self, event) -> None: + """ + Обработка отпускания клавиши. + + Args: + event: Событие клавиатуры + """ + if self._validator: + text = self.entry.get() + if not self._validator(text): + self.entry.configure(foreground="red") + else: + self.entry.configure(foreground=self._default_fg) + + def _show_placeholder(self) -> None: + """Отображение текста подсказки.""" + self.entry.delete(0, tk.END) + self.entry.insert(0, self._placeholder) + self.entry.configure(foreground=self._placeholder_color) + + def get(self) -> str: + """ + Получение текста. + + Returns: + str: Текст поля ввода + """ + text = self.entry.get() + if self._placeholder and text == self._placeholder: + return "" + return text + + def set(self, text: str) -> None: + """ + Установка текста в поле ввода. + + Args: + text: Текст для установки + """ + self.entry.delete(0, tk.END) + if text: + self.entry.insert(0, text) + self.entry.configure(foreground=self._default_fg) + + def clear(self) -> None: + """Очистка текста.""" + self.entry.delete(0, tk.END) + if self._placeholder: + self._show_placeholder() + + def copy(self) -> None: + """Копирование текста.""" + self.entry.event_generate("<>") + + def paste(self) -> None: + """Вставка текста.""" + self.entry.event_generate("<>") + + def cut(self) -> None: + """Вырезание текста.""" + self.entry.event_generate("<>") + + def select_all(self) -> None: + """Выделение всего текста.""" + self.entry.select_range(0, tk.END) + self.entry.icursor(tk.END) + + def set_validator(self, validator: Optional[Callable[[str], bool]]) -> None: + """ + Установка функции валидации. + + Args: + validator: Функция валидации + """ + self._validator = validator + + def set_placeholder(self, text: Optional[str]) -> None: + """ + Установка текста подсказки. + + Args: + text: Текст подсказки + """ + self._placeholder = text + if not self.entry.get(): + self._show_placeholder() + + def toggle_password(self) -> None: + """Переключение отображения пароля.""" + self._show_password = not self._show_password + if self._show_password: + self.entry.configure(show="") + else: + self.entry.configure(show=self._default_show) + + def set_state(self, state: str) -> None: + """ + Установка состояния поля ввода. + + Args: + state: Состояние (tk.NORMAL или tk.DISABLED) + """ + self.entry.configure(state=state) + + def bind_key(self, key: str, callback: Callable) -> None: + """ + Привязка обработчика к клавише. + + Args: + key: Клавиша или комбинация клавиш + callback: Функция обработчик + """ + self.entry.bind(key, callback) diff --git a/src/ui/widgets/custom_text.py b/src/ui/widgets/custom_text.py new file mode 100644 index 0000000..19df589 --- /dev/null +++ b/src/ui/widgets/custom_text.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable +import re + +class CustomText(ttk.Frame): + """Пользовательский текстовый виджет с дополнительными возможностями.""" + + def __init__(self, master, **kwargs): + super().__init__(master) + + # Создаем текстовый виджет + self.text = tk.Text( + self, + wrap=tk.WORD, + undo=True, + **kwargs + ) + + # Создаем скроллбар + self.scrollbar = ttk.Scrollbar( + self, + orient=tk.VERTICAL, + command=self.text.yview + ) + + # Настраиваем прокрутку + self.text.configure(yscrollcommand=self.scrollbar.set) + + # Размещаем виджеты + self.text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # Настраиваем теги + self.text.tag_configure("error", foreground="red") + self.text.tag_configure("success", foreground="green") + self.text.tag_configure("warning", foreground="orange") + self.text.tag_configure("info", foreground="blue") + + # Добавляем контекстное меню + self._create_context_menu() + + # Привязываем события + self.text.bind("", self._show_context_menu) + + # Флаг только для чтения + self._readonly = False + + def _create_context_menu(self) -> None: + """Создание контекстного меню.""" + self.context_menu = tk.Menu(self, tearoff=0) + self.context_menu.add_command(label="Копировать", command=self.copy) + self.context_menu.add_command(label="Вставить", command=self.paste) + self.context_menu.add_command(label="Вырезать", command=self.cut) + self.context_menu.add_separator() + self.context_menu.add_command(label="Выделить всё", command=self.select_all) + + def _show_context_menu(self, event) -> None: + """ + Отображение контекстного меню. + + Args: + event: Событие мыши + """ + if self._readonly: + # В режиме только для чтения оставляем только копирование + self.context_menu.entryconfig("Вставить", state=tk.DISABLED) + self.context_menu.entryconfig("Вырезать", state=tk.DISABLED) + else: + self.context_menu.entryconfig("Вставить", state=tk.NORMAL) + self.context_menu.entryconfig("Вырезать", state=tk.NORMAL) + + self.context_menu.tk_popup(event.x_root, event.y_root) + + def get_text(self) -> str: + """ + Получение текста. + + Returns: + str: Текст виджета + """ + return self.text.get("1.0", tk.END).strip() + + def set_text(self, content: str) -> None: + """ + Установка текста. + + Args: + content: Текст для установки + """ + self.clear() + self.text.insert("1.0", content) + + def append_text(self, content: str, tag: Optional[str] = None) -> None: + """ + Добавление текста. + + Args: + content: Текст для добавления + tag: Тег форматирования + """ + self.text.insert(tk.END, content + "\n", tag) + self.text.see(tk.END) + + def clear(self) -> None: + """Очистка текста.""" + self.text.delete("1.0", tk.END) + + def copy(self) -> None: + """Копирование выделенного текста.""" + self.text.event_generate("<>") + + def paste(self) -> None: + """Вставка текста.""" + if not self._readonly: + self.text.event_generate("<>") + + def cut(self) -> None: + """Вырезание выделенного текста.""" + if not self._readonly: + self.text.event_generate("<>") + + def select_all(self) -> None: + """Выделение всего текста.""" + self.text.tag_add(tk.SEL, "1.0", tk.END) + self.text.mark_set(tk.INSERT, "1.0") + self.text.see(tk.INSERT) + + def highlight_pattern(self, pattern: str, tag: str) -> None: + """ + Подсветка текста по шаблону. + + Args: + pattern: Регулярное выражение + tag: Тег форматирования + """ + self.text.tag_remove(tag, "1.0", tk.END) + + start = "1.0" + while True: + start = self.text.search(pattern, start, tk.END, regexp=True) + if not start: + break + + end = f"{start}+{len(pattern)}c" + self.text.tag_add(tag, start, end) + start = end + + def set_readonly(self, readonly: bool = True) -> None: + """ + Установка режима только для чтения. + + Args: + readonly: Флаг режима + """ + self._readonly = readonly + state = tk.DISABLED if readonly else tk.NORMAL + self.text.configure(state=state) + + def bind_key(self, key: str, callback: Callable) -> None: + """ + Привязка обработчика к клавише. + + Args: + key: Клавиша или комбинация клавиш + callback: Функция обработчик + """ + self.text.bind(key, callback) + + def get_line(self, line_number: int) -> str: + """ + Получение строки по номеру. + + Args: + line_number: Номер строки (1-based) + + Returns: + str: Текст строки + """ + start = f"{line_number}.0" + end = f"{line_number}.end" + return self.text.get(start, end) + + def get_selection(self) -> str: + """ + Получение выделенного текста. + + Returns: + str: Выделенный текст + """ + try: + return self.text.get(tk.SEL_FIRST, tk.SEL_LAST) + except tk.TclError: + return "" diff --git a/src/ui/widgets/transfer_view.py b/src/ui/widgets/transfer_view.py new file mode 100644 index 0000000..7ab5363 --- /dev/null +++ b/src/ui/widgets/transfer_view.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Dict, Any +import time + +from core.events import event_bus, Event, EventTypes + +class TransferView(ttk.Frame): + """Виджет для отображения прогресса передачи файлов.""" + + def __init__(self, master): + super().__init__(master) + + # Создаем элементы интерфейса + self._create_widgets() + + # Подписываемся на события + self._setup_event_handlers() + + # Состояние передачи + self._transfer_id: Optional[str] = None + self._start_time: float = 0 + self._total_bytes: int = 0 + self._transferred_bytes: int = 0 + + def _create_widgets(self) -> None: + """Создание элементов интерфейса.""" + # Заголовок + self.title_label = ttk.Label( + self, + text="Прогресс передачи", + font=("Arial", 10, "bold") + ) + self.title_label.pack(fill=tk.X, padx=5, pady=5) + + # Имя файла + self.file_frame = ttk.Frame(self) + self.file_frame.pack(fill=tk.X, padx=5) + + ttk.Label(self.file_frame, text="Файл:").pack(side=tk.LEFT) + self.file_label = ttk.Label(self.file_frame, text="-") + self.file_label.pack(side=tk.LEFT, padx=(5, 0)) + + # Прогресс-бар + self.progress = ttk.Progressbar( + self, + orient=tk.HORIZONTAL, + mode="determinate", + length=200 + ) + self.progress.pack(fill=tk.X, padx=5, pady=5) + + # Процент выполнения + self.percent_label = ttk.Label(self, text="0%") + self.percent_label.pack() + + # Скорость передачи + self.speed_frame = ttk.Frame(self) + self.speed_frame.pack(fill=tk.X, padx=5) + + ttk.Label(self.speed_frame, text="Скорость:").pack(side=tk.LEFT) + self.speed_label = ttk.Label(self.speed_frame, text="-") + self.speed_label.pack(side=tk.LEFT, padx=(5, 0)) + + # Оставшееся время + self.time_frame = ttk.Frame(self) + self.time_frame.pack(fill=tk.X, padx=5) + + ttk.Label(self.time_frame, text="Осталось:").pack(side=tk.LEFT) + self.time_label = ttk.Label(self.time_frame, text="-") + self.time_label.pack(side=tk.LEFT, padx=(5, 0)) + + # Статус + self.status_label = ttk.Label( + self, + text="Готов к передаче", + font=("Arial", 9, "italic") + ) + self.status_label.pack(pady=5) + + def _setup_event_handlers(self) -> None: + """Настройка обработчиков событий.""" + event_bus.subscribe(EventTypes.TRANSFER_STARTED, self._handle_transfer_started) + event_bus.subscribe(EventTypes.TRANSFER_PROGRESS, self._handle_transfer_progress) + event_bus.subscribe(EventTypes.TRANSFER_COMPLETED, self._handle_transfer_completed) + event_bus.subscribe(EventTypes.TRANSFER_ERROR, self._handle_transfer_error) + + def _handle_transfer_started(self, event: Event) -> None: + """ + Обработка начала передачи. + + Args: + event: Событие начала передачи + """ + data = event.data + self._transfer_id = data.get("transfer_id") + self._total_bytes = data.get("total_bytes", 0) + self._transferred_bytes = 0 + self._start_time = time.time() + + # Обновляем интерфейс + self.file_label.configure(text=data.get("file", "-")) + self.progress.configure(value=0) + self.percent_label.configure(text="0%") + self.speed_label.configure(text="-") + self.time_label.configure(text="-") + self.status_label.configure(text="Передача начата...") + + def _handle_transfer_progress(self, event: Event) -> None: + """ + Обработка прогресса передачи. + + Args: + event: Событие прогресса + """ + data = event.data + if data.get("transfer_id") != self._transfer_id: + return + + # Обновляем прогресс + progress = data.get("progress", 0) + self.progress.configure(value=progress) + self.percent_label.configure(text=f"{progress:.1f}%") + + # Обновляем скорость + speed = data.get("speed", 0) + self.speed_label.configure(text=self._format_speed(speed)) + + # Обновляем оставшееся время + time_left = data.get("estimated_time", 0) + self.time_label.configure(text=self._format_time(time_left)) + + # Обновляем статус + self.status_label.configure(text="Передача данных...") + + def _handle_transfer_completed(self, event: Event) -> None: + """ + Обработка завершения передачи. + + Args: + event: Событие завершения + """ + data = event.data + if data.get("transfer_id") != self._transfer_id: + return + + # Обновляем прогресс + self.progress.configure(value=100) + self.percent_label.configure(text="100%") + + # Обновляем статус + total_time = data.get("total_time", 0) + average_speed = data.get("average_speed", 0) + + status = ( + f"Передача завершена за {self._format_time(total_time)}\n" + f"Средняя скорость: {self._format_speed(average_speed)}" + ) + self.status_label.configure(text=status) + + # Сбрасываем состояние + self._transfer_id = None + + def _handle_transfer_error(self, event: Event) -> None: + """ + Обработка ошибки передачи. + + Args: + event: Событие ошибки + """ + data = event.data + if data.get("transfer_id") != self._transfer_id: + return + + # Обновляем статус + error = data.get("error", "Неизвестная ошибка") + self.status_label.configure(text=f"Ошибка: {error}") + + # Сбрасываем состояние + self._transfer_id = None + + def _format_speed(self, speed: float) -> str: + """ + Форматирование скорости передачи. + + Args: + speed: Скорость в байтах в секунду + + Returns: + str: Отформатированная строка + """ + if speed < 1024: + return f"{speed:.1f} Б/с" + elif speed < 1024 * 1024: + return f"{speed/1024:.1f} КБ/с" + else: + return f"{speed/1024/1024:.1f} МБ/с" + + def _format_time(self, seconds: float) -> str: + """ + Форматирование времени. + + Args: + seconds: Время в секундах + + Returns: + str: Отформатированная строка + """ + if seconds < 60: + return f"{seconds:.1f} сек" + elif seconds < 3600: + minutes = seconds / 60 + return f"{minutes:.1f} мин" + else: + hours = seconds / 3600 + return f"{hours:.1f} ч" + + def reset(self) -> None: + """Сброс состояния виджета.""" + self._transfer_id = None + self._start_time = 0 + self._total_bytes = 0 + self._transferred_bytes = 0 + + self.file_label.configure(text="-") + self.progress.configure(value=0) + self.percent_label.configure(text="0%") + self.speed_label.configure(text="-") + self.time_label.configure(text="-") + self.status_label.configure(text="Готов к передаче") diff --git a/src/utils/decorators/__init__.py b/src/utils/decorators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/decorators/logging.py b/src/utils/decorators/logging.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/decorators/performance.py b/src/utils/decorators/performance.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/formatters.py b/src/utils/formatters.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/updater/__init__.py b/src/utils/updater/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/updater/update_manager.py b/src/utils/updater/update_manager.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/updater/version_checker.py b/src/utils/updater/version_checker.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/validators.py b/src/utils/validators.py new file mode 100644 index 0000000..e69de29