Add mixed text/plot transport and improve plot UI
This commit is contained in:
@@ -1,112 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SerialPlot 协议测试数据生成器
|
||||
|
||||
协议格式:
|
||||
[Sync: AA 55] [Size: 2B LE] [Payload: N×2B i16 LE] [Checksum: 1B LSB sum of payload]
|
||||
|
||||
用法:
|
||||
python3 gen_plot.py /dev/pts/X --channels 2 --wave sine
|
||||
python3 gen_plot.py /dev/pts/X --channels 2 --wave sine --freq 0.3 # 慢波,易观察
|
||||
python3 gen_plot.py /dev/pts/X --channels 3 --wave saw --freq 0.2
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import math
|
||||
import argparse
|
||||
|
||||
|
||||
def checksum(data: bytes) -> int:
|
||||
return sum(data) & 0xFF
|
||||
|
||||
|
||||
def make_frame(channel_data: list[list[int]]) -> bytes:
|
||||
num_channels = len(channel_data)
|
||||
num_samples = len(channel_data[0])
|
||||
payload = bytearray()
|
||||
for i in range(num_samples):
|
||||
for ch in range(num_channels):
|
||||
payload.extend(struct.pack('<h', channel_data[ch][i]))
|
||||
|
||||
frame = bytearray([0xAA, 0x55])
|
||||
frame.append(len(payload) & 0xFF)
|
||||
frame.append((len(payload) >> 8) & 0xFF)
|
||||
frame.extend(payload)
|
||||
frame.append(checksum(payload))
|
||||
return bytes(frame)
|
||||
|
||||
|
||||
def generate_wave(
|
||||
num_channels: int,
|
||||
num_samples: int,
|
||||
wave_type: str,
|
||||
freq: float,
|
||||
amplitude: int,
|
||||
) -> list[list[int]]:
|
||||
channels = []
|
||||
for ch in range(num_channels):
|
||||
phase_offset = ch * (2 * math.pi / num_channels)
|
||||
samples = []
|
||||
for i in range(num_samples):
|
||||
t = i / num_samples
|
||||
if wave_type == 'sine':
|
||||
angle = 2 * math.pi * t * freq + phase_offset
|
||||
value = math.sin(angle)
|
||||
elif wave_type == 'saw':
|
||||
value = 2 * ((t * freq) % 1.0) - 1.0
|
||||
elif wave_type == 'square':
|
||||
value = 1.0 if ((t * freq) % 2.0) < 1.0 else -1.0
|
||||
else:
|
||||
value = 0.0
|
||||
samples.append(int(value * amplitude))
|
||||
channels.append(samples)
|
||||
return channels
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='SerialPlot test data generator')
|
||||
parser.add_argument('port', help='Serial port or file (e.g. /dev/pts/3)')
|
||||
parser.add_argument('--channels', type=int, default=2, help='Number of channels')
|
||||
parser.add_argument('--samples', type=int, default=200, help='Samples per frame')
|
||||
parser.add_argument('--wave', choices=['sine', 'saw', 'square'], default='sine')
|
||||
parser.add_argument('--freq', type=float, default=0.5,
|
||||
help='Wave frequency (cycles per frame; 0.5 = half a sine wave, easy to observe)')
|
||||
parser.add_argument('--amplitude', type=int, default=8000,
|
||||
help='Amplitude (i16 range, max 32767)')
|
||||
parser.add_argument('--interval', type=float, default=0.05,
|
||||
help='Interval between frames (seconds)')
|
||||
parser.add_argument('--frames', type=int, default=0,
|
||||
help='Number of frames (0=infinite)')
|
||||
args = parser.parse_args()
|
||||
|
||||
count = 0
|
||||
try:
|
||||
with open(args.port, 'wb') as f:
|
||||
print(f"Generating {args.channels}-channel {args.wave} wave → {args.port}")
|
||||
print(f" {args.samples} samples/frame, freq={args.freq} cycles/frame")
|
||||
print(f" amplitude={args.amplitude}, interval={args.interval}s")
|
||||
print(f" Protocol: sync=AA55, i16 LE, 1B size+checksum")
|
||||
print()
|
||||
|
||||
while args.frames == 0 or count < args.frames:
|
||||
count += 1
|
||||
data = generate_wave(args.channels, args.samples, args.wave, args.freq, args.amplitude)
|
||||
frame = make_frame(data)
|
||||
f.write(frame)
|
||||
f.flush()
|
||||
|
||||
vals = [data[ch][0] for ch in range(args.channels)]
|
||||
vals_str = ", ".join(f"ch{ch}={v:+6d}" for ch, v in enumerate(vals))
|
||||
print(f" Frame #{count}: {vals_str} ... ({len(frame)} bytes)", end='\r')
|
||||
|
||||
time.sleep(args.interval)
|
||||
except KeyboardInterrupt:
|
||||
print(f"\nDone. {count} frames sent.")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
278
tools/test_plot.py
Executable file
278
tools/test_plot.py
Executable file
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TCP server that sends plot waveform frames for the current xserial GUI."""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
|
||||
PLOT_ESCAPE = 0x1E
|
||||
PLOT_MARKER = ord("P")
|
||||
|
||||
|
||||
def build_frame(
|
||||
sample_index: int,
|
||||
channels: int,
|
||||
plot_format: str,
|
||||
samples_per_channel: int,
|
||||
amplitude: float,
|
||||
frequency_hz: float,
|
||||
sample_rate_hz: float,
|
||||
) -> bytes:
|
||||
values = []
|
||||
if plot_format == "xy":
|
||||
for offset in range(samples_per_channel):
|
||||
t = (sample_index + offset) / sample_rate_hz
|
||||
x = amplitude * math.cos(2 * math.pi * frequency_hz * t)
|
||||
y = amplitude * math.sin(2 * math.pi * frequency_hz * t)
|
||||
values.extend([x, y])
|
||||
return struct.pack(f"<{len(values)}f", *values)
|
||||
|
||||
for offset in range(samples_per_channel):
|
||||
t = (sample_index + offset) / sample_rate_hz
|
||||
for ch in range(channels):
|
||||
phase = 2 * math.pi * ch / max(channels, 1)
|
||||
value = amplitude * math.sin(2 * math.pi * frequency_hz * t + phase)
|
||||
values.append(value)
|
||||
return struct.pack(f"<{len(values)}f", *values)
|
||||
|
||||
|
||||
def cobs_encode(payload: bytes) -> bytes:
|
||||
if not payload:
|
||||
return b"\x01"
|
||||
|
||||
out = bytearray([0])
|
||||
code_index = 0
|
||||
code = 1
|
||||
for byte in payload:
|
||||
if byte == 0:
|
||||
out[code_index] = code
|
||||
code_index = len(out)
|
||||
out.append(0)
|
||||
code = 1
|
||||
else:
|
||||
out.append(byte)
|
||||
code += 1
|
||||
if code == 0xFF:
|
||||
out[code_index] = code
|
||||
code_index = len(out)
|
||||
out.append(0)
|
||||
code = 1
|
||||
out[code_index] = code
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_plot_packet(
|
||||
payload: bytes,
|
||||
channels: int,
|
||||
samples_per_channel: int,
|
||||
plot_format: str,
|
||||
) -> bytes:
|
||||
format_id = {"interleaved": 0, "block": 1, "xy": 2}[plot_format]
|
||||
header = bytearray()
|
||||
header.extend(b"XP")
|
||||
header.append(1) # version
|
||||
header.append(format_id)
|
||||
header.append(8) # f32
|
||||
header.append(0) # little-endian
|
||||
header.append(channels)
|
||||
header.extend(struct.pack("<H", samples_per_channel))
|
||||
header.extend(struct.pack("<I", len(payload)))
|
||||
header.extend(payload)
|
||||
return bytes(header)
|
||||
|
||||
|
||||
def build_mixed_plot_frame(
|
||||
payload: bytes,
|
||||
channels: int,
|
||||
samples_per_channel: int,
|
||||
plot_format: str,
|
||||
) -> bytes:
|
||||
packet = build_plot_packet(
|
||||
payload=payload,
|
||||
channels=channels,
|
||||
samples_per_channel=samples_per_channel,
|
||||
plot_format=plot_format,
|
||||
)
|
||||
return bytes([PLOT_ESCAPE, PLOT_MARKER]) + cobs_encode(packet) + b"\x00"
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="xserial plot test server")
|
||||
p.add_argument("--host", default="127.0.0.1")
|
||||
p.add_argument("--port", type=int, default=8080)
|
||||
p.add_argument("--channels", type=int, default=1)
|
||||
p.add_argument(
|
||||
"--format",
|
||||
choices=("interleaved", "xy"),
|
||||
default="interleaved",
|
||||
help="plot format (default: interleaved)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--rate",
|
||||
type=float,
|
||||
default=1024,
|
||||
help="samples/sec per channel (default: 1024)",
|
||||
)
|
||||
p.add_argument("--freq", type=float, default=1.0, help="sine Hz")
|
||||
p.add_argument("--amp", type=float, default=100)
|
||||
p.add_argument(
|
||||
"--text-interval",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="seconds between status text messages (default: 1.0)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--framelen",
|
||||
type=int,
|
||||
default=0,
|
||||
help="fixed frame bytes (0=one sample per channel per frame)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--wire-format",
|
||||
choices=("mixed", "raw"),
|
||||
default="mixed",
|
||||
help="mixed sends text+plot on one connection; raw sends plot only",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
sample_size = 4 # f32
|
||||
|
||||
if args.channels <= 0:
|
||||
raise SystemExit("--channels must be >= 1")
|
||||
if args.format == "xy" and args.channels != 2:
|
||||
raise SystemExit("--format xy requires --channels 2")
|
||||
if args.rate <= 0:
|
||||
raise SystemExit("--rate must be positive")
|
||||
if args.text_interval <= 0:
|
||||
raise SystemExit("--text-interval must be positive")
|
||||
if args.framelen < 0:
|
||||
raise SystemExit("--framelen must be >= 0")
|
||||
|
||||
bytes_per_sample_group = args.channels * sample_size
|
||||
frame_bytes = args.framelen or bytes_per_sample_group
|
||||
if frame_bytes % bytes_per_sample_group != 0:
|
||||
raise SystemExit(
|
||||
f"--framelen must be a multiple of {bytes_per_sample_group} bytes "
|
||||
f"for {args.channels} channel f32 interleaved data"
|
||||
)
|
||||
samples_per_channel = frame_bytes // bytes_per_sample_group
|
||||
frame_interval = samples_per_channel / args.rate
|
||||
frame_rate = args.rate / samples_per_channel
|
||||
|
||||
def sample_values(sample_index: int) -> list[float]:
|
||||
if args.format == "xy":
|
||||
t = sample_index / args.rate
|
||||
return [
|
||||
args.amp * math.cos(2 * math.pi * args.freq * t),
|
||||
args.amp * math.sin(2 * math.pi * args.freq * t),
|
||||
]
|
||||
|
||||
t = sample_index / args.rate
|
||||
values = []
|
||||
for ch in range(args.channels):
|
||||
phase = 2 * math.pi * ch / max(args.channels, 1)
|
||||
values.append(args.amp * math.sin(2 * math.pi * args.freq * t + phase))
|
||||
return values
|
||||
|
||||
print(f"Listening {args.host}:{args.port} — Ctrl+C to stop")
|
||||
print("GUI:")
|
||||
print(f" transport = TCP {args.host}:{args.port}")
|
||||
if args.wire_format == "mixed":
|
||||
print(" framer = MixedTextPlot")
|
||||
print(" decoder = MixedTextPlot\n")
|
||||
else:
|
||||
print(f" framer = Fixed({frame_bytes})")
|
||||
print(
|
||||
f" decoder = Plot(f32, little-endian, {args.channels} channel, {args.format})\n"
|
||||
)
|
||||
print(f"sending {samples_per_channel} samples/frame at {args.rate:.1f} samples/sec")
|
||||
print(f"effective frame rate: {frame_rate:.2f} fps\n")
|
||||
|
||||
stop_event = threading.Event()
|
||||
|
||||
def serve_stream() -> None:
|
||||
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(1)
|
||||
srv.settimeout(0.5)
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
conn, addr = srv.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
print(f"[conn +] {addr}")
|
||||
|
||||
started_at = time.perf_counter()
|
||||
frame_count = 0
|
||||
text_count = 0
|
||||
sample_index = 0
|
||||
next_text_at = started_at
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
plot_payload = build_frame(
|
||||
sample_index=sample_index,
|
||||
channels=args.channels,
|
||||
plot_format=args.format,
|
||||
samples_per_channel=samples_per_channel,
|
||||
amplitude=args.amp,
|
||||
frequency_hz=args.freq,
|
||||
sample_rate_hz=args.rate,
|
||||
)
|
||||
if args.wire_format == "mixed":
|
||||
frame = build_mixed_plot_frame(
|
||||
payload=plot_payload,
|
||||
channels=args.channels,
|
||||
samples_per_channel=samples_per_channel,
|
||||
plot_format=args.format,
|
||||
)
|
||||
else:
|
||||
frame = plot_payload
|
||||
conn.sendall(frame)
|
||||
|
||||
now = time.perf_counter()
|
||||
if args.wire_format == "mixed" and now >= next_text_at:
|
||||
values = sample_values(sample_index)
|
||||
joined = ", ".join(
|
||||
f"ch{index + 1}={value:8.3f}"
|
||||
for index, value in enumerate(values)
|
||||
)
|
||||
line = (
|
||||
f"t={now - started_at:7.3f}s "
|
||||
f"sample={sample_index:7d} {joined}\n"
|
||||
)
|
||||
conn.sendall(line.encode("utf-8"))
|
||||
text_count += 1
|
||||
next_text_at += args.text_interval
|
||||
|
||||
sample_index += samples_per_channel
|
||||
frame_count += 1
|
||||
|
||||
elapsed = time.perf_counter() - started_at
|
||||
wait = frame_count * frame_interval - elapsed
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
print(f"[conn -] {addr} ({frame_count} plot frames, {text_count} text lines)")
|
||||
finally:
|
||||
conn.close()
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
stream_thread = threading.Thread(target=serve_stream, daemon=True)
|
||||
stream_thread.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
print("\nstopped.")
|
||||
finally:
|
||||
stop_event.set()
|
||||
stream_thread.join(timeout=1.0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user