python串口的stream数据mock

参考

zynq的stream数据mock和fifo缓冲和同步.csdn

MockWebSerialAdStream.py

pip install pyserial

py 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
模拟 Web Serial AD 二进制数据流。

这个脚本用于配合前端的 WebSerial AD Stream 测试。它会持续向指定串口写入二进制数据,
前端按同样的协议解析后,就能看到 mock 波形。

单个 sample 的二进制布局如下,所有字段都是 little-endian u32:
    u32 seq   # 递增序号,只用于协议占位和排查丢包,不算 AD 通道
    u32 ad0   # 第 1 路 AD,默认正弦波
    u32 ad1   # 第 2 路 AD,默认三角波
    u32 ad2   # 第 3 路 AD,默认方波
    u32 ad3   # 第 4 路 AD,默认常量

默认 4 路 AD,seq 不计入通道数。前端页面里"通道数"应设置为 4。

默认每 100 ms 发送 1 个 packet。packet 内包含多少组 sample,由下面公式决定:
    samples_per_packet = sample_rate_hz * interval_ms / 1000

例如默认 sample_rate_hz=1000、interval_ms=100,则每 100ms 发 100 组 sample。
每组 sample 是 5 个 u32,也就是 20 字节,所以默认每包 2000 字节。
"""

from __future__ import annotations

import argparse
import math
import struct
import sys
import time
from typing import BinaryIO


# 默认 AD 通道数。注意 seq 不算通道,所以 4 表示 ad0~ad3 四路。
DEFAULT_CHANNEL_COUNT = 4

# 默认 100ms 发一次 packet。这个是"吐包周期",不是采样周期。
DEFAULT_INTERVAL_MS = 100

# 默认每通道 1000 Sa/s。配合 100ms 周期,每包会有 100 组 sample,正弦波会更光滑。
DEFAULT_SAMPLE_RATE_HZ = 1000

# 默认串口波特率。前端 Web Serial 里也要选同样的波特率。
DEFAULT_BAUD_RATE = 921600

# 波形振幅。最终 AD 值大致在 center +/- amplitude 范围内。
DEFAULT_AMPLITUDE = 1200

# AD 中心值。这里模拟无符号 AD,所以波形围绕 2048 上下变化。
DEFAULT_CENTER = 2048

# 所有动态波形默认 1Hz,方便肉眼观察完整周期。
DEFAULT_WAVE_FREQUENCY_HZ = 1.0


def clamp(value: float, min_value: float, max_value: float) -> float:
    """把数值限制在指定范围内,避免生成负数或超过 u32 上限。"""
    return min(max(value, min_value), max_value)


def triangle_wave(t: float, frequency_hz: float) -> float:
    """
    生成 -1~1 的三角波。

    phase 是当前时间在一个周期里的位置,范围 0~1。
    公式 1 - 4 * abs(phase - 0.5) 会得到:
        phase=0.0 -> -1
        phase=0.5 ->  1
        phase=1.0 -> -1
    """
    phase = (t * frequency_hz) % 1.0
    return 1.0 - 4.0 * abs(phase - 0.5)


def square_wave(t: float, frequency_hz: float) -> float:
    """
    生成 -1 或 1 的方波。

    一个周期前半段输出 1,后半段输出 -1。
    """
    phase = (t * frequency_hz) % 1.0
    return 1.0 if phase < 0.5 else -1.0


def create_sample_values(seq: int, sample_rate_hz: int, channel_count: int, amplitude: int, center: int) -> list[int]:
    """
    根据 seq 生成当前 sample 的各路 AD 值。

    seq 每增加 1,就表示时间向后推进 1 / sample_rate_hz 秒。
    这样即使每次发送多个 sample,波形时间轴也是连续的。
    """
    t = seq / sample_rate_hz
    values: list[int] = []

    for channel_index in range(channel_count):
        if channel_index == 0:
            # ad0:正弦波,范围大致是 center +/- amplitude。
            value = round(center + amplitude * math.sin(2 * math.pi * DEFAULT_WAVE_FREQUENCY_HZ * t))
        elif channel_index == 1:
            # ad1:三角波,范围同样是 center +/- amplitude。
            value = round(center + amplitude * triangle_wave(t, DEFAULT_WAVE_FREQUENCY_HZ))
        elif channel_index == 2:
            # ad2:方波,只在 center + amplitude 和 center - amplitude 之间跳变。
            value = round(center + amplitude * square_wave(t, DEFAULT_WAVE_FREQUENCY_HZ))
        else:
            # ad3 以及更多手动扩展的通道:常量,方便确认通道解析和数码管显示是否稳定。
            value = center

        # 协议使用 u32,因此这里把值限制到 0~0xFFFFFFFF。
        values.append(int(clamp(value, 0, 0xFFFFFFFF)))

    return values


def create_packet(
    start_seq: int,
    samples_per_packet: int,
    sample_rate_hz: int,
    channel_count: int,
    amplitude: int,
    center: int,
) -> bytes:
    """
    创建一整个 packet 的二进制内容。

    packet 里可以包含多组 sample。每组 sample 都按:
        seq, ad0, ad1, ad2, ad3...
    的顺序追加到 words 数组,最后一次性 struct.pack 成小端 u32 字节流。
    """
    words: list[int] = []

    for sample_offset in range(samples_per_packet):
        # seq 使用 u32 回绕,避免长时间运行后超过协议字段范围。
        seq = (start_seq + sample_offset) & 0xFFFFFFFF

        # 每一组 sample 的第一个字段永远是 seq;前端解析时会跳过这个字段。
        words.append(seq)

        # 紧跟 seq 后面的是 channel_count 路 AD 值。
        words.extend(create_sample_values(start_seq + sample_offset, sample_rate_hz, channel_count, amplitude, center))

    # "<" 表示 little-endian;"I" 表示 unsigned int 32-bit。
    return struct.pack(f"<{len(words)}I", *words)


def open_serial(port: str, baud_rate: int) -> BinaryIO:
    """打开串口。这里延迟导入 pyserial,方便 --stdout 模式在没安装 pyserial 时也能用。"""
    try:
        import serial
    except ImportError as exc:
        raise RuntimeError("pyserial is required: pip install pyserial") from exc

    return serial.Serial(port=port, baudrate=baud_rate, timeout=0, write_timeout=1)


def parse_args() -> argparse.Namespace:
    """解析命令行参数,便于测试时临时改串口、波特率、采样率和每包大小。"""
    parser = argparse.ArgumentParser(description="模拟串口 AD 二进制数据流。")
    parser.add_argument("--port", default="COM10", help="要写入的串口,例如 COM10。")
    parser.add_argument("--baud", type=int, default=DEFAULT_BAUD_RATE, help="串口波特率,前端要使用同样的值。")
    parser.add_argument("--interval-ms", type=int, default=DEFAULT_INTERVAL_MS, help="吐包周期,单位 ms,默认 100ms 发一次。")
    parser.add_argument("--sample-rate", type=int, default=DEFAULT_SAMPLE_RATE_HZ, help="每通道采样率,单位 Hz。")
    parser.add_argument(
        "--samples-per-packet",
        type=int,
        default=0,
        help="强制指定每包 sample 数;0 表示按 sample-rate 和 interval-ms 自动计算。",
    )
    parser.add_argument("--channels", type=int, default=DEFAULT_CHANNEL_COUNT, help="AD 通道数,不包含 seq。")
    parser.add_argument("--amplitude", type=int, default=DEFAULT_AMPLITUDE, help="动态波形振幅。")
    parser.add_argument("--center", type=int, default=DEFAULT_CENTER, help="AD 中心值,常量通道也使用这个值。")
    parser.add_argument("--stdout", action="store_true", help="把二进制 packet 写到 stdout,不打开真实串口。")
    return parser.parse_args()


def main() -> int:
    args = parse_args()

    # 基础参数做最小值保护,避免命令行传 0 或负数导致除零、空包等问题。
    channel_count = max(1, int(args.channels))
    sample_rate_hz = max(1, int(args.sample_rate))
    interval_ms = max(1, int(args.interval_ms))

    # 如果用户指定 --samples-per-packet,就优先使用指定值。
    # 否则按"采样率 * 吐包周期"自动推导。
    #
    # 默认:1000Hz * 100ms / 1000 = 100 samples/packet。
    samples_per_packet = (
        max(1, int(args.samples_per_packet))
        if args.samples_per_packet > 0
        else max(1, round(sample_rate_hz * interval_ms / 1000))
    )

    # 这些统计信息只用于启动时打印,方便确认当前真实发送速率。
    packet_interval_seconds = interval_ms / 1000
    bytes_per_sample = 4 * (channel_count + 1)
    bytes_per_packet = bytes_per_sample * samples_per_packet
    data_rate = bytes_per_packet / packet_interval_seconds
    writer: BinaryIO

    if args.stdout:
        # stdout 模式主要用于本地验证二进制格式,不需要 pyserial。
        writer = sys.stdout.buffer
        target = "stdout"
    else:
        # 正常测试 Web Serial 时,写入一个虚拟串口或真实串口。
        writer = open_serial(args.port, max(1, int(args.baud)))
        target = f"{args.port} @ {args.baud}"

    print(
        f"mock serial AD stream -> {target}, channels={channel_count}, "
        f"sample_rate={sample_rate_hz}Hz, interval={interval_ms}ms, "
        f"samples_per_packet={samples_per_packet}, packet={bytes_per_packet}B, "
        f"rate={data_rate:.1f}B/s",
        file=sys.stderr,
    )

    seq = 0

    # 使用 perf_counter 做发送节拍,精度比 time.time 更适合这种周期任务。
    next_send_at = time.perf_counter()

    try:
        while True:
            # 创建当前 packet。packet 内部包含 samples_per_packet 组连续 sample。
            packet = create_packet(
                seq,
                samples_per_packet,
                sample_rate_hz,
                channel_count,
                args.amplitude,
                args.center,
            )

            # 直接写原始二进制字节。不要转 hex / base64 / 文本,否则前端协议解析会错。
            writer.write(packet)
            writer.flush()

            # 推进 seq。因为每包可能有多组 sample,所以一次增加 samples_per_packet。
            seq = (seq + samples_per_packet) & 0xFFFFFFFF

            # 按固定周期发送。用累计 next_send_at 可以减少长期漂移。
            next_send_at += packet_interval_seconds
            sleep_seconds = next_send_at - time.perf_counter()

            if sleep_seconds > 0:
                time.sleep(sleep_seconds)
            else:
                # 如果某次写串口阻塞太久,已经错过下一次发送,就从当前时间重新对齐。
                next_send_at = time.perf_counter()
    except KeyboardInterrupt:
        print("\nstopped", file=sys.stderr)
    finally:
        if not args.stdout:
            # Ctrl+C 退出时关闭串口,避免端口被占用。
            writer.close()

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

测试

bash 复制代码
 python .\MockWebSerialAdStream.py --port COM28
 # 每100ms 输出 100个包
 python .\MockWebSerialAdStream.py --port COM28  --samples-per-packet 100
bash 复制代码
00 00 00 00 00 08 00 00 50 03 00 00 B0 0C 00 00 00 08 00 00 
01 00 00 00 08 08 00 00 55 03 00 00 B0 0C 00 00 00 08 00 00 
02 00 00 00 0F 08 00 00 5A 03 00 00 B0 0C 00 00 00 08 00 00 
03 00 00 00 17 08 00 00 5E 03 00 00 B0 0C 00 00 00 08 00 00 
04 00 00 00 1E 08 00 00 63 03 00 00 B0 0C 00 00 00 08 00 00 
05 00 00 00 26 08 00 00 68 03 00 00 B0 0C 00 00 00 08 00 00 
相关推荐
晚风醉蝶1 小时前
1-16-计数排序-CountingSort
python·算法·排序算法
用户7783366132111 小时前
用搜索数据 API 做一个关键词联想组件(防抖 + 缓存 + 可复用)
python·api
AC赳赳老秦2 小时前
企业标签体系搭建:基于 OpenClaw 采集的多维度公开数据,构建企业画像标签库
java·运维·服务器·python·信息可视化·deepseek·openclaw
吃旺旺雪饼的小男孩2 小时前
U-Net 语义分割详解:原论文、PyTorch 实现与真实消融实
人工智能·pytorch·python·算法
XZ-0700012 小时前
1-1-可视化-练习
开发语言·python
今天AI了吗2 小时前
AI Agent 在数据分析领域的落地判断:哪些场景真的需要 Agent
java·数据库·人工智能·python·sql·数据分析·copilot
橙橙笔记2 小时前
Python学习第三部分
开发语言·python·学习
huainingning2 小时前
个人版WorkBuddy编写设备巡检并导出设备配置python脚本
开发语言·python
欧叶冲冲冲2 小时前
uv Python 环境管理笔记
笔记·python·uv