380 lines
18 KiB
Python
380 lines
18 KiB
Python
import tkinter as tk
|
|
from tkinter import ttk, messagebox
|
|
import json
|
|
import uuid
|
|
import re # 用于验证IP地址和端口
|
|
|
|
from utils import center_toplevel_window
|
|
|
|
class SettingsApp(tk.Toplevel):
|
|
def __init__(self, master, on_save_callback, devices_data):
|
|
super().__init__(master)
|
|
self.master = master
|
|
self.on_save_callback = on_save_callback
|
|
self.original_devices_data = json.loads(json.dumps(devices_data)) # 深拷贝一份原始数据
|
|
self.devices = devices_data # 引用主应用中的设备列表
|
|
|
|
self.title("设备设置")
|
|
self.geometry("800x600")
|
|
|
|
self.create_widgets()
|
|
self.load_devices_to_treeview()
|
|
|
|
# --- 修正点:在所有部件创建并加载数据后,再居中窗口 ---
|
|
center_toplevel_window(self, master)
|
|
# --- 修正点结束 ---
|
|
|
|
self.protocol("WM_DELETE_WINDOW", self.on_closing)
|
|
|
|
# 使 SettingsApp 成为模态窗口
|
|
self.transient(master)
|
|
self.grab_set()
|
|
self.wait_window(self)
|
|
|
|
def create_widgets(self):
|
|
# Top buttons frame
|
|
top_buttons_frame = ttk.Frame(self)
|
|
top_buttons_frame.pack(side=tk.TOP, fill=tk.X, padx=10, pady=5)
|
|
|
|
ttk.Button(top_buttons_frame, text="添加设备", command=self.add_device).pack(side=tk.LEFT, padx=5)
|
|
ttk.Button(top_buttons_frame, text="编辑设备", command=self.edit_selected_device).pack(side=tk.LEFT, padx=5)
|
|
ttk.Button(top_buttons_frame, text="删除设备", command=self.delete_selected_device).pack(side=tk.LEFT, padx=5)
|
|
ttk.Button(top_buttons_frame, text="保存并关闭", command=self.on_closing).pack(side=tk.RIGHT, padx=5)
|
|
|
|
# Device list Treeview
|
|
self.device_tree = ttk.Treeview(self, columns=("Name", "Type", "Protocol", "Address", "Port", "Keep Alive"), show="headings")
|
|
self.device_tree.heading("Name", text="设备名称")
|
|
self.device_tree.heading("Type", text="设备类型")
|
|
self.device_tree.heading("Protocol", text="协议")
|
|
self.device_tree.heading("Address", text="地址")
|
|
self.device_tree.heading("Port", text="端口")
|
|
self.device_tree.heading("Keep Alive", text="保持连接")
|
|
|
|
self.device_tree.column("Name", width=120)
|
|
self.device_tree.column("Type", width=100)
|
|
self.device_tree.column("Protocol", width=80)
|
|
self.device_tree.column("Address", width=120)
|
|
self.device_tree.column("Port", width=60)
|
|
self.device_tree.column("Keep Alive", width=80, anchor=tk.CENTER)
|
|
|
|
self.device_tree.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
|
|
|
|
scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.device_tree.yview)
|
|
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
self.device_tree.config(yscrollcommand=scrollbar.set)
|
|
|
|
self.device_tree.bind("<Double-1>", self.edit_selected_device)
|
|
|
|
def load_devices_to_treeview(self):
|
|
"""将设备数据加载到 Treeview"""
|
|
for i in self.device_tree.get_children():
|
|
self.device_tree.delete(i)
|
|
|
|
for device in self.devices:
|
|
self.device_tree.insert("", tk.END, iid=device["id"],
|
|
values=(device["name"], device["type"], device["protocol"],
|
|
device["address"], device["port"], "是" if device["keep_alive"] else "否"))
|
|
|
|
def add_device(self):
|
|
"""打开一个新窗口以添加设备"""
|
|
self.DeviceEditor(self, None)
|
|
|
|
def edit_selected_device(self, event=None):
|
|
"""编辑选中的设备"""
|
|
selected_item = self.device_tree.focus()
|
|
if selected_item:
|
|
device_id = selected_item
|
|
device = next((d for d in self.devices if d["id"] == device_id), None)
|
|
if device:
|
|
self.DeviceEditor(self, device)
|
|
else:
|
|
messagebox.showerror("错误", "未找到选中的设备。")
|
|
else:
|
|
messagebox.showwarning("提示", "请选择一个设备进行编辑。")
|
|
|
|
def delete_selected_device(self):
|
|
"""删除选中的设备"""
|
|
selected_item = self.device_tree.focus()
|
|
if selected_item:
|
|
if messagebox.askyesno("确认删除", "确定要删除选中的设备吗?", parent=self):
|
|
device_id = selected_item
|
|
self.devices[:] = [d for d in self.devices if d["id"] != device_id]
|
|
self.on_save_callback(self.devices) # 通知主应用保存
|
|
self.load_devices_to_treeview()
|
|
else:
|
|
messagebox.showwarning("提示", "请选择一个设备进行删除。", parent=self)
|
|
|
|
def on_closing(self):
|
|
"""关闭窗口时保存配置"""
|
|
# 检查是否有设备被删除,通知 network_manager 关闭连接
|
|
original_device_ids = {d["id"] for d in self.original_devices_data}
|
|
current_device_ids = {d["id"] for d in self.devices}
|
|
deleted_device_ids = original_device_ids - current_device_ids
|
|
|
|
for device_id in deleted_device_ids:
|
|
# 找到对应的原始设备信息,以便关闭连接
|
|
deleted_device = next((d for d in self.original_devices_data if d["id"] == device_id), None)
|
|
if deleted_device and deleted_device.get("keep_alive"):
|
|
self.master.network_manager.close_connection_for_device(deleted_device)
|
|
|
|
self.on_save_callback(self.devices)
|
|
self.destroy()
|
|
|
|
class DeviceEditor(tk.Toplevel):
|
|
def __init__(self, master_app, device_data=None):
|
|
super().__init__(master_app)
|
|
self.master_app = master_app # SettingsApp instance
|
|
self.device_data = device_data # Existing device data or None for new device
|
|
|
|
self.title("编辑设备" if device_data else "添加设备")
|
|
self.geometry("470x650") # 增加高度以确保所有控件可见
|
|
|
|
self.create_widgets()
|
|
if self.device_data:
|
|
self._load_device_data()
|
|
|
|
# --- 修正点:在所有部件创建并加载数据后,再居中窗口 ---
|
|
center_toplevel_window(self, master_app)
|
|
# --- 修正点结束 ---
|
|
|
|
self.transient(master_app)
|
|
self.grab_set()
|
|
self.wait_window(self)
|
|
|
|
def create_widgets(self):
|
|
main_frame = ttk.Frame(self, padding="10")
|
|
main_frame.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Device Name
|
|
ttk.Label(main_frame, text="设备名称:").grid(row=0, column=0, sticky=tk.W, pady=2)
|
|
self.name_entry = ttk.Entry(main_frame)
|
|
self.name_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), pady=2)
|
|
|
|
# Device Type
|
|
ttk.Label(main_frame, text="设备类型:").grid(row=1, column=0, sticky=tk.W, pady=2)
|
|
self.type_var = tk.StringVar(self)
|
|
self.type_entry = ttk.Entry(main_frame, textvariable=self.type_var)
|
|
self.type_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), pady=2)
|
|
|
|
# Protocol
|
|
ttk.Label(main_frame, text="协议:").grid(row=2, column=0, sticky=tk.W, pady=2)
|
|
self.protocol_var = tk.StringVar(self)
|
|
self.protocol_optionmenu = ttk.OptionMenu(main_frame, self.protocol_var, "TCP", "TCP", "UDP")
|
|
self.protocol_optionmenu.grid(row=2, column=1, sticky=(tk.W, tk.E), pady=2)
|
|
|
|
# Address
|
|
ttk.Label(main_frame, text="地址 (IP/域名):").grid(row=3, column=0, sticky=tk.W, pady=2)
|
|
self.address_entry = ttk.Entry(main_frame)
|
|
self.address_entry.grid(row=3, column=1, sticky=(tk.W, tk.E), pady=2)
|
|
|
|
# Port
|
|
ttk.Label(main_frame, text="端口:").grid(row=4, column=0, sticky=tk.W, pady=2)
|
|
self.port_entry = ttk.Entry(main_frame)
|
|
self.port_entry.grid(row=4, column=1, sticky=(tk.W, tk.E), pady=2)
|
|
|
|
# Keep Alive
|
|
self.keep_alive_var = tk.BooleanVar(self)
|
|
self.keep_alive_checkbutton = ttk.Checkbutton(main_frame, text="保持连接", variable=self.keep_alive_var)
|
|
self.keep_alive_checkbutton.grid(row=5, column=0, columnspan=2, sticky=tk.W, pady=5)
|
|
|
|
# Commands Frame
|
|
self.commands_frame = ttk.LabelFrame(main_frame, text="指令列表", padding="5")
|
|
self.commands_frame.grid(row=6, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5)
|
|
self._create_commands_widgets(self.commands_frame)
|
|
|
|
# Save/Cancel Buttons
|
|
button_frame = ttk.Frame(main_frame)
|
|
button_frame.grid(row=7, column=0, columnspan=2, sticky=tk.E, pady=10)
|
|
ttk.Button(button_frame, text="保存", command=self._save_device).pack(side=tk.LEFT, padx=5)
|
|
ttk.Button(button_frame, text="取消", command=self.destroy).pack(side=tk.LEFT, padx=5)
|
|
|
|
main_frame.columnconfigure(1, weight=1)
|
|
main_frame.rowconfigure(6, weight=1) # 允许 commands_frame 垂直扩展
|
|
main_frame.rowconfigure(7, weight=0) # 确保按钮行不会扩展
|
|
|
|
def _create_commands_widgets(self, parent_frame):
|
|
# Clear existing command widgets
|
|
for widget in parent_frame.winfo_children():
|
|
widget.destroy()
|
|
|
|
self.command_entries = [] # List to hold (name_entry, data_entry, type_var, type_optionmenu) for each command
|
|
|
|
# Add Command Button
|
|
ttk.Button(parent_frame, text="添加指令", command=self._add_command_row).pack(pady=5)
|
|
|
|
# Scrollable frame for commands
|
|
self.command_canvas = tk.Canvas(parent_frame)
|
|
self.command_scrollbar = ttk.Scrollbar(parent_frame, orient="vertical", command=self.command_canvas.yview)
|
|
self.command_scrollable_frame = ttk.Frame(self.command_canvas)
|
|
|
|
# Bind the canvas's configure event to resize the inner frame
|
|
self.command_canvas.bind('<Configure>', self._on_command_canvas_resize)
|
|
|
|
self.command_canvas.create_window((0, 0), window=self.command_scrollable_frame, anchor="nw", tags="scrollable_frame_window")
|
|
self.command_canvas.configure(yscrollcommand=self.command_scrollbar.set)
|
|
|
|
self.command_canvas.pack(side="left", fill="both", expand=True)
|
|
self.command_scrollbar.pack(side="right", fill="y")
|
|
|
|
# Initial command rows if any
|
|
if self.device_data and self.device_data.get("commands"):
|
|
for command in self.device_data["commands"]:
|
|
self._add_command_row(command)
|
|
|
|
def _on_command_canvas_resize(self, event):
|
|
# Update the width of the scrollable frame to match the canvas width
|
|
self.command_canvas.itemconfig("scrollable_frame_window", width=event.width)
|
|
# Update scrollregion
|
|
self.command_canvas.configure(scrollregion=self.command_canvas.bbox("all"))
|
|
|
|
def _add_command_row(self, command_data=None):
|
|
row_frame = ttk.Frame(self.command_scrollable_frame, padding=5, relief=tk.RIDGE, borderwidth=1)
|
|
row_frame.pack(fill=tk.X, expand=True, pady=2) # Make row_frame expand horizontally
|
|
|
|
# Command Name
|
|
ttk.Label(row_frame, text="名称:").pack(side=tk.LEFT)
|
|
name_entry = ttk.Entry(row_frame, width=10)
|
|
name_entry.pack(side=tk.LEFT, padx=2)
|
|
|
|
# Command Data
|
|
ttk.Label(row_frame, text="数据:").pack(side=tk.LEFT)
|
|
data_entry = ttk.Entry(row_frame, width=20) # Keep initial width
|
|
data_entry.pack(side=tk.LEFT, padx=2, fill=tk.X, expand=True) # Allow data_entry to expand
|
|
|
|
# Command Type
|
|
ttk.Label(row_frame, text="类型:").pack(side=tk.LEFT)
|
|
type_var = tk.StringVar(self)
|
|
type_optionmenu = ttk.OptionMenu(row_frame, type_var, "text", "text", "hex")
|
|
type_optionmenu.pack(side=tk.LEFT, padx=2)
|
|
|
|
# Delete button
|
|
delete_btn = ttk.Button(row_frame, text="X", command=lambda: self._delete_command_row(row_frame))
|
|
delete_btn.pack(side=tk.RIGHT)
|
|
|
|
self.command_entries.append((row_frame, name_entry, data_entry, type_var, type_optionmenu))
|
|
|
|
if command_data:
|
|
name_entry.insert(0, command_data.get("name", ""))
|
|
data_entry.insert(0, command_data.get("data", ""))
|
|
type_var.set(command_data.get("type", "text"))
|
|
|
|
self.command_scrollable_frame.update_idletasks()
|
|
self.command_canvas.config(scrollregion=self.command_canvas.bbox("all"))
|
|
|
|
def _delete_command_row(self, row_frame):
|
|
for i, (frame, _, _, _, _) in enumerate(self.command_entries):
|
|
if frame == row_frame:
|
|
self.command_entries.pop(i)
|
|
row_frame.destroy()
|
|
break
|
|
self.command_scrollable_frame.update_idletasks()
|
|
self.command_canvas.config(scrollregion=self.command_canvas.bbox("all"))
|
|
|
|
def _load_device_data(self):
|
|
self.name_entry.insert(0, self.device_data["name"])
|
|
self.type_var.set(self.device_data["type"])
|
|
self.address_entry.insert(0, self.device_data["address"])
|
|
self.port_entry.insert(0, str(self.device_data["port"]))
|
|
self.protocol_var.set(self.device_data["protocol"])
|
|
self.keep_alive_var.set(self.device_data["keep_alive"])
|
|
|
|
def _save_device(self):
|
|
name = self.name_entry.get().strip()
|
|
device_type = self.type_var.get().strip()
|
|
protocol = self.protocol_var.get()
|
|
address = self.address_entry.get().strip()
|
|
port_str = self.port_entry.get().strip()
|
|
keep_alive = self.keep_alive_var.get()
|
|
|
|
# Validation
|
|
if not name:
|
|
messagebox.showerror("错误", "设备名称不能为空。")
|
|
return
|
|
if not device_type:
|
|
messagebox.showerror("错误", "设备类型不能为空。")
|
|
return
|
|
if not address:
|
|
messagebox.showerror("错误", "地址不能为空。")
|
|
return
|
|
if not port_str:
|
|
messagebox.showerror("错误", "端口不能为空。")
|
|
return
|
|
|
|
try:
|
|
port = int(port_str)
|
|
if not (1 <= port <= 65535):
|
|
raise ValueError
|
|
except ValueError:
|
|
messagebox.showerror("错误", "端口号必须是1到65535之间的整数。")
|
|
return
|
|
|
|
# Basic IP/Domain validation
|
|
if not re.match(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$|^([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.[a-zA-Z]{2,6}$", address):
|
|
messagebox.showerror("错误", "地址格式不正确 (IP地址或域名)。")
|
|
return
|
|
|
|
commands = []
|
|
for _, name_entry, data_entry, type_var, _ in self.command_entries:
|
|
cmd_name = name_entry.get().strip()
|
|
cmd_data = data_entry.get().strip()
|
|
cmd_type = type_var.get()
|
|
|
|
if not cmd_name:
|
|
messagebox.showerror("错误", "指令名称不能为空。")
|
|
return
|
|
if not cmd_data:
|
|
messagebox.showerror("错误", "指令数据不能为空。")
|
|
return
|
|
if cmd_type == "hex":
|
|
try:
|
|
bytes.fromhex(cmd_data) # Validate hex string
|
|
except ValueError:
|
|
messagebox.showerror("错误", f"指令 '{cmd_name}' 的十六进制数据格式不正确。")
|
|
return
|
|
|
|
commands.append({
|
|
"name": cmd_name,
|
|
"data": cmd_data,
|
|
"type": cmd_type
|
|
})
|
|
|
|
if not commands:
|
|
messagebox.showerror("错误", "请至少添加一个指令。")
|
|
return
|
|
|
|
# Update existing device or add new one
|
|
if self.device_data:
|
|
# Find the device by ID and update it
|
|
for i, d in enumerate(self.master_app.devices):
|
|
if d["id"] == self.device_data["id"]:
|
|
self.master_app.devices[i].update({
|
|
"name": name,
|
|
"type": device_type,
|
|
"protocol": protocol,
|
|
"address": address,
|
|
"port": port,
|
|
"keep_alive": keep_alive,
|
|
"commands": commands
|
|
})
|
|
self.master_app.master.log_message(f"设备 '{name}' (ID: {self.device_data['id'][:8]}...) 已更新。", "blue")
|
|
break
|
|
else:
|
|
# Add new device
|
|
new_device = {
|
|
"id": str(uuid.uuid4()),
|
|
"name": name,
|
|
"type": device_type,
|
|
"protocol": protocol,
|
|
"address": address,
|
|
"port": port,
|
|
"keep_alive": keep_alive,
|
|
"commands": commands
|
|
}
|
|
self.master_app.devices.append(new_device)
|
|
self.master_app.master.log_message(f"新设备 '{name}' (ID: {new_device['id'][:8]}...) 已添加。", "blue")
|
|
|
|
self.master_app.on_save_callback(self.master_app.devices) # 通知 SettingsApp 保存并刷新
|
|
self.master_app.load_devices_to_treeview() # 刷新设备列表显示
|
|
self.destroy() # Close the editor window
|
|
|