diff --git a/ComConfigCopy.py b/ComConfigCopy.py index 467c80c..0ed0818 100644 --- a/ComConfigCopy.py +++ b/ComConfigCopy.py @@ -37,13 +37,15 @@ import serial import serial.tools.list_ports from serial.serialutil import SerialException from about_window import AboutWindow +from TFTPServer import TFTPServer # from TFTPServer import TFTPServerThread +import socket # Создаем необходимые папки 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("Firmware", exist_ok=True) # Файл настроек находится в папке Settings SETTINGS_FILE = os.path.join("Settings", "settings.json") @@ -137,25 +139,37 @@ def list_serial_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 +def get_network_adapters(): + """Получение списка сетевых адаптеров и их IP-адресов.""" + 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 # ========================== # Функции работы с COM-соединением @@ -535,6 +549,7 @@ class SerialAppGUI(tk.Tk): self.option_add("*Font", default_font) self.settings = settings self.connection = None + self.tftp_server = None # Глобальные биндинги self.bind_class("Text", "", lambda event: event.widget.event_generate("<>")) @@ -613,14 +628,17 @@ class SerialAppGUI(tk.Tk): 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): @@ -823,6 +841,259 @@ class SerialAppGUI(tk.Tk): about_window.transient(self) about_window.grab_set() + def create_tftp_tab(self, frame): + """Создание вкладки TFTP сервера.""" + # Создаем фрейм для управления 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 = ttk.Entry(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) + + # Лог сервера + log_frame = ttk.LabelFrame(tftp_frame, text="Лог сервера") + log_frame.pack(fill=BOTH, expand=True, padx=5, pady=5) + + self.tftp_log_text = tk.Text(log_frame, wrap=tk.WORD, height=10) + self.tftp_log_text.pack(fill=BOTH, expand=True, padx=5, pady=5) + + # Добавляем скроллбар для лога + scrollbar = ttk.Scrollbar(self.tftp_log_text, command=self.tftp_log_text.yview) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self.tftp_log_text.config(yscrollcommand=scrollbar.set) + + # Статус передач + 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 + + def start_tftp_server(self): + """Запуск TFTP сервера.""" + 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() + + except Exception as e: + self.append_tftp_log(f"[ERROR] Ошибка запуска сервера: {str(e)}") + messagebox.showerror("Ошибка", f"Не удалось запустить TFTP сервер: {str(e)}") + + def run_tftp_server(self, ip, port): + """Запуск TFTP сервера в отдельном потоке.""" + try: + self.tftp_server.start_server(ip, port) + except Exception as e: + self.append_tftp_log(f"[ERROR] Ошибка работы сервера: {str(e)}") + + def stop_tftp_server(self): + """Остановка TFTP сервера.""" + 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) + + 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, message): + """Добавление сообщения в лог TFTP сервера.""" + self.tftp_log_text.insert(END, message + "\n") + self.tftp_log_text.see(END) + + 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 = filesize - bytes_sent + elapsed_time = time.time() - start_time + + # Добавляем запись в таблицу + self.transfers_tree.insert("", END, values=( + f"{client_addr[0]}:{client_addr[1]}", + filename, + progress, + f"{remaining} байт", + 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]) + # ========================== # Парсер аргументов (не используется) # ========================== diff --git a/TFTPServer.py b/TFTPServer.py new file mode 100644 index 0000000..f01bd89 --- /dev/null +++ b/TFTPServer.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +TFTP сервер для передачи прошивки с компьютера на коммутатор. + +- Создает сервер по заданному IP и порту. +- Расшаривает папку Firmware. +- Показывает текущее состояние сервера и статус передачи файла: + - кому (IP устройства), + - сколько осталось байт, + - сколько передано байт, + - время передачи. +""" + +import os +import socket +import struct +import threading +import time + +class TFTPServer: + def __init__(self, share_folder): + """ + Инициализация TFTP сервера. + + :param share_folder: Путь к папке, содержащей файлы (например, папка 'Firmware') + """ + self.share_folder = share_folder + self.log_callback = None + self.running = False + self.server_socket = None + self.lock = threading.Lock() + self.transfer_sockets = set() # Множество для хранения всех активных сокетов передачи + # Словарь активных передач для мониторинга их статуса. + # Ключ – адрес клиента, значение – словарь с информацией о передаче. + self.active_transfers = {} + + def set_log_callback(self, callback): + """ + Установка функции обратного вызова для логирования сообщений. + + :param callback: Функция, принимающая строку сообщения. + """ + self.log_callback = callback + + def log(self, message): + """ + Функция логирования: вызывает callback (если задан) или выводит сообщение в консоль. + + :param message: Строка с сообщением для логирования. + """ + if self.log_callback: + self.log_callback(message) + else: + print(message) + + def start_server(self, ip, port): + """ + Запуск TFTP сервера на указанном IP и порту. + + :param ip: IP-адрес для привязки сервера. + :param port: Порт для TFTP сервера. + """ + if self.running: + self.log("[WARN] Сервер уже запущен") + return + + self.running = True + try: + self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.server_socket.bind((ip, port)) + self.log(f"[INFO] TFTP сервер запущен на {ip}:{port}") + + while self.running: + try: + self.server_socket.settimeout(1.0) + data, client_addr = self.server_socket.recvfrom(2048) + if data and self.running: + threading.Thread(target=self.handle_request, args=(data, client_addr), daemon=True).start() + except socket.timeout: + continue + except socket.error as e: + if self.running: # Логируем ошибку только если сервер еще запущен + self.log(f"[ERROR] Ошибка получения данных: {str(e)}") + break + except Exception as e: + if self.running: # Логируем ошибку только если сервер еще запущен + self.log(f"[ERROR] Ошибка получения данных: {str(e)}") + break + except Exception as e: + self.log(f"[ERROR] Ошибка запуска сервера: {str(e)}") + finally: + self.running = False + if self.server_socket: + try: + self.server_socket.close() + except: + pass + self.server_socket = None + + def stop_server(self): + """ + Остановка TFTP сервера. + """ + if not self.running: + return + + self.log("[INFO] Остановка TFTP сервера...") + self.running = False + + try: + # Закрываем основной сокет сервера первым + if self.server_socket: + try: + # Создаем временный сокет и отправляем пакет самому себе, + # чтобы разблокировать recvfrom + temp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + server_address = self.server_socket.getsockname() + temp_socket.sendto(b'', server_address) + except: + pass + finally: + try: + temp_socket.close() + except: + pass + + try: + self.server_socket.close() + except Exception as e: + self.log(f"[WARN] Ошибка при закрытии основного сокета: {str(e)}") + finally: + self.server_socket = None + + # Закрываем все активные сокеты передачи + with self.lock: + active_sockets = list(self.transfer_sockets) + self.transfer_sockets.clear() + active_transfers = dict(self.active_transfers) + self.active_transfers.clear() + + # Закрываем сокеты передачи после очистки множества + for sock in active_sockets: + try: + if sock: + sock.close() + except Exception as e: + self.log(f"[WARN] Ошибка при закрытии сокета передачи: {str(e)}") + + # Отправляем сообщения об остановке для активных передач + for client_addr, transfer_info in active_transfers.items(): + try: + self.send_error(client_addr, 0, "Сервер остановлен") + except: + pass + + except Exception as e: + self.log(f"[ERROR] Ошибка при остановке сервера: {str(e)}") + finally: + self.running = False # Гарантируем, что флаг running будет False + self.log("[INFO] TFTP сервер остановлен") + + def handle_request(self, data, client_addr): + """ + Обработка входящего запроса от клиента. + + :param data: Полученные данные (UDP-пакет). + :param client_addr: Адрес клиента, отправившего пакет. + """ + if len(data) < 2: + self.log(f"[WARN] Получен некорректный пакет от {client_addr}") + return + opcode = struct.unpack("!H", data[:2])[0] + if opcode == 1: # RRQ (Read Request) – запрос на чтение файла + self.handle_rrq(data, client_addr) + else: + self.log(f"[WARN] Неподдерживаемый запрос (опкод {opcode}) от {client_addr}") + + def handle_rrq(self, data, client_addr): + """ + Обработка запроса на чтение файла (RRQ). + + :param data: Данные запроса. + :param client_addr: Адрес клиента. + """ + try: + # RRQ формата: 2 байта опкода, затем строка имени файла, за которой следует 0, + # затем строка режима (например, "octet"), и завершается 0. + parts = data[2:].split(b'\0') + if len(parts) < 2: + self.log(f"[WARN] Некорректный RRQ пакет от {client_addr}") + return + filename = parts[0].decode('utf-8') + mode = parts[1].decode('utf-8').lower() + self.log(f"[INFO] Получен RRQ от {client_addr}: файл '{filename}', режим '{mode}'") + if mode != "octet": + self.send_error(client_addr, 0, "Поддерживается только octet режим") + return + file_path = os.path.join(self.share_folder, filename) + if not os.path.isfile(file_path): + self.send_error(client_addr, 1, "Файл не найден") + return + # Запускаем передачу файла в новом потоке. + threading.Thread(target=self.send_file, args=(file_path, client_addr), daemon=True).start() + except Exception as e: + self.log(f"[ERROR] Ошибка обработки RRQ: {str(e)}") + + def send_error(self, client_addr, error_code, error_message): + """ + Отправка сообщения об ошибке клиенту. + + :param client_addr: Адрес клиента. + :param error_code: Код ошибки. + :param error_message: Текст ошибки. + """ + # Формируем TFTP пакет ошибки: 2 байта опкода (5), 2 байта кода ошибки, сообщение об ошибке и завершающий 0. + packet = struct.pack("!HH", 5, error_code) + error_message.encode('utf-8') + b'\0' + self.server_socket.sendto(packet, client_addr) + self.log(f"[INFO] Отправлено сообщение об ошибке '{error_message}' клиенту {client_addr}") + + def send_file(self, file_path, client_addr): + """ + Передача файла клиенту по протоколу TFTP. + """ + BLOCK_SIZE = 512 + transfer_socket = None + try: + if not os.path.exists(file_path): + self.log(f"[ERROR] Файл '{file_path}' не существует") + self.send_error(client_addr, 1, "Файл не найден") + return + + filesize = os.path.getsize(file_path) + if filesize == 0: + self.log(f"[ERROR] Файл '{file_path}' пуст") + self.send_error(client_addr, 0, "Файл пуст") + return + + start_time = time.time() + file_basename = os.path.basename(file_path) + + # Регистрируем активную передачу + with self.lock: + self.active_transfers[client_addr] = { + 'filename': file_basename, + 'filesize': filesize, + 'bytes_sent': 0, + 'start_time': start_time + } + + self.log(f"[INFO] Начало передачи файла '{file_basename}' клиенту {client_addr}. Размер файла: {filesize} байт.") + + # Создаем отдельный сокет для передачи файла + transfer_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + transfer_socket.settimeout(5.0) # Таймаут ожидания ACK + + # Добавляем сокет в множество активных сокетов + with self.lock: + self.transfer_sockets.add(transfer_socket) + + block_num = 1 + bytes_sent = 0 + last_progress_time = time.time() + + try: + with open(file_path, "rb") as f: + while self.running: # Проверяем флаг running + try: + data_block = f.read(BLOCK_SIZE) + if not data_block: # Достигнут конец файла + break + + # Проверяем флаг running перед отправкой блока + if not self.running: + raise Exception("Передача прервана: сервер остановлен") + + # Формируем TFTP пакет данных + packet = struct.pack("!HH", 3, block_num) + data_block + attempts = 0 + ack_received = False + + # Попытка отправки текущего блока (до 3 повторных попыток) + while attempts < 3 and not ack_received and self.running: + if transfer_socket is None: + raise Exception("Сокет передачи закрыт") + + try: + transfer_socket.sendto(packet, client_addr) + + # Логируем прогресс каждую секунду + current_time = time.time() + if current_time - last_progress_time >= 1.0: + elapsed_time = current_time - start_time + remaining = filesize - bytes_sent + self.log(f"[STATUS] Клиент: {client_addr} | Файл: {file_basename} | " + f"Отправлено: {bytes_sent}/{filesize} байт | " + f"Осталось: {remaining} байт | " + f"Время: {elapsed_time:.2f} сек.") + last_progress_time = current_time + + # Ожидаем подтверждение + ack_data, addr = transfer_socket.recvfrom(4) + if addr == client_addr: + ack_opcode, ack_block = struct.unpack("!HH", ack_data) + if ack_opcode == 4 and ack_block == block_num: + ack_received = True + bytes_sent += len(data_block) + with self.lock: + if client_addr in self.active_transfers: + self.active_transfers[client_addr]['bytes_sent'] = bytes_sent + else: + self.log(f"[WARN] Неверный ACK от {client_addr}. " + f"Ожидался блок {block_num}, получен {ack_block}.") + except socket.timeout: + attempts += 1 + self.log(f"[WARN] Таймаут ожидания ACK для блока {block_num} " + f"от {client_addr}. Попытка {attempts+1}.") + except socket.error as e: + if not self.running: + raise Exception("Передача прервана: сервер остановлен") + self.log(f"[ERROR] Ошибка сокета при отправке блока {block_num}: {str(e)}") + attempts += 1 + except Exception as e: + if not self.running: + raise Exception("Передача прервана: сервер остановлен") + self.log(f"[ERROR] Ошибка при отправке блока {block_num}: {str(e)}") + attempts += 1 + + if not ack_received: + raise Exception(f"Не удалось получить подтверждение для блока {block_num}") + + block_num = (block_num + 1) % 65536 + + except Exception as e: + if not self.running: + raise Exception("Передача прервана: сервер остановлен") + raise + + if bytes_sent == filesize: + elapsed_time = time.time() - start_time + self.log(f"[INFO] Передача файла '{file_basename}' клиенту {client_addr} " + f"завершена за {elapsed_time:.2f} сек. Всего отправлено {bytes_sent} байт.") + except Exception as e: + if not self.running: + self.log(f"[INFO] Передача файла '{file_basename}' клиенту {client_addr} прервана: сервер остановлен") + raise + + except Exception as e: + if not self.running: + return # Не логируем повторно о прерывании передачи + self.log(f"[ERROR] Ошибка при передаче файла '{os.path.basename(file_path)}' " + f"клиенту {client_addr}: {str(e)}") + try: + self.send_error(client_addr, 0, str(e)) + except: + pass + finally: + # Закрываем сокет передачи + if transfer_socket: + try: + with self.lock: + self.transfer_sockets.discard(transfer_socket) + transfer_socket.close() + transfer_socket = None + except: + pass + + # Удаляем информацию о передаче + with self.lock: + if client_addr in self.active_transfers: + del self.active_transfers[client_addr] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d11b739 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +tftpy>=0.8.0 \ No newline at end of file