diff --git a/ComConfigCopy.py b/ComConfigCopy.py index e5aded1..5cf7957 100644 --- a/ComConfigCopy.py +++ b/ComConfigCopy.py @@ -17,6 +17,7 @@ import re import sys import threading import time +import webbrowser from getpass import getpass from logging.handlers import RotatingFileHandler import tkinter as tk @@ -40,12 +41,17 @@ from about_window import AboutWindow from TFTPServer import TFTPServer # from TFTPServer import TFTPServerThread import socket +from update_checker import UpdateChecker + +# Версия программы +VERSION = "1.1.0" # Создаем необходимые папки 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") @@ -643,10 +649,22 @@ class SerialAppGUI(tk.Tk): super().__init__() self.title("ComConfigCopy") self.geometry("900x700") + + # Добавляем VERSION как атрибут класса + self.VERSION = VERSION + + # Инициализация проверки обновлений + self.update_checker = UpdateChecker( + VERSION, + "https://gitea.filow.ru/LowaSC/ComConfigCopy" + ) + + # Настройка стиля 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 @@ -656,7 +674,87 @@ class SerialAppGUI(tk.Tk): # Проверка первого запуска 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.open_documentation) + 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: + response = messagebox.askyesno( + "Доступно обновление", + f"Доступна новая версия {release_info['version']}!\n\n" + f"Изменения:\n{release_info['description']}\n\n" + "Хотите перейти на страницу загрузки?", + ) + if response: + webbrowser.open(release_info["download_url"]) + else: + messagebox.showerror( + "Ошибка", + "Не удалось получить информацию о новой версии" + ) + else: + messagebox.showinfo( + "Проверка обновлений", + "У вас установлена последняя версия программы" + ) + + self.update_checker.check_updates(callback=on_update_check) + + def on_settings_changed(self): + """Обработчик изменения настроек""" + self.settings = settings_load() + + def open_settings(self): + """Открытие окна настроек""" + settings_window = SettingsWindow(self, self.settings, self.on_settings_changed) + settings_window.transient(self) + settings_window.grab_set() + + def open_documentation(self): + """Открытие документации""" + doc_path = os.path.join("docs", "user_manual.md") + if os.path.exists(doc_path): + try: + os.startfile(doc_path) + except AttributeError: + # Для Linux и MacOS + try: + import subprocess + subprocess.run(["xdg-open", doc_path]) + except: + webbrowser.open(f"file://{os.path.abspath(doc_path)}") + else: + messagebox.showerror( + "Ошибка", + "Файл документации не найден" + ) + def check_first_run(self): """Проверка первого запуска программы""" show_settings = False @@ -687,31 +785,6 @@ class SerialAppGUI(tk.Tk): 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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..b5400e1 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# ComConfigCopy + +Программа для копирования конфигураций на коммутаторы. + +## Описание + +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). \ No newline at end of file diff --git a/about_window.py b/about_window.py index 21e1781..1d3240e 100644 --- a/about_window.py +++ b/about_window.py @@ -2,19 +2,22 @@ # -*- coding: utf-8 -*- import tkinter as tk -from tkinter import ttk, BOTH, X, BOTTOM +from tkinter import ttk, BOTH, X, BOTTOM, END import webbrowser class AboutWindow(tk.Toplevel): def __init__(self, parent): super().__init__(parent) self.title("О программе") - self.geometry("400x300") + self.geometry("600x500") self.resizable(False, False) - # Создаем фрейм + # Сохраняем ссылку на родительское окно + self.parent = parent + + # Создаем фрейм для содержимого about_frame = ttk.Frame(self, padding="20") - about_frame.pack(fill=BOTH, expand=True) + about_frame.pack(fill=BOTH, expand=True, padx=10, pady=10) # Заголовок ttk.Label( @@ -33,7 +36,7 @@ class AboutWindow(tk.Toplevel): # Версия ttk.Label( about_frame, - text="Версия 1.0", + text=f"Версия {getattr(parent, 'VERSION', '1.0.0')}", font=("Segoe UI", 10) ).pack(pady=(0, 20)) @@ -80,14 +83,14 @@ class AboutWindow(tk.Toplevel): # Кнопка закрытия ttk.Button( - about_frame, + self, text="Закрыть", command=self.destroy - ).pack(side=BOTTOM, pady=(20, 0)) + ).pack(side=BOTTOM, pady=10) # Центрируем окно self.center_window() - + def center_window(self): self.update_idletasks() width = self.winfo_width() @@ -95,6 +98,6 @@ class AboutWindow(tk.Toplevel): 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) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d11b739..55d38bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,4 @@ -tftpy>=0.8.0 \ No newline at end of file +tftpy>=0.8.0 +pyserial>=3.5 +requests>=2.31.0 +packaging>=23.2 \ No newline at end of file diff --git a/update_checker.py b/update_checker.py new file mode 100644 index 0000000..137bf09 --- /dev/null +++ b/update_checker.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import json +import logging +import requests +import threading +from packaging import version + +class UpdateCheckError(Exception): + """Исключение для ошибок проверки обновлений""" + pass + +class UpdateChecker: + """Класс для проверки обновлений программы""" + + def __init__(self, current_version, repo_url): + self.current_version = current_version + self.repo_url = repo_url + # Формируем базовый URL API + self.api_url = repo_url.replace("gitea.filow.ru", "gitea.filow.ru/api/v1/repos/LowaSC/ComConfigCopy") + self._update_available = False + self._latest_version = None + self._latest_release = None + self._error = None + self._changelog = None + + def get_changelog(self, callback=None): + """ + Получение changelog из репозитория. + :param callback: Функция обратного вызова, которая будет вызвана после получения changelog + """ + def fetch(): + try: + # Пытаемся получить CHANGELOG.md из репозитория + response = requests.get(f"{self.api_url}/contents/CHANGELOG.md", timeout=10) + response.raise_for_status() + + content = response.json() + if "content" in content: + import base64 + changelog_content = base64.b64decode(content["content"]).decode("utf-8") + self._changelog = changelog_content + self._error = None + else: + raise UpdateCheckError("Не удалось получить содержимое CHANGELOG.md") + + except requests.RequestException as e: + error_msg = f"Ошибка получения changelog: {e}" + logging.error(error_msg, exc_info=True) + self._error = error_msg + self._changelog = None + except Exception as e: + error_msg = f"Неизвестная ошибка при получении changelog: {e}" + logging.error(error_msg, exc_info=True) + self._error = error_msg + self._changelog = None + finally: + if callback: + callback(self._changelog, self._error) + + # Запускаем получение в отдельном потоке + threading.Thread(target=fetch, daemon=True).start() + + def check_updates(self, callback=None): + """ + Проверка наличия обновлений. + :param callback: Функция обратного вызова, которая будет вызвана после проверки + """ + def check(): + try: + response = requests.get(f"{self.api_url}/releases", timeout=10) + response.raise_for_status() + + releases = response.json() + if not releases: + raise UpdateCheckError("Не найдено релизов в репозитории") + + latest_release = releases[0] + latest_version = latest_release.get("tag_name", "").lstrip("v") + + if not latest_version: + raise UpdateCheckError("Не удалось определить версию последнего релиза") + + try: + if version.parse(latest_version) > version.parse(self.current_version): + self._update_available = True + self._latest_version = latest_version + self._latest_release = latest_release + logging.info(f"Доступно обновление: {latest_version}") + else: + logging.info("Обновления не требуются") + except version.InvalidVersion as e: + raise UpdateCheckError(f"Некорректный формат версии: {e}") + + self._error = None + + except requests.RequestException as e: + error_msg = f"Ошибка сетевого подключения: {e}" + logging.error(error_msg, exc_info=True) + self._error = error_msg + except UpdateCheckError as e: + logging.error(str(e), exc_info=True) + self._error = str(e) + except Exception as e: + error_msg = f"Неизвестная ошибка при проверке обновлений: {e}" + logging.error(error_msg, exc_info=True) + self._error = error_msg + finally: + if callback: + callback(self._update_available, self._error) + + @property + def update_available(self): + """Доступно ли обновление""" + return self._update_available + + @property + def latest_version(self): + """Последняя доступная версия""" + return self._latest_version + + @property + def error(self): + """Последняя ошибка при проверке обновлений""" + return self._error + + @property + def changelog(self): + """Текущий changelog""" + return self._changelog + + def get_release_notes(self): + """Получение информации о последнем релизе""" + if self._latest_release: + return { + "version": self._latest_version, + "description": self._latest_release.get("body", ""), + "download_url": self._latest_release.get("assets", [{}])[0].get("browser_download_url", "") + } + return None + + def get_releases(self, callback=None): + """ + Получение списка релизов из репозитория. + :param callback: Функция обратного вызова, которая будет вызвана после получения списка релизов + """ + def fetch(): + try: + response = requests.get(f"{self.api_url}/releases", timeout=10) + response.raise_for_status() + releases = response.json() + + if not releases: + raise UpdateCheckError("Не найдено релизов в репозитории") + + self._error = None + if callback: + callback(releases, None) + + except requests.RequestException as e: + error_msg = f"Ошибка сетевого подключения: {e}" + logging.error(error_msg, exc_info=True) + self._error = error_msg + if callback: + callback(None, error_msg) + except Exception as e: + error_msg = f"Ошибка при получении списка релизов: {e}" + logging.error(error_msg, exc_info=True) + self._error = error_msg + if callback: + callback(None, error_msg) + + # Запускаем получение в отдельном потоке + threading.Thread(target=fetch, daemon=True).start() \ No newline at end of file