"""
pytcper TCP调试助手 - 公共标签页基类
====================================
客户端 / 服务端标签页公共部分:日志区、发送区、编码与格式选项、
消息泵、循环发送、历史报文、收发统计与资源清理。
"""
import codecs
import os
import queue
import threading
import time
import tkinter as tk
from collections import deque
from tkinter import filedialog, messagebox, scrolledtext, ttk
from tcp_utils import (ENCODINGS, FONT, HISTORY_MAX, LOG_MAX_LINES,
SEND_TIMEOUT, SOCK_TIMEOUT, close_socket, decode_bytes,
encode_text, guess_decodable)
class BaseTab(ttk.Frame):
"""客户端 / 服务端标签页公共部分:日志区、发送区、编码与格式选项。"""
def __init__(self, master, build_top=None):
super().__init__(master)
self.msg_queue = queue.Queue()
self.sock = None
self.server_sock = None
self.clients = {}
self.client_list = []
self.client_times = {}
self.conn_lock = threading.Lock()
self._stats_lock = threading.Lock()
self.closed = False
self._stats = {"tx_msgs": 0, "tx_bytes": 0, "rx_msgs": 0, "rx_bytes": 0}
self._history = deque(maxlen=HISTORY_MAX)
self._hist_index = None
self._loop_running = False
self._loop_after = None
self._loop_left = None
self._loop_interval = 1.0
self._drain_after = None
self._enc = "UTF-8"
self._hex_view = False
self._ts = True
if build_top is not None:
build_top()
self._build_log_area()
self._build_send_area()
self._drain_after = self.after(50, self._drain)
def _build_log_area(self):
bar = ttk.Frame(self)
bar.pack(fill="x", padx=8, pady=(8, 2))
self.var_timestamp = tk.BooleanVar(value=True)
ttk.Checkbutton(bar, text="时间戳", variable=self.var_timestamp).pack(side="left")
self.var_timestamp.trace_add("write", self._cache_settings)
self.var_hex_view = tk.BooleanVar(value=False)
ttk.Checkbutton(bar, text="接收显示HEX", variable=self.var_hex_view).pack(side="left", padx=10)
self.var_hex_view.trace_add("write", self._cache_settings)
self.stats_label = ttk.Label(bar, text="", foreground="#666666")
self.stats_label.pack(side="left", padx=10)
ttk.Button(bar, text="导出记录", command=self._export_log).pack(side="right")
ttk.Button(bar, text="清空记录", command=self._clear_log).pack(side="right", padx=4)
log_frame = ttk.LabelFrame(self, text="收发记录")
log_frame.pack(fill="both", expand=True, padx=8, pady=2)
self.log_text = scrolledtext.ScrolledText(log_frame, height=8, font=FONT, state="disabled")
self.log_text.pack(fill="both", expand=True, padx=4, pady=4)
self.log_text.tag_configure("rx", foreground="#0066CC")
self.log_text.tag_configure("tx", foreground="#CC3300")
self.log_text.tag_configure("sys", foreground="#888888")
self.log_text.tag_configure("addr", foreground="#666666")
def _build_send_area(self):
send_frame = ttk.LabelFrame(self, text="发送")
send_frame.pack(fill="both", padx=8, pady=(2, 8))
self.send_text = scrolledtext.ScrolledText(send_frame, height=4, font=FONT)
self.send_text.pack(fill="both", expand=True, padx=4, pady=4)
self.send_row = ttk.Frame(send_frame)
self.send_row.pack(fill="x", padx=4, pady=(0, 2))
ttk.Label(self.send_row, text="编码:").pack(side="left")
self.var_encoding = tk.StringVar(value="UTF-8")
ttk.Combobox(self.send_row, textvariable=self.var_encoding, values=ENCODINGS,
width=10, state="readonly").pack(side="left", padx=(2, 8))
self.var_encoding.trace_add("write", self._cache_settings)
self.var_hex_send = tk.BooleanVar(value=False)
ttk.Checkbutton(self.send_row, text="HEX发送", variable=self.var_hex_send).pack(side="left")
self.var_newline = tk.BooleanVar(value=False)
ttk.Checkbutton(self.send_row, text="自动追加换行(\\r\\n)",
variable=self.var_newline).pack(side="left", padx=8)
self.send_btn = ttk.Button(self.send_row, text="发送(Alt+S)", command=self.do_send)
self.send_btn.pack(side="right")
self.clear_btn = ttk.Button(self.send_row, text="清除", command=self._clear_send)
self.clear_btn.pack(side="right", padx=(0, 4))
self.send_row2 = ttk.Frame(send_frame)
self.send_row2.pack(fill="x", padx=4, pady=(0, 4))
self.loop_btn = ttk.Button(self.send_row2, text="循环发送", command=self._loop_toggle)
self.loop_btn.pack(side="left")
ttk.Label(self.send_row2, text="间隔(秒):").pack(side="left", padx=(8, 0))
self.var_loop_interval = tk.StringVar(value="1.0")
ttk.Entry(self.send_row2, textvariable=self.var_loop_interval, width=6).pack(side="left", padx=(2, 8))
ttk.Label(self.send_row2, text="次数(0=无限):").pack(side="left")
self.var_loop_count = tk.StringVar(value="0")
ttk.Entry(self.send_row2, textvariable=self.var_loop_count, width=6).pack(side="left", padx=(2, 8))
ttk.Button(self.send_row2, text="发送文件", command=self._send_file).pack(side="left", padx=8)
ttk.Button(self.send_row2, text="历史报文", command=self._open_history).pack(side="left")
self.send_text.bind("<Control-Return>", self._on_ctrl_enter)
self.send_text.bind("<Up>", lambda e: self._nav_history(-1))
self.send_text.bind("<Down>", lambda e: self._nav_history(1))
def log_msg(self, tag, text, addr=""):
"""可从任意线程调用:投递一条日志到主线程渲染。"""
prefix = time.strftime("[%H:%M:%S] ") if self._ts else ""
self.msg_queue.put(("log", (prefix, addr, tag, text)))
def sys_log(self, text):
self.log_msg("sys", text)
def _clear_log(self):
self.log_text.configure(state="normal")
self.log_text.delete("1.0", "end")
self.log_text.configure(state="disabled")
def _export_log(self):
path = filedialog.asksaveasfilename(
title="导出收发记录", defaultextension=".txt",
filetypes=[("文本文件", "*.txt"), ("所有文件", "*.*")])
if not path:
return
self._write_log_to(path)
def _write_log_to(self, path):
try:
with open(path, "w", encoding="utf-8") as f:
f.write(self.log_text.get("1.0", "end-1c"))
except OSError as exc:
messagebox.showerror("错误", "导出失败: %s" % exc)
return
self.sys_log("记录已导出到 %s" % os.path.basename(path))
def bump_stats(self, kind, data):
"""统计一条收发消息(子线程也会调用,加锁防并发丢计数)。"""
with self._stats_lock:
if kind == "tx":
self._stats["tx_msgs"] += 1
self._stats["tx_bytes"] += len(data)
else:
self._stats["rx_msgs"] += 1
self._stats["rx_bytes"] += len(data)
def _refresh_stats_label(self):
st = self._stats
self.stats_label.configure(
text="发送 %d 条 / %d 字节 接收 %d 条 / %d 字节"
% (st["tx_msgs"], st["tx_bytes"], st["rx_msgs"], st["rx_bytes"]))
def _cache_settings(self, *_):
"""把界面上的编码 / 显示选项同步到普通属性,供子线程安全读取。"""
self._enc = self.var_encoding.get()
self._hex_view = self.var_hex_view.get()
self._ts = self.var_timestamp.get()
def _drain(self):
self._cache_settings()
self._refresh_stats_label()
try:
while True:
kind, payload = self.msg_queue.get_nowait()
if kind == "log":
prefix, addr, tag, text = payload
self.log_text.configure(state="normal")
self.log_text.insert("end", prefix)
if addr:
self.log_text.insert("end", addr + " ", "addr")
self.log_text.insert("end", text + "\n", tag)
self.log_text.configure(state="disabled")
self.log_text.see("end")
self._trim_log()
else:
self.handle_msg(kind, payload)
except queue.Empty:
pass
if not self.closed:
self._drain_after = self.after(50, self._drain)
def _trim_log(self):
"""限制日志区行数,避免长时间运行内存无限增长。"""
try:
lines = int(self.log_text.index("end-1c").split(".")[0])
except tk.TclError:
return
if lines > LOG_MAX_LINES:
self.log_text.delete("1.0", "%d.0" % (lines - LOG_MAX_LINES + 1))
def handle_msg(self, kind, payload):
pass
def prepare_send(self, silent=False):
"""读取发送框内容并编码,返回 (raw, data);失败返回 None。"""
raw = self.send_text.get("1.0", "end-1c")
if not raw.strip():
if not silent:
messagebox.showwarning("提示", "发送内容为空")
return None
try:
data = encode_text(raw, self.var_encoding.get(), self.var_hex_send.get())
except ValueError:
if not silent:
messagebox.showerror("错误", "HEX 字符串不合法:需要偶数个十六进制字符,如 01 0A FF")
return None
if self.var_newline.get() and not self.var_hex_send.get():
data += b"\r\n"
return raw, data
def _clear_send(self):
"""清空发送框内容。"""
self.send_text.delete("1.0", "end")
self._hist_index = None
self.send_text.focus_set()
def do_send(self, silent=False):
raise NotImplementedError
def tx_text(self, data):
return decode_bytes(data, self._enc, self.var_hex_send.get())
def decode_stream(self, state, data):
"""接收流增量解码:state 为 (编码, 增量解码器) 或 None。
多字节字符(GBK/UTF-8 等)被 TCP 拆到多个数据包时,逐包独立 decode
会把半个字符替换成乱码;这里按连接维护解码器状态,并返回更新后的
state 供下次调用。编码或「接收显示HEX」切换时自动重建。
当按当前编码解码出替换字符(客户端使用了不同编码)时,用
guess_decodable 对整包字节做自动编码探测,优先显示无乱码的方案
(仅显示用途,不改变流式解码器状态,也不会误伤后续分包)。
"""
if self._hex_view:
return " ".join("%02X" % b for b in data), None
enc = self._enc
if state is None or state[0] != enc:
state = (enc, codecs.getincrementaldecoder(enc)(errors="replace"))
try:
text = state[1].decode(data)
except UnicodeError:
state = (enc, codecs.getincrementaldecoder(enc + "-le")(errors="replace"))
try:
text = state[1].decode(data)
except UnicodeError:
state = None
text = decode_bytes(data, enc, False)
if "\ufffd" in text:
guessed, repl = guess_decodable(data)
if guessed is not None and repl == 0:
text = guessed
return text, state
def sendall_safe(self, sock, data):
"""带超时地 sendall:成功返回 None,失败返回异常对象。
在 Tk 主线程发送必须限时:对端接收窗口已满或网络中断而未被 TCP
检测到时,无超时的 sendall 会阻塞数分钟并冻结整个界面。
"""
try:
sock.settimeout(SEND_TIMEOUT)
try:
sock.sendall(data)
finally:
try:
sock.settimeout(SOCK_TIMEOUT)
except OSError:
pass
except OSError as exc:
return exc
return None
def _send_file(self):
path = filedialog.askopenfilename(title="选择要发送的文件")
if not path:
return
data = self._read_file_bytes(path)
if data is None:
return
self._send_raw(data, "文件(%s)" % os.path.basename(path))
def _read_file_bytes(self, path):
try:
with open(path, "rb") as f:
data = f.read()
except OSError as exc:
messagebox.showerror("错误", "读取文件失败: %s" % exc)
return None
if not data:
messagebox.showwarning("提示", "文件为空")
return None
return data
def record_history(self, raw):
if raw and (not self._history or self._history[-1] != raw):
self._history.append(raw)
self._hist_index = None
def _nav_history(self, step):
"""↑/↓ 键在发送历史中前后切换。"""
if not self._history:
return None
current = self.send_text.get("1.0", "end-1c")
if self._hist_index is None or self._hist_index >= len(self._history) \
or self._history[self._hist_index] != current:
self._hist_index = len(self._history) - 1 if step < 0 else 0
else:
self._hist_index += step
self._hist_index = max(0, min(len(self._history) - 1, self._hist_index))
self.send_text.delete("1.0", "end")
self.send_text.insert("1.0", self._history[self._hist_index])
self.send_text.mark_set("insert", "end")
return "break"
def _open_history(self):
if not self._history:
messagebox.showinfo("历史报文", "暂无历史记录")
return
win = tk.Toplevel(self)
win.title("历史报文(双击填入)")
win.geometry("420x320")
win.transient(self)
lb = tk.Listbox(win, font=FONT)
lb.pack(fill="both", expand=True, padx=6, pady=6)
for item in reversed(self._history):
lb.insert("end", item)
def fill():
sel = lb.curselection()
if not sel:
return False
text = self._history[len(self._history) - 1 - sel[0]]
self.send_text.delete("1.0", "end")
self.send_text.insert("1.0", text)
self._hist_index = None
return True
lb.bind("<Double-Button-1>", lambda e: (fill(), win.destroy()))
ttk.Button(win, text="填入并重发",
command=lambda: (fill() and self.do_send(), win.destroy())).pack(pady=4)
def _loop_toggle(self):
if self._loop_running:
self._stop_loop()
self.sys_log("循环发送已停止")
return
if not self.send_text.get("1.0", "end-1c").strip():
messagebox.showwarning("提示", "发送内容为空")
return
try:
interval = float(self.var_loop_interval.get())
count = int(self.var_loop_count.get())
except ValueError:
messagebox.showerror("错误", "间隔(秒)/次数格式不正确")
return
if interval <= 0:
messagebox.showerror("错误", "间隔必须大于 0")
return
self._loop_running = True
self._loop_interval = interval
self._loop_left = count if count > 0 else None
self.loop_btn.configure(text="停止循环")
self.record_history(self.send_text.get("1.0", "end-1c"))
self.sys_log("开始循环发送(间隔 %.2fs%s)"
% (interval, "" if self._loop_left is None else ",共 %d 次" % count))
self._loop_tick()
def _loop_tick(self):
if self.closed or not self._loop_running:
return
if self._loop_left is not None:
if self._loop_left <= 0:
self._stop_loop()
self.sys_log("循环发送结束")
return
self._loop_left -= 1
if not self.do_send(silent=True):
self._stop_loop()
self.sys_log("循环发送已停止(发送失败或连接断开)")
return
self._loop_after = self.after(int(self._loop_interval * 1000), self._loop_tick)
def _stop_loop(self):
self._loop_running = False
if self._loop_after is not None:
try:
self.after_cancel(self._loop_after)
except Exception:
pass
self._loop_after = None
self.loop_btn.configure(text="循环发送")
def _on_ctrl_enter(self, _event=None):
self.do_send()
return "break"
def shutdown(self):
self.closed = True
self._stop_loop()
if self._drain_after is not None:
try:
self.after_cancel(self._drain_after)
except Exception:
pass
self._drain_after = None
self.close_conns()
def close_conns(self):
sock, self.sock = self.sock, None
if sock is not None:
close_socket(sock)