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 
相关推荐
特立独行的猫A13 小时前
用仓颉语言 cj-tauri 框架,五分钟给娃做了个鸿蒙形近字游戏
java·后端·python
大可不必了17 小时前
03 函数定义和调用
开发语言·python
承渊政道18 小时前
【Python编程—从入门到实践】(记分与闯关:用Python+Pygame,打造越战越精彩的太空大战!)
开发语言·python·pycharm·游戏程序·pygame
爱学习的程序媛18 小时前
Python常用框架梳理
开发语言·后端·python·ai·web·后端框架·python框架
imgsq18 小时前
GESP Python 一到四级:备考路线与真题特点
python·青少年编程
eggwyw20 小时前
Anaconda安装卸载的详细流程指南
开发语言·python
2601_9679791220 小时前
纯手写:Python安装pip完整教程,搞了几小时,终于成功
python·教程·pip·安装·包管理
Norna21 小时前
缓存文件从没写出来:一个 .json 被当成目录用了
python
aifannihao1 天前
AI 学习第 14 课:CNN 训 6 轮卡在 0.5352,数据没打乱
pytorch·python·深度学习·cnn·学习路线
yuyuxun11 天前
协同过滤的校园二手物品智能推荐系统的设计与实现 毕业设计-附源码40082
javascript·vue.js·spring boot·python·数据分析·django·课程设计