#!/usr/bin/env python3 # -*- coding: utf-8 -*- # ------------------------------------------------------------ # Это программа для копирования конфигураций на коммутаторы # ------------------------------------------------------------ import json import logging import os import re import sys import threading import time import webbrowser from getpass import getpass from logging.handlers import RotatingFileHandler import tkinter as tk from tkinter import ( StringVar, END, BOTH, LEFT, RIGHT, X, W, filedialog, messagebox, simpledialog, Y, ) from tkinter import ttk import serial import serial.tools.list_ports from serial.serialutil import SerialException from about_window import AboutWindow from TFTPServer import TFTPServer import socket from update_checker import UpdateChecker from send2trash import send2trash # Версия программы VERSION = "1.0.2" # Создаем необходимые папки os.makedirs("Logs", exist_ok=True) os.makedirs("Configs", exist_ok=True) os.makedirs("Settings", exist_ok=True) os.makedirs("Firmware", exist_ok=True) os.makedirs("docs", exist_ok=True) # Файл настроек находится в папке Settings SETTINGS_FILE = os.path.join("Settings", "settings.json") # Настройка логирования с использованием RotatingFileHandler. def setup_logging(): 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) # Загрузка настроек из JSON-файла или создание настроек по умолчанию. def settings_load(): 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 # Сохранение настроек в JSON-файл def settings_save(settings): 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] # Получение списка IP-адресов из сетевых адаптеров def get_network_adapters(): adapters = [] try: # Получаем имя хоста hostname = socket.gethostname() # Получаем все адреса для данного хоста addresses = socket.getaddrinfo(hostname, None) # Создаем множество для хранения уникальных IP-адресов unique_ips = set() for addr in addresses: ip = addr[4][0] # Пропускаем IPv6 и локальные адреса if ':' not in ip and not ip.startswith('127.'): unique_ips.add(ip) # Добавляем все найденные IP-адреса в список for ip in sorted(unique_ips): adapters.append(f"{ip}") # Добавляем 0.0.0.0 для прослушивания всех интерфейсов adapters.insert(0, "0.0.0.0") except Exception as e: logging.error(f"Ошибка при получении списка сетевых адаптеров: {e}", exc_info=True) # В случае ошибки возвращаем хотя бы 0.0.0.0 adapters = ["0.0.0.0"] return adapters # Создание соединения с устройством через последовательный порт 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, ): 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"[CMD] {cmd}" # Изменено форматирование для команды 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"[CMD] Отправка блока команд:\n{block}" # Изменено форматирование для блока команд 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"[CMD] {cmd}" # Изменено форматирование для команды 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) # Базовый класс для кастомных виджетов с поддержкой контекстного меню и горячих клавиш class CustomWidgetBase: def __init__(self): self.create_context_menu() self.bind_shortcuts() # Создание контекстного меню def create_context_menu(self): """Создание контекстного меню""" self.context_menu = tk.Menu(self, tearoff=0) self.context_menu.add_command(label="Вырезать", command=self.cut) self.context_menu.add_command(label="Копировать", command=self.copy) self.context_menu.add_command(label="Вставить", command=self.paste) self.context_menu.add_separator() self.context_menu.add_command(label="Выделить всё", command=self.select_all) self.bind("", self.show_context_menu) # Привязка горячих клавиш def bind_shortcuts(self): """Привязка горячих клавиш""" # Стандартные сочетания self.bind("", lambda e: self.event_generate("<>")) self.bind("", lambda e: self.event_generate("<>")) self.bind("", lambda e: self.event_generate("<>")) self.bind("", self.select_all) # Дополнительные сочетания self.bind("", lambda e: self.event_generate("<>")) self.bind("", lambda e: self.event_generate("<>")) self.bind("", lambda e: self.event_generate("<>")) # Отображение контекстного меню def show_context_menu(self, event): """Отображение контекстного меню""" self.context_menu.post(event.x_root, event.y_root) # Вырезание текста def cut(self): self.event_generate("<>") # Копирование текста def copy(self): self.event_generate("<>") # Вставка текста def paste(self): self.event_generate("<>") # Класс для текстового поля с поддержкой контекстного меню и горячих клавиш class CustomText(tk.Text, CustomWidgetBase): def __init__(self, *args, **kwargs): tk.Text.__init__(self, *args, **kwargs) CustomWidgetBase.__init__(self) def select_all(self, event=None): """Выделение всего текста в Text виджете""" self.tag_add(tk.SEL, "1.0", tk.END) self.mark_set(tk.INSERT, "1.0") self.see(tk.INSERT) return "break" # Класс для поля ввода с поддержкой контекстного меню и горячих клавиш class CustomEntry(ttk.Entry, CustomWidgetBase): def __init__(self, *args, **kwargs): ttk.Entry.__init__(self, *args, **kwargs) CustomWidgetBase.__init__(self) def select_all(self, event=None): """Выделение всего текста в Entry виджете""" self.select_range(0, tk.END) return "break" # Класс для окна настроек 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, command=self.toggle_block_size).pack(side=LEFT) ttk.Radiobutton(copy_mode_frame, text="Блоками", value="block", variable=self.copy_mode_var, command=self.toggle_block_size).pack(side=LEFT) # Размер блока self.block_size_label = ttk.Label(settings_frame, text="Размер блока:") self.block_size_label.grid(row=4, column=0, sticky=W, pady=5) self.block_size_var = StringVar(value=str(settings.get("block_size", 15))) self.block_size_entry = CustomEntry(settings_frame, textvariable=self.block_size_var) self.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 = CustomEntry(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.toggle_block_size() # Центрируем окно self.center_window() # Переключение видимости поля размера блока def toggle_block_size(self): if self.copy_mode_var.get() == "line": self.block_size_label.grid_remove() self.block_size_entry.grid_remove() else: self.block_size_label.grid() self.block_size_entry.grid() # Центрирование окна 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("Ошибка", "Проверьте правильность введенных значений") # Общая функция для добавления текста в текстовое поле def append_text_to_widget(widget, text): # Проверяем, заканчивается ли текст символом новой строки if not text.endswith('\n'): text += '\n' widget.insert(END, text) widget.see(END) # Общая функция для выбора файла конфигурации def select_config_file(self, var, save_to_settings=False): filename = filedialog.askopenfilename( title="Выберите файл конфигурации", filetypes=[("Text files", "*.txt")] ) if filename: var.set(filename) if save_to_settings: self.settings["config_file"] = filename settings_save(self.settings) # Общая функция для отправки команды и обработки ответа def send_command_and_process_response( serial_connection, cmd, timeout, max_attempts=3, log_callback=None, login=None, password=None, is_gui=False ): attempt = 0 while attempt < max_attempts: # Проверяем, что соединение всё ещё активно if not serial_connection or not serial_connection.is_open: if log_callback: log_callback("[INFO] Соединение закрыто.\n") return False, None msg = f"\nОтправка команды: {cmd}\n" if log_callback: log_callback(msg) serial_connection.write((cmd + "\n").encode()) logging.info(f"Отправлена команда: {cmd}") 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}") return True, response else: msg = f"Ответ не получен для команды: {cmd}\n" if log_callback: log_callback(msg) logging.warning(f"Нет ответа для команды: {cmd}") return False, None msg = f"[ERROR] Команда не выполнена корректно: {cmd}\n" if log_callback: log_callback(msg) logging.error(msg) return False, None # Класс для терминального виджета с расширенной функциональностью class TerminalWidget(CustomText): def __init__(self, master, *args, **kwargs): super().__init__(master, *args, **kwargs) # Настройка цветов для разных типов сообщений self.tag_configure("error", foreground="red") self.tag_configure("warning", foreground="orange") self.tag_configure("info", foreground="blue") self.tag_configure("command", foreground="green") self.tag_configure("separator", foreground="gray") # Добавляем скроллбар self.scrollbar = ttk.Scrollbar(self, command=self.yview) self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self.config(yscrollcommand=self.scrollbar.set) # Настройка отступов и переносов self.config( wrap=tk.WORD, padx=5, pady=5, spacing1=2, # Отступ перед абзацем spacing2=2, # Межстрочный интервал spacing3=2, # Отступ после абзаца state='disabled' # Делаем виджет только для чтения ) # Счетчик команд для разделителей self.command_counter = 0 # Отключаем возможность вставки текста self.bind('<>', lambda e: 'break') self.bind('', self._readonly) # Изменяем контекстное меню self.context_menu.delete("Вырезать") self.context_menu.delete("Вставить") def _readonly(self, event): """Обработчик для блокировки ввода""" return 'break' def append_text(self, text, message_type=None): """ Добавление текста с определенным типом сообщения message_type может быть: 'error', 'warning', 'info', 'command' """ # Временно разрешаем изменение для добавления текста self.config(state='normal') # Добавляем текст if not text.endswith('\n'): text += '\n' start_index = self.index(tk.END) self.insert(tk.END, text) end_index = self.index(tk.END) # Применяем тег в зависимости от типа сообщения if message_type: self.tag_add(message_type, start_index, end_index) # Автоматическая прокрутка к концу self.see(tk.END) # Возвращаем состояние "только для чтения" self.config(state='disabled') # Обновляем виджет self.update_idletasks() def append_error(self, text): """Добавление сообщения об ошибке""" self.append_text(text, "error") def append_warning(self, text): """Добавление предупреждения""" self.append_text(text, "warning") def append_info(self, text): """Добавление информационного сообщения""" self.append_text(text, "info") def append_command(self, text): """Добавление команды с разделителем""" # Временно разрешаем изменение для добавления текста self.config(state='normal') # Добавляем разделитель между командами if self.command_counter > 0: self.insert(tk.END, "\n" + "─" * 80 + "\n", "separator") self.command_counter += 1 # Добавляем команду self.append_text(text, "command") # Возвращаем состояние "только для чтения" self.config(state='disabled') def clear(self): """Очистка терминала""" self.config(state='normal') self.delete("1.0", tk.END) self.config(state='disabled') self.command_counter = 0 # Основной класс для графического интерфейса class SerialAppGUI(tk.Tk): def __init__(self, settings): super().__init__() self.title("ComConfigCopy") self.geometry("900x700") # Добавляем VERSION как атрибут класса self.VERSION = VERSION # Добавляем атрибуты для управления выполнением файла self.execution_thread = None self.execution_paused = False self.execution_stop = False # Добавляем атрибуты для таймера и прогресса self.start_time = 0 self.elapsed_time = 0 self.timer_running = False # Добавляем атрибут для отслеживания состояния COM-порта self.port_monitor_thread = None self.port_monitoring = False # Инициализация проверки обновлений self.update_checker = UpdateChecker( VERSION, "https://gitea.filow.ru/LowaSC/ComConfigCopy", include_prereleases=False # Проверяем только стабильные версии ) # Настройка стиля 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.tftp_server = None self.create_menu() self.create_tabs() # Добавляем статус бар self.create_status_bar() # Обновляем информацию в статус баре self.update_status_bar() # Проверка первого запуска self.check_first_run() # Создание меню 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.check_for_updates) help_menu.add_separator() help_menu.add_command(label="О программе", command=self.open_about) # Проверка наличия обновлений def check_for_updates(self): def on_update_check(update_available, error): if error: messagebox.showerror( "Ошибка проверки обновлений", f"Не удалось проверить наличие обновлений:\n{error}" ) elif update_available: release_info = self.update_checker.get_release_notes() if release_info: # Форматируем сообщение message = ( f"Доступна новая версия {release_info['version']}!\n\n" f"Тип релиза: {'Пре-релиз' if release_info['type'] == 'prerelease' else 'Стабильный'}\n\n" "Изменения:\n" f"{release_info['description']}\n\n" "Хотите перейти на страницу загрузки?" ) response = messagebox.askyesno( "Доступно обновление", message, ) if response: webbrowser.open(release_info["link"]) else: messagebox.showerror( "Ошибка", "Не удалось получить информацию о новой версии" ) else: messagebox.showinfo( "Проверка обновлений", "У вас установлена последняя версия программы" ) self.update_checker.check_updates(callback=on_update_check) # Обработчик изменения настроек def on_settings_changed(self): self.settings = settings_load() self.update_status_bar() # Добавляем обновление статус бара # Открытие окна настроек def open_settings(self): settings_window = SettingsWindow(self, self.settings, self.on_settings_changed) settings_window.transient(self) settings_window.grab_set() # Проверка первого запуска программы 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_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) tftp_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.notebook.add(tftp_frame, text="TFTP Сервер") self.create_interactive_tab(interactive_frame) self.create_file_exec_tab(file_exec_frame) self.create_config_editor_tab(config_editor_frame) self.create_tftp_tab(tftp_frame) # Создание вкладки "Интерактивный режим" def create_interactive_tab(self, frame): control_frame = ttk.Frame(frame) control_frame.pack(fill=X, pady=5) # Кнопка подключения с иконкой connect_btn = ttk.Button( control_frame, text="⚡ Подключиться", # Unicode символ для "молнии" command=self.connect_device ) connect_btn.pack(side=LEFT, padx=5) # Кнопка отключения с иконкой disconnect_btn = ttk.Button( control_frame, text="✕ Отключиться", # Unicode символ для "крестика" command=self.disconnect_device ) disconnect_btn.pack(side=LEFT, padx=5) # Кнопка очистки терминала clear_btn = ttk.Button( control_frame, text="🗑 Очистить", # Unicode символ для "корзины" command=lambda: self.interactive_text.clear() ) clear_btn.pack(side=LEFT, padx=5) # Используем новый TerminalWidget вместо CustomText self.interactive_text = TerminalWidget(frame, 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 = CustomEntry(input_frame, width=50) self.command_entry.pack(side=LEFT, padx=5) # Привязываем обработчик нажатия Enter к полю ввода self.command_entry.bind('', lambda event: self.send_command()) # Кнопка отправки с иконкой send_btn = ttk.Button( input_frame, text="➤ Отправить", # Unicode символ для "стрелки" command=self.send_command ) send_btn.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.interactive_text.append_info("[INFO] Подключение установлено.") # Запускаем мониторинг состояния порта self.start_port_monitoring() self.update_status_bar() else: self.interactive_text.append_error("[ERROR] Не удалось установить соединение.") self.update_status_bar() # Отключение от устройства def disconnect_device(self): if self.connection: try: # Останавливаем мониторинг перед закрытием соединения self.stop_port_monitoring() self.connection.close() except Exception: pass finally: self.connection = None self.interactive_text.append_info("[INFO] Соединение закрыто.") self.update_status_bar() else: messagebox.showinfo("Информация", "Соединение не установлено.") def start_port_monitoring(self): """Запуск мониторинга состояния COM-порта""" if not self.port_monitor_thread or not self.port_monitor_thread.is_alive(): self.port_monitoring = True self.port_monitor_thread = threading.Thread(target=self.monitor_port_state, daemon=True) self.port_monitor_thread.start() def stop_port_monitoring(self): """Остановка мониторинга состояния COM-порта""" self.port_monitoring = False if self.port_monitor_thread and self.port_monitor_thread.is_alive(): self.port_monitor_thread.join(timeout=1.0) def monitor_port_state(self): """Мониторинг состояния COM-порта""" while self.port_monitoring and self.connection: try: # Проверяем, что порт всё ещё открыт и отвечает if not self.connection.is_open: self.connection.open() # Если порт отвечает, обновляем статус self.after(0, self.update_connection_indicator, True) except (SerialException, OSError): # Если возникла ошибка, помечаем порт как отключенный self.after(0, self.update_connection_indicator, False) # Пытаемся переподключиться try: if self.connection: self.connection.close() except: pass self.connection = None break time.sleep(1) # Проверяем состояние каждую секунду def update_connection_indicator(self, is_connected): """Обновление индикатора подключения COM-порта""" if is_connected: self.connection_indicator.configure(text="⬤", foreground='green') else: self.connection_indicator.configure(text="⬤", foreground='red') # Отправка команды def send_command(self): if not self.connection: messagebox.showerror("Ошибка", "Сначала установите соединение!") return cmd = self.command_entry.get().strip() if not cmd: return self.interactive_text.append_command(f"[CMD] {cmd}") self.command_entry.delete(0, END) # Очищаем поле ввода threading.Thread(target=self.process_command, args=(cmd,), daemon=True).start() # Обработка команды def process_command(self, cmd): try: success, response = send_command_and_process_response( self.connection, cmd, self.settings.get("timeout", 10), max_attempts=3, log_callback=self.append_interactive_text, login=self.settings.get("login"), password=self.settings.get("password"), is_gui=True ) except SerialException as e: self.interactive_text.append_error(f"[ERROR] Ошибка при отправке команды: {e}") logging.error(f"Ошибка отправки команды: {e}", exc_info=True) except Exception as e: self.interactive_text.append_error(f"[ERROR] Неизвестная ошибка: {e}") logging.error(f"Неизвестная ошибка: {e}", exc_info=True) # Добавление текста в текстовое поле def append_interactive_text(self, text): if "[ERROR]" in text: self.interactive_text.append_error(text) elif "[WARNING]" in text or "[WARN]" in text: self.interactive_text.append_warning(text) elif "[INFO]" in text: self.interactive_text.append_info(text) else: self.interactive_text.append_text(text) # Создание вкладки "Выполнить команды из файла" 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 "") self.file_exec_entry = CustomEntry(file_frame, textvariable=self.file_exec_var, width=40) self.file_exec_entry.pack(side=LEFT, padx=5) ttk.Button(file_frame, text="Выбрать", command=self.select_config_file_fileexec).pack(side=LEFT, padx=5) # Создаем фрейм для кнопок управления control_frame = ttk.Frame(frame) control_frame.pack(pady=5) # Кнопки управления выполнением self.start_button = ttk.Button(control_frame, text="▶ Старт", command=self.start_execution) self.start_button.pack(side=LEFT, padx=5) self.pause_button = ttk.Button(control_frame, text="⏸ Пауза", command=self.pause_execution, state="disabled") self.pause_button.pack(side=LEFT, padx=5) self.stop_button = ttk.Button(control_frame, text="⏹ Остановить", command=self.stop_execution, state="disabled") self.stop_button.pack(side=LEFT, padx=5) # Кнопка очистки терминала self.clear_button = ttk.Button(control_frame, text="🗑 Очистить", command=lambda: self.file_exec_text.clear()) self.clear_button.pack(side=LEFT, padx=5) # Создаем фрейм для индикатора прогресса и таймера progress_frame = ttk.Frame(frame) progress_frame.pack(fill=X, pady=5, padx=5) # Добавляем индикатор прогресса progress_label_frame = ttk.Frame(progress_frame) progress_label_frame.pack(fill=X) self.progress_label = ttk.Label(progress_label_frame, text="Прогресс: 0/0 команд") self.progress_label.pack(side=LEFT, padx=5) self.timer_label = ttk.Label(progress_label_frame, text="Время: 00:00:00") self.timer_label.pack(side=RIGHT, padx=5) self.progress_bar = ttk.Progressbar(progress_frame, mode='determinate') self.progress_bar.pack(fill=X, pady=5) # Используем новый TerminalWidget вместо CustomText self.file_exec_text = TerminalWidget(frame, height=15) self.file_exec_text.pack(fill=BOTH, expand=True, padx=5, pady=5) def append_file_exec_text(self, text): if "[ERROR]" in text: self.file_exec_text.append_error(text) elif "[WARNING]" in text or "[WARN]" in text: self.file_exec_text.append_warning(text) elif "[INFO]" in text: self.file_exec_text.append_info(text) elif "[CMD]" in text: # Добавляем обработку команд self.file_exec_text.append_command(text) else: self.file_exec_text.append_text(text) # Выбор файла конфигурации для выполнения команд def select_config_file_fileexec(self): def on_config_selected(path): self.file_exec_var.set(path) # Создаем и показываем окно выбора конфигурации config_selector = ConfigSelectorWindow(self, on_config_selected) self.wait_window(config_selector) # Выполнение команд из файла 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 self.connection: # Запускаем мониторинг при новом подключении self.start_port_monitoring() if not self.connection: self.append_file_exec_text("[ERROR] Не удалось установить соединение.\n") return try: with open(self.file_exec_var.get(), "r", encoding="utf-8") as file: self.commands = [line.strip() for line in file if line.strip()] self.current_command_index = 0 self.execution_paused = False self.execution_stop = False # Инициализируем прогресс бар self.progress_bar['value'] = 0 self.update_progress() # Запускаем таймер self.start_time = time.time() self.elapsed_time = 0 self.timer_running = True self.update_timer() # Запускаем выполнение команд в отдельном потоке threading.Thread(target=self.command_execution_thread, daemon=True).start() except Exception as e: self.append_file_exec_text(f"[ERROR] Ошибка при чтении файла: {str(e)}\n") self.reset_execution_buttons() def command_execution_thread(self): """Отдельный поток для выполнения команд""" copy_mode = self.settings.get("copy_mode", "line") block_size = self.settings.get("block_size", 15) # Счетчик последовательных ошибок consecutive_errors = 0 MAX_CONSECUTIVE_ERRORS = 3 # Максимальное количество последовательных ошибок def handle_no_response(cmd_or_block, is_block=False): """Обработка отсутствия ответа от устройства""" nonlocal consecutive_errors consecutive_errors += 1 if consecutive_errors >= MAX_CONSECUTIVE_ERRORS: if not self.execution_stop: # Проверяем, не была ли выполнена остановка self.append_file_exec_text( f"[ERROR] Обнаружено {consecutive_errors} последовательных ошибок!\n" "Возможно, устройство не отвечает или проблемы с соединением.\n" ) # Автоматически ставим на паузу self.execution_paused = True self.after(0, lambda: self.pause_button.config(text="▶ Продолжить")) # Показываем сообщение пользователю self.after(0, lambda: messagebox.showerror( "Устройство не отвечает", f"Обнаружено {consecutive_errors} последовательных ошибок!\n\n" "Возможные причины:\n" "1. Устройство не отвечает на команды\n" "2. Проблемы с соединением\n" "3. Неверный формат команд\n\n" "Выполнение приостановлено.\n" "Проверьте подключение и состояние устройства,\n" "затем нажмите 'Продолжить' или 'Остановить'." )) return False return True def wait_before_next_command(): """Ожидание перед следующей командой с учетом паузы""" while self.execution_paused and not self.execution_stop: time.sleep(0.1) if self.execution_stop: return False time.sleep(1) # Базовая задержка между командами return True try: if copy_mode == "line": # Построчный режим while self.current_command_index < len(self.commands): if self.execution_stop: break if not wait_before_next_command(): break # Проверяем соединение перед каждой командой if not self.check_connection(): if self.execution_stop: # Если это остановка, прерываем выполнение break continue cmd = self.commands[self.current_command_index] try: success, response = send_command_and_process_response( self.connection, cmd, self.settings.get("timeout", 10), max_attempts=3, log_callback=self.append_file_exec_text if not self.execution_stop else None, login=self.settings.get("login"), password=self.settings.get("password"), is_gui=True ) if self.execution_stop: break if not success: if not self.execution_stop: self.append_file_exec_text(f"[ERROR] Не удалось выполнить команду: {cmd}\n") # Проверяем соединение после неудачной попытки if not self.check_connection(): if self.execution_stop: break continue # Обрабатываем отсутствие ответа if not handle_no_response(cmd): continue else: # Сбрасываем счетчик ошибок при успешном выполнении consecutive_errors = 0 self.current_command_index += 1 self.after(0, self.update_progress) except Exception as e: if not self.execution_stop: self.append_file_exec_text(f"[ERROR] Ошибка при выполнении команды: {str(e)}\n") break else: # Блочный режим blocks = generate_command_blocks(self.commands, block_size) total_blocks = len(blocks) current_block = 0 while current_block < total_blocks: if self.execution_stop: break if not wait_before_next_command(): break # Проверяем соединение перед каждым блоком if not self.check_connection(): if self.execution_stop: break continue block = blocks[current_block] try: if not self.execution_stop: self.append_file_exec_text(f"Выполнение блока команд:\n{block}\n") success, response = send_command_and_process_response( self.connection, block, self.settings.get("timeout", 10), max_attempts=3, log_callback=None, login=self.settings.get("login"), password=self.settings.get("password"), is_gui=True ) if self.execution_stop: break if not success or (response and '^' in response): if not self.execution_stop: self.append_file_exec_text("[WARNING] Ошибка при выполнении блока команд. Отправляю команды по отдельности...\n") # Проверяем соединение перед отправкой отдельных команд if not self.check_connection(): if self.execution_stop: break continue # Обрабатываем отсутствие ответа для блока if not success and not handle_no_response(block, True): continue # Отправляем команды блока по отдельности for cmd in block.splitlines(): if self.execution_stop: break if cmd.strip(): if not self.check_connection(): break success, resp = send_command_and_process_response( self.connection, cmd, self.settings.get("timeout", 10), max_attempts=3, log_callback=self.append_file_exec_text if not self.execution_stop else None, login=self.settings.get("login"), password=self.settings.get("password"), is_gui=True ) if self.execution_stop: break if not success: if not self.execution_stop: self.append_file_exec_text(f"[ERROR] Не удалось выполнить команду: {cmd}\n") if not self.check_connection(): break if not handle_no_response(cmd): break else: consecutive_errors = 0 if not wait_before_next_command(): break # Если блок выполнился успешно if success and not (response and '^' in response): consecutive_errors = 0 if response and not self.execution_stop: self.append_file_exec_text(f"Ответ устройства:\n{response}\n") # Обновляем прогресс current_block += 1 self.current_command_index = (current_block * 100) // total_blocks self.after(0, self.update_progress) except Exception as e: if not self.execution_stop: self.append_file_exec_text(f"[ERROR] Ошибка при выполнении блока команд: {str(e)}\n") break finally: # Завершение выполнения self.after(0, self.execution_completed) def execution_completed(self): """Обработка завершения выполнения в главном потоке""" if self.execution_stop: self.append_file_exec_text("[INFO] Выполнение остановлено.\n") else: self.append_file_exec_text("[INFO] Выполнение завершено.\n") self.reset_execution_buttons() def start_execution(self): # Проверяем подключение перед стартом if not self.connection or not self.connection.is_open: self.connection = create_connection(self.settings) if self.connection: # Запускаем мониторинг при новом подключении self.start_port_monitoring() else: self.append_file_exec_text("[ERROR] Не удалось установить соединение.\n") return if not hasattr(self, 'commands') or not self.commands: self.execution_thread = threading.Thread(target=self.execute_file_commands, daemon=True) self.execution_thread.start() else: # Если команды уже загружены, просто возобновляем выполнение self.execution_paused = False self.execution_stop = False self.timer_running = True threading.Thread(target=self.command_execution_thread, daemon=True).start() self.start_button.config(state="disabled") self.pause_button.config(state="normal") self.stop_button.config(state="normal") self.update_status_bar() # Обновляем статус бар после подключения def pause_execution(self): if not self.execution_paused: self.execution_paused = True self.pause_button.config(text="▶ Продолжить") self.append_file_exec_text("[INFO] Выполнение приостановлено.\n") else: self.execution_paused = False self.pause_button.config(text="⏸ Пауза") self.append_file_exec_text("[INFO] Выполнение возобновлено.\n") def stop_execution(self): """Остановка выполнения команд""" # Устанавливаем флаг остановки self.execution_stop = True self.execution_paused = False self.timer_running = False # Очищаем очередь команд if hasattr(self, 'commands'): delattr(self, 'commands') # Сбрасываем индекс текущей команды и прогресс self.current_command_index = 0 self.progress_bar['value'] = 0 self.update_progress() # Отключаемся от COM-порта без вывода сообщений об ошибках if self.connection: try: self.stop_port_monitoring() self.connection.close() except: pass finally: self.connection = None # Выводим только одно сообщение об остановке self.append_file_exec_text("[INFO] Выполнение остановлено.\n") # Сбрасываем состояние кнопок self.reset_execution_buttons() self.update_status_bar() def reset_execution_buttons(self): self.start_button.config(state="normal") self.pause_button.config(state="disabled", text="⏸ Пауза") self.stop_button.config(state="disabled") self.execution_paused = False self.execution_stop = False self.timer_running = False # Очищаем список команд, чтобы при следующем старте они загрузились заново if hasattr(self, 'commands'): delattr(self, 'commands') def update_timer(self): """Обновление таймера в главном потоке""" if self.timer_running: if not self.execution_paused: self.elapsed_time = time.time() - self.start_time hours = int(self.elapsed_time // 3600) minutes = int((self.elapsed_time % 3600) // 60) seconds = int(self.elapsed_time % 60) self.timer_label.config(text=f"Время: {hours:02d}:{minutes:02d}:{seconds:02d}") self.after(1000, self.update_timer) # Обновление каждую секунду def update_progress(self): """Обновление прогресса в главном потоке""" if hasattr(self, 'commands'): total_commands = len(self.commands) current = self.current_command_index self.progress_label.config(text=f"Прогресс: {current}/{total_commands} команд") if total_commands > 0: progress = (current / total_commands) * 100 self.progress_bar['value'] = progress # Создание вкладки "Редактор конфигурационного файла" 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 "") CustomEntry(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 = CustomText(frame, wrap="word") self.config_editor_text.pack(fill=BOTH, expand=True, padx=5, pady=5) # Выбор файла конфигурации для редактирования def select_config_file_editor(self): select_config_file(self, self.editor_file_var, save_to_settings=True) # Загрузка файла конфигурации 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() # Создание вкладки TFTP сервера def create_tftp_tab(self, frame): # Создаем фрейм для управления TFTP сервером tftp_frame = ttk.Frame(frame) tftp_frame.pack(fill=BOTH, expand=True, padx=5, pady=5) # Создаем и размещаем элементы управления controls_frame = ttk.LabelFrame(tftp_frame, text="Управление TFTP сервером") controls_frame.pack(fill=X, padx=5, pady=5) # IP адрес ip_frame = ttk.Frame(controls_frame) ip_frame.pack(fill=X, padx=5, pady=2) ttk.Label(ip_frame, text="IP адрес:").pack(side=LEFT, padx=5) self.tftp_ip_var = StringVar(value="0.0.0.0") self.tftp_ip_combo = ttk.Combobox(ip_frame, textvariable=self.tftp_ip_var, state="readonly") self.tftp_ip_combo.pack(side=LEFT, fill=X, expand=True, padx=5) ttk.Button(ip_frame, text="Обновить", command=self.update_network_adapters).pack(side=LEFT, padx=5) # Заполняем список адаптеров self.update_network_adapters() # Порт port_frame = ttk.Frame(controls_frame) port_frame.pack(fill=X, padx=5, pady=2) ttk.Label(port_frame, text="Порт:").pack(side=LEFT, padx=5) self.tftp_port_var = StringVar(value="69") self.tftp_port_entry = CustomEntry(port_frame, textvariable=self.tftp_port_var) self.tftp_port_entry.pack(fill=X, expand=True, padx=5) # Кнопки управления buttons_frame = ttk.Frame(controls_frame) buttons_frame.pack(fill=X, padx=5, pady=5) self.start_tftp_button = ttk.Button( buttons_frame, text="Запустить сервер", command=self.start_tftp_server ) self.start_tftp_button.pack(side=LEFT, padx=5) self.stop_tftp_button = ttk.Button( buttons_frame, text="Остановить сервер", command=self.stop_tftp_server, state="disabled" ) self.stop_tftp_button.pack(side=LEFT, padx=5) # Кнопка очистки лога self.clear_tftp_button = ttk.Button( buttons_frame, text="🗑 Очистить лог", command=lambda: self.tftp_log_text.clear() ) self.clear_tftp_button.pack(side=LEFT, padx=5) # Лог сервера log_frame = ttk.LabelFrame(tftp_frame, text="Лог сервера") log_frame.pack(fill=BOTH, expand=True, padx=5, pady=5) # Используем новый TerminalWidget вместо CustomText self.tftp_log_text = TerminalWidget(log_frame, height=10) self.tftp_log_text.pack(fill=BOTH, expand=True, padx=5, pady=5) # Статус передач transfers_frame = ttk.LabelFrame(tftp_frame, text="Активные передачи") transfers_frame.pack(fill=BOTH, expand=True, padx=5, pady=5) # Создаем таблицу для отображения активных передач columns = ("client", "filename", "progress", "remaining", "time") self.transfers_tree = ttk.Treeview(transfers_frame, columns=columns, show="headings") # Настраиваем заголовки колонок self.transfers_tree.heading("client", text="Клиент") self.transfers_tree.heading("filename", text="Файл") self.transfers_tree.heading("progress", text="Прогресс") self.transfers_tree.heading("remaining", text="Осталось") self.transfers_tree.heading("time", text="Время") # Настраиваем ширину колонок self.transfers_tree.column("client", width=120) self.transfers_tree.column("filename", width=150) self.transfers_tree.column("progress", width=100) self.transfers_tree.column("remaining", width=100) self.transfers_tree.column("time", width=80) self.transfers_tree.pack(fill=BOTH, expand=True, padx=5, pady=5) # Инициализация TFTP сервера self.tftp_server = None self.tftp_server_thread = None # Запуск TFTP сервера def start_tftp_server(self): try: # Получаем выбранный IP-адрес ip = self.tftp_ip_var.get() if not ip: messagebox.showerror("Ошибка", "Выберите IP-адрес для TFTP сервера") return # Проверяем корректность порта try: port = int(self.tftp_port_var.get()) if port <= 0 or port > 65535: raise ValueError("Порт должен быть в диапазоне 1-65535") except ValueError as e: messagebox.showerror("Ошибка", f"Некорректный порт: {str(e)}") return # Создаем экземпляр TFTP сервера self.tftp_server = TFTPServer("Firmware") # Устанавливаем callback для логирования def log_callback(message): # Фильтруем дублирующиеся сообщения о запуске/остановке сервера if "[INFO] TFTP сервер запущен" in message and hasattr(self, '_server_started'): return if "[INFO] TFTP сервер остановлен" in message and hasattr(self, '_server_stopped'): return self.append_tftp_log(message) # Устанавливаем флаги для отслеживания состояния if "[INFO] TFTP сервер запущен" in message: self._server_started = True elif "[INFO] TFTP сервер остановлен" in message: self._server_stopped = True # Обновляем информацию о передачах self.update_transfers_info() self.tftp_server.set_log_callback(log_callback) # Запускаем сервер в отдельном потоке self.tftp_server_thread = threading.Thread( target=self.run_tftp_server, args=(ip, port), daemon=True ) self.tftp_server_thread.start() # Обновляем состояние кнопок и элементов управления self.start_tftp_button.config(state="disabled") self.stop_tftp_button.config(state="normal") self.tftp_ip_combo.config(state="disabled") self.tftp_port_entry.config(state="disabled") # Запускаем периодическое обновление информации о передачах self.update_transfers_periodically() # Обновляем статус бар после запуска сервера self.update_status_bar() except Exception as e: self.append_tftp_log(f"[ERROR] Ошибка запуска сервера: {str(e)}") messagebox.showerror("Ошибка", f"Не удалось запустить TFTP сервер: {str(e)}") # Запуск TFTP сервера в отдельном потоке def run_tftp_server(self, ip, port): try: self.tftp_server.start_server(ip, port) except Exception as e: self.append_tftp_log(f"[ERROR] Ошибка работы сервера: {str(e)}") # Остановка TFTP сервера def stop_tftp_server(self): if self.tftp_server: try: # Отключаем кнопки на время остановки сервера self.start_tftp_button.config(state="disabled") self.stop_tftp_button.config(state="disabled") # Сбрасываем флаги состояния if hasattr(self, '_server_started'): delattr(self, '_server_started') if hasattr(self, '_server_stopped'): delattr(self, '_server_stopped') # Останавливаем сервер self.tftp_server.stop_server() # Ждем завершения потока сервера с таймаутом if self.tftp_server_thread: self.tftp_server_thread.join(timeout=5.0) if self.tftp_server_thread.is_alive(): self.append_tftp_log("[WARN] Превышено время ожидания остановки сервера") # Очищаем ссылки на сервер и поток self.tftp_server = None self.tftp_server_thread = None # Обновляем состояние кнопок self.start_tftp_button.config(state="normal") self.stop_tftp_button.config(state="disabled") self.tftp_ip_combo.config(state="normal") self.tftp_port_entry.config(state="normal") # Очищаем таблицу передач for item in self.transfers_tree.get_children(): self.transfers_tree.delete(item) # Обновляем статус бар после остановки сервера self.update_status_bar() except Exception as e: self.append_tftp_log(f"[ERROR] Ошибка остановки сервера: {str(e)}") messagebox.showerror("Ошибка", f"Не удалось остановить TFTP сервер: {str(e)}") # Восстанавливаем состояние кнопок в случае ошибки self.start_tftp_button.config(state="disabled") self.stop_tftp_button.config(state="normal") def append_tftp_log(self, text): if "[ERROR]" in text: self.tftp_log_text.append_error(text) elif "[WARNING]" in text or "[WARN]" in text: self.tftp_log_text.append_warning(text) elif "[INFO]" in text: self.tftp_log_text.append_info(text) else: self.tftp_log_text.append_text(text) # Обновление информации об активных передачах def update_transfers_info(self): if not self.tftp_server: return # Очищаем текущие записи for item in self.transfers_tree.get_children(): self.transfers_tree.delete(item) # Добавляем информацию о текущих передачах for client_addr, transfer_info in self.tftp_server.active_transfers.items(): filename = transfer_info['filename'] bytes_sent = transfer_info['bytes_sent'] filesize = transfer_info['filesize'] start_time = transfer_info['start_time'] # Вычисляем прогресс progress = f"{bytes_sent}/{filesize} байт" remaining_bytes = filesize - bytes_sent elapsed_time = time.time() - start_time # Вычисляем скорость передачи (байт/сек) if elapsed_time > 0: transfer_speed = bytes_sent / elapsed_time # Вычисляем оставшееся время if transfer_speed > 0: remaining_time = remaining_bytes / transfer_speed remaining_str = f"{remaining_bytes} байт (~{int(remaining_time)}с)" else: remaining_str = f"{remaining_bytes} байт (неизвестно)" else: remaining_str = f"{remaining_bytes} байт (вычисляется...)" # Добавляем запись в таблицу self.transfers_tree.insert("", END, values=( f"{client_addr[0]}:{client_addr[1]}", filename, progress, remaining_str, f"{elapsed_time:.1f}с" )) # Периодическое обновление информации о передачах def update_transfers_periodically(self): if self.tftp_server and self.tftp_server.running: self.update_transfers_info() # Планируем следующее обновление через 1 секунду self.after(1000, self.update_transfers_periodically) # Обновление списка сетевых адаптеров def update_network_adapters(self): adapters = get_network_adapters() self.tftp_ip_combo["values"] = adapters if not self.tftp_ip_var.get() in adapters: self.tftp_ip_var.set(adapters[0]) # Добавляем новый метод для создания статус бара def create_status_bar(self): self.status_bar = ttk.Frame(self) self.status_bar.pack(side=tk.BOTTOM, fill=tk.X) # Создаем фрейм для индикаторов indicators_frame = ttk.Frame(self.status_bar) indicators_frame.pack(side=tk.LEFT) # Индикатор подключения к коммутатору self.connection_indicator_frame = ttk.Frame(indicators_frame) self.connection_indicator_frame.pack(side=tk.LEFT) ttk.Label(self.connection_indicator_frame, text="COM:", padding=(5, 2)).pack(side=tk.LEFT) self.connection_indicator = ttk.Label( self.connection_indicator_frame, text="⬤", font=("Segoe UI", 10), width=2, anchor='center', padding=(2, 2) ) self.connection_indicator.pack(side=tk.LEFT) # Индикатор TFTP сервера self.tftp_indicator_frame = ttk.Frame(indicators_frame) self.tftp_indicator_frame.pack(side=tk.LEFT, padx=10) ttk.Label(self.tftp_indicator_frame, text="TFTP:", padding=(5, 2)).pack(side=tk.LEFT) self.tftp_indicator = ttk.Label( self.tftp_indicator_frame, text="⬤", font=("Segoe UI", 10), width=2, anchor='center', padding=(2, 2) ) self.tftp_indicator.pack(side=tk.LEFT) ttk.Separator(self.status_bar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) # Остальные элементы статус бара... self.port_label = ttk.Label(self.status_bar, text="", padding=(5, 2)) self.port_label.pack(side=tk.LEFT) ttk.Separator(self.status_bar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) self.baudrate_label = ttk.Label(self.status_bar, text="", padding=(5, 2)) self.baudrate_label.pack(side=tk.LEFT) ttk.Separator(self.status_bar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) self.copy_mode_label = ttk.Label(self.status_bar, text="", padding=(5, 2)) self.copy_mode_label.pack(side=tk.LEFT) ttk.Separator(self.status_bar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=5) self.version_label = ttk.Label(self.status_bar, text=f"Версия: {VERSION}", padding=(5, 2)) self.version_label.pack(side=tk.RIGHT) # Обновляем метод update_status_bar def update_status_bar(self): """Обновление статус-бара""" # Проверяем реальное состояние подключения is_connected = bool(self.connection and self.connection.is_open) self.update_connection_indicator(is_connected) # Обновляем индикатор TFTP сервера if self.tftp_server and self.tftp_server.running: self.tftp_indicator.configure(text="⬤", foreground='green') else: self.tftp_indicator.configure(text="⬤", foreground='red') # Остальные обновления статус бара port = self.settings.get("port", "Не выбран") baudrate = self.settings.get("baudrate", "9600") copy_mode = "Построчный" if self.settings.get("copy_mode") == "line" else "Блочный" self.port_label.config(text=f"Порт: {port}") self.baudrate_label.config(text=f"Скорость: {baudrate}") self.copy_mode_label.config(text=f"Режим: {copy_mode}") def check_connection(self): """Проверка состояния соединения""" # Если выполнение остановлено пользователем, просто возвращаем False без сообщений if self.execution_stop: return False if not self.connection or not self.connection.is_open: # Если это не ручная остановка, показываем сообщение if not self.execution_stop: self.append_file_exec_text("[ERROR] Соединение потеряно!\n") # Автоматически ставим на паузу self.execution_paused = True self.after(0, lambda: self.pause_button.config(text="▶ Продолжить")) # Показываем сообщение только если это не ручная остановка self.after(0, lambda: messagebox.showerror( "Ошибка соединения", "Соединение с устройством потеряно!\nВыполнение команд приостановлено.\n\n" "Пожалуйста:\n" "1. Проверьте подключение\n" "2. Нажмите 'Продолжить' после восстановления соединения\n" " или 'Остановить' для прекращения выполнения" )) return False return True # Класс для окна выбора конфигурации class ConfigSelectorWindow(tk.Toplevel): def __init__(self, parent, callback): super().__init__(parent) self.title("Выбор конфигурации") self.geometry("600x400") self.callback = callback # Создаем основной фрейм main_frame = ttk.Frame(self, padding="10") main_frame.pack(fill=BOTH, expand=True) # Создаем фрейм для кнопок управления button_frame = ttk.Frame(main_frame) button_frame.pack(fill=X, pady=5) # Кнопка "Добавить новую конфигурацию" add_button = ttk.Button( button_frame, text="➕ Добавить новую конфигурацию", command=self.add_new_config ) add_button.pack(side=LEFT, padx=5) # Кнопка "Создать папку" create_folder_button = ttk.Button( button_frame, text="📁 Создать папку", command=self.create_new_folder ) create_folder_button.pack(side=LEFT, padx=5) # Создаем фрейм для дерева и скроллбара tree_frame = ttk.Frame(main_frame) tree_frame.pack(fill=BOTH, expand=True, pady=5) # Создаем Treeview для отображения файлов и папок self.tree = ttk.Treeview(tree_frame, selectmode="browse") # Добавляем скроллбар scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview) scrollbar.pack(side=RIGHT, fill=Y) self.tree.pack(side=LEFT, fill=BOTH, expand=True) self.tree.configure(yscrollcommand=scrollbar.set) # Настраиваем колонки self.tree["columns"] = () # Убираем колонку действий self.tree.column("#0", width=400, stretch=True) self.tree.heading("#0", text="Конфигурация") # Создаем контекстное меню для файлов self.file_menu = tk.Menu(self, tearoff=0) self.file_menu.add_command(label="✍️ Редактировать", command=self.edit_selected) self.file_menu.add_command(label="📝 Переименовать", command=self.rename_selected) self.file_menu.add_command(label="📦 Переместить", command=self.move_selected) self.file_menu.add_separator() self.file_menu.add_command(label="🗑️ Удалить", command=self.delete_selected) # Создаем контекстное меню для папок self.folder_menu = tk.Menu(self, tearoff=0) self.folder_menu.add_command(label="📝 Переименовать", command=self.rename_selected) self.folder_menu.add_command(label="📦 Переместить", command=self.move_selected) self.folder_menu.add_separator() self.folder_menu.add_command(label="🗑️ Удалить", command=self.delete_selected) # Привязываем события self.tree.bind("", self.on_double_click) self.tree.bind("", self.show_context_menu) # Загружаем файлы и папки self.load_configs() # Центрируем окно self.center_window() # Делаем окно модальным self.transient(parent) self.grab_set() def create_new_folder(self): """Создание новой папки""" selected_item = self.get_selected_item() parent_path = "Configs" if selected_item: item_text = self.tree.item(selected_item)['text'] if item_text.startswith("📁"): parent_path = self.get_full_path(selected_item) else: parent_item = self.tree.parent(selected_item) if parent_item: parent_path = self.get_full_path(parent_item) folder_name = simpledialog.askstring( "Создать папку", "Введите имя новой папки:" ) if folder_name: try: new_folder_path = os.path.join(parent_path, folder_name) os.makedirs(new_folder_path, exist_ok=True) self.load_configs() messagebox.showinfo("Успех", "Папка успешно создана") except Exception as e: messagebox.showerror("Ошибка", f"Не удалось создать папку: {str(e)}") def show_context_menu(self, event): """Показ контекстного меню""" item = self.tree.identify('item', event.x, event.y) if item: self.tree.selection_set(item) item_text = self.tree.item(item)['text'] # Определяем, это файл или папка if item_text.startswith("📁"): # Папка self.folder_menu.post(event.x_root, event.y_root) elif item_text.startswith("📄"): # Файл self.file_menu.post(event.x_root, event.y_root) def get_selected_item(self): """Получение выбранного элемента""" selection = self.tree.selection() if selection: return selection[0] return None def edit_selected(self): """Редактирование выбранного файла""" item_id = self.get_selected_item() if item_id: path = self.get_full_path(item_id) self.edit_config(path) def rename_selected(self): """Переименование выбранного элемента""" item_id = self.get_selected_item() if item_id: path = self.get_full_path(item_id) item_text = self.tree.item(item_id)['text'] # Определяем, это файл или папка is_folder = item_text.startswith("📁") current_name = item_text[2:].strip() # Убираем эмодзи # Запрашиваем новое имя new_name = simpledialog.askstring( "Переименование", "Введите новое имя:" if is_folder else "Введите новое имя файла:", initialvalue=current_name ) if new_name: try: if not is_folder and not new_name.endswith('.txt'): new_name += '.txt' new_path = os.path.join(os.path.dirname(path), new_name) if os.path.exists(new_path): if not messagebox.askyesno( "Подтверждение", f"{'Папка' if is_folder else 'Файл'} с таким именем уже существует. Перезаписать?" ): return os.rename(path, new_path) self.load_configs() # Перезагружаем список except Exception as e: messagebox.showerror("Ошибка", f"Не удалось переименовать: {str(e)}") def move_selected(self): """Перемещение выбранного элемента""" item_id = self.get_selected_item() if item_id: source_path = self.get_full_path(item_id) item_text = self.tree.item(item_id)['text'] is_folder = item_text.startswith("📁") if os.path.exists(source_path): # Создаем окно выбора папки folder_selector = FolderSelectorDialog(self, "Configs") if folder_selector.result: target_folder = folder_selector.result try: # Получаем имя элемента из исходного пути name = os.path.basename(source_path) # Формируем путь назначения target_path = os.path.join(target_folder, name) # Проверяем, существует ли элемент в целевой папке if os.path.exists(target_path): if not messagebox.askyesno( "Подтверждение", f"{'Папка' if is_folder else 'Файл'} {name} уже существует в целевой папке. Перезаписать?" ): return # Перемещаем элемент os.rename(source_path, target_path) self.load_configs() # Обновляем список messagebox.showinfo("Успех", f"{'Папка' if is_folder else 'Файл'} успешно перемещен(а)") except Exception as e: messagebox.showerror("Ошибка", f"Не удалось переместить: {str(e)}") def delete_selected(self): """Удаление выбранного элемента""" item_id = self.get_selected_item() if item_id: path = self.get_full_path(item_id) item_text = self.tree.item(item_id)['text'] is_folder = item_text.startswith("📁") if os.path.exists(path): # Запрашиваем подтверждение if messagebox.askyesno( "Подтверждение", f"Вы действительно хотите переместить {'папку' if is_folder else 'файл'} {os.path.basename(path)} в корзину?" ): try: send2trash(path) self.load_configs() # Перезагружаем список except Exception as e: messagebox.showerror("Ошибка", f"Не удалось переместить в корзину: {str(e)}") def load_configs(self): """Загрузка конфигураций из папки Configs""" # Очищаем дерево for item in self.tree.get_children(): self.tree.delete(item) # Функция для рекурсивного добавления файлов и папок def add_directory(path, parent=""): try: # Сортируем содержимое: сначала папки, потом файлы items = os.listdir(path) directories = [] files = [] for item in items: full_path = os.path.join(path, item) if os.path.isdir(full_path): directories.append(item) elif item.endswith('.txt'): files.append(item) # Добавляем папки for directory in sorted(directories): full_path = os.path.join(path, directory) folder_id = self.tree.insert( parent, "end", text="📁 " + directory, open=False ) add_directory(full_path, folder_id) # Добавляем файлы for file in sorted(files): self.tree.insert( parent, "end", text="📄 " + file ) except Exception as e: messagebox.showerror("Ошибка", f"Ошибка при загрузке файлов: {str(e)}") # Загружаем файлы, начиная с корневой папки Configs add_directory("Configs") def add_new_config(self): """Добавление новой конфигурации через проводник Windows""" file_path = filedialog.askopenfilename( title="Выберите файл конфигурации", filetypes=[("Текстовые файлы", "*.txt")], initialdir=os.path.abspath(".") ) if not file_path: # Если пользователь отменил выбор файла return try: # Создаем имя файла в папке Configs filename = os.path.basename(file_path) new_path = os.path.join("Configs", filename) # Если файл уже существует, спрашиваем о перезаписи if os.path.exists(new_path): if not messagebox.askyesno( "Подтверждение", f"Файл {filename} уже существует. Перезаписать?" ): return # Копируем файл with open(file_path, 'r', encoding='utf-8') as source: content = source.read() with open(new_path, 'w', encoding='utf-8') as dest: dest.write(content) self.load_configs() # Обновляем список messagebox.showinfo("Успех", "Файл конфигурации успешно добавлен") except Exception as e: messagebox.showerror("Ошибка", f"Не удалось добавить файл: {str(e)}") def get_full_path(self, item_id): """Получение полного пути к файлу/папке""" path_parts = [] while item_id: text = self.tree.item(item_id)['text'] # Удаляем эмодзи из начала текста if text.startswith("📁 "): text = text[2:].strip() elif text.startswith("📄 "): text = text[2:].strip() path_parts.insert(0, text) item_id = self.tree.parent(item_id) return os.path.join("Configs", *path_parts) def select_config(self, path): """Выбор конфигурации""" if os.path.isfile(path): self.callback(path) self.destroy() def edit_config(self, path): """Редактирование конфигурации""" if os.path.isfile(path): # Переключаемся на вкладку редактора и загружаем файл self.master.notebook.select(2) # Индекс вкладки редактора self.master.editor_file_var.set(path) self.master.load_config_file() self.destroy() def delete_config(self, path): """Удаление конфигурации""" if not os.path.isfile(path): return # Запрашиваем подтверждение if messagebox.askyesno( "Подтверждение", f"Вы действительно хотите переместить файл {os.path.basename(path)} в корзину?" ): try: send2trash(path) self.load_configs() # Перезагружаем список except Exception as e: messagebox.showerror("Ошибка", f"Не удалось переместить файл в корзину: {str(e)}") def on_double_click(self, event): """Обработка двойного клика""" item_id = self.tree.identify('item', event.x, event.y) if not item_id: return # Если это папка, разворачиваем/сворачиваем её if not self.tree.item(item_id)['text'].startswith("📄"): # Если не файл if self.tree.item(item_id)['open']: self.tree.item(item_id, open=False) else: self.tree.item(item_id, open=True) else: # Если это файл, выбираем его full_path = self.get_full_path(item_id) self.select_config(full_path) 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}") class FolderSelectorDialog(tk.Toplevel): def __init__(self, parent, root_folder): super().__init__(parent) self.title("Выберите папку") self.geometry("500x400") # Увеличиваем начальный размер окна self.result = None # Создаем основной фрейм с отступами main_frame = ttk.Frame(self, padding="10") main_frame.pack(fill=BOTH, expand=True) # Добавляем метку с инструкцией ttk.Label(main_frame, text="Выберите папку назначения:").pack(anchor=W, pady=(0, 5)) # Создаем фрейм для дерева и скроллбара tree_frame = ttk.Frame(main_frame) tree_frame.pack(fill=BOTH, expand=True) # Создаем и настраиваем дерево папок self.tree = ttk.Treeview(tree_frame, selectmode="browse") self.tree.pack(side=LEFT, fill=BOTH, expand=True) # Добавляем скроллбар scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview) scrollbar.pack(side=RIGHT, fill=Y) self.tree.configure(yscrollcommand=scrollbar.set) # Загружаем структуру папок self.load_folders(root_folder) # Кнопки button_frame = ttk.Frame(main_frame) button_frame.pack(fill=X, pady=(10, 0)) # Кнопки справа ttk.Button(button_frame, text="❌ Отмена", command=self.on_cancel).pack(side=RIGHT, padx=(5, 0)) ttk.Button(button_frame, text="✔️ Выбрать папку", command=self.on_select).pack(side=RIGHT) # Центрируем окно self.center_window() # Делаем окно модальным self.transient(parent) self.grab_set() # Устанавливаем минимальный размер окна self.minsize(400, 300) # Увеличиваем минимальный размер окна parent.wait_window(self) 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 load_folders(self, root_folder): """Загрузка структуры папок""" def add_folder(path, parent=""): try: items = os.listdir(path) for item in sorted(items): full_path = os.path.join(path, item) if os.path.isdir(full_path): folder_id = self.tree.insert( parent, "end", text="📁 " + item, values=(full_path,), open=False ) add_folder(full_path, folder_id) except Exception: pass # Добавляем корневую папку root_id = self.tree.insert("", "end", text="📁 Configs", values=(root_folder,), open=True) add_folder(root_folder, root_id) def on_select(self): """Обработка выбора папки""" selection = self.tree.selection() if selection: self.result = self.tree.item(selection[0])['values'][0] self.destroy() def on_cancel(self): """Отмена выбора""" self.destroy() # ========================== # Основной запуск приложения # ========================== 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)