20 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
a140b7d8a0 Clean up .gitignore file
- Remove specific config and test files from tracking
- Add Configs/ directory to ignore list
- Remove unnecessary icon and test script entries
2025-02-16 05:36:53 +03:00
2c9edcd859 Remove outdated ComConfigCopy executable binary 2025-02-16 05:35:35 +03:00
5a00efd175 Update executable binary after feature removal 2025-02-16 05:08:44 +03:00
2f4b2985cd Remove documentation and CLI mode features
- Remove "Documentation" menu option
- Delete unused CLI mode code
- Clean up commented-out argument parsing function
2025-02-16 05:00:43 +03:00
d1a870fed7 Add update checking and documentation features
- Implement UpdateChecker for version comparison and update notifications
- Add menu options for documentation and update checking
- Enhance AboutWindow with dynamic version display
- Update requirements.txt with new dependencies
- Create infrastructure for opening local documentation
- Improve application menu with additional help options
2025-02-16 04:50:33 +03:00
2e2dd9e705 Add custom text and entry widgets with enhanced copy/paste functionality
- Implement CustomText and CustomEntry classes with advanced text interaction features
- Add context menu for text widgets with cut, copy, paste, and select all options
- Support multiple keyboard shortcuts for text manipulation
- Replace standard Tkinter Text and Entry widgets with custom implementations
- Remove global text/entry widget bindings in favor of class-specific methods
2025-02-16 03:57:48 +03:00
d937042ea2 Update application title to reflect project name
- Change window title from "Serial Device Manager" to "ComConfigCopy"
2025-02-16 03:53:21 +03:00
136c7877d3 Refactor TFTP file transfer with improved reliability and error handling
- Implement more robust file transfer mechanism with configurable retry and timeout settings
- Add detailed logging for transfer progress and error scenarios
- Enhance block transfer logic with better error recovery
- Simplify transfer socket management and cleanup process
- Improve overall transfer reliability and error tracking
2025-02-16 03:50:27 +03:00
467d582095 Improve file transfer progress tracking and display
- Add dynamic transfer speed calculation
- Compute and display estimated remaining transfer time
- Enhance remaining bytes display with more informative status
- Update transfers table with more detailed transfer progress information
2025-02-16 03:43:34 +03:00
16526b4643 Merge pull request 'TFTP' (#3) from TFTP into main
Reviewed-on: #3
2025-02-16 00:39:43 +00:00
6d2819a860 Add network adapter selection for TFTP server
- Implement `get_network_adapters()` function to dynamically retrieve available network interfaces
- Replace IP address entry with a combobox for network adapter selection
- Add "Update" button to refresh network adapter list
- Improve IP address validation and error handling for TFTP server configuration
- Enhance UI with more user-friendly network interface selection
2025-02-16 03:34:57 +03:00
a252a0f153 Prevent duplicate TFTP server log messages and improve state tracking
- Add filtering mechanism to prevent repeated server start/stop log entries
- Implement state tracking flags to manage server status
- Remove redundant log messages in both ComConfigCopy.py and TFTPServer.py
- Enhance log callback to avoid unnecessary logging of server state changes
2025-02-16 03:31:32 +03:00
3126811f09 Improve TFTP server shutdown and error handling
- Enhance server stop mechanism with more robust socket and thread management
- Add better handling of active transfers during server shutdown
- Implement additional safety checks and timeout handling
- Improve logging and error reporting for server stop process
- Prevent potential deadlocks and resource leaks during server termination
2025-02-16 03:28:53 +03:00
f1ca31c198 Enhance TFTP server implementation with advanced monitoring and UI improvements
- Completely refactor TFTP server implementation with more robust file transfer handling
- Add detailed transfer tracking with active transfers table
- Implement periodic transfer status updates
- Improve log and UI layout for TFTP server tab
- Add more granular error handling and logging
- Enhance threading and socket management for file transfers
2025-02-16 03:19:44 +03:00
c95915483f Update .gitignore to include virtual environment directory
- Add '.venv/' to .gitignore to exclude Python virtual environment files
2025-02-16 03:09:23 +03:00
299ce329f7 Add TFTP server functionality to the application
- Implement TFTP server tab with IP and port configuration
- Create methods to start and stop TFTP server
- Add logging functionality for TFTP server events
- Integrate TFTPServer class into the main application
- Re-enable Firmware directory creation
2025-02-16 02:51:47 +03:00
53 changed files with 5373 additions and 1026 deletions

5
.gitignore vendored
View File

@@ -1,9 +1,6 @@
app.log app.log
Settings/settings.json Settings/settings.json
Configs/Eltex MES2424 AC - Сеть FTTB 2G, доп.txt Configs/
Configs/конфиг доп 3750-52 с айпи 172.17.141.133 .txt
DALL·E 2024-12-29 01.01.02 - Square vector logo_ A clean and minimalistic app icon for serial port management software. The design prominently features a simplified rectangular CO.ico
test.py
__pycache__/ __pycache__/
Firmware/1.jpg Firmware/1.jpg
Firmware/2 Firmware/2

View File

@@ -1,922 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ------------------------------------------------------------
# Это программа для копирования конфигураций на коммутаторы
# ------------------------------------------------------------
# import argparse Использовался для получения аргументов из командной строки (не используется)
# import platform Использовался для получения списка сетевых адаптеров (не используется)
# import subprocess Использовался для получения списка сетевых адаптеров (не используется)
# import socket не используется
import json
import logging
import os
import re
import sys
import threading
import time
from getpass import getpass
from logging.handlers import RotatingFileHandler
import tkinter as tk
from tkinter import (
StringVar,
END,
BOTH,
LEFT,
X,
W,
filedialog,
messagebox,
simpledialog,
)
from tkinter import ttk
import serial
import serial.tools.list_ports
from serial.serialutil import SerialException
from about_window import AboutWindow
# from TFTPServer import TFTPServerThread
# Создаем необходимые папки
os.makedirs("Logs", exist_ok=True)
os.makedirs("Configs", exist_ok=True)
os.makedirs("Settings", exist_ok=True)
# os.makedirs("Firmware", exist_ok=True)
# Файл настроек находится в папке Settings
SETTINGS_FILE = os.path.join("Settings", "settings.json")
# ==========================
# Функции работы с настройками и логированием
# ==========================
def setup_logging():
"""Настройка логирования с использованием RotatingFileHandler."""
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
log_path = os.path.join("Logs", "app.log")
handler = RotatingFileHandler(
log_path, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
def settings_load():
"""Загрузка настроек из JSON-файла или создание настроек по умолчанию."""
default_settings = {
"port": None, # Порт для подключения
"baudrate": 9600, # Скорость передачи данных
"config_file": None, # Файл конфигурации
"login": None, # Логин для подключения
"password": None, # Пароль для подключения
"timeout": 10, # Таймаут подключения
"copy_mode": "line", # Режим копирования
"block_size": 15, # Размер блока команд
"prompt": ">", # Используется для определения приглашения
}
# Создаем папку Settings, если её нет
os.makedirs("Settings", exist_ok=True)
if not os.path.exists(SETTINGS_FILE):
try:
# При первом запуске создаем новый файл настроек
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(default_settings, f, indent=4, ensure_ascii=False)
logging.info("Файл настроек создан с настройками по умолчанию.")
return default_settings
except Exception as e:
logging.error(f"Ошибка при создании файла настроек: {e}", exc_info=True)
return default_settings
try:
# Пытаемся загрузить существующие настройки
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
settings = json.load(f)
# Проверяем наличие всех необходимых параметров
settings_changed = False
for key, value in default_settings.items():
if key not in settings:
settings[key] = value
settings_changed = True
# Если были добавлены новые параметры, сохраняем обновленные настройки
if settings_changed:
try:
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(settings, f, indent=4, ensure_ascii=False)
logging.info("Файл настроек обновлен с новыми параметрами.")
except Exception as e:
logging.error(f"Ошибка при обновлении файла настроек: {e}", exc_info=True)
return settings
except Exception as e:
logging.error(f"Ошибка загрузки файла настроек: {e}", exc_info=True)
return default_settings
def settings_save(settings):
"""Сохранение настроек в JSON-файл."""
try:
with open(SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(settings, f, indent=4, ensure_ascii=False)
logging.info("Настройки сохранены в файл.")
except Exception as e:
logging.error(f"Ошибка при сохранении настроек: {e}", exc_info=True)
def list_serial_ports():
"""Получение списка доступных последовательных портов."""
ports = serial.tools.list_ports.comports()
logging.debug(f"Найдено {len(ports)} серийных портов.")
return [port.device for port in ports]
# ==========================
# Функции работы с сетевыми адаптерами (не используются)
# ==========================
# def list_network_adapters():
# """Возвращает список названий сетевых адаптеров (Windows)."""
# adapters = []
# if platform.system() == "Windows":
# try:
# output = subprocess.check_output(
# 'wmic nic get NetConnectionID',
# shell=True,
# encoding="cp866"
# )
# for line in output.splitlines():
# line = line.strip()
# if line and line != "NetConnectionID":
# adapters.append(line)
# except Exception as e:
# logging.error(f"Ошибка при получении списка адаптеров: {e}", exc_info=True)
# else:
# adapters = ["eth0"]
# return adapters
# ==========================
# Функции работы с COM-соединением
# ==========================
def create_connection(settings):
"""Создание соединения с устройством через последовательный порт."""
try:
conn = serial.Serial(settings["port"], settings["baudrate"], timeout=1)
logging.info(f"Соединение установлено с {settings['port']} на {settings['baudrate']}.")
time.sleep(1)
return conn
except SerialException as e:
logging.error(f"Ошибка подключения: {e}", exc_info=True)
except Exception as e:
logging.error(f"Неизвестная ошибка подключения: {e}", exc_info=True)
return None
# Проверка наличия логина и пароля в настройках и отправка их на устройство
def send_login_password(serial_connection, login=None, password=None, is_gui=False):
"""Отправка логина и пароля на устройство."""
if not login:
if is_gui:
login = simpledialog.askstring("Login", "Введите логин:")
if login is None:
login = ""
else:
login = input("Введите логин: ")
if not password:
if is_gui:
password = simpledialog.askstring("Password", "Введите пароль:", show="*")
if password is None:
password = ""
else:
password = getpass("Введите пароль: ")
# Чтение ответа от устройства с учётом таймаута.
def read_response(serial_connection, timeout, login=None, password=None, is_gui=False):
"""
Чтение ответа от устройства с учётом таймаута.
Если в последней строке появляется запрос логина или пароля, данные отправляются автоматически.
"""
response = b""
end_time = time.time() + timeout
decoded = ""
while time.time() < end_time:
if serial_connection.in_waiting:
chunk = serial_connection.read(serial_connection.in_waiting)
response += chunk
if b"--More--" in response:
serial_connection.write(b"\n")
response = response.replace(b"--More--", b"")
try:
decoded = response.decode(errors="ignore")
except Exception:
decoded = ""
lines = decoded.rstrip().splitlines()
if lines:
last_line = lines[-1].strip()
if re.search(r'(login:|username:)$', last_line, re.IGNORECASE):
send_login_password(serial_connection, login, None, is_gui)
response = b""
continue
if re.search(r'(password:)$', last_line, re.IGNORECASE):
send_login_password(serial_connection, None, password, is_gui)
response = b""
continue
if last_line.endswith(">") or last_line.endswith("#"):
break
else:
time.sleep(0.1)
return decoded
def generate_command_blocks(lines, block_size):
"""Генерация блоков команд для блочного копирования."""
blocks = []
current_block = []
for line in lines:
trimmed = line.strip()
if not trimmed:
continue
lower_line = trimmed.lower()
if lower_line.startswith("vlan") or lower_line.startswith("enable") or lower_line.startswith("interface"):
if current_block:
blocks.append("\n".join(current_block))
current_block = []
blocks.append(trimmed)
elif lower_line.startswith("exit"):
current_block.append(trimmed)
blocks.append("\n".join(current_block))
current_block = []
else:
current_block.append(trimmed)
if len(current_block) >= block_size:
blocks.append("\n".join(current_block))
current_block = []
if current_block:
blocks.append("\n".join(current_block))
return blocks
def execute_commands_from_file(
serial_connection,
filename,
timeout,
copy_mode,
block_size,
log_callback=None,
login=None,
password=None,
is_gui=False,
):
"""
Выполнение команд из файла конфигурации.
Если передан log_callback, вывод будет отображаться в GUI.
Теперь при обнаружении ошибки (например, если в ответе присутствует символ '^')
команда будет отправляться повторно.
"""
try:
with open(filename, "r", encoding="utf-8") as file:
lines = [line for line in file if line.strip()]
msg = f"Выполнение команд из файла: {filename}\n"
logging.info(msg)
if log_callback:
log_callback(msg)
# Если выбран построчный режим
if copy_mode == "line":
for cmd in lines:
cmd = cmd.strip()
max_attempts = 3
attempt = 0
while attempt < max_attempts:
msg = f"\nОтправка команды: {cmd} (Попытка {attempt+1} из {max_attempts})\n"
if log_callback:
log_callback(msg)
serial_connection.write((cmd + "\n").encode())
logging.info(f"Отправлена команда: {cmd} (Попытка {attempt+1})")
response = read_response(serial_connection, timeout, login=login, password=password, is_gui=is_gui)
if response:
if '^' in response:
msg = (
f"[ERROR] Обнаружена ошибка при выполнении команды: {cmd}\n"
f"Ответ устройства:\n{response}\n"
f"Повторная отправка команды...\n"
)
if log_callback:
log_callback(msg)
logging.warning(f"Ошибка в команде: {cmd}. Повторная отправка.")
attempt += 1
time.sleep(1)
continue
else:
msg = f"Ответ устройства:\n{response}\n"
if log_callback:
log_callback(msg)
logging.info(f"Ответ устройства:\n{response}")
break
else:
msg = f"Ответ не получен для команды: {cmd}\n"
if log_callback:
log_callback(msg)
logging.warning(f"Нет ответа для команды: {cmd}")
break
if attempt == max_attempts:
msg = f"[ERROR] Команда не выполнена корректно после {max_attempts} попыток: {cmd}\n"
if log_callback:
log_callback(msg)
logging.error(msg)
time.sleep(1)
# Если выбран блочный режим
elif copy_mode == "block":
blocks = generate_command_blocks(lines, block_size)
for block in blocks:
msg = f"\nОтправка блока команд:\n{block}\n"
if log_callback:
log_callback(msg)
serial_connection.write((block + "\n").encode())
logging.info(f"Отправлен блок команд:\n{block}")
response = read_response(serial_connection, timeout, login=login, password=password, is_gui=is_gui)
# Если обнаружена ошибка в ответе на блок, отправляем команды по очереди
if response and '^' in response:
msg = (
f"[WARNING] Обнаружена ошибка при выполнении блока команд.\n"
f"Ответ устройства:\n{response}\n"
f"Пересылаются команды по отдельности...\n"
)
if log_callback:
log_callback(msg)
logging.warning("Ошибка в блочном режиме отправляем команды индивидуально.")
for line in block.splitlines():
cmd = line.strip()
if not cmd:
continue
max_attempts = 3
attempt = 0
while attempt < max_attempts:
sub_msg = f"\nОтправка команды: {cmd} (Попытка {attempt+1} из {max_attempts})\n"
if log_callback:
log_callback(sub_msg)
serial_connection.write((cmd + "\n").encode())
logging.info(f"Отправлена команда: {cmd} (Попытка {attempt+1})")
sub_response = read_response(serial_connection, timeout, login=login, password=password, is_gui=is_gui)
if sub_response:
if '^' in sub_response:
sub_msg = (
f"[ERROR] Обнаружена ошибка при выполнении команды: {cmd}\n"
f"Ответ устройства:\n{sub_response}\n"
f"Повторная отправка команды...\n"
)
if log_callback:
log_callback(sub_msg)
logging.warning(f"Ошибка в команде: {cmd}. Повторная отправка.")
attempt += 1
time.sleep(1)
continue
else:
sub_msg = f"Ответ устройства:\n{sub_response}\n"
if log_callback:
log_callback(sub_msg)
logging.info(f"Ответ устройства:\n{sub_response}")
break
else:
sub_msg = f"Ответ не получен для команды: {cmd}\n"
if log_callback:
log_callback(sub_msg)
logging.warning(f"Нет ответа для команды: {cmd}")
break
if attempt == max_attempts:
sub_msg = f"[ERROR] Команда не выполнена корректно после {max_attempts} попыток: {cmd}\n"
if log_callback:
log_callback(sub_msg)
logging.error(sub_msg)
time.sleep(1)
else:
if response:
msg = f"Ответ устройства:\n{response}\n"
if log_callback:
log_callback(msg)
logging.info(f"Ответ устройства:\n{response}")
else:
msg = f"Ответ не получен для блока:\n{block}\n"
if log_callback:
log_callback(msg)
logging.warning(f"Нет ответа для блока:\n{block}")
time.sleep(1)
except SerialException as e:
msg = f"Ошибка при выполнении команды: {e}\n"
if log_callback:
log_callback(msg)
logging.error(f"Ошибка при выполнении команды: {e}", exc_info=True)
except Exception as e:
msg = f"Ошибка при выполнении команд: {e}\n"
if log_callback:
log_callback(msg)
logging.error(f"Ошибка при выполнении команд из файла: {e}", exc_info=True)
# ==========================
# Графический интерфейс (Tkinter)
# ==========================
class SettingsWindow(tk.Toplevel):
def __init__(self, parent, settings, callback=None):
super().__init__(parent)
self.title("Настройки")
self.geometry("600x400")
self.settings = settings
self.callback = callback
self.resizable(False, False)
# Создаем фрейм для настроек
settings_frame = ttk.Frame(self, padding="10")
settings_frame.pack(fill=BOTH, expand=True)
# COM порт
ttk.Label(settings_frame, text="COM порт:").grid(row=0, column=0, sticky=W, pady=5)
self.port_var = StringVar(value=settings.get("port", ""))
self.port_combo = ttk.Combobox(settings_frame, textvariable=self.port_var)
self.port_combo.grid(row=0, column=1, sticky=W, pady=5)
ttk.Button(settings_frame, text="Обновить", command=self.update_ports).grid(row=0, column=2, padx=5)
# Скорость передачи
ttk.Label(settings_frame, text="Скорость:").grid(row=1, column=0, sticky=W, pady=5)
self.baudrate_var = StringVar(value=str(settings.get("baudrate", 9600)))
baudrate_combo = ttk.Combobox(settings_frame, textvariable=self.baudrate_var,
values=["9600", "19200", "38400", "57600", "115200"])
baudrate_combo.grid(row=1, column=1, sticky=W, pady=5)
# Таймаут
ttk.Label(settings_frame, text="Таймаут (сек):").grid(row=2, column=0, sticky=W, pady=5)
self.timeout_var = StringVar(value=str(settings.get("timeout", 10)))
timeout_entry = ttk.Entry(settings_frame, textvariable=self.timeout_var)
timeout_entry.grid(row=2, column=1, sticky=W, pady=5)
# Режим копирования
ttk.Label(settings_frame, text="Режим копирования:").grid(row=3, column=0, sticky=W, pady=5)
self.copy_mode_var = StringVar(value=settings.get("copy_mode", "line"))
copy_mode_frame = ttk.Frame(settings_frame)
copy_mode_frame.grid(row=3, column=1, sticky=W, pady=5)
ttk.Radiobutton(copy_mode_frame, text="Построчно", value="line",
variable=self.copy_mode_var).pack(side=LEFT)
ttk.Radiobutton(copy_mode_frame, text="Блоками", value="block",
variable=self.copy_mode_var).pack(side=LEFT)
# Размер блока
ttk.Label(settings_frame, text="Размер блока:").grid(row=4, column=0, sticky=W, pady=5)
self.block_size_var = StringVar(value=str(settings.get("block_size", 15)))
block_size_entry = ttk.Entry(settings_frame, textvariable=self.block_size_var)
block_size_entry.grid(row=4, column=1, sticky=W, pady=5)
# Приглашение командной строки
ttk.Label(settings_frame, text="Приглашение:").grid(row=5, column=0, sticky=W, pady=5)
self.prompt_var = StringVar(value=settings.get("prompt", ">"))
prompt_entry = ttk.Entry(settings_frame, textvariable=self.prompt_var)
prompt_entry.grid(row=5, column=1, sticky=W, pady=5)
# Кнопки
button_frame = ttk.Frame(settings_frame)
button_frame.grid(row=6, column=0, columnspan=3, pady=20)
ttk.Button(button_frame, text="Сохранить", command=self.save_settings).pack(side=LEFT, padx=5)
ttk.Button(button_frame, text="Отмена", command=self.destroy).pack(side=LEFT, padx=5)
self.update_ports()
# Центрируем окно
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 update_ports(self):
ports = list_serial_ports()
self.port_combo["values"] = ports
if ports and not self.port_var.get():
self.port_var.set(ports[0])
def save_settings(self):
try:
self.settings.update({
"port": self.port_var.get(),
"baudrate": int(self.baudrate_var.get()),
"timeout": int(self.timeout_var.get()),
"copy_mode": self.copy_mode_var.get(),
"block_size": int(self.block_size_var.get()),
"prompt": self.prompt_var.get()
})
settings_save(self.settings)
if self.callback:
self.callback()
self.destroy()
messagebox.showinfo("Успех", "Настройки успешно сохранены")
except ValueError as e:
messagebox.showerror("Ошибка", "Проверьте правильность введенных значений")
class SerialAppGUI(tk.Tk):
def __init__(self, settings):
super().__init__()
self.title("Serial Device Manager")
self.geometry("900x700")
self.style = ttk.Style(self)
self.style.theme_use("clam")
default_font = ("Segoe UI", 10)
self.option_add("*Font", default_font)
self.settings = settings
self.connection = None
# Глобальные биндинги
self.bind_class("Text", "<Control-c>", lambda event: event.widget.event_generate("<<Copy>>"))
self.bind_class("Text", "<Control-v>", lambda event: event.widget.event_generate("<<Paste>>"))
self.bind_class("Text", "<Control-x>", lambda event: event.widget.event_generate("<<Cut>>"))
self.bind_class("Entry", "<Control-c>", lambda event: event.widget.event_generate("<<Copy>>"))
self.bind_class("Entry", "<Control-v>", lambda event: event.widget.event_generate("<<Paste>>"))
self.bind_class("Entry", "<Control-x>", lambda event: event.widget.event_generate("<<Cut>>"))
self.create_menu()
self.create_tabs()
# Проверка первого запуска
self.check_first_run()
def check_first_run(self):
"""Проверка первого запуска программы"""
show_settings = False
# Проверяем существование файла настроек
if not os.path.exists(SETTINGS_FILE):
show_settings = True
else:
# Проверяем содержимое файла настроек
try:
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
settings = json.load(f)
# Если порт не настроен, считаем это первым запуском
if settings.get("port") is None:
show_settings = True
except Exception:
# Если файл поврежден или не читается, тоже показываем настройки
show_settings = True
if show_settings:
# Создаем папку Settings, если её нет
os.makedirs("Settings", exist_ok=True)
response = messagebox.askyesno(
"Первый запуск",
"Это первый запуск программы. Хотите настроить параметры подключения сейчас?"
)
if response:
self.open_settings()
def create_menu(self):
menubar = tk.Menu(self)
self.config(menu=menubar)
# Меню "Файл"
file_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Файл", menu=file_menu)
file_menu.add_command(label="Настройки", command=self.open_settings)
file_menu.add_separator()
file_menu.add_command(label="Выход", command=self.quit)
# Меню "Справка"
help_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Справка", menu=help_menu)
help_menu.add_command(label="О программе", command=self.open_about)
def open_settings(self):
settings_window = SettingsWindow(self, self.settings, self.on_settings_changed)
settings_window.transient(self)
settings_window.grab_set()
def on_settings_changed(self):
# Обновляем настройки в основном приложении
self.settings = settings_load()
def create_tabs(self):
self.notebook = ttk.Notebook(self)
self.notebook.pack(fill=BOTH, expand=True, padx=5, pady=5)
# Создаем вкладки
interactive_frame = ttk.Frame(self.notebook)
file_exec_frame = ttk.Frame(self.notebook)
config_editor_frame = ttk.Frame(self.notebook)
self.notebook.add(interactive_frame, text="Интерактивный режим")
self.notebook.add(file_exec_frame, text="Выполнение файла")
self.notebook.add(config_editor_frame, text="Редактор конфигурации")
self.create_interactive_tab(interactive_frame)
self.create_file_exec_tab(file_exec_frame)
self.create_config_editor_tab(config_editor_frame)
# -------------- Вкладка "Интерактивный режим" --------------
def create_interactive_tab(self, frame):
control_frame = ttk.Frame(frame)
control_frame.pack(fill=X, pady=5)
ttk.Button(control_frame, text="Подключиться", command=self.connect_device).pack(side=LEFT, padx=5)
ttk.Button(control_frame, text="Отключиться", command=self.disconnect_device).pack(side=LEFT, padx=5)
self.interactive_text = tk.Text(frame, wrap="word", height=20)
self.interactive_text.pack(fill=BOTH, expand=True, padx=5, pady=5)
input_frame = ttk.Frame(frame)
input_frame.pack(fill=X, pady=5)
ttk.Label(input_frame, text="Команда:").pack(side=LEFT, padx=5)
self.command_entry = ttk.Entry(input_frame, width=50)
self.command_entry.pack(side=LEFT, padx=5)
ttk.Button(input_frame, text="Отправить", command=self.send_command).pack(side=LEFT, padx=5)
def connect_device(self):
if self.connection:
messagebox.showinfo("Информация", "Уже подключено.")
return
if not self.settings.get("port"):
messagebox.showerror("Ошибка", "COM-порт не выбран!")
return
self.connection = create_connection(self.settings)
if self.connection:
self.append_interactive_text("[INFO] Подключение установлено.\n")
else:
self.append_interactive_text("[ERROR] Не удалось установить соединение.\n")
def disconnect_device(self):
if self.connection:
try:
self.connection.close()
except Exception:
pass
self.connection = None
self.append_interactive_text("[INFO] Соединение закрыто.\n")
else:
messagebox.showinfo("Информация", "Соединение не установлено.")
def send_command(self):
if not self.connection:
messagebox.showerror("Ошибка", "Сначала установите соединение!")
return
cmd = self.command_entry.get().strip()
if not cmd:
return
self.append_interactive_text(f"[INFO] Отправка команды: {cmd}\n")
threading.Thread(target=self.process_command, args=(cmd,), daemon=True).start()
def process_command(self, cmd):
try:
max_attempts = 3
attempt = 0
while attempt < max_attempts:
self.connection.write((cmd + "\n").encode())
logging.info(f"Отправлена команда: {cmd} (Попытка {attempt+1})")
response = read_response(
self.connection,
self.settings.get("timeout", 10),
login=self.settings.get("login"),
password=self.settings.get("password"),
is_gui=True,
)
if response:
if '^' in response:
self.append_interactive_text(
f"[ERROR] Обнаружена ошибка при выполнении команды: {cmd}\n"
f"Ответ устройства:\n{response}\n"
f"Повторная отправка команды...\n"
)
logging.warning(f"Ошибка в команде: {cmd}. Попытка повторной отправки.")
attempt += 1
time.sleep(1)
continue
else:
self.append_interactive_text(f"[INFO] Ответ устройства:\n{response}\n")
logging.info(f"Получен ответ:\n{response}")
break
else:
self.append_interactive_text("[WARN] Ответ не получен.\n")
logging.warning("Нет ответа от устройства в течение таймаута.")
break
if attempt == max_attempts:
self.append_interactive_text(f"[ERROR] Команда не выполнена корректно после {max_attempts} попыток: {cmd}\n")
logging.error(f"Команда не выполнена корректно после {max_attempts} попыток: {cmd}")
except SerialException as e:
self.append_interactive_text(f"[ERROR] Ошибка при отправке команды: {e}\n")
logging.error(f"Ошибка отправки команды: {e}", exc_info=True)
except Exception as e:
self.append_interactive_text(f"[ERROR] Неизвестная ошибка: {e}\n")
logging.error(f"Неизвестная ошибка: {e}", exc_info=True)
def append_interactive_text(self, text):
self.interactive_text.insert(END, text)
self.interactive_text.see(END)
# -------------- Вкладка "Выполнить команды из файла" --------------
def create_file_exec_tab(self, frame):
file_frame = ttk.Frame(frame)
file_frame.pack(fill=X, pady=5)
ttk.Label(file_frame, text="Файл конфигурации:").pack(side=LEFT, padx=5)
self.file_exec_var = StringVar(value=self.settings.get("config_file") or "")
ttk.Entry(file_frame, textvariable=self.file_exec_var, width=40).pack(side=LEFT, padx=5)
ttk.Button(file_frame, text="Выбрать", command=self.select_config_file_fileexec).pack(side=LEFT, padx=5)
ttk.Button(frame, text="Выполнить команды", command=self.execute_file_commands).pack(pady=5)
self.file_exec_text = tk.Text(frame, wrap="word", height=15)
self.file_exec_text.pack(fill=BOTH, expand=True, padx=5, pady=5)
def select_config_file_fileexec(self):
filename = filedialog.askopenfilename(title="Выберите файл конфигурации", filetypes=[("Text files", "*.txt")])
if filename:
self.file_exec_var.set(filename)
def execute_file_commands(self):
if not self.settings.get("port"):
messagebox.showerror("Ошибка", "COM-порт не выбран!")
return
if not self.file_exec_var.get():
messagebox.showerror("Ошибка", "Файл конфигурации не выбран!")
return
if not self.connection:
self.connection = create_connection(self.settings)
if not self.connection:
self.append_file_exec_text("[ERROR] Не удалось установить соединение.\n")
return
threading.Thread(
target=execute_commands_from_file,
args=(
self.connection,
self.file_exec_var.get(),
self.settings.get("timeout", 10),
self.settings.get("copy_mode", "line"),
self.settings.get("block_size", 15),
self.append_file_exec_text,
self.settings.get("login"),
self.settings.get("password"),
True,
),
daemon=True,
).start()
def append_file_exec_text(self, text):
self.file_exec_text.insert(END, text)
self.file_exec_text.see(END)
# -------------- Вкладка "Редактор конфигурационного файла" --------------
def create_config_editor_tab(self, frame):
top_frame = ttk.Frame(frame)
top_frame.pack(fill=X, pady=5)
ttk.Label(top_frame, text="Файл конфигурации:").pack(side=LEFT, padx=5)
self.editor_file_var = StringVar(value=self.settings.get("config_file") or "")
ttk.Entry(top_frame, textvariable=self.editor_file_var, width=40).pack(side=LEFT, padx=5)
ttk.Button(top_frame, text="Выбрать", command=self.select_config_file_editor).pack(side=LEFT, padx=5)
ttk.Button(top_frame, text="Загрузить", command=self.load_config_file).pack(side=LEFT, padx=5)
ttk.Button(top_frame, text="Сохранить", command=self.save_config_file).pack(side=LEFT, padx=5)
self.config_editor_text = tk.Text(frame, wrap="word")
self.config_editor_text.pack(fill=BOTH, expand=True, padx=5, pady=5)
def select_config_file_editor(self):
filename = filedialog.askopenfilename(title="Выберите файл конфигурации", filetypes=[("Text files", "*.txt")])
if filename:
self.editor_file_var.set(filename)
self.settings["config_file"] = filename
settings_save(self.settings)
def load_config_file(self):
filename = self.editor_file_var.get()
if not filename or not os.path.exists(filename):
messagebox.showerror("Ошибка", "Файл конфигурации не выбран или не существует.")
return
try:
with open(filename, "r", encoding="utf-8") as f:
content = f.read()
self.config_editor_text.delete("1.0", END)
self.config_editor_text.insert(END, content)
messagebox.showinfo("Информация", "Файл загружен.")
except Exception as e:
logging.error(f"Ошибка загрузки файла: {e}", exc_info=True)
messagebox.showerror("Ошибка", f"Не удалось загрузить файл:\n{e}")
def save_config_file(self):
filename = self.editor_file_var.get()
if not filename:
messagebox.showerror("Ошибка", "Файл конфигурации не выбран.")
return
try:
content = self.config_editor_text.get("1.0", END)
with open(filename, "w", encoding="utf-8") as f:
f.write(content)
messagebox.showinfo("Информация", "Файл сохранён.")
except Exception as e:
logging.error(f"Ошибка сохранения файла: {e}", exc_info=True)
messagebox.showerror("Ошибка", f"Не удалось сохранить файл:\n{e}")
def open_about(self):
about_window = AboutWindow(self)
about_window.transient(self)
about_window.grab_set()
# ==========================
# Парсер аргументов (не используется)
# ==========================
# def parse_arguments():
# parser = argparse.ArgumentParser(
# description="Программа для работы с устройствами через последовательный порт с графическим интерфейсом."
# )
# parser.add_argument("--cli", action="store_true", help="Запустить в режиме командной строки (без графики)")
# return parser.parse_args()
# ==========================
# Режим командной строки (не используется)
# ==========================
# def run_cli_mode(settings):
# print("Запущен режим командной строки.")
# while True:
# print("\n--- Главное меню ---")
# print("1. Подключиться и войти в интерактивный режим")
# print("2. Выполнить команды из файла конфигурации")
# print("0. Выход")
# choice = input("Выберите действие: ").strip()
# if choice == "1":
# if not settings.get("port"):
# print("[ERROR] COM-порт не выбран!")
# continue
# conn = create_connection(settings)
# if not conn:
# print("[ERROR] Не удалось установить соединение.")
# continue
# print("[INFO] Введите команды (для выхода введите _exit):")
# while True:
# cmd = input("Команда: ")
# if cmd.strip().lower() == "_exit":
# break
# try:
# conn.write((cmd + "\n").encode())
# response = read_response(
# conn, settings.get("timeout", 10),
# login=settings.get("login"),
# password=settings.get("password"),
# is_gui=False
# )
# print(response)
# except Exception as e:
# print(f"[ERROR] {e}")
# break
# conn.close()
# elif choice == "2":
# if not settings.get("config_file"):
# print("[ERROR] Файл конфигурации не выбран!")
# continue
# if not settings.get("port"):
# print("[ERROR] COM-порт не выбран!")
# continue
# conn = create_connection(settings)
# if conn:
# execute_commands_from_file(
# conn,
# settings["config_file"],
# settings.get("timeout", 10),
# settings.get("copy_mode", "line"),
# settings.get("block_size", 15),
# lambda msg: print(msg),
# login=settings.get("login"),
# password=settings.get("password"),
# is_gui=False,
# )
# conn.close()
# else:
# print("[ERROR] Не удалось установить соединение.")
# elif choice == "0":
# break
# else:
# print("[ERROR] Некорректный выбор.")
# ==========================
# Основной запуск приложения
# ==========================
def main():
setup_logging()
settings = settings_load()
app = SerialAppGUI(settings)
app.mainloop()
# ==========================
# Основной запуск приложения
# ==========================
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
logging.info("Программа прервана пользователем (KeyboardInterrupt).")
sys.exit(0)
except Exception as e:
logging.critical(f"Неизвестная ошибка: {e}", exc_info=True)
sys.exit(1)

BIN
Icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

46
README.md Normal file
View File

@@ -0,0 +1,46 @@
# ComConfigCopy
Исходный код для версии 1.1. Работоспособность не гарантирована, работа ведётся.
Программа для копирования конфигураций на коммутаторы.
## Описание
ComConfigCopy - это утилита, разработанная для автоматизации процесса копирования конфигураций на сетевые коммутаторы. Программа предоставляет удобный графический интерфейс для управления процессом копирования и настройки параметров подключения.
## Основные возможности
- Копирование конфигураций на коммутаторы через COM-порт
- Поддержка различных скоростей подключения
- Автоматическое определение доступных COM-портов
- Возможность сохранения и загрузки настроек
- Автоматическое обновление через GitHub
## Системные требования
- Windows 7/8/10/11
- Python 3.8 или выше
- Доступ к COM-портам
## Установка
1. Скачайте последнюю версию программы из [репозитория](https://gitea.filow.ru/LowaSC/ComConfigCopy/releases)
2. Распакуйте архив в удобное место
3. Запустите файл `ComConfigCopy.exe`
## Использование
1. Выберите COM-порт из списка доступных
2. Настройте параметры подключения (скорость, биты данных и т.д.)
3. Выберите файл конфигурации для отправки
4. Нажмите кнопку "Отправить" для начала процесса копирования
## Контакты
- Email: LowaWorkMail@gmail.com
- Telegram: [@LowaSC](https://t.me/LowaSC)
- Репозиторий: [ComConfigCopy](https://gitea.filow.ru/LowaSC/ComConfigCopy)
## Лицензия
Этот проект распространяется под лицензией MIT. Подробности смотрите в файле [LICENSE](LICENSE).

View File

@@ -1,100 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import tkinter as tk
from tkinter import ttk, BOTH, X, BOTTOM
import webbrowser
class AboutWindow(tk.Toplevel):
def __init__(self, parent):
super().__init__(parent)
self.title("О программе")
self.geometry("400x300")
self.resizable(False, False)
# Создаем фрейм
about_frame = ttk.Frame(self, padding="20")
about_frame.pack(fill=BOTH, expand=True)
# Заголовок
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="Версия 1.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(
about_frame,
text="Закрыть",
command=self.destroy
).pack(side=BOTTOM, pady=(20, 0))
# Центрируем окно
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)

Binary file not shown.

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
tftpy>=0.8.0
pyserial>=3.5
requests>=2.31.0
packaging>=23.2

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