diff --git a/crates/modbusmaster-egui/src/app.rs b/crates/modbusmaster-egui/src/app.rs index 361478a..03742c9 100644 --- a/crates/modbusmaster-egui/src/app.rs +++ b/crates/modbusmaster-egui/src/app.rs @@ -612,7 +612,11 @@ impl MasterApp { proj.connections.push(MasterConnectionSave { label: s.label.clone(), - tcp: TcpSpec { host, port }, + tcp: TcpSpec { + host, + port, + tls: None, + }, slave_id: s.slave_id, timeout_ms, poll, diff --git a/crates/modbussim-egui/src/app.rs b/crates/modbussim-egui/src/app.rs index a99c612..a1b9ff9 100644 --- a/crates/modbussim-egui/src/app.rs +++ b/crates/modbussim-egui/src/app.rs @@ -10,12 +10,13 @@ use modbussim_core::log_collector::LogCollector; use modbussim_core::log_entry::LogEntry; use modbussim_core::register::{decode_value, DataType, Endian, RegisterDef, RegisterType}; use modbussim_core::slave::{ConnectionState, SlaveConnection, SlaveDevice}; -use modbussim_core::transport::Transport; +use modbussim_core::transport::{SlaveTlsConfig, Transport}; use modbussim_ui_shared::format::{format_u16, U16Format}; use modbussim_ui_shared::icons; use modbussim_ui_shared::log_panel::{self, LogPanelAction, LogPanelState}; use modbussim_ui_shared::project::{ - deserialize_slave, serialize_slave, SlaveConnectionSave, SlaveDeviceSave, SlaveProject, TcpSpec, + deserialize_slave, serialize_slave, SlaveConnectionSave, SlaveDeviceSave, SlaveProject, + TcpSpec, TlsSpec, }; use modbussim_ui_shared::theme::{self, Flavor}; use modbussim_ui_shared::ui as uikit; @@ -281,6 +282,15 @@ pub struct SlaveApp { new_host: String, new_port: String, show_new_tcp_dialog: bool, + // —— 新建连接的 TLS 表单字段 —— + new_use_tls: bool, + /// PEM cert 路径(与 pkcs12_file 互斥;同时填则 PKCS#12 优先) + new_cert_file: String, + new_key_file: String, + new_ca_file: String, + new_require_client_cert: bool, + new_pkcs12_file: String, + new_pkcs12_password: String, /// 删除连接二次确认状态:(conn_id, 首次点击时刻)。 /// 3 秒内同一连接再次点删除按钮 → 真删;否则按钮 label 自动恢复。 pending_delete: Option<(String, std::time::Instant)>, @@ -499,6 +509,13 @@ impl SlaveApp { new_host: "0.0.0.0".to_string(), new_port: "5502".to_string(), show_new_tcp_dialog: false, + new_use_tls: false, + new_cert_file: String::new(), + new_key_file: String::new(), + new_ca_file: String::new(), + new_require_client_cert: false, + new_pkcs12_file: String::new(), + new_pkcs12_password: String::new(), pending_delete: None, last_error: None, conn_snapshot: Vec::new(), @@ -1065,17 +1082,38 @@ impl SlaveApp { fn allocate_connection(&self) -> (String, String) { let n = self.next_conn_seq.fetch_add(1, Ordering::Relaxed); let id = format!("slave_{}", n); - let label = format!("TCP {}:{}", self.new_host.trim(), self.new_port.trim()); + let proto = if self.new_use_tls { "TLS" } else { "TCP" }; + let label = format!( + "{} {}:{}", + proto, + self.new_host.trim(), + self.new_port.trim() + ); (id, label) } - fn spawn_create_tcp(&self, id: String, label: String, host: String, port: u16) { + fn spawn_create_tcp( + &self, + id: String, + label: String, + host: String, + port: u16, + tls: Option, + ) { let connections = self.connections.clone(); let tx = self.events_tx.clone(); self.rt.spawn(async move { let log_collector = Arc::new(LogCollector::new()); - let connection = SlaveConnection::new(Transport::Tcp { host, port }) - .with_log_collector(log_collector.clone()); + let transport = if tls.is_some() { + Transport::TcpTls { host, port } + } else { + Transport::Tcp { host, port } + }; + let mut connection = + SlaveConnection::new(transport).with_log_collector(log_collector.clone()); + if let Some(cfg) = tls { + connection = connection.with_tls_config(cfg); + } let device = SlaveDevice::with_default_registers(1, "从站 1", 20000); let device_snap = DeviceSnapshot { slave_id: device.slave_id, @@ -1110,8 +1148,29 @@ impl SlaveApp { return; } }; + let tls = if self.new_use_tls { + let has_pem = + !self.new_cert_file.trim().is_empty() && !self.new_key_file.trim().is_empty(); + let has_pkcs12 = !self.new_pkcs12_file.trim().is_empty(); + if !has_pem && !has_pkcs12 { + self.last_error = + Some("启用 TLS 需要填写 cert+key(PEM)或 pkcs12 文件路径".to_string()); + return; + } + Some(SlaveTlsConfig { + enabled: true, + cert_file: self.new_cert_file.trim().to_string(), + key_file: self.new_key_file.trim().to_string(), + ca_file: self.new_ca_file.trim().to_string(), + require_client_cert: self.new_require_client_cert, + pkcs12_file: self.new_pkcs12_file.trim().to_string(), + pkcs12_password: self.new_pkcs12_password.clone(), + }) + } else { + None + }; let (id, label) = self.allocate_connection(); - self.spawn_create_tcp(id, label, host, port); + self.spawn_create_tcp(id, label, host, port, tls); ctx.request_repaint(); } @@ -1291,19 +1350,38 @@ impl SlaveApp { fn build_project(&self) -> SlaveProject { let mut proj = SlaveProject::new(); for snap in &self.conn_snapshot { - let (host, port) = self + // 同时取出 host/port 与可选 TLS 配置,单次 try_read 完成 + let (host, port, tls) = self .connections .try_read() .ok() .and_then(|list| { list.iter().find(|e| e.id == snap.id).and_then(|e| { - e.connection.try_read().ok().map(|c| match &c.transport { - Transport::Tcp { host, port } => (host.clone(), *port), - _ => ("0.0.0.0".to_string(), 502), + e.connection.try_read().ok().map(|c| { + let (h, p) = match &c.transport { + Transport::Tcp { host, port } + | Transport::TcpTls { host, port } => (host.clone(), *port), + _ => ("0.0.0.0".to_string(), 502), + }; + let tls = if matches!(c.transport, Transport::TcpTls { .. }) + && c.tls_config.enabled + { + Some(TlsSpec { + cert_file: c.tls_config.cert_file.clone(), + key_file: c.tls_config.key_file.clone(), + ca_file: c.tls_config.ca_file.clone(), + require_client_cert: c.tls_config.require_client_cert, + pkcs12_file: c.tls_config.pkcs12_file.clone(), + pkcs12_password: c.tls_config.pkcs12_password.clone(), + }) + } else { + None + }; + (h, p, tls) }) }) }) - .unwrap_or_else(|| ("0.0.0.0".to_string(), 502)); + .unwrap_or_else(|| ("0.0.0.0".to_string(), 502, None)); let devices: Vec = snap .devices @@ -1321,7 +1399,7 @@ impl SlaveApp { proj.connections.push(SlaveConnectionSave { label: snap.label.clone(), - tcp: TcpSpec { host, port }, + tcp: TcpSpec { host, port, tls }, devices, }); } @@ -1394,11 +1472,30 @@ impl SlaveApp { let id = format!("slave_{}", next_seq.fetch_add(1, Ordering::Relaxed)); let label = c.label.clone(); let log_collector = Arc::new(LogCollector::new()); - let connection = SlaveConnection::new(Transport::Tcp { - host: c.tcp.host.clone(), - port: c.tcp.port, - }) - .with_log_collector(log_collector.clone()); + let transport = if c.tcp.tls.is_some() { + Transport::TcpTls { + host: c.tcp.host.clone(), + port: c.tcp.port, + } + } else { + Transport::Tcp { + host: c.tcp.host.clone(), + port: c.tcp.port, + } + }; + let mut connection = + SlaveConnection::new(transport).with_log_collector(log_collector.clone()); + if let Some(tls) = c.tcp.tls.as_ref() { + connection = connection.with_tls_config(SlaveTlsConfig { + enabled: true, + cert_file: tls.cert_file.clone(), + key_file: tls.key_file.clone(), + ca_file: tls.ca_file.clone(), + require_client_cert: tls.require_client_cert, + pkcs12_file: tls.pkcs12_file.clone(), + pkcs12_password: tls.pkcs12_password.clone(), + }); + } let mut device_snapshots = Vec::new(); for ds in &c.devices { @@ -3420,6 +3517,45 @@ impl eframe::App for SlaveApp { ui.end_row(); }); ui.add_space(4.0); + ui.checkbox(&mut self.new_use_tls, "启用 TLS"); + if self.new_use_tls { + ui.add_space(2.0); + egui::Grid::new("new_tcp_tls_form") + .num_columns(2) + .spacing([8.0, 4.0]) + .show(ui, |ui| { + ui.label("Cert (PEM)"); + ui.text_edit_singleline(&mut self.new_cert_file); + ui.end_row(); + ui.label("Key (PEM)"); + ui.text_edit_singleline(&mut self.new_key_file); + ui.end_row(); + ui.label("PKCS#12"); + ui.text_edit_singleline(&mut self.new_pkcs12_file); + ui.end_row(); + ui.label("PKCS#12 密码"); + ui.add( + egui::TextEdit::singleline( + &mut self.new_pkcs12_password, + ) + .password(true), + ); + ui.end_row(); + ui.label("CA (可选)"); + ui.text_edit_singleline(&mut self.new_ca_file); + ui.end_row(); + }); + ui.checkbox( + &mut self.new_require_client_cert, + "要求客户端证书 (mTLS)", + ); + theme::text::crumb( + ui, + self.flavor, + "PEM (cert+key) 与 PKCS#12 二选一;两者都填则 PKCS#12 优先", + ); + ui.add_space(4.0); + } ui.horizontal(|ui| { if uikit::primary_button(ui, self.flavor, "创建").clicked() { diff --git a/crates/modbussim-ui-shared/src/project.rs b/crates/modbussim-ui-shared/src/project.rs index 56e6176..c09208d 100644 --- a/crates/modbussim-ui-shared/src/project.rs +++ b/crates/modbussim-ui-shared/src/project.rs @@ -19,6 +19,27 @@ pub enum EguiProjectType { pub struct TcpSpec { pub host: String, pub port: u16, + /// 可选 TLS 配置;旧项目文件无此字段时按 None(明文 TCP)解析。 + #[serde(default)] + pub tls: Option, +} + +/// 子站 TLS 持久化字段,与 `modbussim_core::transport::SlaveTlsConfig` +/// 字段一一对应。任何字段缺失视为空字符串/false,便于演进。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TlsSpec { + #[serde(default)] + pub cert_file: String, + #[serde(default)] + pub key_file: String, + #[serde(default)] + pub ca_file: String, + #[serde(default)] + pub require_client_cert: bool, + #[serde(default)] + pub pkcs12_file: String, + #[serde(default)] + pub pkcs12_password: String, } // --- Slave --- @@ -165,6 +186,7 @@ mod tests { tcp: TcpSpec { host: "0.0.0.0".into(), port: 502, + tls: None, }, devices: vec![SlaveDeviceSave { slave_id: 1, @@ -186,6 +208,7 @@ mod tests { tcp: TcpSpec { host: "127.0.0.1".into(), port: 5502, + tls: None, }, slave_id: 1, timeout_ms: 3000, @@ -201,6 +224,42 @@ mod tests { assert_eq!(q.connections[0].poll.as_ref().unwrap().qty, 10); } + #[test] + fn slave_tls_roundtrip_and_legacy_compat() { + let mut p = SlaveProject::new(); + p.connections.push(SlaveConnectionSave { + label: "TLS 0.0.0.0:8502".into(), + tcp: TcpSpec { + host: "0.0.0.0".into(), + port: 8502, + tls: Some(TlsSpec { + cert_file: "/etc/cert.pem".into(), + key_file: "/etc/key.pem".into(), + ca_file: String::new(), + require_client_cert: false, + pkcs12_file: String::new(), + pkcs12_password: String::new(), + }), + }, + devices: Vec::new(), + }); + let json = serialize_slave(&p).unwrap(); + let q = deserialize_slave(&json).unwrap(); + let tls = q.connections[0].tcp.tls.clone(); + assert_eq!(tls.unwrap().cert_file, "/etc/cert.pem"); + + // 旧文件(无 tls 字段)应当照常解析、tls=None + let legacy = r#"{ + "schema_version": 2, + "type": "slave", + "connections": [ + {"label":"L","tcp":{"host":"0.0.0.0","port":502},"devices":[]} + ] + }"#; + let parsed = deserialize_slave(legacy).unwrap(); + assert!(parsed.connections[0].tcp.tls.is_none()); + } + #[test] fn wrong_type_rejected() { let master = MasterProject::new(); diff --git a/docs/superpowers/specs/2026-04-22-slave-welcome-hero-dancing-strings-design.md b/docs/superpowers/specs/2026-04-22-slave-welcome-hero-dancing-strings-design.md new file mode 100644 index 0000000..d0769c4 --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-slave-welcome-hero-dancing-strings-design.md @@ -0,0 +1,136 @@ +# Slave 端空状态 Hero · 三色 Dancing Strings 动画 + +> 规划日期:2026-04-22 · 分支:`feat/slave-tls-ui-2026-04` + +## Context + +ModbusSim Slave 端 UI (`crates/modbussim-egui`) 目前 `Selection::None` 空状态只有一行大标题 + 一段提示文案(`app.rs:2250-2259`)。当用户还没建任何连接、或临时切走选中项时,CentralPanel 大片留白显得单薄。项目已经有脉动状态灯(`app.rs:3660-3688`,commit `37769f8`)作为生命体征的"微动",但占屏非常小。 + +参考 egui 官方 demo 的 [`dancing_strings.rs`](https://github.com/emilk/egui/blob/main/crates/egui_demo_lib/src/demo/dancing_strings.rs),可以在空状态加一块 Hero 画布,既让首屏更有"活着"的质感,又能把 Slave 运行时的 TX/RX 节奏直观视觉化——"没在忙"时轻轻起伏,"在忙"时琴弦跃动,出错瞬间三条变红。目标是纯视觉强化,不引入新的业务数据通道、不动现有 theme/log_panel。 + +## 最终方案 + +### 1. 模块边界 + +新增 `crates/modbussim-ui-shared/src/hero_anim.rs`,对外: + +```rust +pub struct HeroPulseFeed { + pub amp: f32, // 0.0..=1.0 + pub has_error: bool, + pub disabled: bool, // 预留开关,当前恒 false +} + +pub fn show_welcome_hero(ui: &mut egui::Ui, feed: HeroPulseFeed); +``` + +- 模块不持有业务状态,只负责渲染 + 调用 `ctx.request_repaint_after`。 +- 放在 `ui-shared` 而非 `modbussim-egui` 本地:未来 Master 端 (`modbusmaster-egui`) 可直接复用。 +- `app.rs` `Selection::None` 分支(L2250-2259)整段替换为 `show_welcome_hero(ui, self.hero_pulse.feed())`。 + +### 2. 心跳采样 + +在 `SlaveApp` 加字段 `hero_pulse: HeroPulseState`: + +```rust +struct HeroPulseState { + ring: [u32; 10], // 1s 窗口 × 10 × 100ms bucket + err_ring: [u32; 10], + cursor: usize, + last_tick: std::time::Instant, + last_seen_log_id: u64, +} +``` + +- 每帧 `update()` 开头调用 `hero_pulse.tick(&log_collector)`: + 1. `Instant::now() - last_tick >= 100ms` 时游标前推、旧 bucket 清零。 + 2. 从 `LogCollector` 拉取 `last_seen_log_id` 之后的**原始入账**条目(不受 UI 过滤影响)——需在 `LogCollector` 加一个 `total_ingested_since(id: u64) -> impl Iterator` 访问器(3-5 行)。 + 3. 每条记录累加到当前 bucket;`level == Error` 同步计入 `err_ring`。 +- `feed()` 返回: + - `amp = (sum(ring) as f32 / SATURATION_RATE).clamp(0.0, 1.0)`,`SATURATION_RATE` 取 40(约每 100ms 4 条消息即满振)。 + - `has_error = sum(err_ring) > 0`。 + +不引入新 channel、不改 `UiEvent`、不计时响应延迟。 + +### 3. 绘制与布局 + +`show_welcome_hero` 内部: + +``` +vertical_centered { + heading("ModbusSim · Slave") + add_space(12) + Frame::canvas { + allocate_exact_size(vec2(avail_w.min(640), 180)) + draw 3 strings: mode ∈ {2, 3, 5} + } + add_space(8) + muted_label("从左侧创建或选中一个连接 · 或按 ⌘N 新建") +} +``` + +- 坐标变换:`RectTransform::from_to(Rect{x:0..=1, y:-1..=1}, canvas_rect)`(沿用 demo)。 +- 琴弦公式(在 demo 基础上乘入 `gain`): + + ```rust + let gain = 0.15 + 0.85 * feed.amp; // 底噪 15%,满载 100% + let base = (time * SPEED * mode).sin() / mode; + let y = gain * base * (t * TAU/2.0 * mode).sin(); + ``` + + `SPEED = 1.5`,`n = 120` 采样点,`thickness = 10.0 / mode`。 +- 颜色:默认 `[Theme::accent(), Theme::success(), Theme::warn()]`;`feed.has_error` 时三条各自 `lerp(color, Theme::danger(), 0.6)`。 +- **不用** Window(demo 用的是 Window,这里内联 CentralPanel);**不用** `PathStroke::new_uv` 的渐变(与语义三色冲突)。 + +### 4. 重绘节流 + +- `feed.amp >= 0.3` → `request_repaint_after(16ms)`(~60fps) +- `feed.amp < 0.3` → `request_repaint_after(50ms)`(~20fps,与状态栏脉动节奏对齐) +- `ui.ctx().input(|i| !i.focused)` → `request_repaint_after(100ms)` +- `feed.disabled == true` → 直接 `return`,画纯文本欢迎屏 + +### 5. 需修改/新增文件 + +| 文件 | 改动 | +|---|---| +| `crates/modbussim-ui-shared/src/hero_anim.rs` | 新建 | +| `crates/modbussim-ui-shared/src/lib.rs` | `pub mod hero_anim;` | +| `crates/modbussim-ui-shared/src/log_panel.rs` | `LogCollector::total_ingested_since()` + `next_log_id()` | +| `crates/modbussim-egui/src/app.rs` | `SlaveApp` 加 `hero_pulse`;`update()` 前置 tick;`Selection::None` 分支改调 `show_welcome_hero` | + +**不改**:`theme.rs`、`value_panel.rs`、任何 Master 端 crate。 + +### 6. 测试 · 验证 + +1. **单测**(`hero_pulse_state`,不依赖 egui): + - 注入 10 条日志 → `amp ≈ 1.0` + - 静置 1.1s → `amp == 0.0` + - 注入 Error 级日志 → `has_error == true`;1s 后清零 + - 游标轮转正确性(bucket 越界) +2. **手动视觉验证**: + - `cargo run -p modbussim-egui` → `Selection::None` 可见三色动画 + - 建连接开始 TX/RX → 振幅明显上拉 + - 人为触发解析错误 → 三弦短暂转红 + - 窗口失焦 → CPU 降档(用 Activity Monitor 观察) +3. **回归**:`cargo fmt --all`、`cargo clippy --workspace -- -D warnings`、`cargo test --workspace`。 + +### 7. 风险 / 不做清单 + +已识别: +- **CPU**:三条 120 点 path/frame,60fps,egui 可轻松承受;节流已覆盖。 +- **色盲友好度**:蓝/绿/橙 + 错误红在三色色盲下辨识度一般。本次不加开关,后续可做 `accessibility::color_blind_mode`。 +- **数据源范围**:只统计 `LogCollector` 入账条目(所有 TX/RX + Log);不引入独立计数器以免重复维护。 + +YAGNI(明确不做): +- 动画开关偏好 UI +- 声音效果 +- Master 端接入(API 预留,本次不调) +- 寄存器值→振幅的逐寄存器映射 + +### 8. 关键文件路径(索引) + +- `crates/modbussim-egui/src/app.rs:2250-2259` — `Selection::None` 当前空状态 +- `crates/modbussim-egui/src/app.rs:3660-3688` — 既有脉动状态灯(风格参照) +- `crates/modbussim-ui-shared/src/log_panel.rs` — `LogCollector` 持有者 +- `crates/modbussim-ui-shared/src/theme.rs:66-385` — `accent/success/warn/danger` 色板 +- 参考:egui `crates/egui_demo_lib/src/demo/dancing_strings.rs`