371 lines
14 KiB
Python
371 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
TEM 下位机模拟器 — 用于 App 调试
|
||
用法:
|
||
python tools/mock_device.py
|
||
python tools/mock_device.py --host 0.0.0.0 --port 4321
|
||
python tools/mock_device.py --channels 3 --freq 2 # 3通道, 2Hz
|
||
|
||
在 App 连接界面将 IP 改为本机局域网 IP(或 10.0.2.2 若在 Android 模拟器中)。
|
||
"""
|
||
|
||
import socket
|
||
import struct
|
||
import threading
|
||
import time
|
||
import math
|
||
import random
|
||
import argparse
|
||
import sys
|
||
from datetime import datetime
|
||
|
||
# ── 协议常量 ──────────────────────────────────────────────────────────────────
|
||
|
||
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
|
||
|
||
_FC_NAME = {
|
||
0x01: 'SETUP_REQ', 0x02: 'CONTINUOUS_REQ', 0x03: 'SINGLE_REQ',
|
||
0x04: 'STOP_REQ', 0x05: 'ACTIVE_REQ', 0x08: 'SPLITFRAME_REQ',
|
||
0x81: 'SETUP_ACK', 0x82: 'CONTINUOUS_ACK', 0x83: 'SINGLE_ACK',
|
||
0x84: 'STOP_ACK', 0x85: 'DATA_ACK',
|
||
}
|
||
|
||
# sendFreq code → Hz
|
||
SEND_FREQ_HZ = {
|
||
0x00: 0.5, 0x01: 1.0, 0x02: 2.0, 0x03: 4.0,
|
||
0x04: 8.0, 0x05: 12.5, 0x06: 16.0, 0x07: 25.0,
|
||
0x08: 32.0, 0x09: 50.0, 0x0a: 64.0,
|
||
}
|
||
|
||
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]
|
||
|
||
# ── 帧构建 ──────────────────────────────────────────────────────────────────
|
||
|
||
def build_frame(func: int, payload: bytes) -> bytes:
|
||
header = MAGIC + struct.pack('<BBI', FRAME_FLAG, func, len(payload))
|
||
return header + payload
|
||
|
||
def build_ack(func_ack: int, ok: bool = True, reason: str = '') -> bytes:
|
||
payload = bytearray(54)
|
||
payload[0] = 0x01 if ok else 0x02
|
||
if reason:
|
||
encoded = reason.encode('ascii', errors='replace')[:31]
|
||
payload[22:22 + len(encoded)] = encoded
|
||
return build_frame(func_ack, bytes(payload))
|
||
|
||
def build_data_frame(cfg: dict, phase: float) -> bytes:
|
||
"""
|
||
构建 DATA_ACK 帧,包含 54 字节元数据 + channelNum*sampleDepth 个 int32 ADC 采样。
|
||
phase: 当前帧的相位偏移(秒),用于生成连续正弦波形。
|
||
"""
|
||
ch_num = cfg['channelNum']
|
||
depth = cfg['sampleDepth']
|
||
amp_idx = cfg['ampRatio']
|
||
acc_num = max(cfg['accNum'], 1)
|
||
gain = AMP_GAIN[amp_idx] if amp_idx < len(AMP_GAIN) else 1.0
|
||
|
||
# 54 字节元数据(与 parser.ts parseMetadata 对齐)
|
||
meta = bytearray(54)
|
||
meta[0] = 1 # devId
|
||
struct.pack_into('<I', meta, 1, int(time.time())) # utc
|
||
struct.pack_into('<d', meta, 5, 116.3972) # longitude(北京)
|
||
struct.pack_into('<d', meta, 13, 39.9086) # latitude
|
||
struct.pack_into('<f', meta, 21, 50.0) # altitude
|
||
struct.pack_into('<f', meta, 25, 1.0) # height
|
||
meta[29] = 0x11 # sdStatus=1, gpsStatus=1
|
||
meta[30] = amp_idx & 0xFF # ampRatio
|
||
struct.pack_into('<f', meta, 31, 0.0) # roll
|
||
struct.pack_into('<f', meta, 35, 2.5) # pitch(模拟轻微倾斜)
|
||
struct.pack_into('<f', meta, 39, 0.0) # yaw
|
||
meta[43] = ch_num & 0xFF # channelNum
|
||
struct.pack_into('<I', meta, 44, 1000) # current
|
||
struct.pack_into('<H', meta, 48, 2100) # temperature(raw ADC ~25°C)
|
||
struct.pack_into('<H', meta, 50, 3760) # batteryVolt(raw ~3.7V)
|
||
meta[52] = cfg.get('sourceMode', 0) & 0xFF
|
||
|
||
# ADC 采样数据:每通道不同频率的正弦信号
|
||
SIGNAL_UV = 5e5 # 信号幅度 0.5V 换算为 μV
|
||
NOISE_RATIO = 0.02 # 2% 噪声
|
||
|
||
# 各通道信号频率(单位 Hz,相对于 sampleDepth 归一化)
|
||
CH_FREQS = [1.0, 2.0, 3.0, 5.0, 7.0, 11.0]
|
||
|
||
adc_bytes = bytearray(ch_num * depth * 4)
|
||
for ch in range(ch_num):
|
||
freq = CH_FREQS[ch % len(CH_FREQS)]
|
||
for s in range(depth):
|
||
t_norm = s / depth + phase * freq
|
||
uv = SIGNAL_UV * math.sin(2 * math.pi * t_norm)
|
||
uv += random.gauss(0, SIGNAL_UV * NOISE_RATIO)
|
||
raw = int(uv * gain * acc_num)
|
||
raw = max(-0x80000000, min(0x7FFFFFFF, raw))
|
||
struct.pack_into('<i', adc_bytes, (ch * depth + s) * 4, raw)
|
||
|
||
return build_frame(FC.DATA_ACK, bytes(meta) + bytes(adc_bytes))
|
||
|
||
|
||
# ── TCP 帧解析器 ────────────────────────────────────────────────────────────
|
||
|
||
class FrameParser:
|
||
def __init__(self):
|
||
self.buf = bytearray()
|
||
|
||
def feed(self, data: bytes):
|
||
self.buf += data
|
||
frames = []
|
||
while len(self.buf) >= 10:
|
||
# 搜索帧头
|
||
idx = -1
|
||
for i in range(len(self.buf) - 3):
|
||
if self.buf[i:i+4] == b'\x68\x68\xff\xff':
|
||
idx = i
|
||
break
|
||
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
|
||
payload_len = struct.unpack_from('<I', self.buf, 6)[0]
|
||
if payload_len > 400 * 1024: # 异常长度,跳过此帧头
|
||
self.buf = self.buf[4:]
|
||
continue
|
||
total = 10 + payload_len
|
||
if len(self.buf) < total:
|
||
break
|
||
func = self.buf[5]
|
||
payload = bytes(self.buf[10:total])
|
||
frames.append((func, payload))
|
||
self.buf = self.buf[total:]
|
||
return frames
|
||
|
||
|
||
# ── 客户端会话 ──────────────────────────────────────────────────────────────
|
||
|
||
class Session:
|
||
def __init__(self, sock: socket.socket, addr, cli_cfg: dict):
|
||
self.sock = sock
|
||
self.addr = addr
|
||
self.parser = FrameParser()
|
||
self.phase = 0.0 # 当前正弦相位(秒)
|
||
self.running = False # 连续采集中?
|
||
self._lock = threading.Lock()
|
||
|
||
# 当前设备配置(可被 SETUP_REQ 覆盖)
|
||
self.cfg = {
|
||
'channelNum': cli_cfg.get('channels', 6),
|
||
'sendFreq': cli_cfg.get('freq_code', 0x01),
|
||
'sampleFreq': 0x03,
|
||
'sampleDepth': cli_cfg.get('depth', 256),
|
||
'accNum': 1,
|
||
'ampRatio': 3, # 1×
|
||
'sourceMode': 0x00,
|
||
}
|
||
|
||
def _ts(self):
|
||
return datetime.now().strftime('%H:%M:%S.%f')[:-3]
|
||
|
||
def log(self, msg: str, direction: str = ' '):
|
||
tag = f'{direction} [{self.addr[0]}:{self.addr[1]}]'
|
||
print(f'[{self._ts()}] {tag} {msg}')
|
||
|
||
def send(self, data: bytes) -> bool:
|
||
try:
|
||
self.sock.sendall(data)
|
||
return True
|
||
except OSError:
|
||
return False
|
||
|
||
# ── 连续发送线程 ──────────────────────────────────────────────────────
|
||
|
||
def _data_loop(self):
|
||
freq_hz = SEND_FREQ_HZ.get(self.cfg['sendFreq'], 1.0)
|
||
interval = 1.0 / freq_hz
|
||
self.log(f'开始连续推送 @ {freq_hz} Hz(间隔 {interval*1000:.0f} ms)', '⇄')
|
||
while self.running:
|
||
t0 = time.monotonic()
|
||
frame = build_data_frame(self.cfg, self.phase)
|
||
if not self.send(frame):
|
||
break
|
||
payload_size = len(frame) - 10
|
||
self.log(f'DATA_ACK ch={self.cfg["channelNum"]} '
|
||
f'depth={self.cfg["sampleDepth"]} '
|
||
f'payload={payload_size}B', '→')
|
||
self.phase += interval
|
||
elapsed = time.monotonic() - t0
|
||
sleep_t = interval - elapsed
|
||
if sleep_t > 0:
|
||
time.sleep(sleep_t)
|
||
self.log('连续推送已停止', '⇄')
|
||
|
||
def start_continuous(self):
|
||
if self.running:
|
||
return
|
||
self.running = True
|
||
t = threading.Thread(target=self._data_loop, daemon=True)
|
||
t.start()
|
||
|
||
def stop_continuous(self):
|
||
self.running = False
|
||
|
||
# ── 帧处理 ────────────────────────────────────────────────────────────
|
||
|
||
def _parse_setup(self, payload: bytes):
|
||
if len(payload) < 14:
|
||
return
|
||
self.cfg['channelNum'] = max(1, min(6, payload[0]))
|
||
self.cfg['sendFreq'] = payload[1]
|
||
self.cfg['sampleFreq'] = payload[2]
|
||
depth = struct.unpack_from('<H', payload, 3)[0]
|
||
self.cfg['sampleDepth'] = depth if depth > 0 else 256
|
||
acc_flags = struct.unpack_from('<H', payload, 5)[0]
|
||
acc_num = (acc_flags >> 2) & 0x3FFF
|
||
self.cfg['accNum'] = acc_num if acc_num > 0 else 1
|
||
self.cfg['ampRatio'] = payload[7]
|
||
self.cfg['sourceMode'] = payload[13] if len(payload) > 13 else 0
|
||
prefix = ''
|
||
if len(payload) >= 54:
|
||
raw = payload[38:54]
|
||
prefix = raw.split(b'\x00', 1)[0].decode('ascii', errors='replace')
|
||
freq_hz = SEND_FREQ_HZ.get(self.cfg['sendFreq'], 1.0)
|
||
self.log(
|
||
f'SETUP ch={self.cfg["channelNum"]} '
|
||
f'freq={freq_hz}Hz depth={self.cfg["sampleDepth"]} '
|
||
f'acc={self.cfg["accNum"]} amp=0x{self.cfg["ampRatio"]:02X} '
|
||
f'prefix="{prefix}"', '←'
|
||
)
|
||
|
||
def handle(self, func: int, payload: bytes):
|
||
name = _FC_NAME.get(func, f'0x{func:02X}')
|
||
|
||
if func == FC.SETUP_REQ:
|
||
self._parse_setup(payload)
|
||
self.send(build_ack(FC.SETUP_ACK))
|
||
self.log('SETUP_ACK OK', '→')
|
||
|
||
elif func == FC.CONTINUOUS_REQ:
|
||
self.log('CONTINUOUS_REQ', '←')
|
||
self.send(build_ack(FC.CONTINUOUS_ACK))
|
||
self.log('CONTINUOUS_ACK OK', '→')
|
||
self.start_continuous()
|
||
|
||
elif func == FC.SINGLE_REQ:
|
||
self.log('SINGLE_REQ', '←')
|
||
self.send(build_ack(FC.SINGLE_ACK))
|
||
self.log('SINGLE_ACK OK', '→')
|
||
frame = build_data_frame(self.cfg, self.phase)
|
||
self.send(frame)
|
||
self.log(f'DATA_ACK (单次) {len(frame)-10}B', '→')
|
||
self.phase += 1.0
|
||
|
||
elif func == FC.STOP_REQ:
|
||
self.log('STOP_REQ', '←')
|
||
self.stop_continuous()
|
||
self.send(build_ack(FC.STOP_ACK))
|
||
self.log('STOP_ACK OK', '→')
|
||
|
||
elif func == FC.ACTIVE_REQ:
|
||
pass # keepalive,无需回应
|
||
|
||
elif func == FC.SPLITFRAME_REQ:
|
||
self.log(f'SPLITFRAME_REQ(未实现,忽略)', '←')
|
||
|
||
else:
|
||
self.log(f'未知帧 0x{func:02X} ({len(payload)}B payload)', '←')
|
||
|
||
# ── 主循环 ────────────────────────────────────────────────────────────
|
||
|
||
def run(self):
|
||
self.log('已连接', '✓')
|
||
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('已断开', '✗')
|
||
|
||
|
||
# ── 服务器主入口 ────────────────────────────────────────────────────────────
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(
|
||
description='TEM 下位机模拟器',
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
连接方式:
|
||
Android 模拟器 → App IP 填 10.0.2.2
|
||
真机(同 WiFi) → App IP 填本机局域网 IP(运行 ipconfig 查看)
|
||
Expo Web/桌面 → App IP 填 127.0.0.1
|
||
""",
|
||
)
|
||
parser.add_argument('--host', default='0.0.0.0', help='监听地址 (默认: 0.0.0.0)')
|
||
parser.add_argument('--port', type=int, default=4321, help='监听端口 (默认: 4321)')
|
||
parser.add_argument('--channels', type=int, default=6, help='默认通道数 1-6 (默认: 6)')
|
||
parser.add_argument('--freq', type=float, default=1.0, help='默认发送频率 Hz (默认: 1.0)')
|
||
parser.add_argument('--depth', type=int, default=256, help='默认采样深度 (默认: 256)')
|
||
args = parser.parse_args()
|
||
|
||
# 找到最接近的 freq code
|
||
freq_code = min(SEND_FREQ_HZ, key=lambda k: abs(SEND_FREQ_HZ[k] - args.freq))
|
||
actual_freq = SEND_FREQ_HZ[freq_code]
|
||
|
||
cli_cfg = {
|
||
'channels': min(6, max(1, args.channels)),
|
||
'freq_code': freq_code,
|
||
'depth': max(16, args.depth),
|
||
}
|
||
|
||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
srv.bind((args.host, args.port))
|
||
srv.listen(5)
|
||
|
||
print(f'\n TEM 下位机模拟器')
|
||
print(f' ─────────────────────────────────────────')
|
||
print(f' 监听地址 : {args.host}:{args.port}')
|
||
print(f' 默认通道数: {cli_cfg["channels"]}')
|
||
print(f' 默认频率 : {actual_freq} Hz')
|
||
print(f' 默认采样深: {cli_cfg["depth"]} pts')
|
||
print(f' ─────────────────────────────────────────')
|
||
print(f' 在 App 连接界面将 IP 改为本机局域网 IP')
|
||
print(f' Android 模拟器请填 10.0.2.2')
|
||
print(f' Ctrl+C 停止\n')
|
||
|
||
try:
|
||
while True:
|
||
conn, addr = srv.accept()
|
||
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||
session = Session(conn, addr, cli_cfg)
|
||
t = threading.Thread(target=session.run, daemon=True)
|
||
t.start()
|
||
except KeyboardInterrupt:
|
||
print('\n模拟器已停止')
|
||
finally:
|
||
srv.close()
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|