#!/usr/bin/env python3 """ TEM 下位机模拟器 (GUI 版) 依赖: Python 3.8+ 标准库 (tkinter, socket, threading, struct, math, random) 用法: python tools/mock_device_gui.py [--port 4321] """ import tkinter as tk from tkinter import ttk, scrolledtext import socket import struct import threading import time import math import random import argparse from datetime import datetime from typing import Optional # ── 协议常量 ────────────────────────────────────────────────────────────────── MAGIC = b'\x68\x68\xff\xff' FRAME_FLAG = 0xFE class FC: SETUP_REQ = 0x01 CONTINUOUS_REQ = 0x02 SINGLE_REQ = 0x03 STOP_REQ = 0x04 ACTIVE_REQ = 0x05 SPLITFRAME_REQ = 0x08 SETUP_ACK = 0x81 CONTINUOUS_ACK = 0x82 SINGLE_ACK = 0x83 STOP_ACK = 0x84 DATA_ACK = 0x85 SEND_FREQ_OPTIONS = [ (0x00, '0.5 Hz'), (0x01, '1 Hz'), (0x02, '2 Hz'), (0x03, '4 Hz'), (0x04, '8 Hz'), (0x05, '12.5 Hz'), (0x06, '16 Hz'), (0x07, '25 Hz'), (0x08, '32 Hz'), (0x09, '50 Hz'), (0x0a, '64 Hz'), ] SEND_FREQ_HZ = {c: float(lbl.split()[0]) for c, lbl in SEND_FREQ_OPTIONS} AMP_GAIN = [0.125, 0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 1.0] AMP_LABELS = ['1/8×','1/4×','1/2×','1×','2×','4×','8×','16×','32×','64×','128×','自动'] SIGNAL_TYPES = ['正弦衰减', '指数衰减', '正弦波', '阶跃响应', '纯噪声', '零信号'] CH_COLORS = ['#e05050', '#44aa44', '#4466ff', '#dd9900', '#9944dd', '#00aacc'] # ── 信号生成 ────────────────────────────────────────────────────────────────── _CH_DECAY = [0.5, 0.8, 1.2, 0.3, 1.8, 0.7] _CH_FREQ = [1.0, 2.0, 3.0, 5.0, 7.0, 11.0] def gen_signal(sig_type: int, ch: int, s: int, total: int, phase: float) -> float: """归一化信号值 -1..1""" if total <= 0: return 0.0 t = s / total # 0..1 归一化时间 dk = _CH_DECAY[ch % len(_CH_DECAY)] freq = _CH_FREQ [ch % len(_CH_FREQ)] if sig_type == 0: # 正弦衰减 return math.exp(-t * dk * 8) * math.sin(2 * math.pi * (freq * t + phase * freq)) elif sig_type == 1: # 指数衰减 return math.exp(-t * dk * 8) * math.cos(2 * math.pi * phase * 0.3) elif sig_type == 2: # 正弦 return math.sin(2 * math.pi * (freq * t + phase * freq)) elif sig_type == 3: # 阶跃响应 rise = 1.0 - math.exp(-t * 20) return rise * math.exp(-t * dk * 5) elif sig_type == 4: # 纯噪声 return random.gauss(0, 0.3) else: # 零 return 0.0 # ── 帧构建 ────────────────────────────────────────────────────────────────── def _pack_frame(func: int, payload: bytes) -> bytes: return MAGIC + struct.pack(' bytes: p = bytearray(54) p[0] = 0x01 if ok else 0x02 return _pack_frame(func_ack, bytes(p)) def build_data_frame(snap: dict, phase: float) -> bytes: ch = snap['channelNum'] depth = snap['sampleDepth'] amp = snap['ampRatio'] acc = max(snap['accNum'], 1) gain = AMP_GAIN[amp] if amp < len(AMP_GAIN) else 1.0 sig = snap['signalType'] sig_uv = snap['signalUV'] noise = snap['noiseRatio'] meta = bytearray(54) meta[0] = 1 struct.pack_into(' 0 else 0 raw = int(v * sig_uv * gain * acc) raw = max(-0x80000000, min(0x7FFFFFFF, raw)) struct.pack_into('= 10: idx = next((i for i in range(len(self.buf) - 3) if self.buf[i:i+4] == b'\x68\x68\xff\xff'), -1) if idx == -1: self.buf = bytearray(self.buf[-3:]) if len(self.buf) >= 3 else bytearray() break if idx > 0: self.buf = self.buf[idx:] if len(self.buf) < 10: break plen = struct.unpack_from(' 400 * 1024: self.buf = self.buf[4:] continue total = 10 + plen if len(self.buf) < total: break frames.append((self.buf[5], bytes(self.buf[10:total]))) self.buf = self.buf[total:] return frames # ── 模拟配置(主线程写,采集线程读) ──────────────────────────────────────────── class SimConfig: def __init__(self): self.channel_num = 6 self.send_freq = 0x01 self.sample_depth = 256 self.acc_num = 1 self.amp_ratio = 3 self.source_mode = 0 self.signal_type = 0 self.signal_uv = 5e5 self.noise_ratio = 0.02 self.lat = 39.9086 self.lon = 116.3972 self.alt = 50.0 self.gps_walk = False self.gps_status = 1 self.sd_status = 1 self.roll = 0.0 self.pitch = 2.5 self.yaw = 0.0 self.batt_raw = 3760 self.temp_raw = 2100 self.batt_drain = False self.frame_count = 0 self.bytes_sent = 0 def snapshot(self) -> dict: return { 'channelNum': self.channel_num, 'sendFreq': self.send_freq, 'sampleDepth': self.sample_depth, 'accNum': self.acc_num, 'ampRatio': self.amp_ratio, 'sourceMode': self.source_mode, 'signalType': self.signal_type, 'signalUV': self.signal_uv, 'noiseRatio': self.noise_ratio, 'latitude': self.lat, 'longitude': self.lon, 'altitude': self.alt, 'gpsStatus': self.gps_status, 'sdStatus': self.sd_status, 'roll': self.roll, 'pitch': self.pitch, 'yaw': self.yaw, 'tempRaw': self.temp_raw, 'battRaw': self.batt_raw, } # ── 客户端会话 ───────────────────────────────────────────────────────────────── class Session: def __init__(self, sock: socket.socket, addr, cfg: SimConfig, on_event): self.sock = sock self.addr = addr self.cfg = cfg self.on_event = on_event self.parser = FrameParser() self.running = False self.phase = 0.0 def _ev(self, kind: str, data=None): self.on_event(kind, data) def _log(self, msg: str, tag: str = 'info'): self._ev('log', (tag, f'{self.addr[0]}:{self.addr[1]} | {msg}')) def _send(self, data: bytes) -> bool: try: self.sock.sendall(data) self.cfg.bytes_sent += len(data) return True except OSError: return False # ── 连续发送线程 ── def _data_loop(self): self._log('开始连续推送', 'send') while self.running: t0 = time.monotonic() snap = self.cfg.snapshot() hz = SEND_FREQ_HZ.get(snap['sendFreq'], 1.0) ivl = 1.0 / hz frame = build_data_frame(snap, self.phase) if not self._send(frame): break self.cfg.frame_count += 1 self.phase += ivl if self.cfg.gps_walk: self.cfg.lon += random.gauss(0, 5e-6) self.cfg.lat += random.gauss(0, 3e-6) if self.cfg.batt_drain and self.cfg.batt_raw > 2800: if self.cfg.frame_count % 20 == 0: self.cfg.batt_raw -= 1 self._ev('frame', {'ch': snap['channelNum'], 'depth': snap['sampleDepth'], 'bytes': len(frame) - 10, 'hz': hz}) elapsed = time.monotonic() - t0 if ivl - elapsed > 0.002: time.sleep(ivl - elapsed) self._log('连续推送已停止', 'info') self._ev('acq_status', '已停止') def start_continuous(self): if self.running: return self.running = True threading.Thread(target=self._data_loop, daemon=True).start() self._ev('acq_status', '连续推送') def stop_continuous(self): self.running = False # ── 帧处理 ── def _parse_setup(self, payload: bytes): if len(payload) < 8: return self.cfg.channel_num = max(1, min(6, payload[0])) self.cfg.send_freq = payload[1] depth = struct.unpack_from(' 0 else 256 acc_flags = struct.unpack_from('> 2) & 0x3FFF self.cfg.acc_num = acc if acc > 0 else 1 self.cfg.amp_ratio = payload[7] if payload[7] < len(AMP_GAIN) else 3 if len(payload) > 13: self.cfg.source_mode = payload[13] hz = SEND_FREQ_HZ.get(self.cfg.send_freq, 1.0) self._log(f'SETUP ch={self.cfg.channel_num} freq={hz}Hz ' f'depth={self.cfg.sample_depth} acc={self.cfg.acc_num} ' f'amp={AMP_LABELS[self.cfg.amp_ratio]}', 'recv') self._ev('config_changed', None) def handle(self, func: int, payload: bytes): if func == FC.SETUP_REQ: self._parse_setup(payload) self._send(build_ack(FC.SETUP_ACK)) self._log('SETUP_ACK →', 'send') elif func == FC.CONTINUOUS_REQ: self._log('CONTINUOUS_REQ ←', 'recv') self._send(build_ack(FC.CONTINUOUS_ACK)) self._log('CONTINUOUS_ACK →', 'send') self.start_continuous() elif func == FC.SINGLE_REQ: self._log('SINGLE_REQ ←', 'recv') self._send(build_ack(FC.SINGLE_ACK)) frame = build_data_frame(self.cfg.snapshot(), self.phase) self._send(frame) self.cfg.frame_count += 1 self.phase += 1.0 self._log(f'DATA_ACK (单次) {len(frame)-10}B →', 'send') self._ev('acq_status', '单次完成') elif func == FC.STOP_REQ: self._log('STOP_REQ ←', 'recv') self.stop_continuous() self._send(build_ack(FC.STOP_ACK)) self._log('STOP_ACK →', 'send') elif func == FC.ACTIVE_REQ: pass elif func == FC.SPLITFRAME_REQ: self._log('SPLITFRAME_REQ (忽略) ←', 'recv') else: self._log(f'未知 0x{func:02X} ←', 'info') def run(self): self._log('已连接 ✓', 'info') self._ev('connected', f'{self.addr[0]}:{self.addr[1]}') try: while True: data = self.sock.recv(4096) if not data: break for func, payload in self.parser.feed(data): self.handle(func, payload) except OSError: pass finally: self.stop_continuous() self.sock.close() self._log('已断开 ✗', 'info') self._ev('disconnected', f'{self.addr[0]}:{self.addr[1]}') # ── GUI 主窗口 ───────────────────────────────────────────────────────────────── class MockDeviceApp: def __init__(self, root: tk.Tk, port: int = 4321): self.root = root self.cfg = SimConfig() self.session: Optional[Session] = None self._srv_sock: Optional[socket.socket] = None self._port = port root.title('TEM 下位机模拟器') root.minsize(860, 560) style = ttk.Style() for theme in ('clam', 'alt', 'default'): try: style.theme_use(theme) break except tk.TclError: pass self._build_ui() self._start_server() root.after(600, self._refresh) # first redraw after window is sized # ────────────────────────────────────────────────────────────────────────── # UI 构建 # ────────────────────────────────────────────────────────────────────────── def _build_ui(self): r = self.root # ── 顶部状态栏 top = ttk.Frame(r, padding=(6, 3)) top.pack(fill='x', side='top') self._sv_var = tk.StringVar(value='启动中…') self._conn_var = tk.StringVar(value='未连接') self._acq_var = tk.StringVar(value='已停止') ttk.Label(top, text='监听:', foreground='gray').pack(side='left') ttk.Label(top, textvariable=self._sv_var, foreground='#666').pack(side='left', padx=(2, 12)) ttk.Label(top, text='客户端:', foreground='gray').pack(side='left') self._conn_lbl = ttk.Label(top, textvariable=self._conn_var, foreground='#999') self._conn_lbl.pack(side='left', padx=(2, 12)) ttk.Label(top, text='采集:', foreground='gray').pack(side='left') self._acq_lbl = ttk.Label(top, textvariable=self._acq_var, foreground='#999') self._acq_lbl.pack(side='left', padx=(2, 0)) ttk.Separator(r, orient='horizontal').pack(fill='x') # ── 主体 body = tk.Frame(r) body.pack(fill='both', expand=True, padx=6, pady=4) # Left panel (fixed width) left = ttk.Frame(body, width=270) left.pack(side='left', fill='y', padx=(0, 6)) left.pack_propagate(False) # Right panel right = ttk.Frame(body) right.pack(side='left', fill='both', expand=True) self._build_signal_frame(left) self._build_position_frame(left) self._build_device_frame(left) self._build_right(right) # ── 底部统计栏 ttk.Separator(r, orient='horizontal').pack(fill='x') bot = ttk.Frame(r, padding=(6, 2)) bot.pack(fill='x', side='bottom') self._stat_var = tk.StringVar(value='帧数: 0 | 发送: 0 B') ttk.Label(bot, textvariable=self._stat_var, foreground='gray', font=('Consolas', 9)).pack(side='left') ttk.Button(bot, text='重置统计', command=self._reset_stats, width=8).pack(side='right') def _build_signal_frame(self, parent): f = ttk.LabelFrame(parent, text='信号配置', padding=6) f.pack(fill='x', padx=2, pady=(0, 4)) def row(label, widget_factory, r): ttk.Label(f, text=label).grid(row=r, column=0, sticky='w', pady=1) w = widget_factory(f) w.grid(row=r, column=1, sticky='we', padx=(4, 0), pady=1) return w # Signal type self._sig_var = tk.StringVar(value=SIGNAL_TYPES[0]) cb = ttk.Combobox(f, textvariable=self._sig_var, values=SIGNAL_TYPES, state='readonly', width=14) ttk.Label(f, text='信号类型').grid(row=0, column=0, sticky='w', pady=1) cb.grid(row=0, column=1, sticky='we', padx=(4, 0), pady=1) cb.bind('<>', lambda _: self._apply_signal()) # Channel count self._ch_var = tk.IntVar(value=6) sp = ttk.Spinbox(f, from_=1, to=6, textvariable=self._ch_var, width=5, command=self._apply_signal) ttk.Label(f, text='通道数').grid(row=1, column=0, sticky='w', pady=1) sp.grid(row=1, column=1, sticky='w', padx=(4, 0), pady=1) # Send freq self._freq_var = tk.StringVar(value='1 Hz') cb2 = ttk.Combobox(f, textvariable=self._freq_var, values=[lbl for _, lbl in SEND_FREQ_OPTIONS], state='readonly', width=10) ttk.Label(f, text='发送频率').grid(row=2, column=0, sticky='w', pady=1) cb2.grid(row=2, column=1, sticky='w', padx=(4, 0), pady=1) cb2.bind('<>', lambda _: self._apply_signal()) # Sample depth self._depth_var = tk.IntVar(value=256) ttk.Label(f, text='采样深度').grid(row=3, column=0, sticky='w', pady=1) ttk.Spinbox(f, from_=16, to=8192, increment=16, textvariable=self._depth_var, width=7, command=self._apply_signal).grid( row=3, column=1, sticky='w', padx=(4, 0), pady=1) # Amplitude self._amp_uv_var = tk.StringVar(value='500000') ttk.Label(f, text='幅度 (μV)').grid(row=4, column=0, sticky='w', pady=1) ttk.Entry(f, textvariable=self._amp_uv_var, width=10).grid( row=4, column=1, sticky='w', padx=(4, 0), pady=1) # Noise % self._noise_var = tk.StringVar(value='2') ttk.Label(f, text='噪声 (%)').grid(row=5, column=0, sticky='w', pady=1) ttk.Entry(f, textvariable=self._noise_var, width=6).grid( row=5, column=1, sticky='w', padx=(4, 0), pady=1) # Amp ratio self._amp_ratio_var = tk.StringVar(value='1×') cb3 = ttk.Combobox(f, textvariable=self._amp_ratio_var, values=AMP_LABELS, state='readonly', width=8) ttk.Label(f, text='增益倍率').grid(row=6, column=0, sticky='w', pady=1) cb3.grid(row=6, column=1, sticky='w', padx=(4, 0), pady=1) cb3.bind('<>', lambda _: self._apply_signal()) # Acc num self._acc_var = tk.IntVar(value=1) ttk.Label(f, text='叠加次数').grid(row=7, column=0, sticky='w', pady=1) ttk.Spinbox(f, from_=1, to=512, textvariable=self._acc_var, width=6, command=self._apply_signal).grid( row=7, column=1, sticky='w', padx=(4, 0), pady=1) ttk.Button(f, text='应用', command=self._apply_signal).grid( row=8, column=1, sticky='e', pady=(4, 0)) f.columnconfigure(1, weight=1) def _build_position_frame(self, parent): f = ttk.LabelFrame(parent, text='位置与姿态', padding=6) f.pack(fill='x', padx=2, pady=(0, 4)) self._lat_var = tk.StringVar(value='39.9086') self._lon_var = tk.StringVar(value='116.3972') self._alt_var = tk.StringVar(value='50.0') self._pitch_var = tk.StringVar(value='2.5') self._roll_var = tk.StringVar(value='0.0') fields = [ ('纬度', self._lat_var), ('经度', self._lon_var), ('海拔 (m)', self._alt_var), ('俯仰角 (°)', self._pitch_var), ('侧倾角 (°)', self._roll_var), ] for r, (lbl, var) in enumerate(fields): ttk.Label(f, text=lbl).grid(row=r, column=0, sticky='w', pady=1) ttk.Entry(f, textvariable=var, width=12).grid( row=r, column=1, sticky='we', padx=(4, 0), pady=1) self._gps_walk_var = tk.BooleanVar(value=False) ttk.Checkbutton(f, text='GPS 走点 (小幅随机漂移)', variable=self._gps_walk_var, command=self._apply_pos).grid( row=len(fields), column=0, columnspan=2, sticky='w', pady=(4, 0)) ttk.Button(f, text='应用', command=self._apply_pos).grid( row=len(fields) + 1, column=1, sticky='e', pady=(4, 0)) f.columnconfigure(1, weight=1) def _build_device_frame(self, parent): f = ttk.LabelFrame(parent, text='设备状态', padding=6) f.pack(fill='x', padx=2, pady=(0, 4)) self._batt_var = tk.StringVar(value='3760') self._temp_var = tk.StringVar(value='2100') self._batt_drain_var = tk.BooleanVar(value=False) self._gps_st_var = tk.BooleanVar(value=True) self._sd_st_var = tk.BooleanVar(value=True) ttk.Label(f, text='电池 raw').grid(row=0, column=0, sticky='w', pady=1) ttk.Entry(f, textvariable=self._batt_var, width=8).grid( row=0, column=1, sticky='w', padx=(4, 0)) ttk.Checkbutton(f, text='模拟耗电', variable=self._batt_drain_var, command=self._apply_dev).grid(row=0, column=2, sticky='w', padx=4) ttk.Label(f, text='温度 raw').grid(row=1, column=0, sticky='w', pady=1) ttk.Entry(f, textvariable=self._temp_var, width=8).grid( row=1, column=1, sticky='w', padx=(4, 0)) ttk.Checkbutton(f, text='GPS 有效', variable=self._gps_st_var, command=self._apply_dev).grid(row=2, column=0, sticky='w', pady=(4,0)) ttk.Checkbutton(f, text='SD 就绪', variable=self._sd_st_var, command=self._apply_dev).grid(row=2, column=1, sticky='w', pady=(4,0)) btn_row = ttk.Frame(f) btn_row.grid(row=3, column=0, columnspan=3, sticky='e', pady=(4, 0)) ttk.Button(btn_row, text='充满电', width=7, command=lambda: (self._batt_var.set('4200'), setattr(self.cfg, 'batt_raw', 4200))).pack(side='left', padx=2) ttk.Button(btn_row, text='应用', width=7, command=self._apply_dev).pack(side='left') def _build_right(self, parent): # ── 波形预览 wf = ttk.LabelFrame(parent, text='波形预览', padding=4) wf.pack(fill='x', padx=2, pady=(0, 4)) self._wc = tk.Canvas(wf, height=140, bg='#0a0a14', highlightthickness=1, highlightbackground='#222') self._wc.pack(fill='x') self._wc.bind('', lambda _: self._redraw_waveform()) # ── 通信日志 lf = ttk.LabelFrame(parent, text='通信日志', padding=4) lf.pack(fill='both', expand=True, padx=2) self._log = scrolledtext.ScrolledText( lf, height=12, font=('Consolas', 9), bg='#080810', fg='#aaaacc', insertbackground='white', state='disabled', wrap='none', ) self._log.pack(fill='both', expand=True) self._log.tag_config('recv', foreground='#88bbff') self._log.tag_config('send', foreground='#88ffaa') self._log.tag_config('info', foreground='#dddd88') self._log.tag_config('error', foreground='#ff7777') ctrl = ttk.Frame(lf) ctrl.pack(fill='x', pady=(2, 0)) ttk.Button(ctrl, text='清空日志', command=self._clear_log, width=8).pack(side='right') ttk.Button(ctrl, text='注入帧', command=self._inject_frame, width=8).pack(side='right', padx=4) # ────────────────────────────────────────────────────────────────────────── # 服务器 # ────────────────────────────────────────────────────────────────────────── def _start_server(self): try: srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(('0.0.0.0', self._port)) srv.listen(3) self._srv_sock = srv except OSError as e: self._sv_var.set(f'启动失败: {e}') return # Detect LAN IP try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.connect(('8.8.8.8', 80)) lan = s.getsockname()[0] s.close() except Exception: lan = '127.0.0.1' self._sv_var.set( f'0.0.0.0:{self._port} 局域网: {lan}:{self._port}' f' Android模拟器: 10.0.2.2:{self._port}' ) threading.Thread(target=self._accept_loop, daemon=True).start() self._log_line('服务器已启动', 'info') def _accept_loop(self): while self._srv_sock: try: conn, addr = self._srv_sock.accept() conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) except OSError: break sess = Session(conn, addr, self.cfg, self._on_event) self.session = sess threading.Thread(target=sess.run, daemon=True).start() # ────────────────────────────────────────────────────────────────────────── # 事件处理(后台线程 → root.after → 主线程) # ────────────────────────────────────────────────────────────────────────── def _on_event(self, kind: str, data): self.root.after(0, lambda k=kind, d=data: self._handle(k, d)) def _handle(self, kind: str, data): if kind == 'log': tag, msg = data self._log_line(msg, tag) elif kind == 'connected': self._conn_var.set(f'已连接: {data}') self._conn_lbl.configure(foreground='#44cc44') elif kind == 'disconnected': self._conn_var.set('未连接') self._conn_lbl.configure(foreground='#999') self._acq_var.set('已停止') self._acq_lbl.configure(foreground='#999') elif kind == 'acq_status': colors = {'连续推送': '#44cc44', '已停止': '#999', '单次完成': '#ddaa44'} self._acq_var.set(data) self._acq_lbl.configure(foreground=colors.get(data, '#999')) elif kind == 'config_changed': self._sync_ui_from_cfg() elif kind == 'frame': pass # stats updated via refresh timer # ────────────────────────────────────────────────────────────────────────── # 控件回调 # ────────────────────────────────────────────────────────────────────────── def _apply_signal(self, *_): try: self.cfg.channel_num = max(1, min(6, self._ch_var.get())) self.cfg.sample_depth = max(16, self._depth_var.get()) self.cfg.acc_num = max(1, self._acc_var.get()) self.cfg.signal_uv = float(self._amp_uv_var.get()) self.cfg.noise_ratio = float(self._noise_var.get()) / 100.0 except (ValueError, tk.TclError): pass sig_name = self._sig_var.get() if sig_name in SIGNAL_TYPES: self.cfg.signal_type = SIGNAL_TYPES.index(sig_name) freq_lbl = self._freq_var.get() for code, lbl in SEND_FREQ_OPTIONS: if lbl == freq_lbl: self.cfg.send_freq = code break amp_lbl = self._amp_ratio_var.get() if amp_lbl in AMP_LABELS: self.cfg.amp_ratio = AMP_LABELS.index(amp_lbl) self._redraw_waveform() def _apply_pos(self, *_): try: self.cfg.lat = float(self._lat_var.get()) self.cfg.lon = float(self._lon_var.get()) self.cfg.alt = float(self._alt_var.get()) self.cfg.pitch = float(self._pitch_var.get()) self.cfg.roll = float(self._roll_var.get()) except ValueError: pass self.cfg.gps_walk = self._gps_walk_var.get() def _apply_dev(self, *_): try: self.cfg.batt_raw = int(self._batt_var.get()) self.cfg.temp_raw = int(self._temp_var.get()) except ValueError: pass self.cfg.batt_drain = self._batt_drain_var.get() self.cfg.gps_status = 1 if self._gps_st_var.get() else 0 self.cfg.sd_status = 1 if self._sd_st_var.get() else 0 def _inject_frame(self): """手动注入一帧(不管当前是否在连续推送中)""" if self.session and self.session.sock: frame = build_data_frame(self.cfg.snapshot(), self.session.phase) try: self.session.sock.sendall(frame) self.cfg.frame_count += 1 self.cfg.bytes_sent += len(frame) self.session.phase += 1.0 self._log_line(f'注入帧 {len(frame)-10}B →', 'send') except OSError as e: self._log_line(f'注入失败: {e}', 'error') else: self._log_line('无连接,无法注入帧', 'error') def _sync_ui_from_cfg(self): """SETUP_REQ 后将配置同步回 UI 控件""" self._ch_var.set(self.cfg.channel_num) self._depth_var.set(self.cfg.sample_depth) self._acc_var.set(self.cfg.acc_num) if self.cfg.amp_ratio < len(AMP_LABELS): self._amp_ratio_var.set(AMP_LABELS[self.cfg.amp_ratio]) freq_lbl = next((lbl for c, lbl in SEND_FREQ_OPTIONS if c == self.cfg.send_freq), '1 Hz') self._freq_var.set(freq_lbl) def _reset_stats(self): self.cfg.frame_count = 0 self.cfg.bytes_sent = 0 # ────────────────────────────────────────────────────────────────────────── # 日志 # ────────────────────────────────────────────────────────────────────────── def _log_line(self, msg: str, tag: str = ''): ts = datetime.now().strftime('%H:%M:%S.%f')[:-3] line = f'[{ts}] {msg}\n' self._log.configure(state='normal') self._log.insert('end', line, tag or '') self._log.see('end') # 保留最近 600 行 n = int(self._log.index('end-1c').split('.')[0]) if n > 600: self._log.delete('1.0', f'{n - 600}.0') self._log.configure(state='disabled') def _clear_log(self): self._log.configure(state='normal') self._log.delete('1.0', 'end') self._log.configure(state='disabled') # ────────────────────────────────────────────────────────────────────────── # 波形预览 # ────────────────────────────────────────────────────────────────────────── def _redraw_waveform(self): c = self._wc c.delete('all') W = c.winfo_width() H = c.winfo_height() if W < 20 or H < 20: return PL, PR, PT, PB = 6, 8, 8, 22 pw = W - PL - PR ph = H - PT - PB # Background fill c.create_rectangle(PL, PT, PL + pw, PT + ph, fill='#0a0a14', outline='#1a1a28') # Horizontal grid for i in range(5): y = PT + i * ph // 4 c.create_line(PL, y, PL + pw, y, fill='#15152a', dash=(3, 4)) # Zero line y0 = PT + ph // 2 c.create_line(PL, y0, PL + pw, y0, fill='#22224a') # Draw each channel curve N = 300 n_ch = min(self.cfg.channel_num, 6) sig = self.cfg.signal_type phase = 0.0 for ch in range(n_ch): pts = [] for i in range(N): v = gen_signal(sig, ch, i, N, phase) v = max(-1.0, min(1.0, v)) x = PL + i * pw // (N - 1) y = PT + ph // 2 - int(v * ph * 0.44) y = max(PT, min(PT + ph, y)) pts.extend([x, y]) if len(pts) >= 4: c.create_line(*pts, fill=CH_COLORS[ch], width=1, smooth=False) # Axes c.create_line(PL, PT, PL, PT + ph, fill='#334', width=1) c.create_line(PL, PT + ph, PL + pw, PT + ph, fill='#334', width=1) # Channel legend (bottom) for ch in range(n_ch): lx = PL + ch * 44 c.create_rectangle(lx, PT + ph + 5, lx + 10, PT + ph + 12, fill=CH_COLORS[ch], outline='') c.create_text(lx + 13, PT + ph + 9, text=f'CH{ch+1}', fill=CH_COLORS[ch], anchor='w', font=('Arial', 7)) # Signal type badge c.create_text(PL + pw - 2, PT + 3, text=SIGNAL_TYPES[sig], fill='#555577', anchor='ne', font=('Arial', 8)) # Amplitude label amp_str = f'{self.cfg.signal_uv/1000:.0f} kμV' if self.cfg.signal_uv >= 1000 else f'{self.cfg.signal_uv:.0f} μV' c.create_text(PL + 2, PT + 3, text=amp_str, fill='#444466', anchor='nw', font=('Arial', 7)) # ────────────────────────────────────────────────────────────────────────── # 定时刷新(主线程,500ms 周期) # ────────────────────────────────────────────────────────────────────────── def _refresh(self): # Sync batt / GPS widgets if auto-changing if self.cfg.batt_drain: self._batt_var.set(str(self.cfg.batt_raw)) if self.cfg.gps_walk: self._lat_var.set(f'{self.cfg.lat:.6f}') self._lon_var.set(f'{self.cfg.lon:.6f}') batt_v = self.cfg.batt_raw / 1000.0 temp_c = (self.cfg.temp_raw - 1820) / 11.0 # rough raw→°C hz_str = next((lbl for c, lbl in SEND_FREQ_OPTIONS if c == self.cfg.send_freq), '?') self._stat_var.set( f'帧数: {self.cfg.frame_count:,} | ' f'发送: {self.cfg.bytes_sent / 1024:.1f} KB | ' f'电池: {batt_v:.3f} V | ' f'温度: {temp_c:.1f} °C | ' f'频率: {hz_str}' ) self._redraw_waveform() self.root.after(500, self._refresh) # ── 入口 ────────────────────────────────────────────────────────────────────── def main(): ap = argparse.ArgumentParser(description='TEM 下位机模拟器 (GUI 版)') ap.add_argument('--port', type=int, default=4321, help='监听端口 (默认: 4321)') args = ap.parse_args() root = tk.Tk() app = MockDeviceApp(root, port=args.port) def _on_close(): if app._srv_sock: try: app._srv_sock.close() except Exception: pass if app.session: app.session.stop_continuous() root.destroy() root.protocol('WM_DELETE_WINDOW', _on_close) root.mainloop() if __name__ == '__main__': main()