
Rust+Slint 实现ModbusTCP主机调试助手源码分享
- 一、效果展示
- 二、源码分享
-
- 1、工程结构
- 2、comm_log.rs
- 3、data_model.rs
- 4、modbus_client.rs
- 5、modbus_types.rs
- 6、statistics.rs
- 7、main.rs
- 8、build.rs
- 9、main.slint
- 10、Cargo.toml
- 三、实现原理
-
- 1、UI 与逻辑分离:Slint + Rust 回调机制
- 2、连接层:TCP 与 RTU 的统一封装
- 3、协议层:功能码与数据模型
- 4、数据模型层:多格式解析
- 5、通信日志与统计
- 6、异步运行时与线程模型
- 7、构建流程
一、效果展示




二、源码分享
1、工程结构

2、comm_log.rs
use chrono::Local;
/// 通信日志条目
#[derive(Debug, Clone)]
pub struct LogEntryData {
pub timestamp: String,
pub direction: i32, // 0=TX, 1=RX, 2=ERROR
pub raw_text: String,
}
/// 通信日志管理器
#[derive(Clone)]
pub struct CommLog {
entries: Vec<LogEntryData>,
max_entries: usize,
}
impl CommLog {
pub fn new() -> Self {
Self {
entries: Vec::new(),
max_entries: 5000,
}
}
/// 记录发送数据
pub fn log_tx(&mut self, description: &str) {
self.add_entry(0, description);
}
/// 记录接收数据
pub fn log_rx(&mut self, description: &str) {
self.add_entry(1, description);
}
/// 记录错误
pub fn log_error(&mut self, description: &str) {
self.add_entry(2, description);
}
fn add_entry(&mut self, direction: i32, text: &str) {
let entry = LogEntryData {
timestamp: Local::now().format("%H:%M:%S%.3f").to_string(),
direction,
raw_text: text.to_string(),
};
self.entries.push(entry);
// 限制日志条目数
if self.entries.len() > self.max_entries {
self.entries.drain(0..self.entries.len() – self.max_entries);
}
}
/// 获取所有条目
pub fn entries(&self) -> &[LogEntryData] {
&self.entries
}
/// 清空日志
pub fn clear(&mut self) {
self.entries.clear();
}
/// 格式化原始字节为十六进制字符串
pub fn format_bytes(data: &[u8]) -> String {
data.iter()
.map(|b| format!("{:02X}", b))
.collect::<Vec<_>>()
.join(" ")
}
}
3、data_model.rs
use crate::modbus_types::{FunctionCode, ReadResult};
/// 寄存器数据模型
#[derive(Clone)]
pub struct RegisterModel {
/// 原始值
pub raw_values: Vec<u16>,
/// 起始地址
pub start_address: u16,
/// 当前功能码
pub function_code: FunctionCode,
/// 线圈数据(用于线圈/离散输入模式)
pub coil_values: Vec<bool>,
}
impl RegisterModel {
pub fn new() -> Self {
Self {
raw_values: Vec::new(),
start_address: 0,
function_code: FunctionCode::ReadHoldingRegisters,
coil_values: Vec::new(),
}
}
/// 从读取结果更新
pub fn update_from_result(&mut self, result: &ReadResult, start_addr: u16, fc: FunctionCode) {
self.start_address = start_addr;
self.function_code = fc;
match result {
ReadResult::Registers(regs) => {
self.raw_values = regs.clone();
self.coil_values.clear();
}
ReadResult::Coils(coils) => {
self.coil_values = coils.clone();
self.raw_values.clear();
}
}
}
/// 转换为 Slint 显示模型
pub fn to_slint_rows(&self) -> Vec<slint::ModelRc<slint::SharedString>> {
// 这个函数返回的是每列的数据
// 但更好的方式是返回 RegisterRow 结构体数组
// 实际上我们应该直接生成 Slint 的 RegisterRow
Vec::new()
}
/// 生成 Slint RegisterRow 数组
pub fn to_register_rows(&self) -> Vec<slint::SharedString> {
// 这个函数只是为了辅助,实际逻辑在 generate_rows 中
Vec::new()
}
/// 生成用于 Slint 的行数据
/// 返回 Vec<(address, unsigned, signed, hex, binary, float, ascii)>
pub fn generate_rows(&self) -> Vec<(i32, String, String, String, String, String, String)> {
if self.function_code.is_coil() {
// 线圈模式
return self.coil_values.iter().enumerate().map(|(i, &coil)| {
let addr = self.start_address + i as u16;
let val = if coil { 1u16 } else { 0u16 };
(
addr as i32,
val.to_string(),
val.to_string(),
format!("{:04X}", val),
format!("{:016b}", val),
"-".to_string(),
if coil { "ON" } else { "OFF" }.to_string(),
)
}).collect();
}
// 寄存器模式
self.raw_values.iter().enumerate().map(|(i, &raw)| {
let addr = self.start_address + i as u16;
let unsigned_val = raw;
let signed_val = raw as i16;
let hex_val = format!("{:04X}", raw);
let binary_val = format!("{:016b}", raw);
// IEEE 754 浮点(需要两个寄存器)
let float_val = if i + 1 < self.raw_values.len() {
let next_raw = self.raw_values[i + 1];
let bits = ((raw as u32) << 16) | (next_raw as u32);
let float = f32::from_bits(bits);
if float.is_finite() && float.abs() < 1e10 && (float == 0.0 || float.abs() >= 1e-10) {
format!("{:.4}", float)
} else {
format!("{:e}", float)
}
} else {
"-".to_string()
};
// ASCII
let ascii_val = {
let hi = (raw >> 8) as u8;
let lo = (raw & 0xFF) as u8;
let hi_char = if hi >= 0x20 && hi <= 0x7E { hi as char } else { '.' };
let lo_char = if lo >= 0x20 && lo <= 0x7E { lo as char } else { '.' };
format!("{}{}", hi_char, lo_char)
};
(
addr as i32,
unsigned_val.to_string(),
signed_val.to_string(),
hex_val,
binary_val,
float_val,
ascii_val,
)
}).collect()
}
/// 清空数据
pub fn clear(&mut self) {
self.raw_values.clear();
self.coil_values.clear();
self.start_address = 0;
}
}
/// 格式转换工具
pub mod format_utils {
/// u16 转无符号字符串
pub fn to_unsigned(value: u16) -> String {
value.to_string()
}
/// u16 转有符号字符串
pub fn to_signed(value: u16) -> String {
(value as i16).to_string()
}
/// u16 转十六进制
pub fn to_hex(value: u16) -> String {
format!("{:04X}", value)
}
/// u16 转二进制
pub fn to_binary(value: u16) -> String {
format!("{:016b}", value)
}
/// 两个 u16 转 IEEE 754 浮点
pub fn to_float(high: u16, low: u16) -> f32 {
let bits = ((high as u32) << 16) | (low as u32);
f32::from_bits(bits)
}
/// f32 转两个 u16 寄存器
pub fn from_float(value: f32) -> (u16, u16) {
let bits = value.to_bits();
let high = (bits >> 16) as u16;
let low = bits as u16;
(high, low)
}
/// u16 转 ASCII
pub fn to_ascii(value: u16) -> String {
let hi = (value >> 8) as u8;
let lo = (value & 0xFF) as u8;
let hi_char = if hi >= 0x20 && hi <= 0x7E { hi as char } else { '.' };
let lo_char = if lo >= 0x20 && lo <= 0x7E { lo as char } else { '.' };
format!("{}{}", hi_char, lo_char)
}
/// 解析字符串为 u16(支持十进制和十六进制)
pub fn parse_value(s: &str) -> Result<u16, String> {
let s = s.trim();
if s.starts_with("0x") || s.starts_with("0X") {
u16::from_str_radix(&s[2..], 16).map_err(|e| format!("无效的十六进制: {}", e))
} else if s.starts_with("0b") || s.starts_with("0B") {
u16::from_str_radix(&s[2..], 2).map_err(|e| format!("无效的二进制: {}", e))
} else {
s.parse::<u16>().map_err(|e| format!("无效的十进制: {}", e))
}
}
}
4、modbus_client.rs
use std::net::SocketAddr;
use std::str::FromStr;
use tokio_modbus::prelude::*;
use tokio_modbus::client::{self, Context};
use crate::modbus_types::*;
/// 统一的 Modbus 客户端封装
pub struct ModbusClientWrapper {
ctx: Context,
mode: String,
}
impl ModbusClientWrapper {
/// 连接 TCP
pub async fn connect_tcp(ip: &str, port: u16, slave_id: u8) -> Result<Self, ModbusAppError> {
let addr_str = format!("{}:{}", ip, port);
let socket_addr = SocketAddr::from_str(&addr_str)
.map_err(|e| ModbusAppError::ConnectionError(format!("无效的地址: {}", e)))?;
let slave = Slave(slave_id);
let ctx = client::tcp::connect_slave(socket_addr, slave)
.await
.map_err(|e| ModbusAppError::ConnectionError(e.to_string()))?;
Ok(Self {
ctx,
mode: format!("TCP {}:{}", ip, port),
})
}
/// 连接 RTU
pub async fn connect_rtu(
port_name: &str,
baud_rate: u32,
data_bits: u8,
stop_bits: u8,
parity: ParityMode,
slave_id: u8,
) -> Result<Self, ModbusAppError> {
let parity_mode = match parity {
ParityMode::None => tokio_serial::Parity::None,
ParityMode::Even => tokio_serial::Parity::Even,
ParityMode::Odd => tokio_serial::Parity::Odd,
};
let data_bits_mode = match data_bits {
5 => tokio_serial::DataBits::Five,
6 => tokio_serial::DataBits::Six,
7 => tokio_serial::DataBits::Seven,
_ => tokio_serial::DataBits::Eight,
};
let stop_bits_mode = match stop_bits {
1 => tokio_serial::StopBits::One,
_ => tokio_serial::StopBits::Two,
};
let builder = tokio_serial::new(port_name, baud_rate)
.data_bits(data_bits_mode)
.stop_bits(stop_bits_mode)
.parity(parity_mode);
let serial_port = tokio_serial::SerialStream::open(&builder)
.map_err(|e| ModbusAppError::ConnectionError(format!("串口打开失败: {}", e)))?;
let slave = Slave(slave_id);
let ctx = client::rtu::attach_slave(serial_port, slave);
Ok(Self {
ctx,
mode: format!("RTU {} {}-{}{}{}", port_name, baud_rate, data_bits, stop_bits, parity),
})
}
/// 读取线圈 (FC 01)
pub async fn read_coils(&mut self, addr: u16, cnt: u16) -> Result<Vec<bool>, ModbusAppError> {
self.ctx
.read_coils(addr, cnt)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 读取离散输入 (FC 02)
pub async fn read_discrete_inputs(&mut self, addr: u16, cnt: u16) -> Result<Vec<bool>, ModbusAppError> {
self.ctx
.read_discrete_inputs(addr, cnt)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 读取保持寄存器 (FC 03)
pub async fn read_holding_registers(&mut self, addr: u16, cnt: u16) -> Result<Vec<u16>, ModbusAppError> {
self.ctx
.read_holding_registers(addr, cnt)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 读取输入寄存器 (FC 04)
pub async fn read_input_registers(&mut self, addr: u16, cnt: u16) -> Result<Vec<u16>, ModbusAppError> {
self.ctx
.read_input_registers(addr, cnt)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 写单个线圈 (FC 05)
pub async fn write_single_coil(&mut self, addr: u16, value: bool) -> Result<(), ModbusAppError> {
self.ctx
.write_single_coil(addr, value)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 写单个寄存器 (FC 06)
pub async fn write_single_register(&mut self, addr: u16, value: u16) -> Result<(), ModbusAppError> {
self.ctx
.write_single_register(addr, value)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 写多个线圈 (FC 15)
pub async fn write_multiple_coils(&mut self, addr: u16, values: &[bool]) -> Result<(), ModbusAppError> {
self.ctx
.write_multiple_coils(addr, values)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 写多个寄存器 (FC 16)
pub async fn write_multiple_registers(&mut self, addr: u16, values: &[u16]) -> Result<(), ModbusAppError> {
self.ctx
.write_multiple_registers(addr, values)
.await
.map_err(|e| ModbusAppError::ProtocolError(e.to_string()))?
.map_err(|e| ModbusAppError::ProtocolError(format!("异常: {:?}", e)))
}
/// 执行读取请求
pub async fn execute_read(&mut self, req: &ReadRequest) -> Result<ReadResult, ModbusAppError> {
match req.function_code {
FunctionCode::ReadCoils => {
let coils = self.read_coils(req.start_address, req.quantity).await?;
Ok(ReadResult::Coils(coils))
}
FunctionCode::ReadDiscreteInputs => {
let inputs = self.read_discrete_inputs(req.start_address, req.quantity).await?;
Ok(ReadResult::Coils(inputs))
}
FunctionCode::ReadHoldingRegisters => {
let regs = self.read_holding_registers(req.start_address, req.quantity).await?;
Ok(ReadResult::Registers(regs))
}
FunctionCode::ReadInputRegisters => {
let regs = self.read_input_registers(req.start_address, req.quantity).await?;
Ok(ReadResult::Registers(regs))
}
_ => Err(ModbusAppError::InvalidParam("不是读取功能码".to_string())),
}
}
/// 执行写入请求
pub async fn execute_write(&mut self, req: &WriteRequest) -> Result<(), ModbusAppError> {
match req.function_code {
FunctionCode::WriteSingleCoil => {
let value = req.values.first().copied().unwrap_or(0) != 0;
self.write_single_coil(req.address, value).await
}
FunctionCode::WriteSingleRegister => {
let value = req.values.first().copied().unwrap_or(0);
self.write_single_register(req.address, value).await
}
FunctionCode::WriteMultipleCoils => {
let values: Vec<bool> = req.values.iter().map(|&v| v != 0).collect();
self.write_multiple_coils(req.address, &values).await
}
FunctionCode::WriteMultipleRegisters => {
self.write_multiple_registers(req.address, &req.values).await
}
_ => Err(ModbusAppError::InvalidParam("不是写入功能码".to_string())),
}
}
/// 获取连接描述
pub fn connection_description(&self) -> &str {
&self.mode
}
}
5、modbus_types.rs
use thiserror::Error;
/// Modbus 功能码
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum FunctionCode {
ReadCoils = 0x01,
ReadDiscreteInputs = 0x02,
ReadHoldingRegisters = 0x03,
ReadInputRegisters = 0x04,
WriteSingleCoil = 0x05,
WriteSingleRegister = 0x06,
WriteMultipleCoils = 0x0F,
WriteMultipleRegisters = 0x10,
}
impl FunctionCode {
pub fn from_u8(v: u8) -> Option<Self> {
match v {
0x01 => Some(Self::ReadCoils),
0x02 => Some(Self::ReadDiscreteInputs),
0x03 => Some(Self::ReadHoldingRegisters),
0x04 => Some(Self::ReadInputRegisters),
0x05 => Some(Self::WriteSingleCoil),
0x06 => Some(Self::WriteSingleRegister),
0x0F => Some(Self::WriteMultipleCoils),
0x10 => Some(Self::WriteMultipleRegisters),
_ => None,
}
}
pub fn is_read(&self) -> bool {
matches!(
self,
Self::ReadCoils
| Self::ReadDiscreteInputs
| Self::ReadHoldingRegisters
| Self::ReadInputRegisters
)
}
pub fn is_coil(&self) -> bool {
matches!(self, Self::ReadCoils | Self::ReadDiscreteInputs | Self::WriteSingleCoil | Self::WriteMultipleCoils)
}
pub fn display_name(&self) -> &'static str {
match self {
Self::ReadCoils => "01 读线圈 (Read Coils)",
Self::ReadDiscreteInputs => "02 读离散输入 (Read Discrete Inputs)",
Self::ReadHoldingRegisters => "03 读保持寄存器 (Read Holding Registers)",
Self::ReadInputRegisters => "04 读输入寄存器 (Read Input Registers)",
Self::WriteSingleCoil => "05 写单个线圈 (Write Single Coil)",
Self::WriteSingleRegister => "06 写单个寄存器 (Write Single Register)",
Self::WriteMultipleCoils => "15 写多个线圈 (Write Multiple Coils)",
Self::WriteMultipleRegisters => "16 写多个寄存器 (Write Multiple Registers)",
}
}
/// 用于 ComboBox 显示
pub fn short_name(&self) -> &'static str {
match self {
Self::ReadCoils => "01 – Read Coils",
Self::ReadDiscreteInputs => "02 – Read Discrete Inputs",
Self::ReadHoldingRegisters => "03 – Read Holding Registers",
Self::ReadInputRegisters => "04 – Read Input Registers",
Self::WriteSingleCoil => "05 – Write Single Coil",
Self::WriteSingleRegister => "06 – Write Single Register",
Self::WriteMultipleCoils => "15 – Write Multiple Coils",
Self::WriteMultipleRegisters => "16 – Write Multiple Registers",
}
}
}
/// 连接模式
#[derive(Debug, Clone)]
pub enum ConnectionMode {
Tcp {
ip: String,
port: u16,
},
Rtu {
port: String,
baud_rate: u32,
data_bits: u8,
stop_bits: u8,
parity: ParityMode,
},
}
/// 校验模式
#[derive(Debug, Clone, Copy)]
pub enum ParityMode {
None,
Even,
Odd,
}
impl std::fmt::Display for ParityMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParityMode::None => write!(f, "N"),
ParityMode::Even => write!(f, "E"),
ParityMode::Odd => write!(f, "O"),
}
}
}
/// Modbus 错误类型
#[derive(Debug, Error)]
pub enum ModbusAppError {
#[error("连接错误: {0}")]
ConnectionError(String),
#[error("通信超时")]
Timeout,
#[error("协议错误: {0}")]
ProtocolError(String),
#[error("IO 错误: {0}")]
IoError(#[from] std::io::Error),
#[error("未连接")]
NotConnected,
#[error("参数错误: {0}")]
InvalidParam(String),
}
/// 读取结果
#[derive(Debug, Clone)]
pub enum ReadResult {
Registers(Vec<u16>),
Coils(Vec<bool>),
}
/// 读取请求参数
#[derive(Debug, Clone)]
pub struct ReadRequest {
pub function_code: FunctionCode,
pub slave_id: u8,
pub start_address: u16,
pub quantity: u16,
}
/// 写入请求参数
#[derive(Debug, Clone)]
pub struct WriteRequest {
pub function_code: FunctionCode,
pub slave_id: u8,
pub address: u16,
pub values: Vec<u16>,
}
6、statistics.rs
/// 通信统计
#[derive(Clone)]
pub struct CommStatistics {
pub tx_count: i32,
pub rx_count: i32,
pub error_count: i32,
pub last_error: String,
}
impl CommStatistics {
pub fn new() -> Self {
Self {
tx_count: 0,
rx_count: 0,
error_count: 0,
last_error: String::new(),
}
}
pub fn record_tx(&mut self) {
self.tx_count += 1;
}
pub fn record_rx(&mut self) {
self.rx_count += 1;
}
pub fn record_error(&mut self, error: &str) {
self.error_count += 1;
self.last_error = error.to_string();
}
pub fn reset(&mut self) {
self.tx_count = 0;
self.rx_count = 0;
self.error_count = 0;
self.last_error.clear();
}
}
7、main.rs
slint::include_modules!();
mod modbus_types;
mod modbus_client;
mod data_model;
mod comm_log;
mod statistics;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::Mutex as TokioMutex;
use modbus_types::*;
use modbus_client::ModbusClientWrapper;
use data_model::RegisterModel;
use comm_log::CommLog;
use statistics::CommStatistics;
/// 应用全局状态
struct AppState {
client: Arc<TokioMutex<Option<ModbusClientWrapper>>>,
model: Arc<TokioMutex<RegisterModel>>,
log: Arc<TokioMutex<CommLog>>,
stats: Arc<TokioMutex<CommStatistics>>,
poll_cancel: Arc<AtomicBool>,
}
impl AppState {
fn new() -> Self {
Self {
client: Arc::new(TokioMutex::new(None)),
model: Arc::new(TokioMutex::new(RegisterModel::new())),
log: Arc::new(TokioMutex::new(CommLog::new())),
stats: Arc::new(TokioMutex::new(CommStatistics::new())),
poll_cancel: Arc::new(AtomicBool::new(false)),
}
}
}
/// 将寄存器数据更新到 UI
fn update_ui_registers(ui: &MainWindow, model: &RegisterModel, stats: &CommStatistics) {
let rows = model.generate_rows();
let slint_rows: Vec<RegisterRow> = rows
.into_iter()
.map(|(addr, unsigned, signed, hex, binary, float, ascii)| RegisterRow {
address: addr,
value_unsigned: unsigned.into(),
value_signed: signed.into(),
value_hex: hex.into(),
value_binary: binary.into(),
value_float: float.into(),
value_ascii: ascii.into(),
})
.collect();
ui.set_registers(slint::ModelRc::new(slint::VecModel::from(slint_rows)));
ui.set_tx_count(stats.tx_count);
ui.set_rx_count(stats.rx_count);
ui.set_error_count(stats.error_count);
ui.set_last_error(stats.last_error.clone().into());
}
/// 将日志更新到 UI
fn update_ui_log(ui: &MainWindow, log: &CommLog) {
let entries: Vec<LogEntry> = log
.entries()
.iter()
.map(|e| LogEntry {
timestamp: e.timestamp.clone().into(),
direction: e.direction,
raw_text: e.raw_text.clone().into(),
})
.collect();
ui.set_log_entries(slint::ModelRc::new(slint::VecModel::from(entries)));
}
fn main() {
env_logger::init();
log::info!("Modbus 调试助手启动");
let runtime = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
let _enter = runtime.enter();
let ui = MainWindow::new().expect("Failed to create main window");
let state = AppState::new();
// 功能码选项(共享给对话框)
let read_fc_choices: Vec<slint::SharedString> = vec![
"01 – Read Coils".into(),
"02 – Read Discrete Inputs".into(),
"03 – Read Holding Registers".into(),
"04 – Read Input Registers".into(),
];
let write_fc_choices: Vec<slint::SharedString> = vec![
"05 – Write Single Coil".into(),
"06 – Write Single Register".into(),
"15 – Write Multiple Coils".into(),
"16 – Write Multiple Registers".into(),
];
let rtu_baud_rates: Vec<slint::SharedString> = vec![
"1200".into(), "2400".into(), "4800".into(), "9600".into(),
"19200".into(), "38400".into(), "57600".into(), "115200".into(),
];
let rtu_data_bits: Vec<slint::SharedString> = vec![
"5".into(), "6".into(), "7".into(), "8".into(),
];
let rtu_stop_bits: Vec<slint::SharedString> = vec![
"1".into(), "2".into(),
];
let rtu_parity: Vec<slint::SharedString> = vec![
"None".into(), "Even".into(), "Odd".into(),
];
// ===== 注册回调 =====
register_toolbar_callbacks(&ui, &state);
register_cell_edit_callback(&ui, &state);
// 存储对话框需要的选项数据
let read_fc_for_dialog = read_fc_choices.clone();
let write_fc_for_dialog = write_fc_choices.clone();
let baud_rates_for_dialog = rtu_baud_rates.clone();
let data_bits_for_dialog = rtu_data_bits.clone();
let stop_bits_for_dialog = rtu_stop_bits.clone();
let parity_for_dialog = rtu_parity.clone();
// 连接按钮回调 → 显示连接方式选择对话框
{
let ui_weak = ui.as_weak();
let state_client = state.client.clone();
let state_log = state.log.clone();
let state_stats = state.stats.clone();
let read_fcs = read_fc_for_dialog.clone();
let write_fcs = write_fc_for_dialog.clone();
let baud_rates = baud_rates_for_dialog.clone();
let data_bits_list = data_bits_for_dialog.clone();
let stop_bits_list = stop_bits_for_dialog.clone();
let parity_list = parity_for_dialog.clone();
ui.on_on_connect(move || {
show_connection_dialog(
&ui_weak,
&state_client,
&state_log,
&state_stats,
&read_fcs,
&write_fcs,
&baud_rates,
&data_bits_list,
&stop_bits_list,
&parity_list,
);
});
}
// 断开连接
{
let ui_weak = ui.as_weak();
let state_client = state.client.clone();
let state_log = state.log.clone();
let state_stats = state.stats.clone();
let poll_cancel = state.poll_cancel.clone();
ui.on_on_disconnect(move || {
let ui_weak = ui_weak.clone();
let client = state_client.clone();
let log = state_log.clone();
let stats = state_stats.clone();
let poll_cancel = poll_cancel.clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
// 停止轮询
poll_cancel.store(true, Ordering::SeqCst);
// 清空客户端
if let Ok(mut guard) = client.try_lock() {
*guard = None;
}
ui.set_connected(false);
ui.set_connection_mode("".into());
ui.set_connection_info("".into());
ui.set_polling(false);
if let Ok(mut log_guard) = log.try_lock() {
log_guard.log_error("已断开连接");
update_ui_log(&ui, &log_guard);
}
}
})
.ok();
});
}
// 读取
{
let ui_weak = ui.as_weak();
let state_client = state.client.clone();
let state_model = state.model.clone();
let state_log = state.log.clone();
let state_stats = state.stats.clone();
let read_fcs = read_fc_for_dialog.clone();
ui.on_on_read(move || {
let ui_weak = ui_weak.clone();
let client = state_client.clone();
let model = state_model.clone();
let log = state_log.clone();
let stats = state_stats.clone();
let fc_choices = read_fcs.clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
show_read_dialog(&ui, &client, &model, &log, &stats, &fc_choices);
}
})
.ok();
});
}
// 写入
{
let ui_weak = ui.as_weak();
let state_client = state.client.clone();
let state_log = state.log.clone();
let state_stats = state.stats.clone();
let write_fcs = write_fc_for_dialog.clone();
ui.on_on_write(move || {
let ui_weak = ui_weak.clone();
let client = state_client.clone();
let log = state_log.clone();
let stats = state_stats.clone();
let fc_choices = write_fcs.clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
show_write_dialog(&ui, &client, &log, &stats, &fc_choices);
}
})
.ok();
});
}
// 自动轮询
{
let ui_weak = ui.as_weak();
let state_client = state.client.clone();
let state_model = state.model.clone();
let state_log = state.log.clone();
let state_stats = state.stats.clone();
let poll_cancel = state.poll_cancel.clone();
ui.on_on_poll_toggle(move || {
let ui_weak = ui_weak.clone();
let client = state_client.clone();
let model = state_model.clone();
let log = state_log.clone();
let stats = state_stats.clone();
let poll_cancel = poll_cancel.clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
let is_polling = ui.get_polling();
if is_polling {
// 停止轮询
poll_cancel.store(true, Ordering::SeqCst);
ui.set_polling(false);
} else {
// 启动轮询
poll_cancel.store(false, Ordering::SeqCst);
ui.set_polling(true);
let ui_weak2 = ui.as_weak();
let client2 = client.clone();
let model2 = model.clone();
let log2 = log.clone();
let stats2 = stats.clone();
let poll_cancel2 = poll_cancel.clone();
tokio::spawn(async move {
start_polling(
ui_weak2,
client2,
model2,
log2,
stats2,
poll_cancel2,
)
.await;
});
}
}
})
.ok();
});
}
// 清空日志
{
let ui_weak = ui.as_weak();
let state_log = state.log.clone();
ui.on_on_clear_log(move || {
let ui_weak = ui_weak.clone();
let log = state_log.clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
if let Ok(mut log_guard) = log.try_lock() {
log_guard.clear();
update_ui_log(&ui, &log_guard);
}
}
})
.ok();
});
}
log::info!("UI 初始化完成,启动事件循环");
ui.run().expect("Failed to run event loop");
}
// ============================================================
// 回调注册
// ============================================================
fn register_toolbar_callbacks(_ui: &MainWindow, _state: &AppState) {
// 工具栏回调在 main() 中单独注册,因为它们需要更多上下文
}
fn register_cell_edit_callback(ui: &MainWindow, state: &AppState) {
let ui_weak = ui.as_weak();
let state_client = state.client.clone();
let state_log = state.log.clone();
let state_stats = state.stats.clone();
ui.on_on_cell_edit(move |address, value| {
let ui_weak = ui_weak.clone();
let client = state_client.clone();
let log = state_log.clone();
let stats = state_stats.clone();
let value_str = value.to_string();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
let addr = address as u16;
// 解析值
let parsed = data_model::format_utils::parse_value(&value_str);
let val = match parsed {
Ok(v) => v,
Err(e) => {
if let Ok(mut log_guard) = log.try_lock() {
log_guard.log_error(&format!("写入失败: {}", e));
update_ui_log(&ui, &log_guard);
}
return;
}
};
let client_clone = client.clone();
let log_clone = log.clone();
let stats_clone = stats.clone();
let ui_weak2 = ui.as_weak();
tokio::spawn(async move {
let mut guard = client_clone.lock().await;
if let Some(ref mut c) = *guard {
let req = WriteRequest {
function_code: FunctionCode::WriteSingleRegister,
slave_id: 1,
address: addr,
values: vec![val],
};
let mut log_guard = log_clone.lock().await;
log_guard.log_tx(&format!(
"FC06 写寄存器 地址={} 值={}",
addr, val
));
drop(log_guard);
match c.execute_write(&req).await {
Ok(()) => {
if let Ok(mut stats_guard) = stats_clone.try_lock() {
stats_guard.record_tx();
stats_guard.record_rx();
}
let mut log_guard = log_clone.lock().await;
log_guard.log_rx("写入成功");
let log_snapshot = (*log_guard).clone();
drop(log_guard);
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak2.upgrade() {
update_ui_log(&ui, &log_snapshot);
}
})
.ok();
}
Err(e) => {
if let Ok(mut stats_guard) = stats_clone.try_lock() {
stats_guard.record_error(&e.to_string());
}
let mut log_guard = log_clone.lock().await;
log_guard.log_error(&format!("写入失败: {}", e));
let log_snapshot = (*log_guard).clone();
drop(log_guard);
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak2.upgrade() {
update_ui_log(&ui, &log_snapshot);
}
})
.ok();
}
}
}
});
}
})
.ok();
});
}
// ============================================================
// 连接对话框
// ============================================================
fn show_connection_dialog(
ui_weak: &slint::Weak<MainWindow>,
client: &Arc<TokioMutex<Option<ModbusClientWrapper>>>,
log: &Arc<TokioMutex<CommLog>>,
_stats: &Arc<TokioMutex<CommStatistics>>,
_read_fcs: &[slint::SharedString],
_write_fcs: &[slint::SharedString],
_baud_rates: &[slint::SharedString],
_data_bits_list: &[slint::SharedString],
_stop_bits_list: &[slint::SharedString],
_parity_list: &[slint::SharedString],
) {
// 为了简单起见,先显示 TCP 连接对话框
// 后续可以增加选择对话框让用户选择 TCP 或 RTU
let dialog = TcpSetupDialog::new().expect("Failed to create TCP dialog");
dialog.set_ip_address("127.0.0.1".into());
dialog.set_port(502);
dialog.set_slave_id(1);
let ui_weak = ui_weak.clone();
let client = client.clone();
let log = log.clone();
dialog.on_on_confirm({
let dialog = dialog.as_weak();
let ui_weak = ui_weak.clone();
let client = client.clone();
let log = log.clone();
move || {
if let Some(dlg) = dialog.upgrade() {
let ip = dlg.get_ip_address().to_string();
let port = dlg.get_port() as u16;
let slave_id = dlg.get_slave_id() as u8;
let ui_weak = ui_weak.clone();
let client = client.clone();
let log = log.clone();
let dialog_weak = dialog.clone();
tokio::spawn(async move {
let result = ModbusClientWrapper::connect_tcp(&ip, port, slave_id).await;
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
match result {
Ok(c) => {
tokio::spawn(async move {
let mut guard = client.lock().await;
*guard = Some(c);
});
ui.set_connected(true);
ui.set_connection_mode("TCP".into());
ui.set_connection_info(
format!("{}:{}", ip, port).into(),
);
ui.set_slave_id(slave_id as i32);
if let Ok(mut log_guard) = log.try_lock() {
log_guard.log_tx(&format!(
"TCP 连接成功 {}:{}",
ip, port
));
update_ui_log(&ui, &log_guard);
}
}
Err(e) => {
let err_msg = e.to_string();
ui.set_last_error(err_msg.clone().into());
if let Ok(mut log_guard) = log.try_lock() {
log_guard
.log_error(&format!("TCP 连接失败: {}", err_msg));
update_ui_log(&ui, &log_guard);
}
}
}
}
})
.ok();
// 关闭对话框
if let Some(dlg) = dialog_weak.upgrade() {
let _ = dlg.hide();
}
});
}
}
});
dialog.on_on_cancel({
let dialog = dialog.as_weak();
move || {
if let Some(dlg) = dialog.upgrade() {
let _ = dlg.hide();
}
}
});
dialog.show().expect("Failed to show TCP dialog");
}
// ============================================================
// 读取对话框
// ============================================================
fn show_read_dialog(
ui: &MainWindow,
client: &Arc<TokioMutex<Option<ModbusClientWrapper>>>,
model: &Arc<TokioMutex<RegisterModel>>,
log: &Arc<TokioMutex<CommLog>>,
stats: &Arc<TokioMutex<CommStatistics>>,
fc_choices: &[slint::SharedString],
) {
let dialog = ReadSetupDialog::new().expect("Failed to create read dialog");
dialog.set_function_codes(slint::ModelRc::new(slint::VecModel::from(
fc_choices.to_vec(),
)));
dialog.set_function_index(2); // 默认 Read Holding Registers
dialog.set_slave_id(ui.get_slave_id());
dialog.set_start_address(0);
dialog.set_quantity(10);
let ui_weak = ui.as_weak();
let client = client.clone();
let model = model.clone();
let log = log.clone();
let stats = stats.clone();
dialog.on_on_confirm({
let dialog = dialog.as_weak();
let ui_weak = ui_weak.clone();
let client = client.clone();
let model = model.clone();
let log = log.clone();
let stats = stats.clone();
move || {
if let Some(dlg) = dialog.upgrade() {
let fc_index = dlg.get_function_index() as u8;
let slave_id = dlg.get_slave_id() as u8;
let start_addr = dlg.get_start_address() as u16;
let quantity = dlg.get_quantity() as u16;
let function_code = match fc_index {
0 => FunctionCode::ReadCoils,
1 => FunctionCode::ReadDiscreteInputs,
2 => FunctionCode::ReadHoldingRegisters,
3 => FunctionCode::ReadInputRegisters,
_ => FunctionCode::ReadHoldingRegisters,
};
let req = ReadRequest {
function_code,
slave_id,
start_address: start_addr,
quantity,
};
let ui_weak = ui_weak.clone();
let client = client.clone();
let model = model.clone();
let log = log.clone();
let stats = stats.clone();
let dialog_weak = dialog.clone();
tokio::spawn(async move {
let mut guard = client.lock().await;
if let Some(ref mut c) = *guard {
// 记录发送
{
let mut log_guard = log.lock().await;
log_guard.log_tx(&format!(
"FC{:02X} 读取 从站={} 地址={} 数量={}",
function_code as u8, slave_id, start_addr, quantity
));
}
let result = c.execute_read(&req).await;
// 记录接收/错误
{
let mut log_guard = log.lock().await;
match &result {
Ok(_) => log_guard.log_rx("读取成功"),
Err(e) => log_guard.log_error(&format!("读取失败: {}", e)),
}
}
// 更新状态
{
let mut stats_guard = stats.try_lock().unwrap();
stats_guard.record_tx();
match &result {
Ok(_) => stats_guard.record_rx(),
Err(e) => stats_guard.record_error(&e.to_string()),
}
}
// 更新模型
if let Ok(ref read_result) = result {
model
.try_lock().unwrap()
.update_from_result(read_result, start_addr, function_code);
}
// 更新 UI
let model_snapshot = model.try_lock().unwrap().clone();
let stats_snapshot = stats.try_lock().unwrap().clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
update_ui_registers(&ui, &model_snapshot, &stats_snapshot);
let log_guard = log.try_lock();
if let Ok(log_guard) = log_guard {
update_ui_log(&ui, &log_guard);
}
ui.set_function_code(function_code as i32);
}
})
.ok();
} else {
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
ui.set_last_error("未连接到设备".into());
}
})
.ok();
}
if let Some(dlg) = dialog_weak.upgrade() {
let _ = dlg.hide();
}
});
}
}
});
dialog.on_on_cancel({
let dialog = dialog.as_weak();
move || {
if let Some(dlg) = dialog.upgrade() {
let _ = dlg.hide();
}
}
});
dialog.show().expect("Failed to show read dialog");
}
// ============================================================
// 写入对话框
// ============================================================
fn show_write_dialog(
ui: &MainWindow,
client: &Arc<TokioMutex<Option<ModbusClientWrapper>>>,
log: &Arc<TokioMutex<CommLog>>,
stats: &Arc<TokioMutex<CommStatistics>>,
fc_choices: &[slint::SharedString],
) {
let dialog = WriteSetupDialog::new().expect("Failed to create write dialog");
dialog.set_write_function_codes(slint::ModelRc::new(slint::VecModel::from(
fc_choices.to_vec(),
)));
dialog.set_function_index(1); // 默认 Write Single Register
dialog.set_slave_id(ui.get_slave_id());
dialog.set_start_address(0);
dialog.set_write_value("0".into());
let ui_weak = ui.as_weak();
let client = client.clone();
let log = log.clone();
let stats = stats.clone();
dialog.on_on_confirm({
let dialog = dialog.as_weak();
let ui_weak = ui_weak.clone();
let client = client.clone();
let log = log.clone();
let stats = stats.clone();
move || {
if let Some(dlg) = dialog.upgrade() {
let fc_index = dlg.get_function_index() as u8;
let slave_id = dlg.get_slave_id() as u8;
let address = dlg.get_start_address() as u16;
let value_str = dlg.get_write_value().to_string();
let function_code = match fc_index {
0 => FunctionCode::WriteSingleCoil,
1 => FunctionCode::WriteSingleRegister,
2 => FunctionCode::WriteMultipleCoils,
3 => FunctionCode::WriteMultipleRegisters,
_ => FunctionCode::WriteSingleRegister,
};
let val = match data_model::format_utils::parse_value(&value_str) {
Ok(v) => v,
Err(e) => {
let ui_weak = ui_weak.clone();
let log = log.clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
ui.set_last_error(e.clone().into());
if let Ok(mut log_guard) = log.try_lock() {
log_guard.log_error(&format!("写入失败: {}", e));
update_ui_log(&ui, &log_guard);
}
}
})
.ok();
return;
}
};
let req = WriteRequest {
function_code,
slave_id,
address,
values: vec![val],
};
let ui_weak = ui_weak.clone();
let client = client.clone();
let log = log.clone();
let stats = stats.clone();
let dialog_weak = dialog.clone();
tokio::spawn(async move {
let mut guard = client.lock().await;
if let Some(ref mut c) = *guard {
{
let mut log_guard = log.lock().await;
log_guard.log_tx(&format!(
"FC{:02X} 写入 从站={} 地址={} 值={}",
function_code as u8, slave_id, address, val
));
}
let result = c.execute_write(&req).await;
{
let mut log_guard = log.lock().await;
match &result {
Ok(()) => log_guard.log_rx("写入成功"),
Err(e) => log_guard.log_error(&format!("写入失败: {}", e)),
}
}
{
let mut stats_guard = stats.try_lock().unwrap();
stats_guard.record_tx();
match &result {
Ok(()) => stats_guard.record_rx(),
Err(e) => stats_guard.record_error(&e.to_string()),
}
}
let stats_snapshot = stats.try_lock().unwrap().clone();
let log_snapshot = (*log.lock().await).clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
ui.set_tx_count(stats_snapshot.tx_count);
ui.set_rx_count(stats_snapshot.rx_count);
ui.set_error_count(stats_snapshot.error_count);
ui.set_last_error(stats_snapshot.last_error.clone().into());
update_ui_log(&ui, &log_snapshot);
}
})
.ok();
}
if let Some(dlg) = dialog_weak.upgrade() {
let _ = dlg.hide();
}
});
}
}
});
dialog.on_on_cancel({
let dialog = dialog.as_weak();
move || {
if let Some(dlg) = dialog.upgrade() {
let _ = dlg.hide();
}
}
});
dialog.show().expect("Failed to show write dialog");
}
// ============================================================
// 自动轮询
// ============================================================
async fn start_polling(
ui_weak: slint::Weak<MainWindow>,
client: Arc<TokioMutex<Option<ModbusClientWrapper>>>,
model: Arc<TokioMutex<RegisterModel>>,
log: Arc<TokioMutex<CommLog>>,
stats: Arc<TokioMutex<CommStatistics>>,
cancel: Arc<AtomicBool>,
) {
let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000));
loop {
interval.tick().await;
if cancel.load(Ordering::SeqCst) {
break;
}
// 读取当前 UI 设置
let (fc, slave_id, start_addr, quantity) = {
let (tx, rx) = std::sync::mpsc::channel();
let ui_weak = ui_weak.clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak.upgrade() {
let _ = tx.send((
ui.get_function_code() as u8,
ui.get_slave_id() as u8,
0u16, // 需要保存 start_address
10u16, // 需要保存 quantity
));
}
})
.ok();
rx.recv().unwrap_or((0u8, 1u8, 0u16, 10u16))
};
let function_code = match FunctionCode::from_u8(fc) {
Some(fc) if fc.is_read() => fc,
_ => FunctionCode::ReadHoldingRegisters,
};
let req = ReadRequest {
function_code,
slave_id,
start_address: start_addr,
quantity,
};
let mut guard = client.lock().await;
if let Some(ref mut c) = *guard {
let result = c.execute_read(&req).await;
// 更新日志
{
let mut log_guard = log.lock().await;
match &result {
Ok(_) => {
log_guard.log_tx(&format!(
"轮询 FC{:02X} 从站={} 地址={} 数量={}",
fc, slave_id, start_addr, quantity
));
log_guard.log_rx("轮询成功");
}
Err(e) => {
log_guard.log_error(&format!("轮询失败: {}", e));
}
}
}
// 更新统计
{
let mut stats_guard = stats.try_lock().unwrap();
stats_guard.record_tx();
match &result {
Ok(_) => stats_guard.record_rx(),
Err(e) => stats_guard.record_error(&e.to_string()),
}
}
// 更新模型
if let Ok(ref read_result) = result {
model
.try_lock().unwrap()
.update_from_result(read_result, start_addr, function_code);
}
// 更新 UI
let model_snapshot = model.try_lock().unwrap().clone();
let stats_snapshot = stats.try_lock().unwrap().clone();
let ui_weak2 = ui_weak.clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak2.upgrade() {
update_ui_registers(&ui, &model_snapshot, &stats_snapshot);
}
})
.ok();
} else {
// 客户端已断开,停止轮询
cancel.store(true, Ordering::SeqCst);
let ui_weak2 = ui_weak.clone();
slint::invoke_from_event_loop(move || {
if let Some(ui) = ui_weak2.upgrade() {
ui.set_polling(false);
}
})
.ok();
break;
}
}
}
8、build.rs
fn main() {
// 编译 Slint UI
slint_build::compile("ui/main.slint").unwrap();
}
9、main.slint
import { Button, LineEdit, SpinBox, ComboBox, ScrollView } from "std-widgets.slint";
// ==================== 主题 ====================
export global Theme {
in property <color> bg–primary: #0d1117;
in property <color> bg–secondary: #161b22;
in property <color> bg–tertiary: #1c2333;
in property <color> bg–elevated: #21283b;
in property <color> bg–hover: #292e3e;
in property <color> bg–active: #333a4d;
in property <color> border: #2a3040;
in property <color> border–focus: #58a6ff;
in property <color> text–primary: #e6edf3;
in property <color> text–secondary: #8b949e;
in property <color> text–muted: #636c76;
in property <color> accent: #58a6ff;
in property <color> success: #3fb950;
in property <color> warning: #d29922;
in property <color> error: #f85149;
in property <color> info: #58a6ff;
in property <color> tx–color: #58a6ff;
in property <color> rx–color: #3fb950;
in property <color> table–header–bg: #1c2333;
in property <color> table–row–even: #0d1117;
in property <color> table–row–odd: #111820;
in property <length> spacing–xs: 4px;
in property <length> spacing–sm: 8px;
in property <length> spacing–md: 12px;
in property <length> spacing–lg: 16px;
in property <length> spacing–xl: 24px;
in property <length> radius–sm: 4px;
in property <length> font–size–sm: 12px;
in property <length> font–size–md: 13px;
in property <length> font–size–lg: 14px;
}
// ==================== 数据结构 ====================
export struct RegisterRow {
address: int,
value–unsigned: string,
value–signed: string,
value–hex: string,
value–binary: string,
value–float: string,
value–ascii: string,
}
export struct LogEntry {
timestamp: string,
direction: int,
raw–text: string,
}
// ==================== 基础组件(先定义,后使用) ====================
component Sep inherits Rectangle {
width: 1px;
background: Theme.border;
}
component HeaderCell inherits Rectangle {
in property <string> text;
background: transparent;
border–width: 1px;
border–color: Theme.border;
Text { text: root.text; color: Theme.text–secondary; font–size: Theme.font–size–sm; font–weight: 600; horizontal–alignment: center; vertical–alignment: center; }
}
component CellText inherits Rectangle {
in property <string> text;
in property <color> cell–color: Theme.text–primary;
in property <length> cell–font–size: Theme.font–size–md;
background: transparent;
border–width: 1px;
border–color: Theme.border;
Text { text: root.text; color: root.cell–color; font–size: root.cell–font–size; horizontal–alignment: center; vertical–alignment: center; font–family: "monospace"; }
}
component EditableCell inherits Rectangle {
in property <string> text;
callback edited(string);
private property <bool> editing;
background: transparent;
border–width: 1px;
border–color: Theme.border;
if editing == false : Text {
text: root.text; color: Theme.text–primary; font–size: Theme.font–size–md;
horizontal–alignment: center; vertical–alignment: center;
}
if editing : LineEdit {
text: root.text;
accepted(value) => { editing = false; root.edited(value); }
}
TouchArea {
visible: editing == false;
clicked => { editing = true; }
mouse–cursor: pointer;
}
}
component StatusItem inherits Rectangle {
in property <string> label;
in property <string> value;
in property <color> value–color: Theme.text–primary;
HorizontalLayout {
spacing: 4px;
Text { text: root.label + ":"; color: Theme.text–muted; font–size: Theme.font–size–sm; vertical–alignment: center; }
Text { text: root.value; color: root.value–color; font–size: Theme.font–size–sm; font–weight: 600; vertical–alignment: center; }
}
}
component ToolButton inherits Rectangle {
in property <string> label;
in property <bool> enabled: true;
in property <color> btn–color: Theme.accent;
callback clicked();
private property <bool> is–hovered;
height: 32px;
width: 100px;
background: is–hovered && enabled ? Theme.bg–hover : Theme.bg–tertiary;
border–radius: Theme.radius–sm;
border–width: 1px;
border–color: enabled ? btn–color : Theme.border;
Text { text: root.label; color: root.enabled ? Theme.text–primary : Theme.text–muted; font–size: Theme.font–size–sm; horizontal–alignment: center; vertical–alignment: center; }
TouchArea {
mouse–cursor: root.enabled ? pointer : default;
clicked => { root.clicked(); }
moved => { is–hovered = self.mouse–x >= 0 && self.mouse–x <= root.width && self.mouse–y >= 0 && self.mouse–y <= root.height; }
}
}
// ==================== 复合组件 ====================
component ToolBar inherits Rectangle {
in property <bool> connected;
in property <bool> polling;
in property <string> connection–mode;
callback on–connect();
callback on–disconnect();
callback on–read();
callback on–write();
callback on–poll–toggle();
callback on–clear–log();
background: Theme.bg–secondary;
height: 48px;
border–width: 1px;
border–color: Theme.border;
HorizontalLayout {
padding–left: Theme.spacing–md; padding–right: Theme.spacing–md;
padding–top: Theme.spacing–sm; padding–bottom: Theme.spacing–sm;
spacing: Theme.spacing–sm; alignment: start;
ToolButton { label: "连接"; enabled: root.connected == false; btn–color: Theme.accent; clicked => { root.on–connect(); } }
ToolButton { label: "断开"; enabled: root.connected; btn–color: Theme.error; clicked => { root.on–disconnect(); } }
Sep {}
ToolButton { label: "读取"; enabled: root.connected; btn–color: Theme.success; clicked => { root.on–read(); } }
ToolButton { label: "写入"; enabled: root.connected; btn–color: Theme.warning; clicked => { root.on–write(); } }
Sep {}
ToolButton { label: root.polling ? "停止轮询" : "自动轮询"; enabled: root.connected; btn–color: root.polling ? Theme.warning : Theme.accent; clicked => { root.on–poll–toggle(); } }
Sep {}
ToolButton { label: "清空日志"; btn–color: Theme.text–secondary; clicked => { root.on–clear–log(); } }
Rectangle { horizontal–stretch: 1; }
Rectangle {y:parent.height/2 – self.height/2; width: 8px; height: 8px; border–radius: 4px; background: root.connected ? Theme.success : Theme.text–muted; }
Text { text: root.connected ? root.connection–mode : "未连接"; color: root.connected ? Theme.success : Theme.text–muted; font–size: Theme.font–size–sm; vertical–alignment: center; }
}
}
component RegisterTable inherits Rectangle {
in property <[RegisterRow]> registers;
callback on–cell–edit(int, string);
background: Theme.bg–primary;
VerticalLayout {
Rectangle {
height: 36px; background: Theme.table–header–bg; border–width: 1px; border–color: Theme.border;
HorizontalLayout {
padding–left: Theme.spacing–sm; padding–right: Theme.spacing–sm;
HeaderCell { text: "地址"; width: 80px; }
HeaderCell { text: "无符号"; width: 90px; }
HeaderCell { text: "有符号"; width: 90px; }
HeaderCell { text: "十六进制"; width: 90px; }
HeaderCell { text: "二进制"; width: 150px; }
HeaderCell { text: "浮点数"; width: 130px; }
HeaderCell { text: "ASCII"; width: 60px; }
}
}
ScrollView {
viewport := VerticalLayout {
spacing: 0;
for row[idx] in root.registers : Rectangle {
height: 30px;
background: Math.mod(idx, 2) == 0 ? Theme.table–row–even : Theme.table–row–odd;
HorizontalLayout {
padding–left: Theme.spacing–sm; padding–right: Theme.spacing–sm;
CellText { text: row.address + ""; width: 80px; cell–color: Theme.accent; }
EditableCell { text: row.value–unsigned; width: 90px; edited(value) => { root.on–cell–edit(row.address, value); } }
CellText { text: row.value–signed; width: 90px; cell–color: Theme.text–secondary; }
CellText { text: row.value–hex; width: 90px; cell–color: Theme.warning; }
CellText { text: row.value–binary; width: 150px; cell–color: Theme.info; cell–font–size: Theme.font–size–sm; }
CellText { text: row.value–float; width: 130px; cell–color: Theme.success; }
CellText { text: row.value–ascii; width: 60px; cell–color: Theme.text–primary; }
}
}
}
}
}
}
component StatusBar inherits Rectangle {
in property <bool> connected;
in property <string> connection–info;
in property <int> slave–id;
in property <int> tx–count;
in property <int> rx–count;
in property <int> error–count;
in property <string> last–error;
height: 28px; background: Theme.bg–secondary; border–width: 1px; border–color: Theme.border;
HorizontalLayout {
padding–left: Theme.spacing–md; padding–right: Theme.spacing–md; spacing: Theme.spacing–lg;
Rectangle {y:parent.height/2 – self.height/2; width: 8px; height: 8px; border–radius: 4px; background: root.connected ? Theme.success : Theme.error; }
Text { text: root.connected ? root.connection–info : "未连接"; color: root.connected ? Theme.text–primary : Theme.text–muted; font–size: Theme.font–size–sm; vertical–alignment: center; }
if root.connected : Text { text: "从站:" + root.slave–id; color: Theme.text–secondary; font–size: Theme.font–size–sm; vertical–alignment: center; }
Sep {}
StatusItem { label: "TX"; value: root.tx–count + ""; value–color: Theme.tx–color; }
StatusItem { label: "RX"; value: root.rx–count + ""; value–color: Theme.rx–color; }
StatusItem { label: "错误"; value: root.error–count + ""; value–color: root.error–count > 0 ? Theme.error : Theme.text–secondary; }
Rectangle { horizontal–stretch: 1; }
if root.last–error != "" : Text { text: root.last–error; color: Theme.error; font–size: Theme.font–size–sm; vertical–alignment: center; overflow: elide; max–width: 300px; }
}
}
component LogPanel inherits Rectangle {
in property <[LogEntry]> log–entries;
height: 180px;
VerticalLayout {
Rectangle {
height: 32px; background: Theme.bg–tertiary; border–width: 1px; border–color: Theme.border;
HorizontalLayout {
padding–left: Theme.spacing–md; padding–right: Theme.spacing–md;
Text { text: "通信日志"; color: Theme.text–secondary; font–size: Theme.font–size–sm; font–weight: 600; vertical–alignment: center; }
Rectangle { horizontal–stretch: 1; }
Text { text: root.log–entries.length + " 条记录"; color: Theme.text–muted; font–size: Theme.font–size–sm; vertical–alignment: center; }
}
}
log–scroll := ScrollView {
viewport := VerticalLayout {
spacing: 0; padding: Theme.spacing–xs;
for entry in root.log–entries : Rectangle {
height: 22px;
background: entry.direction == 2 ? #1a0a0a : transparent;
HorizontalLayout {
padding–left: Theme.spacing–sm; spacing: Theme.spacing–sm;
Text { text: entry.timestamp; color: Theme.text–muted; font–size: 11px; font–family: "monospace"; vertical–alignment: center; width: 100px; }
Rectangle {
width: 28px; height: 16px; border–radius: 3px;
background: entry.direction == 0 ? Theme.tx–color : (entry.direction == 1 ? Theme.rx–color : Theme.error);
Text { text: entry.direction == 0 ? "TX" : (entry.direction == 1 ? "RX" : "ERR"); color: white; font–size: 10px; font–weight: 700; horizontal–alignment: center; vertical–alignment: center; }
}
Text { text: entry.raw–text; color: entry.direction == 0 ? Theme.tx–color : (entry.direction == 1 ? Theme.rx–color : Theme.error); font–size: 11px; font–family: "monospace"; vertical–alignment: center; overflow: elide; }
}
}
changed height => {
if self.height > parent.height {
// 内容溢出:滚动到底部
parent.viewport–y = parent.height – self.height;
} else {
// 内容未溢出:置顶
parent.viewport–y = 0px;
}
}
}
}
}
}
// ==================== 主窗口 ====================
export component MainWindow inherits Window {
title: "Modbus 调试助手";
preferred–width: 1200px; preferred–height: 800px;
min–width: 960px; min–height: 640px;
background: Theme.bg–primary;
in property <bool> connected: false;
in property <string> connection–mode: "";
in property <string> connection–info: "";
in property <int> slave–id: 1;
in property <int> tx–count: 0;
in property <int> rx–count: 0;
in property <int> error–count: 0;
in property <string> last–error: "";
in property <[RegisterRow]> registers;
in property <[LogEntry]> log–entries;
in property <bool> polling: false;
in property <int> function–code: 3;
callback on–connect();
callback on–disconnect();
callback on–read();
callback on–write();
callback on–poll–toggle();
callback on–clear–log();
callback on–cell–edit(int, string);
VerticalLayout {
ToolBar {
connected: root.connected; polling: root.polling; connection–mode: root.connection–mode;
on–connect => { root.on–connect(); } on–disconnect => { root.on–disconnect(); }
on–read => { root.on–read(); } on–write => { root.on–write(); }
on–poll–toggle => { root.on–poll–toggle(); } on–clear–log => { root.on–clear–log(); }
}
VerticalLayout {
vertical–stretch: 1;
RegisterTable { vertical–stretch: 1; registers: root.registers; on–cell–edit(a, v) => { root.on–cell–edit(a, v); } }
Rectangle { height: 1px; background: Theme.border; }
LogPanel { log–entries: root.log–entries; }
}
StatusBar {
connected: root.connected; connection–info: root.connection–info;
slave–id: root.slave–id; tx–count: root.tx–count; rx–count: root.rx–count;
error–count: root.error–count; last–error: root.last–error;
}
}
}
// ==================== TCP 连接对话框 ====================
export component TcpSetupDialog inherits Window {
title: "TCP 连接设置";
preferred–width: 420px; preferred–height: 280px;
background: Theme.bg–primary;
in–out property <string> ip–address <=> ip–input.text;
in–out property <int> port <=> port–input.value;
in–out property <int> slave–id <=> slave–input.value;
callback on–confirm();
callback on–cancel();
VerticalLayout {
padding: Theme.spacing–xl; spacing: Theme.spacing–lg;
Text { text: "Modbus TCP 连接设置"; color: Theme.text–primary; font–size: 18px; font–weight: 700; horizontal–alignment: center; }
Rectangle { height: 1px; background: Theme.border; }
VerticalLayout {
spacing: Theme.spacing–md;
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "IP 地址:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } ip–input := LineEdit { text: "127.0.0.1"; horizontal–stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "端口:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } port–input := SpinBox { value: 502; minimum: 1; maximum: 65535; horizontal–stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "从站 ID:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } slave–input := SpinBox { value: 1; minimum: 1; maximum: 247; horizontal–stretch: 1; } }
}
Rectangle { vertical–stretch: 1; }
HorizontalLayout { spacing: Theme.spacing–md; alignment: end; Button { text: "取消"; clicked => { root.on–cancel(); } } Button { text: "连接"; primary: true; clicked => { root.on–confirm(); } } }
}
}
// ==================== RTU 连接对话框 ====================
export component RtuSetupDialog inherits Window {
title: "RTU 连接设置";
preferred–width: 460px; preferred–height: 400px;
background: Theme.bg–primary;
in property <[string]> baud–rates; in property <[string]> data–bits–list;
in property <[string]> stop–bits–list; in property <[string]> parity–list;
in–out property <int> baud–index <=> baud–combo.current–index;
in–out property <int> data–bits–index <=> data–combo.current–index;
in–out property <int> stop–bits–index <=> stop–combo.current–index;
in–out property <int> parity–index <=> parity–combo.current–index;
in–out property <int> slave–id <=> slave–input.value;
in–out property <string> port–name <=> port–input.text;
callback on–confirm(); callback on–cancel();
VerticalLayout {
padding: Theme.spacing–xl; spacing: Theme.spacing–lg;
Text { text: "Modbus RTU 连接设置"; color: Theme.text–primary; font–size: 18px; font–weight: 700; horizontal–alignment: center; }
Rectangle { height: 1px; background: Theme.border; }
VerticalLayout {
spacing: Theme.spacing–md;
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "串口号:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } port–input := LineEdit { text: "COM1"; horizontal–stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "波特率:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } baud–combo := ComboBox { model: root.baud–rates; current–index: 3; horizontal–stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "数据位:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } data–combo := ComboBox { model: root.data–bits–list; current–index: 3; horizontal–stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "停止位:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } stop–combo := ComboBox { model: root.stop–bits–list; current–index: 0; horizontal–stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "校验位:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } parity–combo := ComboBox { model: root.parity–list; current–index: 0; horizontal–stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "从站 ID:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } slave–input := SpinBox { value: 1; minimum: 1; maximum: 247; horizontal–stretch: 1; } }
}
Rectangle { vertical–stretch: 1; }
HorizontalLayout { spacing: Theme.spacing–md; alignment: end; Button { text: "取消"; clicked => { root.on–cancel(); } } Button { text: "连接"; primary: true; clicked => { root.on–confirm(); } } }
}
}
// ==================== 读取设置对话框 ====================
export component ReadSetupDialog inherits Window {
title: "读取设置";
preferred–width: 400px; preferred–height: 320px;
background: Theme.bg–primary;
in property <[string]> function–codes;
in–out property <int> function–index <=> func–combo.current–index;
in–out property <int> start–address <=> addr–input.value;
in–out property <int> quantity <=> qty–input.value;
in–out property <int> slave–id <=> slave–input.value;
callback on–confirm(); callback on–cancel();
VerticalLayout {
padding: Theme.spacing–xl; spacing: Theme.spacing–lg;
Text { text: "读取参数设置"; color: Theme.text–primary; font–size: 18px; font–weight: 700; horizontal–alignment: center; }
Rectangle { height: 1px; background: Theme.border; }
VerticalLayout {
spacing: Theme.spacing–md;
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "功能码:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } func–combo := ComboBox { model: root.function–codes; current–index: 2; horizontal–stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "从站 ID:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } slave–input := SpinBox { value: 1; minimum: 1; maximum: 247; horizontal–stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "起始地址:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } addr–input := SpinBox { value: 0; minimum: 0; maximum: 65535; horizontal–stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "数量:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } qty–input := SpinBox { value: 10; minimum: 1; maximum: 125; horizontal–stretch: 1; } }
}
Rectangle { vertical–stretch: 1; }
Text { text: "寄存器地址从 0 开始, 数量最大 125"; color: Theme.text–muted; font–size: Theme.font–size–sm; horizontal–alignment: center; }
HorizontalLayout { spacing: Theme.spacing–md; alignment: end; Button { text: "取消"; clicked => { root.on–cancel(); } } Button { text: "读取"; primary: true; clicked => { root.on–confirm(); } } }
}
}
// ==================== 写入设置对话框 ====================
export component WriteSetupDialog inherits Window {
title: "写入设置";
preferred–width: 420px; preferred–height: 320px;
background: Theme.bg–primary;
in property <[string]> write–function–codes;
in–out property <int> function–index <=> func–combo.current–index;
in–out property <int> slave–id <=> slave–input.value;
in–out property <int> start–address <=> addr–input.value;
in–out property <string> write–value <=> value–input.text;
callback on–confirm(); callback on–cancel();
VerticalLayout {
padding: Theme.spacing–xl; spacing: Theme.spacing–lg;
Text { text: "写入参数设置"; color: Theme.text–primary; font–size: 18px; font–weight: 700; horizontal–alignment: center; }
Rectangle { height: 1px; background: Theme.border; }
VerticalLayout {
spacing: Theme.spacing–md;
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "功能码:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } func–combo := ComboBox { model: root.write–function–codes; current–index: 1; horizontal–stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "从站 ID:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } slave–input := SpinBox { value: 1; minimum: 1; maximum: 247; horizontal–stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "地址:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } addr–input := SpinBox { value: 0; minimum: 0; maximum: 65535; horizontal–stretch: 1; } }
HorizontalLayout { spacing: Theme.spacing–md; alignment: start; Text { text: "写入值:"; color: Theme.text–secondary; vertical–alignment: center; width: 100px; } value–input := LineEdit { text: "0"; horizontal–stretch: 1; } }
}
Rectangle { vertical–stretch: 1; }
Text { text: "线圈: 0=断开 1=闭合 | 寄存器: 0~65535"; color: Theme.text–muted; font–size: Theme.font–size–sm; horizontal–alignment: center; }
HorizontalLayout { spacing: Theme.spacing–md; alignment: end; Button { text: "取消"; clicked => { root.on–cancel(); } } Button { text: "写入"; primary: true; clicked => { root.on–confirm(); } } }
}
}
10、Cargo.toml
[package]
name = "modbus-debug-assistant"
version = "0.1.0"
edition = "2024"
[dependencies]
slint = "1.16.1"
tokio = { version = "1", features = ["full"] }
chrono = "0.4"
hex = "0.4"
tokio–modbus = "0.17"
serialport = "4"
tokio–serial = "5"
anyhow = "1"
thiserror = "2"
log = "0.4"
env_logger = "0.11"
[build–dependencies]
slint–build = "1.16.1"
三、实现原理
本工具基于 Rust 与 Slint 构建,整体采用「UI 层 + 业务层 + 协议层」的分层架构。UI 层由 Slint 声明式语言编写,负责界面渲染与交互;业务层由 Rust 实现,负责连接管理、数据模型与通信统计;协议层则基于 tokio-modbus 库完成 Modbus TCP/RTU 的报文收发。下面从几个关键维度拆解其实现思路。
1、UI 与逻辑分离:Slint + Rust 回调机制
Slint 采用声明式 UI 语法,界面结构(main.slint)与业务逻辑(main.rs)完全分离。main.slint 中通过 callback 声明事件接口,例如:
callback on-connect();
callback on-read();
callback on-write();
callback on-poll-toggle();
在 Rust 侧,通过 slint::include_modules!() 引入编译后的 UI 组件,再用 on_connect、on_read 等闭包绑定这些回调,实现「界面只负责展示与触发、逻辑全部下沉到 Rust」的松耦合设计。这样既保证了 UI 的快速迭代,也便于对通信逻辑做单元测试。
2、连接层:TCP 与 RTU 的统一封装
modbus_client.rs 中的 ModbusClientWrapper 对两种传输方式做了统一封装:
- TCP 模式:通过 client::tcp::connect_slave(addr, slave) 建立连接,内部基于 Tokio 异步运行时;
- RTU 模式:通过 tokio_serial 打开串口,再调用 client::rtu::attach_slave(serial_port, slave) 挂载从站。
两种模式最终都返回统一的 Context 对象,上层调用方无需关心底层是 TCP 还是串口,只需调用 read_holding_registers、write_single_register 等统一方法即可,这大大降低了业务代码的复杂度。
3、协议层:功能码与数据模型
modbus_types.rs 定义了完整的 Modbus 功能码枚举:
pub enum FunctionCode {
ReadCoils = 0x01,
ReadDiscreteInputs = 0x02,
ReadHoldingRegisters = 0x03,
ReadInputRegisters = 0x04,
WriteSingleCoil = 0x05,
WriteSingleRegister = 0x06,
WriteMultipleCoils = 0x0F,
WriteMultipleRegisters = 0x10,
}
execute_read 与 execute_write 两个方法根据功能码分发到具体的读写操作,并将结果统一封装为 ReadResult(寄存器数组或线圈布尔数组),供上层数据模型消费。
4、数据模型层:多格式解析
data_model.rs 中的 RegisterModel 负责把原始寄存器值转换为多种展示格式,这是调试助手最核心的价值之一。generate_rows 方法对每个寄存器地址生成:
- 无符号数:直接输出 u16 十进制;
- 有符号数:将 u16 强转为 i16,得到补码对应的有符号值;
- 十六进制:format!("{:04X}", raw);
- 二进制:format!("{:016b}", raw);
- IEEE 754 浮点:将相邻两个寄存器拼接为 32 位,再通过 f32::from_bits 还原浮点数;
- ASCII:将高字节与低字节分别映射为可打印字符,不可打印的显示为 .。
这种「一址多解」的设计,让调试人员无需在多个工具间切换,即可同时观察同一寄存器的多种语义。
5、通信日志与统计
comm_log.rs 实现了环形日志缓冲,最多保留 5000 条记录,每条记录包含时间戳、方向(TX/RX/ERROR)与原始报文,并自动滚动到最新一条。statistics.rs 则维护发送帧数、接收帧数与错误计数,实时刷新到底部状态栏,方便用户评估通信质量。
6、异步运行时与线程模型
整个应用基于 Tokio 异步运行时。连接、读写等耗时操作均通过 tokio::spawn 放入异步任务中执行,避免阻塞 UI 线程;Slint 的 invoke_from_event_loop 机制用于在异步任务完成后安全地更新界面属性,从而保证界面流畅不卡顿。
7、构建流程
build.rs 在编译期调用 slint_build::compile("ui/main.slint"),将 Slint 声明式 UI 编译为 Rust 代码,与业务代码一起链接成最终可执行文件。这种「编译期生成」的方式既保证了类型安全,也避免了运行时解析 UI 的开销。


