一、简介
该仪表可以实时显示位移传感器的测量值,并可设定阈值等。先谈谈简单的使用方法,通过说明书,我们可以知道长按SET键可以进入参数选择状态,按“↑”“↓”可以选择该组参数的上一个或者下一个参数。
从参数一览中可以看到有不同组的参数,当我们第一次进入参数选择状态时会进入第一组参数,可以设置不同的阈值。只不过由于是数码管,显示字母时会用一些比较奇怪的表达,比如“5”其实就是“S”,可以通过对照参数表,获取不同字母的显示。
如果想进入其他组参数,可以在第一组参数中,通过“↑”或“↓”找到最后一个oA,然后按“←”开始设置参数,当把4位数码管都设为1,即输入密码1111后,再按下SET确定,就可以解锁密码了。此时可以通过长按SET键切换到其他组参数。
更多功能可自行查看数据手册,不过要注意的是说明书中的参数并非全部与实际仪器一一对应,实际仪器有时会缺少一两个参数。
二、串口使用
串口使用的是RS485电平或者RS232,千万要注意的就是A、B的接线,不要接错。这个一般需要按照说明书的来,说明书上说“7”对应的是“B”,“8”对应的是“A”,如果仪器上贴着的标签是相反的,那么可以先按照说明书上的接法,如果不行再按照仪器上的,不要忘记接地。
接下来说说具体的串口通信,仪器默认波特率是9600,通信协议是Modbus-RTU。这里推荐使用Modbus-RTU协议。
这个通信过程并非是仪表主动不断地发送测量数据给上位机,而是需要你先发送相应命令给仪器,然后接收仪器数据。使用过程中倒是发现一些与说明书不同吻合的地方,比如读取测量值这一步,按理来说应答应如下,但实际过程中接收的是“01 04 04 42 47 3F 3F 3F 28”,即多了一个04
不过当我们需要连续读取时就会发现单次发送实在是麻烦,现在可以有下面几种方法连续发下
1,使用llcom
这个串口助手可以写lua脚本,以实现自动发送数据,并读取数据保存
chenxuuu/llcom: 🛠功能强大的串口工具。支持Lua自动化处理、串口调试、WinUSB、串口曲线、TCP测试、MQTT测试、编码转换、乱码恢复等功能 (github.com)
下面是可以发送命令并把读取数据保存起来的lua脚本。只不过lua脚本很难运行什么GUI,自然就无法显示图表
-- 发送数据中间间隔时间(单位ms) local sendDelay = 100 -- 生成16进制数据“01 04 0000 0002 71CB” local usartData = "01040000000271CB" usartData=usartData:fromHex() -- 获取当前日期和时间 local function get_current_datetime() local datetime = os.date("%Y%m%d_%H%M%S") return datetime end -- 生成带有日期和时间的文件名 local function generate_filename() local datetime = get_current_datetime() return "D:/Script/python/expr_com/data_log/log_" .. datetime .. ".txt" end -- 打开文件,如果文件不存在则创建,如果存在则覆盖 local filePath = generate_filename() local file, err = io.open(filePath, "w") if not file then -- 如果文件打开失败,输出错误信息 print("无法打开文件: " .. err) else print("文件成功打开: " .. filePath) end local value_str = "" -- 发送数据的函数 apiSetCb("uart", function(data) -- 写入数据 value_str = data:toHex():sub(7, 14) file:write(value_str .. "\n") -- 添加换行符以便区分不同数据 print(value_str) end) -- 循环发送任务 sys.taskInit(function() while true do -- 发送数据 apiSendUartData(usartData) sys.wait(sendDelay) end end) -- 确保在脚本结束时关闭文件 --[[ atexit(function() if file then file:close() print("文件已关闭") end end) ]]
2,使用Python脚本
使用Python脚本直接打开串口,然后发送命令并读取数据,需要注意的是下面脚本里指定了一个串口,你需要打开设备管理器来找到实际串口并修改脚本里的串口为实际串口号。同时注意波特率设置。
由于仪器发送的其实是浮点数据的实际表达,所以下面脚本就自动做了这个转换
可惜的是,使用Python后,无法很好地实时更新数据到图表中,下面也就没有添加这个功能。
import serial import struct import time import os from datetime import datetime # 配置串口 ser = serial.Serial( port='COM7', # 根据实际情况修改端口号 baudrate=115200, # 波特率 timeout=1 # 超时设置 ) # 要发送的16进制数据 send_data = bytes.fromhex('01040000000271CB') # 确保 data_log 目录存在 log_dir = 'data_log' os.makedirs(log_dir, exist_ok=True) # 获取当前日期和时间 current_time = datetime.now().strftime('%Y%m%d_%H%M%S') log_file_path = os.path.join(log_dir, f"log_{current_time}.csv") # 打开日志文件 with open(log_file_path, 'w') as log_file: log_file.write("Timestamp,Value\n") # 写入表头 try: while True: # 发送16进制数据 ser.write(send_data) # 从串口读取9字节的数据,并提取中间的5-8位16进制数据 data = ser.read(9)[3:7] # 将16进制数据转换为32位浮点数 try: float_data = struct.unpack('>f', data)[0] # '>f' 表示大端模式 print(f"data: {float_data}") # 打印浮点数 # 获取当前时间戳 timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') # 写入日志文件 log_file.write(f"{timestamp},{float_data}\n") log_file.flush() # 立即写入文件 except struct.error as e: print(f"Error unpacking data: {e}") # 每100毫秒(即每秒10次)更新一次 time.sleep(0.1) except KeyboardInterrupt: print("Program terminated by user") finally: # 关闭串口 ser.close()
CRC校验计算
补充一点,为了可以使用其他命令,需要计算16位CRC校验值。比如下面可以数据手册上的读取测量值命令的CRC校验值“01 04 0000 0002 71CB”,其中71CB是16位校验值,在下面脚本中输入前面命令“01 04 0000 0002”即可
import crcmod def calculate_crc16(data): # 创建一个CRC16校验对象 crc16_func = crcmod.mkCrcFun(0x18005, initCrc=0xFFFF, rev=True, xorOut=0x0000) # 将16进制字符串转换为字节 byte_data = bytes.fromhex(data) # 计算CRC16校验码 crc_value = crc16_func(byte_data) # 交换高低字节 crc_value = ((crc_value & 0xFF) << 8) | ((crc_value & 0xFF00) >> 8) return crc_value # 输入的16进制内容 hex_data = "01 04 0000 0002" # 去除空格并计算CRC16 hex_data_no_spaces = hex_data.replace(" ", "") crc16_result = calculate_crc16(hex_data_no_spaces) # 打印结果 print(f"CRC16:{crc16_result:04X}")
8位16进制字符串转为32位浮点数据
def hex_to_float(hex_str): # 将 16 进制字符串转换为 32 位无符号整数 uint_value = int(hex_str, 16) # 将 32 位无符号整数转换为浮点数 float_value = struct.unpack('!f', struct.pack('!I', uint_value))[0] return float_value
3,使用WPF
就功能而言,这个方法我是最满意的,可以自己定制化写一个专用的串口助手。不过它们各有缺陷,llcom虽然可以使用lua脚本做很多自动化处理,但无法显示图表。python虽然可以显示图表,但实时更新的效果并不好。使用WPF,虽然可以实现很多功能,但需要搭建Visual Studio环境,并且需要写不少代码。
由于时间限制,目前我只实现了简单的定时发送,转为浮点数据的功能。后续,我会添加显示图表的功能,记录日志的功能还没有做好,使用会出问题。并且我暂时并不打算在这上面花太多时间,所以没怎么考虑界面设计。
因此,界面很简单,使用起来也很简单。
XAML文件
<Window x:Class="expr_com.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="expr_com" Height="700" Width="1000"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <!-- 可用串口 --> <RowDefinition Height="Auto" /> <!-- 波特率 --> <RowDefinition Height="Auto" /> <!-- 打开串口按钮 --> <RowDefinition Height="Auto" /> <!-- 发送数据 --> <RowDefinition Height="Auto" /> <!-- 16进制复选框 --> <RowDefinition Height="Auto" /> <!-- 16进制显示复选框 --> <RowDefinition Height="Auto" /> <!-- 单次发送和定时循环发送按钮 --> <RowDefinition Height="Auto" /> <!-- 周期(秒) --> <RowDefinition Height="Auto" /> <!-- 记录数据复选框 --> <RowDefinition Height="Auto" /> <!-- 处理数据复选框及参数 --> <RowDefinition Height="*" /> <!-- 接收数据框 --> <RowDefinition Height="*" /> <!-- 处理数据框 --> </Grid.RowDefinitions> <StackPanel Orientation="Horizontal" Grid.Row="0" Margin="10,10,10,0"> <Label Content="可用串口:" Width="80" /> <ComboBox Name="cmbPorts" SelectionChanged="cmbPorts_SelectionChanged" Width="150" /> </StackPanel> <StackPanel Orientation="Horizontal" Grid.Row="1" Margin="10,0,10,0"> <Label Content="波特率:" Width="80" /> <TextBox Name="txtBaudRate" Text="115200" Width="100" /> </StackPanel> <Button Name="btnOpenPort" Content="打开串口" Click="btnOpenPort_Click" Width="120" Margin="10,0,10,10" Grid.Row="2" /> <StackPanel Orientation="Horizontal" Grid.Row="3" Margin="10,0,10,0"> <Label Content="发送数据:" Width="80" /> <TextBox Name="txtSendData" Text="01040000000271CB" Width="200" /> </StackPanel> <CheckBox Name="chkHex" Content="16进制" Margin="10,0,10,10" Grid.Row="4" /> <CheckBox Name="chkHexDisplay" Content="16进制显示" Margin="10,0,10,10" Grid.Row="5" /> <StackPanel Orientation="Horizontal" Grid.Row="6" Margin="10,0,10,0"> <Button Name="btnSendOnce" Content="单次发送" Click="btnSendOnce_Click" Width="120" /> <Button Name="btnStartLoopSend" Content="定时循环发送" Click="btnStartLoopSend_Click" Width="120" Margin="10,0,0,0" /> </StackPanel> <StackPanel Orientation="Horizontal" Grid.Row="7" Margin="10,0,10,0"> <Label Content="周期(秒):" Width="80" /> <TextBox Name="txtPeriod" Text="0.1" Width="50" /> </StackPanel> <CheckBox Name="chkLog" Content="记录数据" Margin="10,0,10,10" Grid.Row="8" /> <StackPanel Orientation="Horizontal" Grid.Row="9" Margin="10,0,10,0"> <CheckBox Name="chkProcessData" Content="处理数据" Margin="0,0,10,0" /> <Label Content="起始位置:" Width="80" /> <TextBox Name="txtStartIndex" Text="7" Width="50" Margin="0,0,10,0" /> <Label Content="长度:" Width="50" /> <TextBox Name="txtLength" Text="8" Width="50" /> </StackPanel> <TextBox Name="txtReceivedData" IsReadOnly="True" Margin="10,0,10,10" Grid.Row="10" VerticalScrollBarVisibility="Auto" /> <Grid Grid.Row="11" Margin="10,0,10,10"> <Grid.ColumnDefinitions> <ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <TextBox Name="txtExtractedData" IsReadOnly="True" VerticalScrollBarVisibility="Auto" Grid.Column="0" /> <TextBox Name="txtFloatData" IsReadOnly="True" VerticalScrollBarVisibility="Auto" Grid.Column="1" /> </Grid> </Grid> </Window>
CS代码
using System; using System.IO.Ports; using System.Text; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.IO; using System.Timers; namespace expr_com { public partial class MainWindow : Window { private SerialPort? _serialPort; // 声明为可为 null 的类型 private System.Timers.Timer? _timer; // 声告为可为 null 的类型 public MainWindow() { InitializeComponent(); LoadAvailablePorts(); _serialPort = null; // 初始化为 null _timer = null; // 初始化为 null } private void LoadAvailablePorts() { cmbPorts.ItemsSource = SerialPort.GetPortNames(); } private void cmbPorts_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (cmbPorts.SelectedItem != null) { txtBaudRate.Focus(); } } private void btnOpenPort_Click(object sender, RoutedEventArgs e) { if (cmbPorts.SelectedItem == null) { MessageBox.Show("请选择一个串口。", "错误", MessageBoxButton.OK, MessageBoxImage.Error); return; } if (_serialPort == null || !_serialPort.IsOpen) { try { _serialPort = new SerialPort(cmbPorts.SelectedItem.ToString(), int.Parse(txtBaudRate.Text)) { ReadTimeout = 1000, // 增加读取超时时间 WriteTimeout = 500, DataBits = 8, StopBits = StopBits.One, Parity = Parity.None }; // 注册 DataReceived 事件 _serialPort.DataReceived += SerialPort_DataReceived; _serialPort.Open(); btnOpenPort.Content = "关闭串口"; // 显示成功消息 txtReceivedData.AppendText("← 串口已打开。" + Environment.NewLine); } catch (Exception ex) { MessageBox.Show($"打开串口失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); txtReceivedData.AppendText($"← 打开串口失败: {ex.Message}" + Environment.NewLine); } } else { _serialPort.DataReceived -= SerialPort_DataReceived; // 取消注册 DataReceived 事件 _serialPort.Close(); btnOpenPort.Content = "打开串口"; // 显示成功消息 txtReceivedData.AppendText("← 串口已关闭。" + Environment.NewLine); } } private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e) { try { string data = _serialPort.ReadExisting(); if (!string.IsNullOrEmpty(data)) { Dispatcher.Invoke(() => { if (chkHexDisplay.IsChecked == true) { // 将接收到的数据转换为16进制字符串 byte[] bytes = Encoding.ASCII.GetBytes(data); string hexData = BitConverter.ToString(bytes).Replace("-", " "); txtReceivedData.AppendText($"→ {hexData}" + Environment.NewLine); if (chkProcessData.IsChecked == true) { ProcessData(hexData); } } else { txtReceivedData.AppendText($"→ {data}" + Environment.NewLine); } // 立即滚动到底部 txtReceivedData.ScrollToEnd(); }); } } catch (Exception ex) { // 处理其他异常 Dispatcher.Invoke(() => { txtReceivedData.AppendText($"→ 读取错误: {ex.Message}" + Environment.NewLine); }); } } private void LogData(string data) { string logFilePath = Path.Combine("data_log", $"log_{DateTime.Now:yyyyMMdd_HHmmss}.csv"); File.AppendAllText(logFilePath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss},{data}{Environment.NewLine}"); } private async void btnSendOnce_Click(object sender, RoutedEventArgs e) { await SendDataAsync(false); } private async void btnStartLoopSend_Click(object sender, RoutedEventArgs e) { if (_timer == null || !_timer.Enabled) { try { double period = double.Parse(txtPeriod.Text); _timer = new System.Timers.Timer(period * 1000); // 转换为毫秒 _timer.Elapsed += OnTimedEvent; _timer.AutoReset = true; _timer.Enabled = true; btnStartLoopSend.Content = "停止循环发送"; // 显示启动消息 txtReceivedData.AppendText($"← 定时循环发送已启动,周期: {period} 秒。" + Environment.NewLine); } catch (Exception ex) { MessageBox.Show($"设置定时发送失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); txtReceivedData.AppendText($"← 设置定时发送失败: {ex.Message}" + Environment.NewLine); } } else { _timer.Enabled = false; _timer.Dispose(); _timer = null; btnStartLoopSend.Content = "定时循环发送"; // 显示停止消息 txtReceivedData.AppendText("← 定时循环发送已停止。" + Environment.NewLine); } } private async void OnTimedEvent(object source, ElapsedEventArgs e) { try { await Dispatcher.InvokeAsync(async () => { await SendDataAsync(true); }); } catch (Exception ex) { // 处理其他异常 await Dispatcher.InvokeAsync(() => { txtReceivedData.AppendText($"← 发送错误: {ex.Message}" + Environment.NewLine); }); } } private async Task SendDataAsync(bool isLoop) { try { if (_serialPort != null && _serialPort.IsOpen) { string sendData = txtSendData.Text.Trim(); if (string.IsNullOrEmpty(sendData)) { throw new InvalidOperationException("发送数据不能为空。"); } if (chkHex.IsChecked == true) { byte[] hexData = Convert.FromHexString(sendData); _serialPort.Write(hexData, 0, hexData.Length); } else { _serialPort.WriteLine(sendData); } // 显示发送数据 txtReceivedData.AppendText($"← {sendData}" + Environment.NewLine); if (chkLog.IsChecked == true) { LogData(sendData); } } else { // 串口未打开,显示错误信息 txtReceivedData.AppendText("← 串口未打开,无法发送数据。" + Environment.NewLine); } } catch (Exception ex) { // 处理其他异常 txtReceivedData.AppendText($"← 发送错误: {ex.Message}" + Environment.NewLine); } } private void ProcessData(string hexData) { try { // 从文本框中获取用户输入的起始位置和长度 int startIndex = int.Parse(txtStartIndex.Text) - 1; // 减1是为了适应索引从0开始 int length = int.Parse(txtLength.Text); // 去除空格 string hexDataWithoutSpaces = hexData.Replace(" ", ""); // 检查数据长度是否足够 if (hexDataWithoutSpaces.Length >= startIndex + length) { // 截取指定位置和长度的数据 string hexDataToProcess = hexDataWithoutSpaces.Substring(startIndex, length); // 将16进制字符串转换为字节数组 byte[] bytes = Convert.FromHexString(hexDataToProcess); // 检查字节顺序 if (BitConverter.IsLittleEndian) { // 如果系统是小端序,而数据是大端序,则需要反转字节顺序 Array.Reverse(bytes); } // 将字节数组转换为浮点数 float floatValue = BitConverter.ToSingle(bytes, 0); // 追加结果 txtExtractedData.AppendText($"{hexDataToProcess}" + Environment.NewLine); txtFloatData.AppendText($"{floatValue}" + Environment.NewLine); // 立即滚动到底部 txtExtractedData.ScrollToEnd(); txtFloatData.ScrollToEnd(); } else { txtExtractedData.AppendText("数据长度不足,无法处理。" + Environment.NewLine); } } catch (FormatException ex) { txtExtractedData.AppendText($"格式错误: {ex.Message}" + Environment.NewLine); } } } }
实际效果:
单次发送
循环发送