4 Commits

Author SHA1 Message Date
d442b790b7 Add sys.path modification to support module imports
- Append src directory to Python module search path
- Ensure proper module resolution for local imports
- Improve project structure import handling
2025-02-17 17:55:32 +03:00
56a8d80de8 Update README with version information and development status
- Add note about version 1.1 in README
2025-02-17 00:06:06 +03:00
57d173e00e Remove standalone modules after application refactoring
- Delete about_window.py, ComConfigCopy.py, TFTPServer.py, and update_checker.py
- These modules have been integrated into the main application structure
- Cleanup of redundant files following previous refactoring efforts
2025-02-16 23:54:59 +03:00
cb5329ddb7 The application has been refactored. Functions have been moved to separate libraries. Many different functions have been added. Performance is still poor 2025-02-16 21:57:01 +03:00
51 changed files with 5324 additions and 1901 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
# ComConfigCopy # ComConfigCopy
Исходный код для версии 1.1. Работоспособность не гарантирована, работа ведётся.
Программа для копирования конфигураций на коммутаторы. Программа для копирования конфигураций на коммутаторы.
## Описание ## Описание

View File

@@ -1,349 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
TFTP сервер для передачи прошивки с компьютера на коммутатор.
- Создает сервер по заданному IP и порту.
- Расшаривает папку Firmware.
- Показывает текущее состояние сервера и статус передачи файла:
- кому (IP устройства),
- сколько осталось байт,
- сколько передано байт,
- время передачи.
"""
import os
import socket
import struct
import threading
import time
class TFTPServer:
def __init__(self, share_folder):
"""
Инициализация TFTP сервера.
:param share_folder: Путь к папке, содержащей файлы (например, папка 'Firmware')
"""
self.share_folder = share_folder
self.log_callback = None
self.running = False
self.server_socket = None
self.lock = threading.Lock()
self.transfer_sockets = set() # Множество для хранения всех активных сокетов передачи
# Словарь активных передач для мониторинга их статуса.
# Ключ адрес клиента, значение словарь с информацией о передаче.
self.active_transfers = {}
def set_log_callback(self, callback):
"""
Установка функции обратного вызова для логирования сообщений.
:param callback: Функция, принимающая строку сообщения.
"""
self.log_callback = callback
def log(self, message):
"""
Функция логирования: вызывает callback (если задан) или выводит сообщение в консоль.
:param message: Строка с сообщением для логирования.
"""
if self.log_callback:
self.log_callback(message)
else:
print(message)
def start_server(self, ip, port):
"""
Запуск TFTP сервера на указанном IP и порту.
:param ip: IP-адрес для привязки сервера.
:param port: Порт для TFTP сервера.
"""
if self.running:
self.log("[WARN] Сервер уже запущен")
return
self.running = True
try:
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.server_socket.bind((ip, port))
self.log(f"[INFO] TFTP сервер запущен на {ip}:{port}")
while self.running:
try:
self.server_socket.settimeout(1.0)
data, client_addr = self.server_socket.recvfrom(2048)
if data and self.running:
threading.Thread(target=self.handle_request, args=(data, client_addr), daemon=True).start()
except socket.timeout:
continue
except socket.error as e:
if self.running: # Логируем ошибку только если сервер еще запущен
self.log(f"[ERROR] Ошибка получения данных: {str(e)}")
break
except Exception as e:
if self.running: # Логируем ошибку только если сервер еще запущен
self.log(f"[ERROR] Ошибка получения данных: {str(e)}")
break
except Exception as e:
self.log(f"[ERROR] Ошибка запуска сервера: {str(e)}")
finally:
self.running = False
if self.server_socket:
try:
self.server_socket.close()
except:
pass
self.server_socket = None
def stop_server(self):
"""
Остановка TFTP сервера.
"""
if not self.running:
return
self.log("[INFO] Остановка TFTP сервера...")
self.running = False
try:
# Закрываем основной сокет сервера первым
if self.server_socket:
try:
# Создаем временный сокет и отправляем пакет самому себе,
# чтобы разблокировать recvfrom
temp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
server_address = self.server_socket.getsockname()
temp_socket.sendto(b'', server_address)
except:
pass
finally:
try:
temp_socket.close()
except:
pass
try:
self.server_socket.close()
except Exception as e:
self.log(f"[WARN] Ошибка при закрытии основного сокета: {str(e)}")
finally:
self.server_socket = None
# Закрываем все активные сокеты передачи
with self.lock:
active_sockets = list(self.transfer_sockets)
self.transfer_sockets.clear()
active_transfers = dict(self.active_transfers)
self.active_transfers.clear()
# Закрываем сокеты передачи после очистки множества
for sock in active_sockets:
try:
if sock:
sock.close()
except Exception as e:
self.log(f"[WARN] Ошибка при закрытии сокета передачи: {str(e)}")
# Отправляем сообщения об остановке для активных передач
for client_addr, transfer_info in active_transfers.items():
try:
self.send_error(client_addr, 0, "Сервер остановлен")
except:
pass
except Exception as e:
self.log(f"[ERROR] Ошибка при остановке сервера: {str(e)}")
finally:
self.running = False # Гарантируем, что флаг running будет False
self.log("[INFO] TFTP сервер остановлен")
def handle_request(self, data, client_addr):
"""
Обработка входящего запроса от клиента.
:param data: Полученные данные (UDP-пакет).
:param client_addr: Адрес клиента, отправившего пакет.
"""
if len(data) < 2:
self.log(f"[WARN] Получен некорректный пакет от {client_addr}")
return
opcode = struct.unpack("!H", data[:2])[0]
if opcode == 1: # RRQ (Read Request) запрос на чтение файла
self.handle_rrq(data, client_addr)
else:
self.log(f"[WARN] Неподдерживаемый запрос (опкод {opcode}) от {client_addr}")
def handle_rrq(self, data, client_addr):
"""
Обработка запроса на чтение файла (RRQ).
:param data: Данные запроса.
:param client_addr: Адрес клиента.
"""
try:
# RRQ формата: 2 байта опкода, затем строка имени файла, за которой следует 0,
# затем строка режима (например, "octet"), и завершается 0.
parts = data[2:].split(b'\0')
if len(parts) < 2:
self.log(f"[WARN] Некорректный RRQ пакет от {client_addr}")
return
filename = parts[0].decode('utf-8')
mode = parts[1].decode('utf-8').lower()
self.log(f"[INFO] Получен RRQ от {client_addr}: файл '{filename}', режим '{mode}'")
if mode != "octet":
self.send_error(client_addr, 0, "Поддерживается только octet режим")
return
file_path = os.path.join(self.share_folder, filename)
if not os.path.isfile(file_path):
self.send_error(client_addr, 1, "Файл не найден")
return
# Запускаем передачу файла в новом потоке.
threading.Thread(target=self.send_file, args=(file_path, client_addr), daemon=True).start()
except Exception as e:
self.log(f"[ERROR] Ошибка обработки RRQ: {str(e)}")
def send_error(self, client_addr, error_code, error_message):
"""
Отправка сообщения об ошибке клиенту.
:param client_addr: Адрес клиента.
:param error_code: Код ошибки.
:param error_message: Текст ошибки.
"""
# Формируем TFTP пакет ошибки: 2 байта опкода (5), 2 байта кода ошибки, сообщение об ошибке и завершающий 0.
packet = struct.pack("!HH", 5, error_code) + error_message.encode('utf-8') + b'\0'
self.server_socket.sendto(packet, client_addr)
self.log(f"[INFO] Отправлено сообщение об ошибке '{error_message}' клиенту {client_addr}")
def send_file(self, file_path, client_addr):
"""
Передача файла клиенту по протоколу TFTP.
"""
BLOCK_SIZE = 512
MAX_RETRIES = 5
TIMEOUT = 2.0
transfer_socket = None
try:
if not os.path.exists(file_path):
self.log(f"[ERROR] Файл '{file_path}' не существует")
self.send_error(client_addr, 1, "Файл не найден")
return
filesize = os.path.getsize(file_path)
if filesize == 0:
self.log(f"[ERROR] Файл '{file_path}' пуст")
self.send_error(client_addr, 0, "Файл пуст")
return
start_time = time.time()
file_basename = os.path.basename(file_path)
# Регистрируем активную передачу
with self.lock:
self.active_transfers[client_addr] = {
'filename': file_basename,
'filesize': filesize,
'bytes_sent': 0,
'start_time': start_time
}
# Создаем новый сокет для передачи данных
transfer_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
transfer_socket.settimeout(TIMEOUT)
with self.lock:
self.transfer_sockets.add(transfer_socket)
self.log(f"[INFO] Начало передачи файла '{file_basename}' клиенту {client_addr}. Размер файла: {filesize} байт.")
with open(file_path, 'rb') as file:
block_number = 1
last_successful_block = 0
while True:
# Читаем блок данных
data = file.read(BLOCK_SIZE)
# Формируем и отправляем пакет данных
packet = struct.pack('!HH', 3, block_number) + data
retries = 0
while retries < MAX_RETRIES:
try:
transfer_socket.sendto(packet, client_addr)
# Ожидаем подтверждение
while True:
try:
ack_data, ack_addr = transfer_socket.recvfrom(4)
if ack_addr == client_addr and len(ack_data) >= 4:
opcode, ack_block = struct.unpack('!HH', ack_data)
if opcode == 4: # ACK
if ack_block == block_number:
# Успешное подтверждение
last_successful_block = block_number
bytes_sent = min((block_number * BLOCK_SIZE), filesize)
# Обновляем информацию о прогрессе
with self.lock:
if client_addr in self.active_transfers:
self.active_transfers[client_addr]['bytes_sent'] = bytes_sent
# Логируем статус каждую секунду
current_time = time.time()
if current_time - start_time >= 1.0:
bytes_remaining = filesize - bytes_sent
elapsed_time = current_time - start_time
self.log(f"[STATUS] Клиент: {client_addr} | Файл: {file_basename} | "
f"Отправлено: {bytes_sent}/{filesize} байт | "
f"Осталось: {bytes_remaining} байт | "
f"Время: {elapsed_time:.2f} сек.")
break
elif ack_block < block_number:
# Получен старый ACK, игнорируем
continue
except socket.timeout:
break
if last_successful_block == block_number:
break
else:
retries += 1
self.log(f"[WARN] Таймаут ожидания ACK для блока {block_number} от {client_addr}. "
f"Попытка {retries + 1}.")
except Exception as e:
retries += 1
self.log(f"[ERROR] Ошибка при передаче блока {block_number}: {str(e)}")
if retries >= MAX_RETRIES:
self.log(f"[ERROR] Превышено максимальное количество попыток для блока {block_number}")
return
block_number += 1
# Если отправили меньше BLOCK_SIZE байт, это был последний блок
if len(data) < BLOCK_SIZE:
break
self.log(f"[INFO] Передача файла '{file_basename}' клиенту {client_addr} завершена успешно")
except Exception as e:
self.log(f"[ERROR] Ошибка при передаче файла: {str(e)}")
finally:
# Очищаем информацию о передаче
with self.lock:
if client_addr in self.active_transfers:
del self.active_transfers[client_addr]
if transfer_socket in self.transfer_sockets:
self.transfer_sockets.remove(transfer_socket)
if transfer_socket:
try:
transfer_socket.close()
except:
pass

View File

@@ -1,103 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import tkinter as tk
from tkinter import ttk, BOTH, X, BOTTOM, END
import webbrowser
class AboutWindow(tk.Toplevel):
def __init__(self, parent):
super().__init__(parent)
self.title("О программе")
self.geometry("600x500")
self.resizable(False, False)
# Сохраняем ссылку на родительское окно
self.parent = parent
# Создаем фрейм для содержимого
about_frame = ttk.Frame(self, padding="20")
about_frame.pack(fill=BOTH, expand=True, padx=10, pady=10)
# Заголовок
ttk.Label(
about_frame,
text="ComConfigCopy",
font=("Segoe UI", 16, "bold")
).pack(pady=(0, 10))
# Описание
ttk.Label(
about_frame,
text="Программа для копирования конфигураций на коммутаторы",
wraplength=350
).pack(pady=(0, 20))
# Версия
ttk.Label(
about_frame,
text=f"Версия {getattr(parent, 'VERSION', '1.0.0')}",
font=("Segoe UI", 10)
).pack(pady=(0, 20))
# Контактная информация
contact_frame = ttk.Frame(about_frame)
contact_frame.pack(fill=X, pady=(0, 20))
# Исходный код
ttk.Label(
contact_frame,
text="Исходный код:",
font=("Segoe UI", 10, "bold")
).pack(anchor="w")
source_link = ttk.Label(
contact_frame,
text="https://gitea.filow.ru/LowaSC/ComConfigCopy",
cursor="hand2",
foreground="blue"
)
source_link.pack(anchor="w")
source_link.bind("<Button-1>", lambda e: self.open_url("https://gitea.filow.ru/LowaSC/ComConfigCopy"))
# Контакты
ttk.Label(
contact_frame,
text="\nКонтакты:",
font=("Segoe UI", 10, "bold")
).pack(anchor="w")
ttk.Label(
contact_frame,
text="Email: LowaWorkMail@gmail.com"
).pack(anchor="w")
telegram_link = ttk.Label(
contact_frame,
text="Telegram: @LowaSC",
cursor="hand2",
foreground="blue"
)
telegram_link.pack(anchor="w")
telegram_link.bind("<Button-1>", lambda e: self.open_url("https://t.me/LowaSC"))
# Кнопка закрытия
ttk.Button(
self,
text="Закрыть",
command=self.destroy
).pack(side=BOTTOM, pady=10)
# Центрируем окно
self.center_window()
def center_window(self):
self.update_idletasks()
width = self.winfo_width()
height = self.winfo_height()
x = (self.winfo_screenwidth() // 2) - (width // 2)
y = (self.winfo_screenheight() // 2) - (height // 2)
self.geometry(f"{width}x{height}+{x}+{y}")
def open_url(self, url):
webbrowser.open(url)

39
src/README.md Normal file
View File

@@ -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
```

View File

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

0
src/core/__init__.py Normal file
View File

298
src/core/app.py Normal file
View File

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

102
src/core/config.py Normal file
View File

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

90
src/core/events.py Normal file
View File

@@ -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"

38
src/core/exceptions.py Normal file
View File

@@ -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

412
src/core/state.py Normal file
View File

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

View File

View File

@@ -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}")

70
src/filesystem/logger.py Normal file
View File

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

View File

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

View File

@@ -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}

View File

View File

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

View File

@@ -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

View File

View File

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

View File

@@ -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)} активных передач")

View File

View File

View File

4
src/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
pyserial>=3.5
tftpy>=0.8.0
requests>=2.31.0
watchdog>=3.0.0

0
src/ui/__init__.py Normal file
View File

View File

View File

@@ -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(
"<Button-1>",
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(
"<Button-1>",
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(
"<Button-1>",
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}")

View File

@@ -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

741
src/ui/main_window.py Normal file
View File

@@ -0,0 +1,741 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
import os
# Добавляем директорию src в путь поиска модулей
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
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("<Control-s>", lambda e: self._save_config_changes())
# Привязка отслеживания изменений в редакторе
self._editor.bind("<<Modified>>", 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("<<ListboxSelect>>", 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()

View File

View File

@@ -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("<FocusIn>", self._on_focus_in)
self.entry.bind("<FocusOut>", self._on_focus_out)
self.entry.bind("<KeyRelease>", self._on_key_release)
# Добавляем контекстное меню
self._create_context_menu()
self.entry.bind("<Button-3>", 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("<<Copy>>")
def paste(self) -> None:
"""Вставка текста."""
self.entry.event_generate("<<Paste>>")
def cut(self) -> None:
"""Вырезание текста."""
self.entry.event_generate("<<Cut>>")
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)

View File

@@ -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("<Button-3>", 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("<<Copy>>")
def paste(self) -> None:
"""Вставка текста."""
if not self._readonly:
self.text.event_generate("<<Paste>>")
def cut(self) -> None:
"""Вырезание выделенного текста."""
if not self._readonly:
self.text.event_generate("<<Cut>>")
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 ""

View File

@@ -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="Готов к передаче")

View File

View File

View File

0
src/utils/formatters.py Normal file
View File

View File

View File

View File

0
src/utils/validators.py Normal file
View File

View File

@@ -1,175 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import logging
import requests
import threading
from packaging import version
class UpdateCheckError(Exception):
"""Исключение для ошибок проверки обновлений"""
pass
class UpdateChecker:
"""Класс для проверки обновлений программы"""
def __init__(self, current_version, repo_url):
self.current_version = current_version
self.repo_url = repo_url
# Формируем базовый URL API
self.api_url = repo_url.replace("gitea.filow.ru", "gitea.filow.ru/api/v1/repos/LowaSC/ComConfigCopy")
self._update_available = False
self._latest_version = None
self._latest_release = None
self._error = None
self._changelog = None
def get_changelog(self, callback=None):
"""
Получение changelog из репозитория.
:param callback: Функция обратного вызова, которая будет вызвана после получения changelog
"""
def fetch():
try:
# Пытаемся получить CHANGELOG.md из репозитория
response = requests.get(f"{self.api_url}/contents/CHANGELOG.md", timeout=10)
response.raise_for_status()
content = response.json()
if "content" in content:
import base64
changelog_content = base64.b64decode(content["content"]).decode("utf-8")
self._changelog = changelog_content
self._error = None
else:
raise UpdateCheckError("Не удалось получить содержимое CHANGELOG.md")
except requests.RequestException as e:
error_msg = f"Ошибка получения changelog: {e}"
logging.error(error_msg, exc_info=True)
self._error = error_msg
self._changelog = None
except Exception as e:
error_msg = f"Неизвестная ошибка при получении changelog: {e}"
logging.error(error_msg, exc_info=True)
self._error = error_msg
self._changelog = None
finally:
if callback:
callback(self._changelog, self._error)
# Запускаем получение в отдельном потоке
threading.Thread(target=fetch, daemon=True).start()
def check_updates(self, callback=None):
"""
Проверка наличия обновлений.
:param callback: Функция обратного вызова, которая будет вызвана после проверки
"""
def check():
try:
response = requests.get(f"{self.api_url}/releases", timeout=10)
response.raise_for_status()
releases = response.json()
if not releases:
raise UpdateCheckError("Не найдено релизов в репозитории")
latest_release = releases[0]
latest_version = latest_release.get("tag_name", "").lstrip("v")
if not latest_version:
raise UpdateCheckError("Не удалось определить версию последнего релиза")
try:
if version.parse(latest_version) > version.parse(self.current_version):
self._update_available = True
self._latest_version = latest_version
self._latest_release = latest_release
logging.info(f"Доступно обновление: {latest_version}")
else:
logging.info("Обновления не требуются")
except version.InvalidVersion as e:
raise UpdateCheckError(f"Некорректный формат версии: {e}")
self._error = None
except requests.RequestException as e:
error_msg = f"Ошибка сетевого подключения: {e}"
logging.error(error_msg, exc_info=True)
self._error = error_msg
except UpdateCheckError as e:
logging.error(str(e), exc_info=True)
self._error = str(e)
except Exception as e:
error_msg = f"Неизвестная ошибка при проверке обновлений: {e}"
logging.error(error_msg, exc_info=True)
self._error = error_msg
finally:
if callback:
callback(self._update_available, self._error)
@property
def update_available(self):
"""Доступно ли обновление"""
return self._update_available
@property
def latest_version(self):
"""Последняя доступная версия"""
return self._latest_version
@property
def error(self):
"""Последняя ошибка при проверке обновлений"""
return self._error
@property
def changelog(self):
"""Текущий changelog"""
return self._changelog
def get_release_notes(self):
"""Получение информации о последнем релизе"""
if self._latest_release:
return {
"version": self._latest_version,
"description": self._latest_release.get("body", ""),
"download_url": self._latest_release.get("assets", [{}])[0].get("browser_download_url", "")
}
return None
def get_releases(self, callback=None):
"""
Получение списка релизов из репозитория.
:param callback: Функция обратного вызова, которая будет вызвана после получения списка релизов
"""
def fetch():
try:
response = requests.get(f"{self.api_url}/releases", timeout=10)
response.raise_for_status()
releases = response.json()
if not releases:
raise UpdateCheckError("Не найдено релизов в репозитории")
self._error = None
if callback:
callback(releases, None)
except requests.RequestException as e:
error_msg = f"Ошибка сетевого подключения: {e}"
logging.error(error_msg, exc_info=True)
self._error = error_msg
if callback:
callback(None, error_msg)
except Exception as e:
error_msg = f"Ошибка при получении списка релизов: {e}"
logging.error(error_msg, exc_info=True)
self._error = error_msg
if callback:
callback(None, error_msg)
# Запускаем получение в отдельном потоке
threading.Thread(target=fetch, daemon=True).start()