Improve GUI configuration and plotting tooling

This commit is contained in:
2026-06-09 21:57:45 +08:00
parent 594637ec50
commit 882b7f391f
7 changed files with 737 additions and 95 deletions

28
AGENTS.md Normal file
View File

@@ -0,0 +1,28 @@
# Repository Guidelines
## Project Structure & Module Organization
`xserial` is a Rust workspace. The root [Cargo.toml](/D:/Dev/xserial/Cargo.toml) defines four crates in `crates/`:
- `xserial-core`: transport, framing, protocol, and pipeline primitives in `src/`, with integration tests in `tests/pipeline.rs`.
- `xserial-client`: session management, history, commands, and Lua bindings, with integration tests under `tests/`.
- `xserial-tui`: terminal app entrypoint in `src/main.rs`.
- `xserial-gui`: egui/eframe app, with UI panels in `src/panels/` and font helpers in `src/ui_fonts.rs`.
Use `tools/test_plot.py` as a local waveform source for GUI plot testing. Treat `target/` as generated output.
## Build, Test, and Development Commands
- `cargo check --workspace`: fast compile check for all crates.
- `cargo test --workspace`: run the workspace test suite, including `crates/xserial-core/tests` and `crates/xserial-client/tests`.
- `cargo run -p xserial-gui`: launch the desktop GUI.
- `cargo run -p xserial-tui`: launch the terminal UI.
- `cargo fmt --all`: apply standard Rust formatting.
- `cargo clippy --workspace --all-targets -- -D warnings`: catch lint issues before review.
- `python tools/test_plot.py --help`: inspect options for the TCP plot-frame generator.
## Coding Style & Naming Conventions
Follow standard Rust formatting: 4-space indentation, trailing commas in multiline literals, and `snake_case` for modules, files, functions, and tests. Use `PascalCase` for structs/enums and `SCREAMING_SNAKE_CASE` for constants. Keep crate boundaries clean: protocol/transport code belongs in `xserial-core`; session and Lua integration belong in `xserial-client`; UI state stays in `xserial-gui` or `xserial-tui`.
## Testing Guidelines
Prefer focused unit tests next to implementation and integration tests in `crates/<crate>/tests/*.rs`. Name tests after behavior, for example `session_lifecycle` or `pipeline`. Run `cargo test --workspace` before opening a PR; add targeted regression tests for transport, framing, parsing, and session-state fixes.
## Commit & Pull Request Guidelines
Recent history uses short, imperative subjects such as `Persist GUI font settings` and `Improve session controls and GUI views`, with occasional `feat:` prefixes for major additions. Keep subjects concise and action-oriented. PRs should state which crate(s) changed, list validation commands, and include screenshots or short recordings for GUI/TUI changes. Call out protocol, transport, or Lua API compatibility impacts explicitly.

View File

@@ -9,6 +9,7 @@ use xserial_client::SessionManager;
use xserial_client::config::SessionConfig;
use xserial_client::session::SessionEvent;
use xserial_core::protocol::DecodedData;
use xserial_core::transport::TransportConfig;
#[derive(Clone)]
pub enum ConnectionStatus {
@@ -283,7 +284,11 @@ impl XserialApp {
let sessions: Vec<_> = self
.tabs
.iter()
.map(|tab| (tab.id, tab.status.clone()))
.map(|tab| sidebar::SessionListItem {
id: tab.id,
status: tab.status.clone(),
transport_summary: transport_summary(&tab.session_config.transport),
})
.collect();
sidebar::render(ui, &sessions, &mut self.active, &mut on_new, &mut on_delete);
});
@@ -528,6 +533,20 @@ impl XserialApp {
}
}
fn transport_summary(transport: &TransportConfig) -> String {
match transport {
TransportConfig::Serial { port, .. } => format!("Serial {}", port),
TransportConfig::Tcp { addr } => format!("TCP {}", addr),
TransportConfig::Udp {
bind_addr,
remote_addr,
} => match remote_addr {
Some(remote_addr) => format!("UDP {} -> {}", bind_addr, remote_addr),
None => format!("UDP {}", bind_addr),
},
}
}
fn render_font_selector(
ui: &mut egui::Ui,
title: &str,
@@ -569,7 +588,8 @@ fn render_font_selector(
.auto_shrink([false, false])
.show_rows(ui, row_height, filtered.len(), |ui, row_range| {
for row in row_range {
if let Some(candidate) = filtered.get(row).and_then(|index| candidates.get(*index)) {
if let Some(candidate) = filtered.get(row).and_then(|index| candidates.get(*index))
{
let response = ui.selectable_value(
choice,
FontChoice::System(candidate.id.clone()),
@@ -802,3 +822,53 @@ fn build_payload(tab: &SessionTab) -> Result<Option<Vec<u8>>, String> {
}
}
}
#[cfg(test)]
mod tests {
use super::transport_summary;
use xserial_core::transport::TransportConfig;
use xserial_core::transport::serial::{
SerialDataBits, SerialFlowControl, SerialParity, SerialStopBits,
};
#[test]
fn transport_summary_formats_tcp_and_serial() {
assert_eq!(
transport_summary(&TransportConfig::Tcp {
addr: String::from("127.0.0.1:8080"),
}),
"TCP 127.0.0.1:8080"
);
assert_eq!(
transport_summary(&TransportConfig::Serial {
port: String::from("COM7"),
baud_rate: 115200,
data_bits: SerialDataBits::Eight,
parity: SerialParity::None,
stop_bits: SerialStopBits::One,
flow_control: SerialFlowControl::None,
}),
"Serial COM7"
);
}
#[test]
fn transport_summary_formats_udp() {
assert_eq!(
transport_summary(&TransportConfig::Udp {
bind_addr: String::from("0.0.0.0:9000"),
remote_addr: Some(String::from("127.0.0.1:9001")),
}),
"UDP 0.0.0.0:9000 -> 127.0.0.1:9001"
);
assert_eq!(
transport_summary(&TransportConfig::Udp {
bind_addr: String::from("127.0.0.1:0"),
remote_addr: None,
}),
"UDP 127.0.0.1:0"
);
}
}

View File

@@ -1,5 +1,6 @@
use egui::{ComboBox, DragValue, ScrollArea, TextEdit, Ui};
use xserial_client::config::{DecoderConfig, FramerConfig, PipelineConfig, SessionConfig};
use xserial_core::protocol::Endian;
use xserial_core::protocol::plot::PlotFormat;
use xserial_core::protocol::text::TextEncoding;
use xserial_core::transport::TransportConfig;
@@ -32,7 +33,9 @@ pub struct PipelineForm {
pub framer: FramerChoice,
pub decoder: DecoderChoice,
pub text_encoding: TextEncoding,
pub hex_endian: Endian,
pub plot_channels: usize,
pub plot_endian: Endian,
pub plot_format: PlotFormat,
}
@@ -76,7 +79,9 @@ impl Default for ConfigForm {
framer: FramerChoice::Line,
decoder: DecoderChoice::Text,
text_encoding: TextEncoding::Utf8,
hex_endian: Endian::Big,
plot_channels: 1,
plot_endian: Endian::Little,
plot_format: PlotFormat::Interleaved,
}],
history: 10000,
@@ -151,6 +156,14 @@ impl ConfigForm {
DecoderConfig::Plot { channels, .. } => *channels,
_ => 1,
};
let hex_endian = match &pipeline.decoder {
DecoderConfig::Hex { endian, .. } => *endian,
_ => Endian::Big,
};
let plot_endian = match &pipeline.decoder {
DecoderConfig::Plot { endian, .. } => *endian,
_ => Endian::Little,
};
let text_encoding = match &pipeline.decoder {
DecoderConfig::Text { encoding } => *encoding,
DecoderConfig::MixedTextPlot { encoding } => *encoding,
@@ -165,7 +178,9 @@ impl ConfigForm {
framer,
decoder,
text_encoding,
hex_endian,
plot_channels,
plot_endian,
plot_format,
}
})
@@ -224,11 +239,11 @@ impl ConfigForm {
uppercase: false,
separator: " ".into(),
bytes_per_group: 1,
endian: xserial_core::protocol::Endian::Big,
endian: pipeline.hex_endian,
},
DecoderChoice::Plot => DecoderConfig::Plot {
sample_type: xserial_core::protocol::plot::SampleType::F32,
endian: xserial_core::protocol::Endian::Little,
endian: pipeline.plot_endian,
channels: pipeline.plot_channels.max(1),
format: pipeline.plot_format,
},
@@ -465,6 +480,10 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option<
});
if matches!(pipeline.decoder, DecoderChoice::Plot) {
ui.horizontal(|ui| {
ui.label("Plot endian:");
render_endian_combo(ui, &mut pipeline.plot_endian, format!("plot_endian{}", i));
});
ui.horizontal(|ui| {
ui.label("Plot channels:");
ui.add(DragValue::new(&mut pipeline.plot_channels).range(1..=32));
@@ -538,6 +557,13 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option<
}
}
if matches!(pipeline.decoder, DecoderChoice::Hex) {
ui.horizontal(|ui| {
ui.label("Hex endian:");
render_endian_combo(ui, &mut pipeline.hex_endian, format!("hex_endian{}", i));
});
}
if matches!(pipeline.decoder, DecoderChoice::Text) {
ui.horizontal(|ui| {
ui.label("Text encoding:");
@@ -573,7 +599,9 @@ pub fn render(ui: &mut Ui, form: &mut ConfigForm, submit_label: &str) -> Option<
framer: FramerChoice::Line,
decoder: DecoderChoice::Text,
text_encoding: TextEncoding::Utf8,
hex_endian: Endian::Big,
plot_channels: 1,
plot_endian: Endian::Little,
plot_format: PlotFormat::Interleaved,
});
}
@@ -612,3 +640,15 @@ fn render_text_encoding_combo(ui: &mut Ui, encoding: &mut TextEncoding, index: u
ui.selectable_value(encoding, TextEncoding::Ascii, "ASCII");
});
}
fn render_endian_combo(ui: &mut Ui, endian: &mut Endian, id: impl std::hash::Hash) {
ComboBox::from_id_salt(id)
.selected_text(match endian {
Endian::Little => "Little",
Endian::Big => "Big",
})
.show_ui(ui, |ui| {
ui.selectable_value(endian, Endian::Little, "Little");
ui.selectable_value(endian, Endian::Big, "Big");
});
}

View File

@@ -1,16 +1,21 @@
use crate::app::ConnectionStatus;
use egui::{Color32, RichText, Ui};
pub struct SessionListItem {
pub id: u64,
pub status: ConnectionStatus,
pub transport_summary: String,
}
pub fn render(
ui: &mut Ui,
sessions: &[(u64, ConnectionStatus)], // (id, 连接状态)
active: &mut usize, // 当前选中的会话索引
on_new: &mut bool, // 点击了"新建"
on_delete: &mut Option<usize>, // 点击了"删除"
sessions: &[SessionListItem],
active: &mut usize,
on_new: &mut bool,
on_delete: &mut Option<usize>,
) {
ui.heading("Sessions");
// 新建按钮 — 绿色文字
if ui
.button(RichText::new("+ New Session").color(Color32::GREEN))
.clicked()
@@ -20,17 +25,18 @@ pub fn render(
ui.separator();
// 会话列表
for (i, (id, status)) in sessions.iter().enumerate() {
let (color, icon) = match status {
ConnectionStatus::Connected => (Color32::GREEN, ""),
ConnectionStatus::Disconnected => (Color32::GRAY, ""),
ConnectionStatus::Connecting => (Color32::YELLOW, ""),
ConnectionStatus::Error(_) => (Color32::RED, ""),
for (i, session) in sessions.iter().enumerate() {
let (color, status_tag) = match &session.status {
ConnectionStatus::Connected => (Color32::GREEN, "[connected]"),
ConnectionStatus::Disconnected => (Color32::GRAY, "[disconnected]"),
ConnectionStatus::Connecting => (Color32::YELLOW, "[connecting]"),
ConnectionStatus::Error(_) => (Color32::RED, "[error]"),
};
// selectable_label: 点击高亮clicked() 判断是否被点击
let label = format!("{} Session {}", icon, id);
let label = format!(
"{} Session {}\n{}",
status_tag, session.id, session.transport_summary
);
if ui
.selectable_label(*active == i, RichText::new(label).color(color))
.clicked()
@@ -41,7 +47,6 @@ pub fn render(
ui.separator();
// 删除按钮 — 红色文字
if ui
.button(RichText::new("Delete").color(Color32::RED))
.clicked()

View File

@@ -60,7 +60,21 @@ impl Default for UiFontSettings {
}
pub fn discover_font_candidates() -> Vec<FontCandidate> {
discover_via_fc_list().unwrap_or_else(discover_via_fallback_paths)
let mut candidates = discover_via_fc_list().unwrap_or_default();
let mut seen_paths = candidates
.iter()
.map(|candidate| candidate.path.clone())
.collect::<std::collections::BTreeSet<_>>();
for candidate in discover_via_font_directories() {
if seen_paths.insert(candidate.path.clone()) {
candidates.push(candidate);
}
}
candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.path.cmp(&b.path)));
candidates.dedup_by(|a, b| a.path == b.path);
candidates
}
pub fn load_font_settings() -> UiFontSettings {
@@ -143,9 +157,7 @@ fn load_selected_font(
) -> Option<(String, Vec<u8>)> {
match choice {
FontChoice::Default => None,
FontChoice::Auto => {
load_auto_font(candidates, already_loaded)
}
FontChoice::Auto => load_auto_font(candidates, already_loaded),
FontChoice::System(id) => {
let candidate = candidates.iter().find(|candidate| &candidate.id == id)?;
if already_loaded.iter().any(|loaded| loaded == &candidate.id) {
@@ -195,19 +207,22 @@ fn apply_font_size(style: &mut Style, settings: &UiFontSettings) {
style
.text_styles
.insert(TextStyle::Body, FontId::proportional(settings.ui_font_size));
style
.text_styles
.insert(TextStyle::Button, FontId::proportional(settings.ui_font_size));
style.text_styles.insert(
TextStyle::Button,
FontId::proportional(settings.ui_font_size),
);
style.text_styles.insert(
TextStyle::Monospace,
FontId::monospace(settings.monospace_font_size),
);
style
.text_styles
.insert(TextStyle::Small, FontId::proportional((settings.ui_font_size - 2.0).max(8.0)));
style
.text_styles
.insert(TextStyle::Heading, FontId::proportional(settings.heading_font_size));
style.text_styles.insert(
TextStyle::Small,
FontId::proportional((settings.ui_font_size - 2.0).max(8.0)),
);
style.text_styles.insert(
TextStyle::Heading,
FontId::proportional(settings.heading_font_size),
);
}
fn discover_via_fc_list() -> Option<Vec<FontCandidate>> {
@@ -262,38 +277,47 @@ fn discover_via_fc_list() -> Option<Vec<FontCandidate>> {
});
}
candidates.sort_by(|a, b| a.label.cmp(&b.label).then(a.path.cmp(&b.path)));
candidates.dedup_by(|a, b| a.path == b.path);
(!candidates.is_empty()).then_some(candidates)
}
fn discover_via_fallback_paths() -> Vec<FontCandidate> {
let fallback_paths = [
"/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-sans-cjk-vf-fonts/NotoSansCJK-VF.ttc",
"/usr/share/fonts/google-droid-sans-fonts/DroidSansFallbackFull.ttf",
];
fallback_paths
fn discover_via_font_directories() -> Vec<FontCandidate> {
let mut candidates = Vec::new();
let mut stack = platform_font_directories()
.into_iter()
.filter_map(|path| {
fs::metadata(path).ok().map(|_| {
let stem = Path::new(path)
.file_stem()
.and_then(|name| name.to_str())
.unwrap_or("font")
.to_owned();
FontCandidate {
id: sanitize_id(&stem),
label: stem.clone(),
display_label: format!("{stem} [CJK]"),
path: path.to_owned(),
likely_cjk: true,
search_key: build_search_key(&stem, "Regular", path),
.filter(|path| path.is_dir())
.map(|path| (path, 0usize))
.collect::<Vec<_>>();
while let Some((dir, depth)) = stack.pop() {
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(err) => {
warn!(path = %dir.display(), error = %err, "Failed to read font directory");
continue;
}
};
for entry in entries.flatten() {
let path = entry.path();
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(_) => continue,
};
if file_type.is_dir() {
if depth < 4 {
stack.push((path, depth + 1));
}
})
})
.collect()
continue;
}
if let Some(candidate) = font_candidate_from_path(&path) {
candidates.push(candidate);
}
}
}
candidates
}
fn font_display_label(family: &str, style: &str, path: &str) -> String {
@@ -315,24 +339,7 @@ fn build_search_key(family: &str, style: &str, path: &str) -> String {
}
fn font_settings_path() -> PathBuf {
if let Ok(path) = env::var("XDG_CONFIG_HOME") {
let trimmed = path.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed).join("xserial").join(FONT_SETTINGS_FILE_NAME);
}
}
if let Ok(home) = env::var("HOME") {
let trimmed = home.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed)
.join(".config")
.join("xserial")
.join(FONT_SETTINGS_FILE_NAME);
}
}
PathBuf::from(FONT_SETTINGS_FILE_NAME)
font_settings_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
}
fn load_font_settings_from_path(path: &Path) -> io::Result<UiFontSettings> {
@@ -348,6 +355,165 @@ fn save_font_settings_to_path(settings: &UiFontSettings, path: &Path) -> io::Res
fs::write(path, json)
}
fn font_settings_path_for_os_and_env(
os: &str,
get_env: impl Fn(&str) -> Option<String>,
) -> PathBuf {
match os {
"windows" => {
if let Some(path) = get_env("APPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("xserial")
.join(FONT_SETTINGS_FILE_NAME);
}
if let Some(path) = get_env("LOCALAPPDATA").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("xserial")
.join(FONT_SETTINGS_FILE_NAME);
}
}
"macos" => {
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("xserial")
.join(FONT_SETTINGS_FILE_NAME);
}
}
_ => {
if let Some(path) = get_env("XDG_CONFIG_HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(path)
.join("xserial")
.join(FONT_SETTINGS_FILE_NAME);
}
if let Some(home) = get_env("HOME").filter(|path| !path.trim().is_empty()) {
return PathBuf::from(home)
.join(".config")
.join("xserial")
.join(FONT_SETTINGS_FILE_NAME);
}
}
}
PathBuf::from(FONT_SETTINGS_FILE_NAME)
}
fn platform_font_directories() -> Vec<PathBuf> {
let mut dirs = Vec::new();
#[cfg(target_os = "windows")]
{
if let Some(windir) = env::var("WINDIR")
.ok()
.filter(|path| !path.trim().is_empty())
{
dirs.push(PathBuf::from(windir).join("Fonts"));
} else {
dirs.push(PathBuf::from(r"C:\Windows\Fonts"));
}
if let Some(local_app_data) = env::var("LOCALAPPDATA")
.ok()
.filter(|path| !path.trim().is_empty())
{
dirs.push(
PathBuf::from(local_app_data)
.join("Microsoft")
.join("Windows")
.join("Fonts"),
);
}
}
#[cfg(target_os = "macos")]
{
dirs.push(PathBuf::from("/System/Library/Fonts"));
dirs.push(PathBuf::from("/Library/Fonts"));
if let Some(home) = env::var("HOME").ok().filter(|path| !path.trim().is_empty()) {
dirs.push(PathBuf::from(home).join("Library").join("Fonts"));
}
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
{
dirs.push(PathBuf::from("/usr/share/fonts"));
dirs.push(PathBuf::from("/usr/local/share/fonts"));
if let Some(home) = env::var("HOME").ok().filter(|path| !path.trim().is_empty()) {
dirs.push(
PathBuf::from(&home)
.join(".local")
.join("share")
.join("fonts"),
);
dirs.push(PathBuf::from(home).join(".fonts"));
}
}
dirs
}
fn font_candidate_from_path(path: &Path) -> Option<FontCandidate> {
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
if !matches!(extension.as_str(), "ttf" | "ttc" | "otf" | "otc") {
return None;
}
let stem = path.file_stem()?.to_str()?.trim();
if stem.is_empty() {
return None;
}
let (family, style) = split_font_name(stem);
let path_string = path.to_string_lossy().into_owned();
let id = sanitize_id(&format!("{family}_{style}_{stem}"));
let label = if style.is_empty() || style == "Regular" {
family.clone()
} else {
format!("{family} ({style})")
};
Some(FontCandidate {
id,
label,
display_label: font_display_label(&family, &style, &path_string),
path: path_string.clone(),
likely_cjk: is_likely_cjk(&family, &style, &path_string),
search_key: build_search_key(&family, &style, &path_string),
})
}
fn split_font_name(stem: &str) -> (String, String) {
let normalized = stem.replace(['_', '-'], " ");
let compact = normalized.split_whitespace().collect::<Vec<_>>().join(" ");
let lower = compact.to_lowercase();
for style in [
"ExtraBold Italic",
"Extra Light Italic",
"SemiBold Italic",
"Bold Italic",
"ExtraBold",
"SemiBold",
"Medium",
"Regular",
"Italic",
"Bold",
"Light",
"Thin",
] {
let suffix = style.to_lowercase();
if let Some(prefix) = lower.strip_suffix(&suffix) {
let family = compact[..prefix.trim_end().len()].trim().to_owned();
if !family.is_empty() {
return (family, style.to_owned());
}
}
}
(compact, String::from("Regular"))
}
const fn default_ui_font_size() -> f32 {
14.0
}
@@ -397,6 +563,21 @@ fn is_likely_cjk(family: &str, style: &str, path: &str) -> bool {
"hei",
"kai",
"song",
"fang",
"yahei",
"deng",
"ming",
"simsun",
"simhei",
"simkai",
"simfang",
"msyh",
"msjh",
"meiryo",
"msgothic",
"ms gothic",
"malgun",
"gulim",
"gothic",
"mincho",
]
@@ -436,4 +617,62 @@ mod tests {
let _ = fs::remove_file(&path);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn linux_config_path_uses_xdg() {
let path = font_settings_path_for_os_and_env("linux", |key| match key {
"XDG_CONFIG_HOME" => Some(String::from("/tmp/xdg-config")),
_ => None,
});
assert_eq!(
path,
PathBuf::from("/tmp/xdg-config")
.join("xserial")
.join(FONT_SETTINGS_FILE_NAME)
);
}
#[test]
fn windows_config_path_uses_appdata() {
let path = font_settings_path_for_os_and_env("windows", |key| match key {
"APPDATA" => Some(String::from(r"C:\Users\Test\AppData\Roaming")),
_ => None,
});
assert_eq!(
path,
PathBuf::from(r"C:\Users\Test\AppData\Roaming")
.join("xserial")
.join(FONT_SETTINGS_FILE_NAME)
);
}
#[test]
fn macos_config_path_uses_application_support() {
let path = font_settings_path_for_os_and_env("macos", |key| match key {
"HOME" => Some(String::from("/Users/tester")),
_ => None,
});
assert_eq!(
path,
PathBuf::from("/Users/tester")
.join("Library")
.join("Application Support")
.join("xserial")
.join(FONT_SETTINGS_FILE_NAME)
);
}
#[test]
fn detects_windows_cjk_fonts() {
assert!(is_likely_cjk(
"Microsoft YaHei UI",
"Regular",
r"C:\Windows\Fonts\msyh.ttc"
));
assert!(is_likely_cjk(
"SimSun",
"Regular",
r"C:\Windows\Fonts\simsun.ttc"
));
}
}

View File

@@ -10,6 +10,11 @@ import time
PLOT_ESCAPE = 0x1E
PLOT_MARKER = ord("P")
DISCONNECT_WINERRORS = {
10053, # Software caused connection abort
10054, # Connection reset by peer
10058, # Socket shutdown race on Windows
}
def build_frame(
@@ -98,44 +103,56 @@ def build_mixed_plot_frame(
)
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(
def is_disconnect_error(err: OSError) -> bool:
return isinstance(
err,
(
BrokenPipeError,
ConnectionAbortedError,
ConnectionResetError,
),
) or getattr(err, "winerror", None) in DISCONNECT_WINERRORS
def main() -> None:
parser = argparse.ArgumentParser(description="xserial plot test server")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8091)
parser.add_argument("--channels", type=int, default=1)
parser.add_argument(
"--format",
choices=("interleaved", "xy"),
default="interleaved",
help="plot format (default: interleaved)",
)
p.add_argument(
parser.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(
parser.add_argument("--freq", type=float, default=1.0, help="sine Hz")
parser.add_argument("--amp", type=float, default=100)
parser.add_argument(
"--text-interval",
type=float,
default=1.0,
help="seconds between status text messages (default: 1.0)",
)
p.add_argument(
parser.add_argument(
"--framelen",
type=int,
default=0,
help="fixed frame bytes (0=one sample per channel per frame)",
)
p.add_argument(
parser.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()
args = parser.parse_args()
sample_size = 4 # f32
@@ -176,7 +193,7 @@ def main():
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(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":
@@ -241,8 +258,8 @@ def main():
for index, value in enumerate(values)
)
line = (
f"时间={now - started_at:7.3f}s "
f"样本={sample_index:7d} {joined}\n"
f"time={now - started_at:7.3f}s "
f"sample={sample_index:7d} {joined}\n"
)
conn.sendall(line.encode("utf-8"))
text_count += 1
@@ -255,8 +272,13 @@ def main():
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)")
except OSError as err:
if not is_disconnect_error(err):
raise
print(
f"[conn -] {addr} "
f"({frame_count} plot frames, {text_count} text lines)"
)
finally:
conn.close()
finally:
@@ -274,5 +296,6 @@ def main():
stop_event.set()
stream_thread.join(timeout=1.0)
if __name__ == "__main__":
main()

237
tools/xs_mixed_plot.h Normal file
View File

@@ -0,0 +1,237 @@
#ifndef XS_MIXED_PLOT_H
#define XS_MIXED_PLOT_H
#include <stddef.h>
#include <stdint.h>
/* MixedTextPlot plot frame:
* 0x1E 'P' + COBS(packet) + 0x00
*
* Plot packet:
* "XP" +
* version:u8 +
* format:u8 +
* sample_type:u8 +
* endian:u8 +
* channels:u8 +
* samples_per_channel:u16le +
* payload_len:u32le +
* payload
*/
#define XS_MIXED_PLOT_ESCAPE 0x1E
#define XS_MIXED_PLOT_MARKER 0x50 /* 'P' */
#define XS_PLOT_MAGIC_0 0x58 /* 'X' */
#define XS_PLOT_MAGIC_1 0x50 /* 'P' */
#define XS_PLOT_VERSION 1
#define XS_PLOT_FORMAT_INTERLEAVED 0
#define XS_PLOT_FORMAT_BLOCK 1
#define XS_PLOT_FORMAT_XY 2
#define XS_SAMPLE_TYPE_F32 8
#define XS_ENDIAN_LITTLE 0
#define XS_PLOT_HEADER_SIZE 13
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
XS_OK = 0,
XS_ERR_ARG = -1,
XS_ERR_BUF = -2
} xs_status_t;
static size_t xs_cobs_max_encoded_size(size_t input_len) {
return input_len + (input_len / 254u) + 1u;
}
static void xs_write_u16_le(uint8_t *dst, uint16_t v) {
dst[0] = (uint8_t)(v & 0xFFu);
dst[1] = (uint8_t)((v >> 8) & 0xFFu);
}
static void xs_write_u32_le(uint8_t *dst, uint32_t v) {
dst[0] = (uint8_t)(v & 0xFFu);
dst[1] = (uint8_t)((v >> 8) & 0xFFu);
dst[2] = (uint8_t)((v >> 16) & 0xFFu);
dst[3] = (uint8_t)((v >> 24) & 0xFFu);
}
static void xs_write_f32_le(uint8_t *dst, float v) {
union {
float f;
uint8_t b[4];
} u;
u.f = v;
#if defined(__BYTE_ORDER__) && (__BYTE_ORDER__ == __ORDER_BIG_ENDIAN__)
dst[0] = u.b[3];
dst[1] = u.b[2];
dst[2] = u.b[1];
dst[3] = u.b[0];
#else
dst[0] = u.b[0];
dst[1] = u.b[1];
dst[2] = u.b[2];
dst[3] = u.b[3];
#endif
}
/* output must have at least xs_cobs_max_encoded_size(input_len) bytes */
static size_t xs_cobs_encode(const uint8_t *input, size_t input_len, uint8_t *output) {
size_t read_index = 0;
size_t write_index = 1;
size_t code_index = 0;
uint8_t code = 1;
while (read_index < input_len) {
if (input[read_index] == 0) {
output[code_index] = code;
code = 1;
code_index = write_index++;
read_index++;
} else {
output[write_index++] = input[read_index++];
code++;
if (code == 0xFFu) {
output[code_index] = code;
code = 1;
code_index = write_index++;
}
}
}
output[code_index] = code;
return write_index;
}
static xs_status_t xs_plot_build_packet_f32(
const float *samples,
uint8_t channels,
uint16_t samples_per_channel,
uint8_t plot_format,
uint8_t *out,
size_t out_cap,
size_t *out_len
) {
size_t i;
uint32_t value_count;
uint32_t payload_len;
size_t packet_len;
uint8_t *p;
if (!samples || !out || !out_len) {
return XS_ERR_ARG;
}
if (channels == 0) {
return XS_ERR_ARG;
}
if (plot_format == XS_PLOT_FORMAT_XY && channels != 2) {
return XS_ERR_ARG;
}
if (plot_format > XS_PLOT_FORMAT_XY) {
return XS_ERR_ARG;
}
value_count = (uint32_t)channels * (uint32_t)samples_per_channel;
payload_len = value_count * 4u;
packet_len = XS_PLOT_HEADER_SIZE + (size_t)payload_len;
if (out_cap < packet_len) {
return XS_ERR_BUF;
}
out[0] = XS_PLOT_MAGIC_0;
out[1] = XS_PLOT_MAGIC_1;
out[2] = XS_PLOT_VERSION;
out[3] = plot_format;
out[4] = XS_SAMPLE_TYPE_F32;
out[5] = XS_ENDIAN_LITTLE;
out[6] = channels;
xs_write_u16_le(&out[7], samples_per_channel);
xs_write_u32_le(&out[9], payload_len);
p = &out[13];
for (i = 0; i < (size_t)value_count; ++i) {
xs_write_f32_le(p, samples[i]);
p += 4;
}
*out_len = packet_len;
return XS_OK;
}
static xs_status_t xs_mixed_build_plot_frame_from_packet(
const uint8_t *packet,
size_t packet_len,
uint8_t *out,
size_t out_cap,
size_t *out_len
) {
size_t encoded_len;
if (!packet || !out || !out_len) {
return XS_ERR_ARG;
}
if (out_cap < 2u + xs_cobs_max_encoded_size(packet_len) + 1u) {
return XS_ERR_BUF;
}
out[0] = XS_MIXED_PLOT_ESCAPE;
out[1] = XS_MIXED_PLOT_MARKER;
encoded_len = xs_cobs_encode(packet, packet_len, &out[2]);
out[2 + encoded_len] = 0x00;
*out_len = 2u + encoded_len + 1u;
return XS_OK;
}
static xs_status_t xs_mixed_build_plot_frame_f32(
const float *samples,
uint8_t channels,
uint16_t samples_per_channel,
uint8_t plot_format,
uint8_t *packet_buf,
size_t packet_buf_cap,
uint8_t *frame_buf,
size_t frame_buf_cap,
size_t *frame_len
) {
xs_status_t rc;
size_t packet_len = 0;
if (!packet_buf || !frame_buf || !frame_len) {
return XS_ERR_ARG;
}
rc = xs_plot_build_packet_f32(
samples,
channels,
samples_per_channel,
plot_format,
packet_buf,
packet_buf_cap,
&packet_len
);
if (rc != XS_OK) {
return rc;
}
return xs_mixed_build_plot_frame_from_packet(
packet_buf,
packet_len,
frame_buf,
frame_buf_cap,
frame_len
);
}
#ifdef __cplusplus
}
#endif
#endif /* XS_MIXED_PLOT_H */