Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9e5a5483c | ||
|
|
bc09280d4e |
No files matched your search
@@ -22,6 +22,7 @@
|
||||
<ProjectReference Include="..\Log\Log.csproj" />
|
||||
<ProjectReference Include="..\Sharp.Infrastructure\Sharp.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\Plc.Siemens\Plc.Siemens.csproj" />
|
||||
<ProjectReference Include="..\Ports\Ports.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -49,6 +49,11 @@
|
||||
Padding="18,6"
|
||||
Margin="0,0,12,0"
|
||||
Command="{Binding ThrowCommand}" />
|
||||
<Button Content="串口调试"
|
||||
Padding="18,6"
|
||||
Margin="0,0,12,0"
|
||||
Style="{StaticResource ButtonInfo}"
|
||||
Command="{Binding OpenSerialPortToolCommand}" />
|
||||
<Button Content="PLC 调试"
|
||||
Padding="18,6"
|
||||
Style="{StaticResource ButtonPrimary}"
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
<hc:GlowWindow x:Class="Client.SerialPortWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:hc="https://handyorg.github.io/handycontrol"
|
||||
mc:Ignorable="d"
|
||||
Title="串口通信调试工具" Height="750" Width="1200"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TabControl Grid.Row="0" Grid.RowSpan="2" Style="{StaticResource TabControlCapsule}">
|
||||
<!-- Tab 1: 串口发现 -->
|
||||
<TabItem Header="串口发现">
|
||||
<Grid Margin="15">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Background="{DynamicResource RegionBrush}" Padding="15" CornerRadius="8" Margin="0,0,0,15">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Content="刷新串口列表" Click="RefreshPorts_Click" Style="{StaticResource ButtonPrimary}" Margin="0,0,10,0"/>
|
||||
<Button Content="仅显示USB串口" Click="ShowUsbPorts_Click" Margin="0,0,10,0"/>
|
||||
<Button Content="测试连接" Click="TestConnection_Click" Margin="0,0,10,0"/>
|
||||
<TextBlock x:Name="PortCountText" Text="发现 0 个串口" VerticalAlignment="Center" Margin="20,0,0,0" FontWeight="Bold"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<DataGrid x:Name="PortsDataGrid" Grid.Row="1" AutoGenerateColumns="False" IsReadOnly="True"
|
||||
SelectionMode="Single" HeadersVisibility="Column" CanUserResizeColumns="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="端口" Binding="{Binding PortName}" Width="100"/>
|
||||
<DataGridTextColumn Header="描述" Binding="{Binding Description}" Width="200"/>
|
||||
<DataGridTextColumn Header="制造商" Binding="{Binding Manufacturer}" Width="150"/>
|
||||
<DataGridCheckBoxColumn Header="USB设备" Binding="{Binding IsUsbSerial}" Width="80"/>
|
||||
<DataGridTextColumn Header="VID" Binding="{Binding VendorId}" Width="80"/>
|
||||
<DataGridTextColumn Header="PID" Binding="{Binding ProductId}" Width="80"/>
|
||||
<DataGridCheckBoxColumn Header="被占用" Binding="{Binding IsInUse}" Width="80"/>
|
||||
<DataGridTextColumn Header="设备ID" Binding="{Binding DeviceId}" Width="*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<TextBlock Grid.Row="2" x:Name="DiscoveryStatusText" Text="点击'刷新串口列表'开始"
|
||||
Foreground="{DynamicResource SecondaryTextBrush}" Margin="0,10,0,0"/>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- Tab 2: 基础通信 -->
|
||||
<TabItem Header="基础通信">
|
||||
<Grid Margin="15">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- 连接配置 -->
|
||||
<Border Background="{DynamicResource RegionBrush}" Padding="15" CornerRadius="8" Margin="0,0,0,15">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="端口:" VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||
<ComboBox x:Name="PortNameCombo" Width="100" Margin="0,0,15,0"/>
|
||||
|
||||
<TextBlock Text="波特率:" VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||
<ComboBox x:Name="BaudRateCombo" Width="100" Margin="0,0,15,0" SelectedIndex="3">
|
||||
<ComboBoxItem Content="4800"/>
|
||||
<ComboBoxItem Content="9600"/>
|
||||
<ComboBoxItem Content="19200"/>
|
||||
<ComboBoxItem Content="38400"/>
|
||||
<ComboBoxItem Content="57600"/>
|
||||
<ComboBoxItem Content="115200"/>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Text="数据位:" VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||
<ComboBox x:Name="DataBitsCombo" Width="60" Margin="0,0,15,0" SelectedIndex="3">
|
||||
<ComboBoxItem Content="5"/>
|
||||
<ComboBoxItem Content="6"/>
|
||||
<ComboBoxItem Content="7"/>
|
||||
<ComboBoxItem Content="8"/>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Text="校验位:" VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||
<ComboBox x:Name="ParityCombo" Width="80" Margin="0,0,15,0" SelectedIndex="0">
|
||||
<ComboBoxItem Content="None"/>
|
||||
<ComboBoxItem Content="Odd"/>
|
||||
<ComboBoxItem Content="Even"/>
|
||||
<ComboBoxItem Content="Mark"/>
|
||||
<ComboBoxItem Content="Space"/>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Text="停止位:" VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||
<ComboBox x:Name="StopBitsCombo" Width="80" Margin="0,0,0,0" SelectedIndex="1">
|
||||
<ComboBoxItem Content="None"/>
|
||||
<ComboBoxItem Content="One"/>
|
||||
<ComboBoxItem Content="Two"/>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,10,0,0">
|
||||
<Button x:Name="OpenButton" Content="打开串口" Click="OpenPort_Click" Style="{StaticResource ButtonSuccess}" Margin="0,0,10,0"/>
|
||||
<Button x:Name="CloseButton" Content="关闭串口" Click="ClosePort_Click" Style="{StaticResource ButtonDanger}" Margin="0,0,10,0" IsEnabled="False"/>
|
||||
<TextBlock x:Name="ConnectionStatusText" Text="未连接" VerticalAlignment="Center" Margin="20,0,0,0" FontWeight="Bold"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- 发送区域 -->
|
||||
<GroupBox Header="发送数据" Grid.Row="1" Margin="0,0,0,15">
|
||||
<Grid Margin="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBox x:Name="SendDataTextBox" Width="400" hc:InfoElement.Placeholder="输入要发送的数据 (十六进制,如: 01 02 03)" Margin="0,0,10,0"/>
|
||||
<Button Content="发送 (HEX)" Click="SendHex_Click" Margin="0,0,10,0"/>
|
||||
<Button Content="发送 (文本)" Click="SendText_Click" Margin="0,0,10,0"/>
|
||||
<Button Content="清空" Click="ClearSend_Click"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,10,0,0">
|
||||
<CheckBox x:Name="AutoAddNewLineCheckBox" Content="自动添加换行符" IsChecked="True" Margin="0,0,15,0"/>
|
||||
<TextBlock Text="编码:" VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||
<ComboBox x:Name="EncodingCombo" Width="100" SelectedIndex="0">
|
||||
<ComboBoxItem Content="UTF-8"/>
|
||||
<ComboBoxItem Content="ASCII"/>
|
||||
<ComboBoxItem Content="GBK"/>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<!-- 接收区域 -->
|
||||
<GroupBox Header="接收数据" Grid.Row="2" Margin="0,0,0,10">
|
||||
<StackPanel Orientation="Horizontal" Margin="10">
|
||||
<CheckBox x:Name="HexDisplayCheckBox" Content="HEX显示" IsChecked="True" Margin="0,0,15,0"/>
|
||||
<CheckBox x:Name="AutoScrollCheckBox" Content="自动滚动" IsChecked="True" Margin="0,0,15,0"/>
|
||||
<Button Content="清空接收区" Click="ClearReceive_Click" Margin="0,0,10,0"/>
|
||||
<TextBlock x:Name="ReceivedBytesText" Text="已接收: 0 字节" VerticalAlignment="Center" Margin="10,0,0,0"/>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<TextBox x:Name="ReceiveTextBox" Grid.Row="3" IsReadOnly="True" VerticalScrollBarVisibility="Auto"
|
||||
TextWrapping="Wrap" FontFamily="Consolas" Background="{DynamicResource RegionBrush}"/>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- Tab 3: 高级监听 -->
|
||||
<TabItem Header="高级监听">
|
||||
<Grid Margin="15">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Background="{DynamicResource RegionBrush}" Padding="15" CornerRadius="8" Margin="0,0,0,15">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<TextBlock Text="端口:" VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||
<ComboBox x:Name="ServicePortCombo" Width="100" Margin="0,0,15,0"/>
|
||||
<TextBlock Text="波特率:" VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||
<ComboBox x:Name="ServiceBaudRateCombo" Width="100" Margin="0,0,15,0" SelectedIndex="3">
|
||||
<ComboBoxItem Content="9600"/>
|
||||
<ComboBoxItem Content="19200"/>
|
||||
<ComboBoxItem Content="38400"/>
|
||||
<ComboBoxItem Content="57600"/>
|
||||
<ComboBoxItem Content="115200"/>
|
||||
</ComboBox>
|
||||
<CheckBox x:Name="AutoReconnectCheckBox" Content="自动重连" IsChecked="True" Margin="0,0,15,0" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button x:Name="StartListeningButton" Content="启动监听" Click="StartListening_Click" Style="{StaticResource ButtonSuccess}" Margin="0,0,10,0"/>
|
||||
<Button x:Name="StopListeningButton" Content="停止监听" Click="StopListening_Click" Style="{StaticResource ButtonDanger}" Margin="0,0,10,0" IsEnabled="False"/>
|
||||
<TextBlock x:Name="ServiceStatusText" Text="未启动" VerticalAlignment="Center" Margin="20,0,0,0" FontWeight="Bold"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<DataGrid x:Name="EventLogDataGrid" Grid.Row="1" AutoGenerateColumns="False" IsReadOnly="True"
|
||||
HeadersVisibility="Column" CanUserResizeColumns="True" Margin="0,0,0,15">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="时间" Binding="{Binding Time, StringFormat=HH:mm:ss.fff}" Width="100"/>
|
||||
<DataGridTextColumn Header="类型" Binding="{Binding EventType}" Width="100"/>
|
||||
<DataGridTextColumn Header="数据/消息" Binding="{Binding Data}" Width="*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal">
|
||||
<Button Content="清空日志" Click="ClearEventLog_Click" Margin="0,0,10,0"/>
|
||||
<TextBlock x:Name="EventCountText" Text="事件数: 0" VerticalAlignment="Center" Margin="10,0,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- Tab 4: 数据工具 -->
|
||||
<TabItem Header="数据工具">
|
||||
<Grid Margin="15">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- 数据转换 -->
|
||||
<GroupBox Header="数据转换" Margin="0,0,0,15">
|
||||
<Grid Margin="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBox x:Name="ConvertInputTextBox" Width="300" hc:InfoElement.Placeholder="输入数据" Margin="0,0,10,0"/>
|
||||
<ComboBox x:Name="ConvertTypeCombo" Width="120" Margin="0,0,10,0" SelectedIndex="0">
|
||||
<ComboBoxItem Content="HEX -> 字节"/>
|
||||
<ComboBoxItem Content="字节 -> HEX"/>
|
||||
<ComboBoxItem Content="字节 -> Int16"/>
|
||||
<ComboBoxItem Content="字节 -> Int32"/>
|
||||
<ComboBoxItem Content="字节 -> Float"/>
|
||||
<ComboBoxItem Content="Int32 -> 字节"/>
|
||||
</ComboBox>
|
||||
<CheckBox x:Name="BigEndianCheckBox" Content="大端序" Margin="0,0,10,0" VerticalAlignment="Center"/>
|
||||
<Button Content="转换" Click="ConvertData_Click"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Row="1" Text="转换结果:" Margin="0,10,0,5"/>
|
||||
<TextBox Grid.Row="2" x:Name="ConvertResultTextBox" IsReadOnly="True" Background="{DynamicResource RegionBrush}"/>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<!-- 校验计算 -->
|
||||
<GroupBox Header="校验计算" Grid.Row="1" Margin="0,0,0,15">
|
||||
<Grid Margin="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBox x:Name="ChecksumInputTextBox" Width="400" hc:InfoElement.Placeholder="输入十六进制数据 (如: 01 02 03)" Margin="0,0,10,0"/>
|
||||
<Button Content="CRC16" Click="CalculateCrc16_Click" Margin="0,0,5,0"/>
|
||||
<Button Content="LRC" Click="CalculateLrc_Click" Margin="0,0,5,0"/>
|
||||
<Button Content="XOR" Click="CalculateXor_Click" Margin="0,0,5,0"/>
|
||||
<Button Content="累加和" Click="CalculateChecksum_Click"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,10,0,0">
|
||||
<TextBlock Text="校验结果:" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
||||
<TextBox x:Name="ChecksumResultTextBox" Width="200" IsReadOnly="True" Background="{DynamicResource RegionBrush}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<!-- 诊断工具 -->
|
||||
<GroupBox Header="诊断工具" Grid.Row="2" Margin="0,0,0,15">
|
||||
<Grid Margin="10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="端口:" VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||
<ComboBox x:Name="DiagPortCombo" Width="100" Margin="0,0,10,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal">
|
||||
<Button Content="测试连接" Click="DiagTestConnection_Click" Margin="0,0,5,0"/>
|
||||
<Button Content="波特率扫描" Click="ScanBaudRates_Click" Margin="0,0,5,0"/>
|
||||
<Button Content="流量监控(10s)" Click="MonitorTraffic_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</GroupBox>
|
||||
|
||||
<!-- 诊断结果 -->
|
||||
<TextBox x:Name="DiagResultTextBox" Grid.Row="3" IsReadOnly="True" VerticalScrollBarVisibility="Auto"
|
||||
TextWrapping="Wrap" FontFamily="Consolas" Background="{DynamicResource RegionBrush}"/>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
</hc:GlowWindow>
|
||||
@@ -0,0 +1,644 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO.Ports;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using HandyControl.Controls;
|
||||
using Ports.Abstractions;
|
||||
using Ports.Discovery;
|
||||
using Ports.Drivers;
|
||||
using Ports.Models;
|
||||
using Ports.Services;
|
||||
using Ports.Utils;
|
||||
|
||||
namespace Client;
|
||||
|
||||
public partial class SerialPortWindow
|
||||
{
|
||||
private ISerialPortDriver? _driver;
|
||||
private ISerialPortService? _service;
|
||||
private int _totalReceivedBytes;
|
||||
private readonly ObservableCollection<EventLogItem> _eventLog = new();
|
||||
|
||||
public SerialPortWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
InitializeComboBoxes();
|
||||
EventLogDataGrid.ItemsSource = _eventLog;
|
||||
}
|
||||
|
||||
private void InitializeComboBoxes()
|
||||
{
|
||||
// 初始化波特率下拉框
|
||||
BaudRateCombo.SelectedItem = BaudRateCombo.Items[4]; // 115200
|
||||
|
||||
// 加载串口列表
|
||||
RefreshPortsList();
|
||||
}
|
||||
|
||||
private void RefreshPortsList()
|
||||
{
|
||||
var result = SerialPortDiscovery.GetAvailablePorts();
|
||||
if (result.IsSuccess && result.Value != null)
|
||||
{
|
||||
var portNames = result.Value.Select(p => p.PortName).ToList();
|
||||
|
||||
PortNameCombo.ItemsSource = portNames;
|
||||
ServicePortCombo.ItemsSource = portNames;
|
||||
DiagPortCombo.ItemsSource = portNames;
|
||||
|
||||
if (portNames.Count > 0)
|
||||
{
|
||||
PortNameCombo.SelectedIndex = 0;
|
||||
ServicePortCombo.SelectedIndex = 0;
|
||||
DiagPortCombo.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#region Tab 1: 串口发现
|
||||
|
||||
private void RefreshPorts_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var result = SerialPortDiscovery.GetAvailablePorts();
|
||||
if (result.IsSuccess && result.Value != null)
|
||||
{
|
||||
PortsDataGrid.ItemsSource = result.Value;
|
||||
PortCountText.Text = $"发现 {result.Value.Count} 个串口";
|
||||
DiscoveryStatusText.Text = $"最后刷新: {DateTime.Now:HH:mm:ss}";
|
||||
RefreshPortsList();
|
||||
}
|
||||
else
|
||||
{
|
||||
DiscoveryStatusText.Text = $"错误: {result.ErrorMessage}";
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowUsbPorts_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var result = SerialPortDiscovery.GetUsbSerialPorts();
|
||||
if (result.IsSuccess && result.Value != null)
|
||||
{
|
||||
PortsDataGrid.ItemsSource = result.Value;
|
||||
PortCountText.Text = $"发现 {result.Value.Count} 个USB串口";
|
||||
DiscoveryStatusText.Text = $"仅显示USB设备";
|
||||
}
|
||||
else
|
||||
{
|
||||
DiscoveryStatusText.Text = $"错误: {result.ErrorMessage}";
|
||||
}
|
||||
}
|
||||
|
||||
private async void TestConnection_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var selected = PortsDataGrid.SelectedItem as SerialPortInfo;
|
||||
if (selected == null)
|
||||
{
|
||||
HandyControl.Controls.MessageBox.Show("请先选择一个串口", "提示");
|
||||
return;
|
||||
}
|
||||
|
||||
DiscoveryStatusText.Text = $"正在测试 {selected.PortName}...";
|
||||
|
||||
var options = new SerialPortOptions { PortName = selected.PortName };
|
||||
var result = await SerialPortDiagnostics.TestConnectionAsync(selected.PortName, options);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
DiscoveryStatusText.Text = "测试完成";
|
||||
HandyControl.Controls.MessageBox.Show(result.Value, "测试结果");
|
||||
}
|
||||
else
|
||||
{
|
||||
DiscoveryStatusText.Text = $"测试失败: {result.ErrorMessage}";
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tab 2: 基础通信
|
||||
|
||||
private async void OpenPort_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PortNameCombo.SelectedItem == null)
|
||||
{
|
||||
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new SerialPortOptions
|
||||
{
|
||||
PortName = PortNameCombo.SelectedItem.ToString()!,
|
||||
BaudRate = int.Parse(((ComboBoxItem)BaudRateCombo.SelectedItem).Content.ToString()!),
|
||||
DataBits = int.Parse(((ComboBoxItem)DataBitsCombo.SelectedItem).Content.ToString()!),
|
||||
Parity = Enum.Parse<Parity>(((ComboBoxItem)ParityCombo.SelectedItem).Content.ToString()!),
|
||||
StopBits = Enum.Parse<StopBits>(((ComboBoxItem)StopBitsCombo.SelectedItem).Content.ToString()!)
|
||||
};
|
||||
|
||||
_driver = new SerialPortDriver();
|
||||
var result = await _driver.OpenAsync(options);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
ConnectionStatusText.Text = "已连接";
|
||||
OpenButton.IsEnabled = false;
|
||||
CloseButton.IsEnabled = true;
|
||||
_totalReceivedBytes = 0;
|
||||
|
||||
// 启动接收线程
|
||||
_ = Task.Run(ReceiveLoop);
|
||||
}
|
||||
else
|
||||
{
|
||||
ConnectionStatusText.Text = $"连接失败: {result.ErrorMessage}";
|
||||
HandyControl.Controls.MessageBox.Show(result.ErrorMessage ?? "连接失败", "错误");
|
||||
}
|
||||
}
|
||||
|
||||
private async void ClosePort_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_driver != null)
|
||||
{
|
||||
await _driver.CloseAsync();
|
||||
_driver.Dispose();
|
||||
_driver = null;
|
||||
}
|
||||
|
||||
ConnectionStatusText.Text = "未连接";
|
||||
OpenButton.IsEnabled = true;
|
||||
CloseButton.IsEnabled = false;
|
||||
}
|
||||
|
||||
private async void SendHex_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_driver == null || !_driver.IsOpen)
|
||||
{
|
||||
HandyControl.Controls.MessageBox.Show("请先打开串口", "提示");
|
||||
return;
|
||||
}
|
||||
|
||||
var hexResult = SerialDataConverter.HexStringToBytes(SendDataTextBox.Text);
|
||||
if (hexResult.IsFailure)
|
||||
{
|
||||
HandyControl.Controls.MessageBox.Show(hexResult.ErrorMessage ?? "数据格式错误", "错误");
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _driver.WriteBytesAsync(hexResult.Value!);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
HandyControl.Controls.MessageBox.Show(result.ErrorMessage ?? "发送失败", "错误");
|
||||
}
|
||||
}
|
||||
|
||||
private async void SendText_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_driver == null || !_driver.IsOpen)
|
||||
{
|
||||
HandyControl.Controls.MessageBox.Show("请先打开串口", "提示");
|
||||
return;
|
||||
}
|
||||
|
||||
var encoding = GetSelectedEncoding();
|
||||
var text = SendDataTextBox.Text;
|
||||
|
||||
if (AutoAddNewLineCheckBox.IsChecked == true)
|
||||
text += "\r\n";
|
||||
|
||||
var bytesResult = SerialDataConverter.StringToBytes(text, encoding);
|
||||
if (bytesResult.IsSuccess && bytesResult.Value != null)
|
||||
{
|
||||
await _driver.WriteBytesAsync(bytesResult.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearSend_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
SendDataTextBox.Clear();
|
||||
}
|
||||
|
||||
private void ClearReceive_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ReceiveTextBox.Clear();
|
||||
_totalReceivedBytes = 0;
|
||||
ReceivedBytesText.Text = "已接收: 0 字节";
|
||||
}
|
||||
|
||||
private async Task ReceiveLoop()
|
||||
{
|
||||
while (_driver != null && _driver.IsOpen)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_driver.BytesToRead > 0)
|
||||
{
|
||||
var result = await _driver.ReadAvailableAsync();
|
||||
if (result.IsSuccess && result.Value != null && result.Value.Length > 0)
|
||||
{
|
||||
_totalReceivedBytes += result.Value.Length;
|
||||
Dispatcher.Invoke(() => DisplayReceivedData(result.Value));
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(10);
|
||||
}
|
||||
catch
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DisplayReceivedData(byte[] data)
|
||||
{
|
||||
string display;
|
||||
if (HexDisplayCheckBox.IsChecked == true)
|
||||
{
|
||||
var hexResult = SerialDataConverter.BytesToHexString(data, " ");
|
||||
display = hexResult.Value + "\r\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
var encoding = GetSelectedEncoding();
|
||||
var strResult = SerialDataConverter.BytesToString(data, encoding);
|
||||
display = strResult.Value ?? string.Empty;
|
||||
}
|
||||
|
||||
ReceiveTextBox.AppendText($"[{DateTime.Now:HH:mm:ss}] {display}");
|
||||
ReceivedBytesText.Text = $"已接收: {_totalReceivedBytes} 字节";
|
||||
|
||||
if (AutoScrollCheckBox.IsChecked == true)
|
||||
{
|
||||
ReceiveTextBox.ScrollToEnd();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tab 3: 高级监听
|
||||
|
||||
private async void StartListening_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ServicePortCombo.SelectedItem == null)
|
||||
{
|
||||
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new SerialPortOptions
|
||||
{
|
||||
PortName = ServicePortCombo.SelectedItem.ToString()!,
|
||||
BaudRate = int.Parse(((ComboBoxItem)ServiceBaudRateCombo.SelectedItem).Content.ToString()!)
|
||||
};
|
||||
|
||||
_service = new SerialPortService(new SerialPortDriver());
|
||||
|
||||
// 订阅事件
|
||||
_service.DataReceived += OnServiceDataReceived;
|
||||
_service.ErrorOccurred += OnServiceErrorOccurred;
|
||||
_service.ConnectionStateChanged += OnServiceConnectionStateChanged;
|
||||
|
||||
var autoReconnect = AutoReconnectCheckBox.IsChecked == true;
|
||||
var result = await _service.StartListeningAsync(options, autoReconnect);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
ServiceStatusText.Text = "监听中";
|
||||
StartListeningButton.IsEnabled = false;
|
||||
StopListeningButton.IsEnabled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
ServiceStatusText.Text = $"启动失败";
|
||||
HandyControl.Controls.MessageBox.Show(result.ErrorMessage ?? "启动失败", "错误");
|
||||
}
|
||||
}
|
||||
|
||||
private async void StopListening_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_service != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _service.StopListeningAsync();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 任务取消是正常情况,忽略
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
HandyControl.Controls.MessageBox.Show($"停止监听时发生错误: {ex.Message}", "错误");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_service.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略清理异常
|
||||
}
|
||||
|
||||
_service = null;
|
||||
}
|
||||
|
||||
ServiceStatusText.Text = "未启动";
|
||||
StartListeningButton.IsEnabled = true;
|
||||
StopListeningButton.IsEnabled = false;
|
||||
}
|
||||
|
||||
private void OnServiceDataReceived(object? sender, DataReceivedEventArgs e)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
var hexResult = SerialDataConverter.BytesToHexString(e.Data, " ");
|
||||
_eventLog.Add(new EventLogItem
|
||||
{
|
||||
Time = e.ReceivedAt,
|
||||
EventType = "数据接收",
|
||||
Data = $"{e.Data.Length} 字节: {hexResult.Value}"
|
||||
});
|
||||
EventCountText.Text = $"事件数: {_eventLog.Count}";
|
||||
});
|
||||
}
|
||||
|
||||
private void OnServiceErrorOccurred(object? sender, Ports.Models.ErrorEventArgs e)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
_eventLog.Add(new EventLogItem
|
||||
{
|
||||
Time = e.OccurredAt,
|
||||
EventType = "错误",
|
||||
Data = e.ErrorMessage
|
||||
});
|
||||
EventCountText.Text = $"事件数: {_eventLog.Count}";
|
||||
});
|
||||
}
|
||||
|
||||
private void OnServiceConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
_eventLog.Add(new EventLogItem
|
||||
{
|
||||
Time = e.ChangedAt,
|
||||
EventType = "状态变化",
|
||||
Data = $"{e.PreviousState} -> {e.CurrentState}"
|
||||
});
|
||||
ServiceStatusText.Text = e.CurrentState.ToString();
|
||||
EventCountText.Text = $"事件数: {_eventLog.Count}";
|
||||
});
|
||||
}
|
||||
|
||||
private void ClearEventLog_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_eventLog.Clear();
|
||||
EventCountText.Text = "事件数: 0";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tab 4: 数据工具
|
||||
|
||||
private void ConvertData_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var input = ConvertInputTextBox.Text;
|
||||
var type = ((ComboBoxItem)ConvertTypeCombo.SelectedItem).Content.ToString();
|
||||
var isBigEndian = BigEndianCheckBox.IsChecked == true;
|
||||
|
||||
try
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case "HEX -> 字节":
|
||||
var hexBytes = SerialDataConverter.HexStringToBytes(input);
|
||||
ConvertResultTextBox.Text = hexBytes.IsSuccess ? $"字节数组长度: {hexBytes.Value!.Length}" : hexBytes.ErrorMessage;
|
||||
break;
|
||||
|
||||
case "字节 -> HEX":
|
||||
var bytes = SerialDataConverter.HexStringToBytes(input);
|
||||
if (bytes.IsSuccess)
|
||||
{
|
||||
var hex = SerialDataConverter.BytesToHexString(bytes.Value!, " ");
|
||||
ConvertResultTextBox.Text = hex.Value;
|
||||
}
|
||||
break;
|
||||
|
||||
case "字节 -> Int16":
|
||||
var int16Bytes = SerialDataConverter.HexStringToBytes(input);
|
||||
if (int16Bytes.IsSuccess)
|
||||
{
|
||||
var int16 = SerialDataConverter.BytesToInt16(int16Bytes.Value!, isBigEndian);
|
||||
ConvertResultTextBox.Text = int16.IsSuccess ? int16.Value.ToString() : int16.ErrorMessage;
|
||||
}
|
||||
break;
|
||||
|
||||
case "字节 -> Int32":
|
||||
var int32Bytes = SerialDataConverter.HexStringToBytes(input);
|
||||
if (int32Bytes.IsSuccess)
|
||||
{
|
||||
var int32 = SerialDataConverter.BytesToInt32(int32Bytes.Value!, isBigEndian);
|
||||
ConvertResultTextBox.Text = int32.IsSuccess ? int32.Value.ToString() : int32.ErrorMessage;
|
||||
}
|
||||
break;
|
||||
|
||||
case "字节 -> Float":
|
||||
var floatBytes = SerialDataConverter.HexStringToBytes(input);
|
||||
if (floatBytes.IsSuccess)
|
||||
{
|
||||
var floatValue = SerialDataConverter.BytesToFloat(floatBytes.Value!, isBigEndian);
|
||||
ConvertResultTextBox.Text = floatValue.IsSuccess ? floatValue.Value.ToString("F4") : floatValue.ErrorMessage;
|
||||
}
|
||||
break;
|
||||
|
||||
case "Int32 -> 字节":
|
||||
if (int.TryParse(input, out var intValue))
|
||||
{
|
||||
var intBytes = SerialDataConverter.Int32ToBytes(intValue, isBigEndian);
|
||||
if (intBytes.IsSuccess)
|
||||
{
|
||||
var hex = SerialDataConverter.BytesToHexString(intBytes.Value!, " ");
|
||||
ConvertResultTextBox.Text = hex.Value;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ConvertResultTextBox.Text = "输入不是有效的整数";
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ConvertResultTextBox.Text = $"转换错误: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void CalculateCrc16_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var hexBytes = SerialDataConverter.HexStringToBytes(ChecksumInputTextBox.Text);
|
||||
if (hexBytes.IsSuccess && hexBytes.Value != null)
|
||||
{
|
||||
var crc = SerialChecksumHelper.CalculateCrc16(hexBytes.Value);
|
||||
ChecksumResultTextBox.Text = crc.IsSuccess ? $"0x{crc.Value:X4}" : crc.ErrorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
private void CalculateLrc_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var hexBytes = SerialDataConverter.HexStringToBytes(ChecksumInputTextBox.Text);
|
||||
if (hexBytes.IsSuccess && hexBytes.Value != null)
|
||||
{
|
||||
var lrc = SerialChecksumHelper.CalculateLrc(hexBytes.Value);
|
||||
ChecksumResultTextBox.Text = lrc.IsSuccess ? $"0x{lrc.Value:X2}" : lrc.ErrorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
private void CalculateXor_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var hexBytes = SerialDataConverter.HexStringToBytes(ChecksumInputTextBox.Text);
|
||||
if (hexBytes.IsSuccess && hexBytes.Value != null)
|
||||
{
|
||||
var xor = SerialChecksumHelper.CalculateXor(hexBytes.Value);
|
||||
ChecksumResultTextBox.Text = xor.IsSuccess ? $"0x{xor.Value:X2}" : xor.ErrorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
private void CalculateChecksum_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var hexBytes = SerialDataConverter.HexStringToBytes(ChecksumInputTextBox.Text);
|
||||
if (hexBytes.IsSuccess && hexBytes.Value != null)
|
||||
{
|
||||
var checksum = SerialChecksumHelper.CalculateChecksum(hexBytes.Value);
|
||||
ChecksumResultTextBox.Text = checksum.IsSuccess ? $"0x{checksum.Value:X2}" : checksum.ErrorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
private async void DiagTestConnection_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DiagPortCombo.SelectedItem == null)
|
||||
{
|
||||
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
|
||||
return;
|
||||
}
|
||||
|
||||
DiagResultTextBox.Text = "正在测试连接...\r\n";
|
||||
|
||||
var portName = DiagPortCombo.SelectedItem.ToString()!;
|
||||
var options = new SerialPortOptions { PortName = portName };
|
||||
|
||||
var result = await SerialPortDiagnostics.TestConnectionAsync(portName, options);
|
||||
DiagResultTextBox.AppendText(result.IsSuccess ? result.Value! : $"错误: {result.ErrorMessage}");
|
||||
}
|
||||
|
||||
private async void ScanBaudRates_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DiagPortCombo.SelectedItem == null)
|
||||
{
|
||||
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
|
||||
return;
|
||||
}
|
||||
|
||||
DiagResultTextBox.Text = "正在扫描波特率...\r\n";
|
||||
|
||||
var portName = DiagPortCombo.SelectedItem.ToString()!;
|
||||
var result = await SerialPortDiagnostics.TestBaudRatesAsync(
|
||||
portName, 4800, 9600, 19200, 38400, 57600, 115200);
|
||||
|
||||
if (result.IsSuccess && result.Value != null)
|
||||
{
|
||||
DiagResultTextBox.AppendText("\r\n波特率测试结果:\r\n");
|
||||
foreach (var kvp in result.Value)
|
||||
{
|
||||
DiagResultTextBox.AppendText($" {kvp.Key,6} bps: {(kvp.Value ? "✓ 成功" : "✗ 失败")}\r\n");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
DiagResultTextBox.AppendText($"错误: {result.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
private async void MonitorTraffic_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DiagPortCombo.SelectedItem == null)
|
||||
{
|
||||
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
|
||||
return;
|
||||
}
|
||||
|
||||
DiagResultTextBox.Text = "正在监控流量 (10秒)...\r\n";
|
||||
|
||||
var portName = DiagPortCombo.SelectedItem.ToString()!;
|
||||
var options = new SerialPortOptions { PortName = portName };
|
||||
|
||||
var result = await SerialPortDiagnostics.MonitorTrafficAsync(
|
||||
portName,
|
||||
options,
|
||||
(data, isSent) => Dispatcher.Invoke(() =>
|
||||
{
|
||||
var hexResult = SerialDataConverter.BytesToHexString(data, " ");
|
||||
DiagResultTextBox.AppendText($"[{DateTime.Now:HH:mm:ss}] {(isSent ? "发送" : "接收")}: {hexResult.Value}\r\n");
|
||||
}),
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
if (result.IsSuccess && result.Value != null)
|
||||
{
|
||||
DiagResultTextBox.AppendText($"\r\n{result.Value}\r\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
DiagResultTextBox.AppendText($"错误: {result.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 辅助方法
|
||||
|
||||
private Encoding GetSelectedEncoding()
|
||||
{
|
||||
var encoding = ((ComboBoxItem)EncodingCombo.SelectedItem)?.Content.ToString();
|
||||
return encoding switch
|
||||
{
|
||||
"ASCII" => Encoding.ASCII,
|
||||
"GBK" => Encoding.GetEncoding("GBK"),
|
||||
_ => Encoding.UTF8
|
||||
};
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
base.OnClosed(e);
|
||||
|
||||
// 清理资源
|
||||
try
|
||||
{
|
||||
_driver?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略清理异常
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_service?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略清理异常
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class EventLogItem
|
||||
{
|
||||
public DateTime Time { get; set; }
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
public string Data { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
# 串口通信调试工具使用说明
|
||||
|
||||
## 功能概览
|
||||
|
||||
串口通信调试工具集成了Ports模块的所有功能,分为4个主要标签页:
|
||||
|
||||
## Tab 1: 串口发现
|
||||
|
||||
**功能:**
|
||||
- 发现和查询所有可用串口
|
||||
- 识别USB转串口设备
|
||||
- 查看串口详细信息(描述、厂商、VID/PID)
|
||||
- 检查串口是否被占用
|
||||
- 测试串口连接
|
||||
|
||||
**操作步骤:**
|
||||
1. 点击"刷新串口列表"获取所有串口
|
||||
2. 点击"仅显示USB串口"过滤USB设备
|
||||
3. 选择一个串口,点击"测试连接"进行连接测试
|
||||
|
||||
## Tab 2: 基础通信
|
||||
|
||||
**功能:**
|
||||
- 配置串口参数(波特率、数据位、校验位、停止位)
|
||||
- 打开/关闭串口连接
|
||||
- 发送数据(十六进制或文本)
|
||||
- 实时接收和显示数据
|
||||
- 支持多种字符编码(UTF-8、ASCII、GBK)
|
||||
|
||||
**操作步骤:**
|
||||
1. 选择串口和配置参数
|
||||
2. 点击"打开串口"
|
||||
3. 在发送框输入数据
|
||||
- 十六进制格式:01 02 03 04
|
||||
- 文本格式:直接输入文本
|
||||
4. 点击"发送 (HEX)"或"发送 (文本)"
|
||||
5. 接收区会实时显示收到的数据
|
||||
|
||||
**提示:**
|
||||
- 勾选"自动添加换行符"会在文本末尾添加\r\n
|
||||
- 勾选"HEX显示"会以十六进制格式显示接收数据
|
||||
- 勾选"自动滚动"会自动滚动到最新数据
|
||||
|
||||
## Tab 3: 高级监听
|
||||
|
||||
**功能:**
|
||||
- 使用服务层进行高级数据监听
|
||||
- 自动重连机制
|
||||
- 事件日志记录(数据接收、错误、状态变化)
|
||||
- 实时监控连接状态
|
||||
|
||||
**操作步骤:**
|
||||
1. 选择串口和波特率
|
||||
2. 勾选"自动重连"启用断线重连
|
||||
3. 点击"启动监听"
|
||||
4. 事件日志会实时显示所有事件
|
||||
5. 点击"停止监听"结束
|
||||
|
||||
**事件类型:**
|
||||
- 数据接收:显示接收到的字节数和内容
|
||||
- 错误:显示错误信息
|
||||
- 状态变化:显示连接状态变化(连接中→已连接→断开等)
|
||||
|
||||
## Tab 4: 数据工具
|
||||
|
||||
### 数据转换
|
||||
**支持的转换:**
|
||||
- HEX ↔ 字节数组
|
||||
- 字节 → Int16/Int32/Float
|
||||
- Int32 → 字节
|
||||
- 大端/小端序切换
|
||||
|
||||
**操作:**
|
||||
1. 输入数据
|
||||
2. 选择转换类型
|
||||
3. 勾选"大端序"(如需要)
|
||||
4. 点击"转换"
|
||||
|
||||
**示例:**
|
||||
- 输入:`01 02 03 04`,选择"字节 → Int32",大端序 → 结果:16909060
|
||||
|
||||
### 校验计算
|
||||
**支持的校验算法:**
|
||||
- CRC16(Modbus标准)
|
||||
- LRC(纵向冗余校验)
|
||||
- XOR(异或校验)
|
||||
- 累加和
|
||||
|
||||
**操作:**
|
||||
1. 输入十六进制数据(如:01 02 03)
|
||||
2. 点击对应的校验算法按钮
|
||||
3. 查看校验结果
|
||||
|
||||
### 诊断工具
|
||||
**功能:**
|
||||
- 测试连接:测试指定串口的连接性
|
||||
- 波特率扫描:自动测试多个波特率
|
||||
- 流量监控:监控10秒内的数据收发
|
||||
|
||||
**操作:**
|
||||
1. 选择要诊断的串口
|
||||
2. 点击相应功能按钮
|
||||
3. 在结果区查看诊断报告
|
||||
|
||||
## 使用技巧
|
||||
|
||||
1. **串口被占用?**
|
||||
- 在"串口发现"页查看哪个串口被占用
|
||||
- 确保其他程序没有打开该串口
|
||||
|
||||
2. **数据接收不完整?**
|
||||
- 检查波特率设置是否正确
|
||||
- 尝试使用"基础通信"的实时接收
|
||||
|
||||
3. **需要自动重连?**
|
||||
- 使用"高级监听"标签页
|
||||
- 勾选"自动重连"选项
|
||||
|
||||
4. **调试Modbus设备?**
|
||||
- 使用"数据工具"计算CRC16
|
||||
- 使用"基础通信"发送命令并查看响应
|
||||
|
||||
5. **不确定波特率?**
|
||||
- 使用"诊断工具"的波特率扫描功能
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 打开串口失败?**
|
||||
A: 检查串口是否被其他程序占用,或者没有权限访问该串口
|
||||
|
||||
**Q: 发送十六进制数据格式?**
|
||||
A: 使用空格分隔,如:01 02 03 04,也支持不带空格:01020304
|
||||
|
||||
**Q: 如何保存接收的数据?**
|
||||
A: 可以从接收框复制文本,后续版本会增加保存功能
|
||||
|
||||
**Q: 支持哪些操作系统?**
|
||||
A: Windows(完整功能)、Linux、macOS(基础功能)
|
||||
@@ -41,13 +41,19 @@ public sealed class MainViewModel : ViewModelBase
|
||||
RefreshDashboardCommand = new AsyncCommand(RefreshDashboardAsync);
|
||||
ThrowCommand = new RelayCommand(() => throw new InvalidOperationException("演示:未处理异常将被全局处理"));
|
||||
|
||||
OpenPlcToolCommand = new RelayCommand(() =>
|
||||
OpenPlcToolCommand = new RelayCommand(() =>
|
||||
{
|
||||
var sp = Global.Get<IServiceProvider>();
|
||||
var win = sp.GetRequiredService<PlcWindow>();
|
||||
win.Show();
|
||||
});
|
||||
|
||||
OpenSerialPortToolCommand = new RelayCommand(() =>
|
||||
{
|
||||
var win = new SerialPortWindow();
|
||||
win.Show();
|
||||
});
|
||||
|
||||
// 1. 获取 PLC 服务
|
||||
var plcService = Global.Get<IServiceProvider>().GetRequiredService<IPlcService>();
|
||||
|
||||
@@ -102,6 +108,7 @@ public sealed class MainViewModel : ViewModelBase
|
||||
public IAsyncCommand RefreshDashboardCommand { get; }
|
||||
public ICommand ThrowCommand { get; }
|
||||
public ICommand OpenPlcToolCommand { get; }
|
||||
public ICommand OpenSerialPortToolCommand { get; }
|
||||
public ICommand StartMachineCommand { get; }
|
||||
public ICommand StopMachineCommand { get; }
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using Plc.Models;
|
||||
using Plc.Siemens.Options;
|
||||
using S7.Net;
|
||||
using System.Windows;
|
||||
using Sharp.Wpf.Abstractions;
|
||||
|
||||
namespace Client.ViewModels;
|
||||
|
||||
@@ -14,6 +15,7 @@ public class PlcViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IPlcService _plcService;
|
||||
private readonly SiemensPlcOptions _siemensOptions;
|
||||
private readonly IDispatcher _dispatcher;
|
||||
|
||||
// 连接配置
|
||||
private PlcBrand _selectedBrand = PlcBrand.Siemens;
|
||||
@@ -26,24 +28,29 @@ public class PlcViewModel : ViewModelBase
|
||||
private string _readValue = "-";
|
||||
private string _writeAddress = "DB1.DBW0";
|
||||
private string _writeValue = "0";
|
||||
|
||||
|
||||
// 监控
|
||||
private string _monitorAddress = "DB1.DBW0";
|
||||
|
||||
public PlcViewModel(IPlcService plcService, SiemensPlcOptions siemensOptions)
|
||||
public PlcViewModel(
|
||||
IPlcService plcService,
|
||||
SiemensPlcOptions siemensOptions,
|
||||
IDispatcher dispatcher)
|
||||
{
|
||||
_plcService = plcService;
|
||||
_siemensOptions = siemensOptions;
|
||||
|
||||
_dispatcher = dispatcher;
|
||||
|
||||
// 初始化界面值为当前配置值
|
||||
_ipAddress = _siemensOptions.IpAddress;
|
||||
_selectedCpuType = _siemensOptions.CpuType;
|
||||
|
||||
|
||||
ConnectCommand = new AsyncCommand(ConnectAsync);
|
||||
DisconnectCommand = new AsyncCommand(DisconnectAsync);
|
||||
ReadCommand = new AsyncCommand(ReadAsync);
|
||||
WriteCommand = new AsyncCommand(WriteAsync);
|
||||
AddMonitorCommand = new RelayCommand(AddMonitor);
|
||||
RemoveMonitorCommand = new RelayCommand<PlcTag>(RemoveMonitor, tag => tag != null);
|
||||
}
|
||||
|
||||
#region Properties
|
||||
@@ -124,6 +131,7 @@ public class PlcViewModel : ViewModelBase
|
||||
public IAsyncCommand ReadCommand { get; }
|
||||
public IAsyncCommand WriteCommand { get; }
|
||||
public ICommand AddMonitorCommand { get; }
|
||||
public ICommand RemoveMonitorCommand { get; }
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -201,12 +209,34 @@ public class PlcViewModel : ViewModelBase
|
||||
DataType = typeof(short),
|
||||
OnChanged = (t, v) =>
|
||||
{
|
||||
// 注意:UI绑定更新需要在UI线程,这里简化处理,假设WPF做了同步或者是后台线程安全集合
|
||||
t.Value = v;
|
||||
// 使用Dispatcher在UI线程更新值,避免跨线程访问异常
|
||||
_dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
t.Value = v;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
_plcService.Subscribe(tag);
|
||||
MonitoredTags.Add(tag);
|
||||
|
||||
// 在UI线程添加到集合
|
||||
_dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
MonitoredTags.Add(tag);
|
||||
});
|
||||
}
|
||||
|
||||
private void RemoveMonitor(PlcTag? tag)
|
||||
{
|
||||
if (tag == null) return;
|
||||
|
||||
// 从PLC服务取消订阅
|
||||
_plcService.Unsubscribe(tag.Id);
|
||||
|
||||
// 从UI集合移除
|
||||
_dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
MonitoredTags.Remove(tag);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Client")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+f7660c49e81d5fe2747f6514545ceb961c2066d3")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+bc09280d4ea03ae8d0db7cd8c25ddd02d63abd84")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Client")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Client")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@@ -1 +1 @@
|
||||
eeea53cf7db257ee716efdd2fdcdb5f1a810b49927811d410a05e1ff19899369
|
||||
8b7343fa73644682ba621a0a8c61df82ebb0d6cdd1d8d63cf26b3522a08f12ee
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Plc.Abstractions;
|
||||
using Plc.Services;
|
||||
using Plc.Siemens;
|
||||
@@ -8,17 +9,44 @@ namespace Plc.Siemens.DependencyInjection;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSiemensPlc(this IServiceCollection services, Action<SiemensPlcOptions> configureOptions)
|
||||
/// <summary>
|
||||
/// 添加西门子PLC服务到DI容器
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="configureOptions">配置选项的委托</param>
|
||||
/// <returns>服务集合</returns>
|
||||
public static IServiceCollection AddSiemensPlc(
|
||||
this IServiceCollection services,
|
||||
Action<SiemensPlcOptions> configureOptions)
|
||||
{
|
||||
var options = new SiemensPlcOptions();
|
||||
configureOptions(options);
|
||||
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton<IPlcDriver, SiemensDriver>();
|
||||
services.AddSingleton<IPlcOptimizer, SiemensOptimizer>();
|
||||
// 使用标准的 IOptions 模式
|
||||
services.Configure(configureOptions);
|
||||
|
||||
// 同时注册 SiemensPlcOptions 单例,供需要直接访问的地方使用(如 PlcViewModel)
|
||||
// 注意:这是一个共享实例,从 IOptions 获取值
|
||||
services.AddSingleton(sp => sp.GetRequiredService<IOptions<SiemensPlcOptions>>().Value);
|
||||
|
||||
// 注册驱动程序(使用 IOptions)
|
||||
services.AddSingleton<IPlcDriver>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SiemensPlcOptions>>().Value;
|
||||
return new SiemensDriver(options);
|
||||
});
|
||||
|
||||
// 注册优化器(使用 IOptions 中的 MaxGapBytes)
|
||||
services.AddSingleton<IPlcOptimizer>(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<SiemensPlcOptions>>().Value;
|
||||
var logger = sp.GetService<Microsoft.Extensions.Logging.ILogger<SiemensOptimizer>>();
|
||||
return new SiemensOptimizer(options.MaxGapBytes, logger);
|
||||
});
|
||||
|
||||
// 注册数据转换器
|
||||
services.AddSingleton<IDataConverter, BigEndianConverter>();
|
||||
|
||||
// 注册PLC服务
|
||||
services.AddSingleton<IPlcService, PlcMonitorService>();
|
||||
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Plc.Abstractions;
|
||||
|
||||
namespace Plc.Siemens.Models;
|
||||
|
||||
public enum SiemensMemoryArea
|
||||
@@ -10,13 +12,13 @@ public enum SiemensMemoryArea
|
||||
Counter = 0x1C
|
||||
}
|
||||
|
||||
public class SiemensAddress
|
||||
public class SiemensAddress : IPlcAddress
|
||||
{
|
||||
public SiemensMemoryArea Area { get; set; }
|
||||
public int DbNumber { get; set; } // Only for DataBlock
|
||||
public int ByteOffset { get; set; }
|
||||
public int BitOffset { get; set; }
|
||||
|
||||
|
||||
// Helper to sort/group
|
||||
public override string ToString()
|
||||
{
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
using S7.Net;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Net;
|
||||
|
||||
namespace Plc.Siemens.Options;
|
||||
|
||||
public class SiemensPlcOptions
|
||||
public class SiemensPlcOptions : IValidatableObject
|
||||
{
|
||||
[Required]
|
||||
public string IpAddress { get; set; } = "127.0.0.1";
|
||||
|
||||
[Range(1, 65535)]
|
||||
public int Port { get; set; } = 102;
|
||||
|
||||
[Range(0, 255)]
|
||||
public short Rack { get; set; } = 0;
|
||||
|
||||
[Range(0, 255)]
|
||||
public short Slot { get; set; } = 1;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// S7-1200/1500 通常支持 960,S7-300 通常支持 240。
|
||||
/// 默认保守值设为 240。
|
||||
/// </summary>
|
||||
[Range(128, 960)]
|
||||
public int PduSize { get; set; } = 240;
|
||||
|
||||
/// <summary>
|
||||
@@ -20,4 +30,39 @@ public class SiemensPlcOptions
|
||||
/// 默认为 S71200。
|
||||
/// </summary>
|
||||
public CpuType CpuType { get; set; } = CpuType.S71200;
|
||||
|
||||
/// <summary>
|
||||
/// 优化器允许的最大间隔字节数。
|
||||
/// 两个标签之间如果间隔小于此值,会合并为一次读取。
|
||||
/// 默认为 20。
|
||||
/// </summary>
|
||||
[Range(0, 1000)]
|
||||
public int MaxGapBytes { get; set; } = 20;
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
// 验证IP地址格式
|
||||
if (!IPAddress.TryParse(IpAddress, out _))
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
$"Invalid IP address format: {IpAddress}",
|
||||
new[] { nameof(IpAddress) });
|
||||
}
|
||||
|
||||
// 验证PDU大小合理性
|
||||
if (PduSize < 128 || PduSize > 960)
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
$"PDU size must be between 128 and 960, got: {PduSize}",
|
||||
new[] { nameof(PduSize) });
|
||||
}
|
||||
|
||||
// 验证MaxGapBytes的合理性
|
||||
if (MaxGapBytes < 0 || MaxGapBytes > PduSize)
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
$"MaxGapBytes ({MaxGapBytes}) must be between 0 and PduSize ({PduSize})",
|
||||
new[] { nameof(MaxGapBytes) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
|
||||
<PackageReference Include="S7netplus" Version="0.20.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
# Plc.Siemens - 西门子S7 PLC通信驱动
|
||||
|
||||
[](https://dotnet.microsoft.com/)
|
||||
[](https://github.com/S7NetPlus/s7netplus)
|
||||
|
||||
## 📖 概述
|
||||
|
||||
Plc.Siemens 是基于 [S7.Net Plus](https://github.com/S7NetPlus/s7netplus) 库的西门子S7系列PLC通信驱动模块,实现了 `Plc` 核心库的抽象接口,提供了对西门子 S7-200、S7-300、S7-400、S7-1200、S7-1500 系列PLC的完整支持。
|
||||
|
||||
### 核心特性
|
||||
|
||||
- ✅ **全系列支持** - S7-200/300/400/1200/1500
|
||||
- ✅ **S7协议** - 基于标准 S7 通信协议
|
||||
- ✅ **地址智能解析** - 支持多种西门子地址格式
|
||||
- ✅ **批量优化** - 针对S7协议的智能批量读取
|
||||
- ✅ **大端序转换** - 自动处理字节序
|
||||
- ✅ **完整配置** - 支持所有连接参数
|
||||
- ✅ **类型安全** - 严格的配置验证
|
||||
|
||||
---
|
||||
|
||||
## 🎯 支持的PLC型号
|
||||
|
||||
| 系列 | CPU类型 | 默认端口 | PDU大小 | 测试状态 |
|
||||
|------|---------|---------|---------|---------|
|
||||
| S7-200 | S7200 | 102 | 240 | ✅ 已测试 |
|
||||
| S7-300 | S7300 | 102 | 240 | ✅ 已测试 |
|
||||
| S7-400 | S7400 | 102 | 480 | ✅ 已测试 |
|
||||
| S7-1200 | S71200 | 102 | 960 | ✅ 已测试 |
|
||||
| S7-1500 | S71500 | 102 | 960 | ✅ 已测试 |
|
||||
| S7-200 Smart | S7200Smart | 102 | 240 | ⚠️ 未测试 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
dotnet add package YourCompany.Plc.Siemens
|
||||
```
|
||||
|
||||
### 基本配置
|
||||
|
||||
```csharp
|
||||
using Plc.Siemens.DependencyInjection;
|
||||
|
||||
services.AddSiemensPlc(options =>
|
||||
{
|
||||
options.IpAddress = "192.168.1.100";
|
||||
options.CpuType = S7.Net.CpuType.S71200;
|
||||
options.Port = 102;
|
||||
options.Rack = 0;
|
||||
options.Slot = 1;
|
||||
options.PduSize = 960; // S7-1200/1500: 960, S7-300: 240
|
||||
options.MaxGapBytes = 20; // 批量优化间隔阈值
|
||||
});
|
||||
```
|
||||
|
||||
### 完整示例
|
||||
|
||||
```csharp
|
||||
using Plc.Abstractions;
|
||||
using Plc.Models;
|
||||
|
||||
public class PlcController
|
||||
{
|
||||
private readonly IPlcService _plcService;
|
||||
|
||||
public PlcController(IPlcService plcService)
|
||||
{
|
||||
_plcService = plcService;
|
||||
}
|
||||
|
||||
public async Task RunExample()
|
||||
{
|
||||
// 1. 启动连接
|
||||
await _plcService.StartAsync();
|
||||
|
||||
// 2. 读取数据块
|
||||
var temp = await _plcService.ReadAsync<float>("DB1.DBD0");
|
||||
Console.WriteLine($"温度: {temp.Value}°C");
|
||||
|
||||
// 3. 写入数据
|
||||
await _plcService.WriteAsync("DB1.DBW10", (short)100);
|
||||
|
||||
// 4. 读取位
|
||||
var alarm = await _plcService.ReadAsync<bool>("M0.5");
|
||||
Console.WriteLine($"报警状态: {alarm.Value}");
|
||||
|
||||
// 5. 订阅监控
|
||||
var tag = new PlcTag
|
||||
{
|
||||
Address = "DB1.DBD0",
|
||||
DataType = typeof(float),
|
||||
OnChanged = (t, v) => Console.WriteLine($"温度变化: {v}°C")
|
||||
};
|
||||
_plcService.Subscribe(tag);
|
||||
|
||||
// 6. 等待一段时间观察变化
|
||||
await Task.Delay(10000);
|
||||
|
||||
// 7. 停止服务
|
||||
await _plcService.StopAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📍 西门子地址格式
|
||||
|
||||
### 支持的存储区域
|
||||
|
||||
| 区域 | 标识符 | 说明 | 示例 |
|
||||
|------|--------|------|------|
|
||||
| 数据块 (DB) | DB | Data Block | `DB1.DBW0` |
|
||||
| 标志位 (M) | M | Memory/Flags | `M100.0` |
|
||||
| 输入 (I) | I | Input | `I0.0` |
|
||||
| 输出 (Q) | Q | Output | `Q1.5` |
|
||||
| 定时器 (T) | T | Timer | `T0` |
|
||||
| 计数器 (C) | C | Counter | `C5` |
|
||||
|
||||
### 数据类型标识
|
||||
|
||||
| 标识符 | 类型 | 字节数 | C# 类型 | 示例 |
|
||||
|--------|------|--------|---------|------|
|
||||
| DBX | Bool (位) | 1 bit | `bool` | `DB1.DBX0.0` |
|
||||
| DBB | Byte | 1 | `byte` | `DB1.DBB0` |
|
||||
| DBW | Word | 2 | `short/ushort` | `DB1.DBW0` |
|
||||
| DBD | DWord | 4 | `int/uint/float` | `DB1.DBD0` |
|
||||
|
||||
### 地址格式详解
|
||||
|
||||
#### 1. 数据块 (DB)
|
||||
|
||||
```csharp
|
||||
// 格式: DB{块号}.DB{类型}{偏移}[.{位}]
|
||||
|
||||
"DB1.DBX0.5" // DB1, 字节0, 位5 (bool)
|
||||
"DB1.DBB0" // DB1, 字节0 (byte)
|
||||
"DB1.DBW0" // DB1, 字0 (起始字节0, 长度2) (short/ushort)
|
||||
"DB1.DBD0" // DB1, 双字0 (起始字节0, 长度4) (int/uint/float)
|
||||
"DB100.DBD500" // DB100, 双字500 (起始字节500)
|
||||
```
|
||||
|
||||
#### 2. 标志位/内部存储器 (M)
|
||||
|
||||
```csharp
|
||||
// 格式: M{字节}[.{位}] 或 M{类型}{字节}
|
||||
|
||||
"M0.0" // 标志位 M0.0 (bool)
|
||||
"M0.7" // 标志位 M0.7 (bool)
|
||||
"MB10" // 标志字节 MB10 (byte)
|
||||
"MW100" // 标志字 MW100 (short/ushort)
|
||||
"MD200" // 标志双字 MD200 (int/uint/float)
|
||||
```
|
||||
|
||||
#### 3. 输入 (I) 和输出 (Q)
|
||||
|
||||
```csharp
|
||||
// 输入区域
|
||||
"I0.0" // 输入位 I0.0
|
||||
"IB0" // 输入字节 IB0
|
||||
"IW0" // 输入字 IW0
|
||||
|
||||
// 输出区域
|
||||
"Q1.5" // 输出位 Q1.5
|
||||
"QB10" // 输出字节 QB10
|
||||
"QW20" // 输出字 QW20
|
||||
```
|
||||
|
||||
### 地址解析示例
|
||||
|
||||
```csharp
|
||||
// 西门子地址解析器会自动识别格式
|
||||
|
||||
var examples = new[]
|
||||
{
|
||||
("DB1.DBX0.0", "数据块1, 字节0, 位0"),
|
||||
("DB1.DBB0", "数据块1, 字节0"),
|
||||
("DB1.DBW0", "数据块1, 字0 (字节0-1)"),
|
||||
("DB1.DBD0", "数据块1, 双字0 (字节0-3)"),
|
||||
("M100.5", "标志位 M100.5"),
|
||||
("MW100", "标志字 MW100 (字节100-101)"),
|
||||
("I0.0", "输入位 I0.0"),
|
||||
("Q1.5", "输出位 Q1.5")
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置选项
|
||||
|
||||
### SiemensPlcOptions 详解
|
||||
|
||||
```csharp
|
||||
public class SiemensPlcOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// PLC IP地址
|
||||
/// 验证: 必须是有效的IPv4地址
|
||||
/// 默认: "127.0.0.1"
|
||||
/// </summary>
|
||||
public string IpAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通信端口
|
||||
/// 验证: 1-65535
|
||||
/// 默认: 102 (S7标准端口)
|
||||
/// </summary>
|
||||
public int Port { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 机架号
|
||||
/// 验证: 0-255
|
||||
/// 默认: 0
|
||||
/// </summary>
|
||||
public short Rack { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 插槽号
|
||||
/// S7-300/400: 通常为 2
|
||||
/// S7-1200/1500: 通常为 1
|
||||
/// 验证: 0-255
|
||||
/// 默认: 1
|
||||
/// </summary>
|
||||
public short Slot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// PDU (协议数据单元) 大小
|
||||
/// S7-1200/1500: 最大 960
|
||||
/// S7-300/400: 最大 480
|
||||
/// S7-200: 最大 240
|
||||
/// 验证: 128-960
|
||||
/// 默认: 240 (保守值)
|
||||
/// </summary>
|
||||
public int PduSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// CPU 类型
|
||||
/// 默认: S71200
|
||||
/// </summary>
|
||||
public CpuType CpuType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 批量优化最大间隔字节数
|
||||
/// 两个标签之间如果间隔小于此值,会合并为一次读取
|
||||
/// 验证: 0-1000
|
||||
/// 默认: 20
|
||||
/// </summary>
|
||||
public int MaxGapBytes { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### 配置验证
|
||||
|
||||
配置类实现了 `IValidatableObject`,会在启动时自动验证:
|
||||
|
||||
```csharp
|
||||
services.AddSiemensPlc(options =>
|
||||
{
|
||||
options.IpAddress = "invalid-ip"; // ❌ 验证失败
|
||||
options.Port = 70000; // ❌ 端口超出范围
|
||||
options.PduSize = 2000; // ❌ PDU过大
|
||||
});
|
||||
|
||||
// 启动时会抛出 OptionsValidationException
|
||||
```
|
||||
|
||||
### 针对不同型号的推荐配置
|
||||
|
||||
#### S7-1200 配置
|
||||
|
||||
```csharp
|
||||
services.AddSiemensPlc(options =>
|
||||
{
|
||||
options.IpAddress = "192.168.1.100";
|
||||
options.CpuType = S7.Net.CpuType.S71200;
|
||||
options.Port = 102;
|
||||
options.Rack = 0;
|
||||
options.Slot = 1; // S7-1200 通常是槽位1
|
||||
options.PduSize = 960; // S7-1200 支持最大960
|
||||
options.MaxGapBytes = 30;
|
||||
});
|
||||
```
|
||||
|
||||
#### S7-1500 配置
|
||||
|
||||
```csharp
|
||||
services.AddSiemensPlc(options =>
|
||||
{
|
||||
options.IpAddress = "192.168.1.200";
|
||||
options.CpuType = S7.Net.CpuType.S71500;
|
||||
options.Port = 102;
|
||||
options.Rack = 0;
|
||||
options.Slot = 1; // S7-1500 通常是槽位1
|
||||
options.PduSize = 960; // S7-1500 支持最大960
|
||||
options.MaxGapBytes = 50;
|
||||
});
|
||||
```
|
||||
|
||||
#### S7-300 配置
|
||||
|
||||
```csharp
|
||||
services.AddSiemensPlc(options =>
|
||||
{
|
||||
options.IpAddress = "192.168.1.50";
|
||||
options.CpuType = S7.Net.CpuType.S7300;
|
||||
options.Port = 102;
|
||||
options.Rack = 0;
|
||||
options.Slot = 2; // S7-300 通常是槽位2
|
||||
options.PduSize = 240; // S7-300 支持最大240
|
||||
options.MaxGapBytes = 20;
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 高级功能
|
||||
|
||||
### 1. 批量优化原理
|
||||
|
||||
`SiemensOptimizer` 针对S7协议优化批量读取:
|
||||
|
||||
```csharp
|
||||
// 假设订阅了以下标签
|
||||
var tags = new[]
|
||||
{
|
||||
new PlcTag { Address = "DB1.DBW0", DataType = typeof(short) }, // 字节 0-1
|
||||
new PlcTag { Address = "DB1.DBW4", DataType = typeof(short) }, // 字节 4-5
|
||||
new PlcTag { Address = "DB1.DBW8", DataType = typeof(short) }, // 字节 8-9
|
||||
new PlcTag { Address = "DB1.DBW100", DataType = typeof(short) }, // 字节 100-101
|
||||
};
|
||||
|
||||
// 优化器会生成2个批次:
|
||||
// 批次1: DB1.DBW0, 10字节 (包含字节0-9, 覆盖前3个标签)
|
||||
// 批次2: DB1.DBW100, 2字节 (第4个标签距离太远)
|
||||
```
|
||||
|
||||
**优化规则:**
|
||||
1. 按 **存储区域** 和 **DB号** 分组
|
||||
2. 按 **字节偏移** 排序
|
||||
3. 间隔 ≤ `MaxGapBytes` 的标签合并
|
||||
4. 单次读取 ≤ `PduSize` 限制
|
||||
|
||||
### 2. 字节序处理
|
||||
|
||||
西门子PLC使用 **大端序 (Big-Endian)**:
|
||||
|
||||
```csharp
|
||||
// PLC中的字节序: [00] [64]
|
||||
// 读取为 short: 0x0064 = 100
|
||||
|
||||
var value = await _plcService.ReadAsync<short>("DB1.DBW0");
|
||||
// BigEndianConverter 自动转换
|
||||
// 结果: value = 100
|
||||
```
|
||||
|
||||
**支持的转换:**
|
||||
- ✅ Bool (按位)
|
||||
- ✅ Byte
|
||||
- ✅ Short / UShort (2字节大端)
|
||||
- ✅ Int / UInt / Float (4字节大端)
|
||||
- ✅ Double (8字节大端)
|
||||
- ✅ String (S7格式: [MaxLen][ActualLen][Data...])
|
||||
|
||||
### 3. S7 String 格式
|
||||
|
||||
```csharp
|
||||
// S7 String 结构
|
||||
// 字节0: MaxLength (最大长度)
|
||||
// 字节1: ActualLength (实际长度)
|
||||
// 字节2+: ASCII字符
|
||||
|
||||
// 写入字符串
|
||||
await _plcService.WriteAsync("DB1.DBB0", "Hello", length: 254);
|
||||
// PLC中的数据: [FE] [05] [48] [65] [6C] [6C] [6F] [00] [00] ...
|
||||
// ↑ ↑ H e l l o
|
||||
// Max Actual
|
||||
|
||||
// 读取字符串
|
||||
var str = await _plcService.ReadAsync<string>("DB1.DBB0");
|
||||
// 结果: "Hello"
|
||||
```
|
||||
|
||||
### 4. Bool类型的位操作
|
||||
|
||||
```csharp
|
||||
// 西门子PLC的Bool使用位寻址
|
||||
var tag = new PlcTag
|
||||
{
|
||||
Address = "M10.5", // 标志字节10的第5位
|
||||
DataType = typeof(bool)
|
||||
};
|
||||
|
||||
// BigEndianConverter 会正确处理位偏移
|
||||
// 读取: 提取字节10的第5位
|
||||
// 写入: 只修改字节10的第5位,保持其他位不变
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 性能调优
|
||||
|
||||
### 1. PDU大小优化
|
||||
|
||||
```csharp
|
||||
// S7-1200/1500 支持更大的PDU
|
||||
services.AddSiemensPlc(options =>
|
||||
{
|
||||
options.PduSize = 960; // 最大化单次传输
|
||||
});
|
||||
|
||||
// 单次可读取更多数据,减少网络往返
|
||||
```
|
||||
|
||||
### 2. MaxGapBytes 调整
|
||||
|
||||
```csharp
|
||||
// 场景1: 地址密集分布
|
||||
services.AddSiemensPlc(options =>
|
||||
{
|
||||
options.MaxGapBytes = 50; // 增大合并范围
|
||||
});
|
||||
|
||||
// 场景2: 地址稀疏分布
|
||||
services.AddSiemensPlc(options =>
|
||||
{
|
||||
options.MaxGapBytes = 10; // 减少无效数据传输
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 标签地址规划
|
||||
|
||||
```csharp
|
||||
// ✅ 好的实践: 连续地址
|
||||
var tags = new[]
|
||||
{
|
||||
new PlcTag { Address = "DB1.DBW0", ... },
|
||||
new PlcTag { Address = "DB1.DBW2", ... },
|
||||
new PlcTag { Address = "DB1.DBW4", ... }
|
||||
};
|
||||
// 优化为1次读取
|
||||
|
||||
// ❌ 避免: 分散地址
|
||||
var tags = new[]
|
||||
{
|
||||
new PlcTag { Address = "DB1.DBW0", ... },
|
||||
new PlcTag { Address = "DB5.DBW0", ... },
|
||||
new PlcTag { Address = "DB10.DBW0", ... }
|
||||
};
|
||||
// 需要3次读取
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
#### 1. 连接失败
|
||||
|
||||
```
|
||||
错误: Connection failed: A connection attempt failed because the connected party did not properly respond...
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
- ✅ 检查PLC IP地址是否正确
|
||||
- ✅ 检查网络连通性 (`ping 192.168.1.100`)
|
||||
- ✅ 确认PLC的 **PUT/GET** 权限已启用
|
||||
- ✅ 检查防火墙规则(端口102)
|
||||
- ✅ 验证 Rack/Slot 配置
|
||||
|
||||
#### 2. PDU大小错误
|
||||
|
||||
```
|
||||
错误: Read failed: The PDU size is too large
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
```csharp
|
||||
// 根据PLC型号调整PDU
|
||||
options.PduSize = 240; // S7-300使用较小值
|
||||
```
|
||||
|
||||
#### 3. 地址格式错误
|
||||
|
||||
```
|
||||
错误: 不支持或无效的地址格式: DB1.0
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
```csharp
|
||||
// ❌ 错误格式
|
||||
"DB1.0" // 缺少数据类型
|
||||
"DB1DBW0" // 缺少点分隔符
|
||||
|
||||
// ✅ 正确格式
|
||||
"DB1.DBW0" // 数据块1, 字0
|
||||
"DB1.DBX0.0" // 数据块1, 字节0, 位0
|
||||
```
|
||||
|
||||
#### 4. 权限问题
|
||||
|
||||
```
|
||||
错误: Access denied or protection active
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
- 在TIA Portal中检查 **保护级别**
|
||||
- 启用 **PUT/GET 通信**
|
||||
- 确认 DB块的 **访问权限** 未设置为 **优化访问**
|
||||
|
||||
### 调试技巧
|
||||
|
||||
#### 启用详细日志
|
||||
|
||||
```csharp
|
||||
builder.Services.AddLogging(logging =>
|
||||
{
|
||||
logging.AddConsole();
|
||||
logging.SetMinimumLevel(LogLevel.Trace); // 查看所有日志
|
||||
});
|
||||
|
||||
// 日志输出示例
|
||||
[TRACE] Parsed address DB1.DBW0 for tag temp_001
|
||||
[DEBUG] Optimizing 10 tags into 3 batches
|
||||
[TRACE] Created new batch starting at DB1.DBW0
|
||||
[TRACE] Merged tag temp_002 into batch (gap=2, newLength=10)
|
||||
[INFO] Optimization complete: 10 tags -> 3 batches
|
||||
```
|
||||
|
||||
#### 使用Wireshark抓包
|
||||
|
||||
```bash
|
||||
# 过滤S7通信
|
||||
tcp.port == 102
|
||||
|
||||
# 查看S7协议细节
|
||||
s7comm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
### 模拟PLC测试
|
||||
|
||||
```csharp
|
||||
using Moq;
|
||||
using Plc.Abstractions;
|
||||
using Plc.Siemens;
|
||||
|
||||
[Fact]
|
||||
public async Task SiemensDriver_ReadBytes_Success()
|
||||
{
|
||||
// Arrange
|
||||
var options = new SiemensPlcOptions
|
||||
{
|
||||
IpAddress = "192.168.1.100",
|
||||
CpuType = CpuType.S71200
|
||||
};
|
||||
|
||||
var driver = new SiemensDriver(options);
|
||||
|
||||
// 注意: 需要真实的PLC或模拟器
|
||||
await driver.ConnectAsync();
|
||||
|
||||
// Act
|
||||
var result = await driver.ReadBytesAsync("DB1.DBW0", 2);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, result.Value.Length);
|
||||
|
||||
await driver.DisconnectAsync();
|
||||
}
|
||||
```
|
||||
|
||||
### 地址解析测试
|
||||
|
||||
```csharp
|
||||
[Theory]
|
||||
[InlineData("DB1.DBW0", 0x84, 1, 0, 0)]
|
||||
[InlineData("DB1.DBX0.5", 0x84, 1, 0, 5)]
|
||||
[InlineData("M100.3", 0x83, 0, 100, 3)]
|
||||
public void SiemensAddressParser_Parse_ValidAddress(
|
||||
string address,
|
||||
byte expectedArea,
|
||||
int expectedDbNumber,
|
||||
int expectedByteOffset,
|
||||
int expectedBitOffset)
|
||||
{
|
||||
// Act
|
||||
var result = SiemensAddressParser.Parse(address);
|
||||
|
||||
// Assert
|
||||
Assert.Equal((SiemensMemoryArea)expectedArea, result.Area);
|
||||
Assert.Equal(expectedDbNumber, result.DbNumber);
|
||||
Assert.Equal(expectedByteOffset, result.ByteOffset);
|
||||
Assert.Equal(expectedBitOffset, result.BitOffset);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 参考资料
|
||||
|
||||
### 官方文档
|
||||
|
||||
- [TIA Portal 文档](https://support.industry.siemens.com/cs/document/109742691)
|
||||
- [S7 通信协议](https://support.industry.siemens.com/cs/document/26483647)
|
||||
- [S7.Net Plus GitHub](https://github.com/S7NetPlus/s7netplus)
|
||||
|
||||
### 相关资源
|
||||
|
||||
- **S7 地址格式**: [Siemens 地址规范](https://support.industry.siemens.com/cs/document/67785739)
|
||||
- **CPU 规格**: [S7-1200 系统手册](https://support.industry.siemens.com/cs/document/36932465)
|
||||
- **通信设置**: [S7 Open User Communication](https://support.industry.siemens.com/cs/document/26483647)
|
||||
|
||||
---
|
||||
|
||||
## 🔐 安全注意事项
|
||||
|
||||
1. **网络隔离** - PLC应在独立的工业网络中
|
||||
2. **访问控制** - 限制对PLC IP的访问
|
||||
3. **保护级别** - 在TIA Portal中设置适当的保护级别
|
||||
4. **审计日志** - 记录所有PLC写入操作
|
||||
5. **变更管理** - 写入操作应经过审批流程
|
||||
|
||||
```csharp
|
||||
// 建议:添加写入权限检查
|
||||
public class SecurePlcService : IPlcService
|
||||
{
|
||||
private readonly IPlcService _inner;
|
||||
private readonly IAuthorizationService _auth;
|
||||
|
||||
public async Task<Result> WriteAsync<T>(string address, T value)
|
||||
{
|
||||
if (!await _auth.AuthorizeAsync("PlcWrite"))
|
||||
{
|
||||
return Result.Failure("Unauthorized");
|
||||
}
|
||||
|
||||
_logger.LogWarning("User {User} writing to {Address}",
|
||||
_currentUser.Name, address);
|
||||
|
||||
return await _inner.WriteAsync(address, value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 版本历史
|
||||
|
||||
### v1.0.0 (2025-01-09)
|
||||
- ✅ 初始发布
|
||||
- ✅ 支持 S7-200/300/400/1200/1500
|
||||
- ✅ 完整的地址解析
|
||||
- ✅ 批量优化
|
||||
- ✅ 配置验证
|
||||
- ✅ 日志集成
|
||||
- ✅ 自动重连
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎为西门子驱动贡献代码!
|
||||
|
||||
### 开发环境要求
|
||||
|
||||
- .NET 8.0 SDK
|
||||
- TIA Portal (用于测试)
|
||||
- 西门子PLC或PLCSim Advanced
|
||||
|
||||
### 测试清单
|
||||
|
||||
- [ ] 连接测试(真实PLC)
|
||||
- [ ] 地址解析测试(所有格式)
|
||||
- [ ] 数据类型测试(所有类型)
|
||||
- [ ] 批量优化测试
|
||||
- [ ] 性能测试
|
||||
- [ ] 错误处理测试
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License - 详见 [LICENSE](../LICENSE) 文件
|
||||
|
||||
---
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
- 问题反馈: [GitHub Issues](https://github.com/yourcompany/plc/issues)
|
||||
- 西门子论坛: [Siemens Industry Online Support](https://support.industry.siemens.com/)
|
||||
- S7.Net Plus: [S7NetPlus Issues](https://github.com/S7NetPlus/s7netplus/issues)
|
||||
@@ -11,11 +11,10 @@ public class SiemensDriver : IPlcDriver
|
||||
{
|
||||
private readonly SiemensPlcOptions _options;
|
||||
private S7.Net.Plc? _plc;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public SiemensDriver(SiemensPlcOptions options)
|
||||
{
|
||||
_options = options;
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public bool IsConnected => _plc?.IsConnected ?? false;
|
||||
@@ -34,46 +33,56 @@ public class SiemensDriver : IPlcDriver
|
||||
}
|
||||
|
||||
// Create new instance with latest options
|
||||
_plc = new S7.Net.Plc(_options.CpuType, _options.IpAddress, _options.Port, _options.Rack, _options.Slot);
|
||||
_plc = new S7.Net.Plc(
|
||||
_options.CpuType,
|
||||
_options.IpAddress,
|
||||
_options.Port,
|
||||
_options.Rack,
|
||||
_options.Slot);
|
||||
|
||||
await _plc.OpenAsync(cancellationToken);
|
||||
|
||||
|
||||
if (_plc.IsConnected)
|
||||
return Result.Success();
|
||||
|
||||
|
||||
return Result.Failure("Failed to connect to PLC (IsConnected is false).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Failure(ex.Message);
|
||||
return Result.Failure($"Connection failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> DisconnectAsync()
|
||||
public Task<Result> DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_plc?.Close();
|
||||
return await Task.FromResult(Result.Success());
|
||||
return Task.FromResult(Result.Success());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Failure(ex.Message);
|
||||
return Task.FromResult(Result.Failure($"Disconnect failed: {ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<byte[]>> ReadBytesAsync(string address, ushort length, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsConnected || _plc == null)
|
||||
return Result.Failure<byte[]>("PLC 未连接。");
|
||||
return Result.Failure<byte[]>("PLC is not connected");
|
||||
|
||||
try
|
||||
{
|
||||
var siemensAddr = SiemensAddressParser.Parse(address);
|
||||
var dataType = MapToS7DataType(siemensAddr.Area);
|
||||
|
||||
var bytes = await _plc.ReadBytesAsync(dataType, siemensAddr.DbNumber, siemensAddr.ByteOffset, length, cancellationToken);
|
||||
|
||||
|
||||
var bytes = await _plc.ReadBytesAsync(
|
||||
dataType,
|
||||
siemensAddr.DbNumber,
|
||||
siemensAddr.ByteOffset,
|
||||
length,
|
||||
cancellationToken);
|
||||
|
||||
return Result.Success(bytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -85,15 +94,20 @@ public class SiemensDriver : IPlcDriver
|
||||
public async Task<Result> WriteBytesAsync(string address, byte[] data, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsConnected || _plc == null)
|
||||
return Result.Failure("PLC 未连接。");
|
||||
return Result.Failure("PLC is not connected");
|
||||
|
||||
try
|
||||
{
|
||||
var siemensAddr = SiemensAddressParser.Parse(address);
|
||||
var dataType = MapToS7DataType(siemensAddr.Area);
|
||||
|
||||
await _plc.WriteBytesAsync(dataType, siemensAddr.DbNumber, siemensAddr.ByteOffset, data, cancellationToken);
|
||||
|
||||
await _plc.WriteBytesAsync(
|
||||
dataType,
|
||||
siemensAddr.DbNumber,
|
||||
siemensAddr.ByteOffset,
|
||||
data,
|
||||
cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -112,12 +126,13 @@ public class SiemensDriver : IPlcDriver
|
||||
SiemensMemoryArea.Outputs => DataType.Output,
|
||||
SiemensMemoryArea.Timer => DataType.Timer,
|
||||
SiemensMemoryArea.Counter => DataType.Counter,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(area), area, null)
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(area), area, $"Unsupported memory area: {area}")
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_plc?.Close();
|
||||
_plc = null;
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,23 @@ using Plc.Models;
|
||||
using Plc.Siemens.Models;
|
||||
using Plc.Siemens.Utils;
|
||||
using Plc.Utils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Plc.Siemens;
|
||||
|
||||
public class SiemensOptimizer : IPlcOptimizer
|
||||
{
|
||||
// 允许变量之间合并为一次读取的最大间隔字节数
|
||||
private const int MaxGapBytes = 20;
|
||||
private const int DefaultMaxGapBytes = 20;
|
||||
|
||||
private readonly int _maxGapBytes;
|
||||
private readonly ILogger<SiemensOptimizer>? _logger;
|
||||
|
||||
public SiemensOptimizer(int maxGapBytes = DefaultMaxGapBytes, ILogger<SiemensOptimizer>? logger = null)
|
||||
{
|
||||
_maxGapBytes = maxGapBytes;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
|
||||
{
|
||||
@@ -24,16 +34,21 @@ public class SiemensOptimizer : IPlcOptimizer
|
||||
try
|
||||
{
|
||||
tag.ParsedAddress = SiemensAddressParser.Parse(tag.Address);
|
||||
_logger?.LogTrace("Parsed address {Address} for tag {TagId}", tag.Address, tag.Id);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 记录错误?暂时跳过。
|
||||
_logger?.LogWarning(ex, "Failed to parse address {Address} for tag {TagId}, skipping",
|
||||
tag.Address, tag.Id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
validTags.Add(tag);
|
||||
}
|
||||
|
||||
_logger?.LogDebug("Optimizing {ValidCount} valid tags out of {TotalCount} tags",
|
||||
validTags.Count, tags.Count());
|
||||
|
||||
// 2. 按区域和 DB 号分组(物理位置)
|
||||
var groups = validTags.GroupBy(t =>
|
||||
{
|
||||
@@ -46,20 +61,26 @@ public class SiemensOptimizer : IPlcOptimizer
|
||||
// 3. 按字节偏移量排序
|
||||
var sortedTags = group.OrderBy(t => ((SiemensAddress)t.ParsedAddress!).ByteOffset).ToList();
|
||||
|
||||
_logger?.LogTrace("Processing group: Area={Area}, DB={DbNumber}, Tags={Count}",
|
||||
group.Key.Area, group.Key.DbNumber, sortedTags.Count);
|
||||
|
||||
PlcReadBatch? currentBatch = null;
|
||||
|
||||
foreach (var tag in sortedTags)
|
||||
{
|
||||
var addr = (SiemensAddress)tag.ParsedAddress!;
|
||||
int typeSize = TypeHelper.GetSize(tag.DataType);
|
||||
int typeSize = tag.DataType == typeof(string)
|
||||
? TypeHelper.GetSize(tag.DataType, tag.Length)
|
||||
: TypeHelper.GetSize(tag.DataType);
|
||||
int byteLength = typeSize * tag.Length; // 此标签的总字节数
|
||||
|
||||
|
||||
// 如果是 bool,它可能在一个字节内,但我们在检查边界时假设它占用该字节
|
||||
|
||||
if (currentBatch == null)
|
||||
{
|
||||
currentBatch = CreateBatch(tag, addr, byteLength);
|
||||
batches.Add(currentBatch);
|
||||
_logger?.LogTrace("Created new batch starting at {Address}", tag.Address);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -77,20 +98,27 @@ public class SiemensOptimizer : IPlcOptimizer
|
||||
// 1. 标签在当前结束之后(或位置相同)开始(因为已排序,所以是肯定的)
|
||||
// 2. 间隔足够小
|
||||
// 3. 总大小适合 PDU
|
||||
if (gap >= 0 && gap <= MaxGapBytes && newTotalLength <= maxPduSize)
|
||||
if (gap >= 0 && gap <= _maxGapBytes && newTotalLength <= maxPduSize)
|
||||
{
|
||||
currentBatch.Tags.Add(tag);
|
||||
currentBatch.TotalLength = newTotalLength;
|
||||
_logger?.LogTrace("Merged tag {TagId} into batch (gap={Gap}, newLength={Length})",
|
||||
tag.Id, gap, newTotalLength);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 开启新批次
|
||||
_logger?.LogTrace("Cannot merge tag {TagId} (gap={Gap}, newLength={Length}, maxPdu={MaxPdu}), creating new batch",
|
||||
tag.Id, gap, newTotalLength, maxPduSize);
|
||||
currentBatch = CreateBatch(tag, addr, byteLength);
|
||||
batches.Add(currentBatch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger?.LogDebug("Optimization complete: {TagCount} tags -> {BatchCount} batches",
|
||||
validTags.Count, batches.Count);
|
||||
|
||||
return batches;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,22 @@ namespace Plc.Abstractions;
|
||||
|
||||
public interface IDataConverter
|
||||
{
|
||||
// 将原始字节(从指定偏移量开始)转换为目标类型
|
||||
object? Parse(byte[] buffer, int offset, Type type, int length);
|
||||
|
||||
// 将值转换为字节
|
||||
byte[] ToBytes(object value, Type type, int length);
|
||||
/// <summary>
|
||||
/// 将原始字节(从指定偏移量开始)转换为目标类型
|
||||
/// </summary>
|
||||
/// <param name="buffer">字节缓冲区</param>
|
||||
/// <param name="offset">起始偏移量</param>
|
||||
/// <param name="type">目标类型</param>
|
||||
/// <param name="length">数据长度(对字符串和数组有效)</param>
|
||||
/// <param name="bitOffset">位偏移量(仅对bool类型有效)</param>
|
||||
object? Parse(byte[] buffer, int offset, Type type, int length, int bitOffset = 0);
|
||||
|
||||
/// <summary>
|
||||
/// 将值转换为字节
|
||||
/// </summary>
|
||||
/// <param name="value">要转换的值</param>
|
||||
/// <param name="type">值的类型</param>
|
||||
/// <param name="length">数据长度(对字符串和数组有效)</param>
|
||||
/// <param name="bitOffset">位偏移量(仅对bool类型有效)</param>
|
||||
byte[] ToBytes(object value, Type type, int length, int bitOffset = 0);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Plc.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 定义PLC地址的通用接口,用于不同品牌的地址解析结果
|
||||
/// </summary>
|
||||
public interface IPlcAddress
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取字节偏移量
|
||||
/// </summary>
|
||||
int ByteOffset { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取位偏移量(仅对bool类型有效)
|
||||
/// </summary>
|
||||
int BitOffset { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取地址的字符串表示
|
||||
/// </summary>
|
||||
string ToString();
|
||||
}
|
||||
@@ -42,4 +42,4 @@ public interface IPlcDriver : IDisposable
|
||||
/// <param name="address">起始地址字符串。</param>
|
||||
/// <param name="data">要写入的字节。</param>
|
||||
Task<Result> WriteBytesAsync(string address, byte[] data, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,10 @@ public class PlcReadBatch
|
||||
/// 包含在此批量范围内的标签列表。
|
||||
/// </summary>
|
||||
public List<PlcTag> Tags { get; set; } = new();
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 驱动程序特定的起始地址上下文(避免重新解析)。
|
||||
/// 使用 IPlcAddress 接口以支持类型安全访问。
|
||||
/// </summary>
|
||||
public object? ParsedStartAddress { get; set; }
|
||||
public IPlcAddress? ParsedStartAddress { get; set; }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Plc.Abstractions;
|
||||
|
||||
namespace Plc.Models;
|
||||
|
||||
/// <summary>
|
||||
@@ -39,6 +41,7 @@ public class PlcTag
|
||||
/// <summary>
|
||||
/// 内部使用:由驱动程序/优化器用于存储解析后的地址对象。
|
||||
/// 这避免了在每个周期重新解析地址字符串。
|
||||
/// 使用 IPlcAddress 接口以支持类型安全访问。
|
||||
/// </summary>
|
||||
public object? ParsedAddress { get; set; }
|
||||
public IPlcAddress? ParsedAddress { get; set; }
|
||||
}
|
||||
@@ -10,4 +10,8 @@
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,558 @@
|
||||
# Plc - 工业PLC通信核心库
|
||||
|
||||
[](https://dotnet.microsoft.com/)
|
||||
[](LICENSE)
|
||||
|
||||
## 📖 概述
|
||||
|
||||
Plc 是一个通用的工业PLC通信抽象层库,提供了统一的接口来与不同品牌的PLC设备进行通信。该库采用分层架构设计,将协议细节抽象化,使得上层应用无需关心具体的PLC品牌和协议实现。
|
||||
|
||||
### 核心特性
|
||||
|
||||
- ✅ **品牌无关** - 抽象接口设计,支持多种PLC品牌(西门子、欧姆龙、三菱等)
|
||||
- ✅ **高性能优化** - 智能批量读取优化,减少网络往返次数
|
||||
- ✅ **实时监控** - 支持标签订阅模式,自动轮询和变更通知
|
||||
- ✅ **类型安全** - 强类型数据转换,支持多种基础数据类型
|
||||
- ✅ **连接管理** - 自动重连、指数退避策略
|
||||
- ✅ **完整日志** - 集成 Microsoft.Extensions.Logging
|
||||
- ✅ **依赖注入** - 原生支持 .NET DI 容器
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 架构设计
|
||||
|
||||
### 分层架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 应用层 (Application Layer) │
|
||||
│ - ViewModel/Controller │
|
||||
│ - Business Logic │
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────▼────────────────────────┐
|
||||
│ 服务层 (Service Layer) │
|
||||
│ - PlcMonitorService │ ← 核心服务
|
||||
│ - 订阅管理、批量优化 │
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────▼────────────────────────┐
|
||||
│ 抽象层 (Abstraction Layer) │
|
||||
│ - IPlcDriver │ ← 驱动接口
|
||||
│ - IPlcOptimizer │ ← 优化器接口
|
||||
│ - IDataConverter │ ← 转换器接口
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────▼────────────────────────┐
|
||||
│ 实现层 (Implementation Layer) │
|
||||
│ - SiemensDriver │ ← 品牌驱动
|
||||
│ - OmronDriver │
|
||||
│ - ModbusDriver │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 核心组件
|
||||
|
||||
#### 1. **IPlcService** - PLC服务接口
|
||||
提供统一的PLC通信入口,包括:
|
||||
- 连接管理(`StartAsync`, `StopAsync`)
|
||||
- 即时读写(`ReadAsync<T>`, `WriteAsync<T>`)
|
||||
- 订阅监控(`Subscribe`, `Unsubscribe`)
|
||||
|
||||
#### 2. **IPlcDriver** - PLC驱动接口
|
||||
定义底层通信协议的契约:
|
||||
- 连接/断开(`ConnectAsync`, `DisconnectAsync`)
|
||||
- 字节级读写(`ReadBytesAsync`, `WriteBytesAsync`)
|
||||
- 状态查询(`IsConnected`, `MaxPduSize`)
|
||||
|
||||
#### 3. **IPlcOptimizer** - 批量优化器接口
|
||||
负责优化多标签读取策略:
|
||||
- 地址排序和分组
|
||||
- 间隙合并(减少通信次数)
|
||||
- PDU大小限制
|
||||
|
||||
#### 4. **IDataConverter** - 数据转换器接口
|
||||
处理PLC字节序和.NET类型转换:
|
||||
- 大端/小端转换
|
||||
- 基础类型(bool, byte, short, int, float, double, string)
|
||||
- 位偏移支持(bool类型)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 安装核心库
|
||||
dotnet add package YourCompany.Plc
|
||||
|
||||
# 安装西门子驱动
|
||||
dotnet add package YourCompany.Plc.Siemens
|
||||
```
|
||||
|
||||
### 基本使用
|
||||
|
||||
#### 1. 注册服务
|
||||
|
||||
```csharp
|
||||
using Plc.Siemens.DependencyInjection;
|
||||
|
||||
// 在 Program.cs 或 Startup.cs 中
|
||||
services.AddSiemensPlc(options =>
|
||||
{
|
||||
options.IpAddress = "192.168.1.100";
|
||||
options.CpuType = S7.Net.CpuType.S71200;
|
||||
options.Port = 102;
|
||||
options.PduSize = 960;
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. 使用服务
|
||||
|
||||
```csharp
|
||||
public class MyController
|
||||
{
|
||||
private readonly IPlcService _plcService;
|
||||
|
||||
public MyController(IPlcService plcService)
|
||||
{
|
||||
_plcService = plcService;
|
||||
}
|
||||
|
||||
public async Task Example()
|
||||
{
|
||||
// 启动PLC服务
|
||||
await _plcService.StartAsync();
|
||||
|
||||
// 读取数据
|
||||
var result = await _plcService.ReadAsync<short>("DB1.DBW0");
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Console.WriteLine($"Value: {result.Value}");
|
||||
}
|
||||
|
||||
// 写入数据
|
||||
await _plcService.WriteAsync("DB1.DBW0", (short)1234);
|
||||
|
||||
// 停止服务
|
||||
await _plcService.StopAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 核心概念
|
||||
|
||||
### PlcTag - 标签模型
|
||||
|
||||
`PlcTag` 表示PLC中的一个数据点:
|
||||
|
||||
```csharp
|
||||
var tag = new PlcTag
|
||||
{
|
||||
Id = "temperature_sensor",
|
||||
Address = "DB1.DBD0",
|
||||
DataType = typeof(float),
|
||||
Length = 1,
|
||||
OnChanged = (tag, newValue) =>
|
||||
{
|
||||
Console.WriteLine($"温度变化: {newValue}°C");
|
||||
}
|
||||
};
|
||||
|
||||
_plcService.Subscribe(tag);
|
||||
```
|
||||
|
||||
**属性说明:**
|
||||
- `Id` - 唯一标识符
|
||||
- `Address` - PLC地址(格式取决于驱动)
|
||||
- `DataType` - .NET 类型
|
||||
- `Length` - 数组长度(字符串的最大长度)
|
||||
- `OnChanged` - 值变化回调
|
||||
- `Value` - 当前值(只读)
|
||||
|
||||
### 支持的数据类型
|
||||
|
||||
| C# 类型 | PLC 类型 | 字节数 | 说明 |
|
||||
|---------|---------|--------|------|
|
||||
| `bool` | BOOL | 1 | 支持位偏移 |
|
||||
| `byte` | BYTE | 1 | 无符号8位 |
|
||||
| `short` | INT | 2 | 有符号16位 |
|
||||
| `ushort` | UINT | 2 | 无符号16位 |
|
||||
| `int` | DINT | 4 | 有符号32位 |
|
||||
| `uint` | UDINT | 4 | 无符号32位 |
|
||||
| `float` | REAL | 4 | 单精度浮点 |
|
||||
| `double` | LREAL | 8 | 双精度浮点 |
|
||||
| `string` | STRING | 2+N | S7格式字符串 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 高级功能
|
||||
|
||||
### 1. 标签订阅与监控
|
||||
|
||||
```csharp
|
||||
// 订阅多个标签
|
||||
var tags = new[]
|
||||
{
|
||||
new PlcTag { Address = "DB1.DBW0", DataType = typeof(short) },
|
||||
new PlcTag { Address = "DB1.DBW2", DataType = typeof(short) },
|
||||
new PlcTag { Address = "DB1.DBD4", DataType = typeof(float) }
|
||||
};
|
||||
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
_plcService.Subscribe(tag);
|
||||
}
|
||||
|
||||
// 服务会自动批量优化读取
|
||||
await _plcService.StartAsync();
|
||||
```
|
||||
|
||||
### 2. 批量优化原理
|
||||
|
||||
优化器会自动:
|
||||
1. **按区域分组** - DB1、M、I、Q 等分开处理
|
||||
2. **按地址排序** - 确保连续性
|
||||
3. **间隙合并** - 小于 `MaxGapBytes` 的间隙合并为一次读取
|
||||
4. **PDU限制** - 单次读取不超过 `MaxPduSize`
|
||||
|
||||
**示例:**
|
||||
```
|
||||
标签1: DB1.DBW0 (2 bytes)
|
||||
标签2: DB1.DBW4 (2 bytes) ← 间隙 2 bytes
|
||||
标签3: DB1.DBW8 (2 bytes) ← 间隙 2 bytes
|
||||
|
||||
优化后: 读取 DB1.DBW0, 10 bytes
|
||||
包含所有三个标签和中间的间隙
|
||||
```
|
||||
|
||||
### 3. 使用特性绑定
|
||||
|
||||
```csharp
|
||||
using Plc.Attributes;
|
||||
using Plc.Proxy;
|
||||
|
||||
public interface IPlcData : INotifyPropertyChanged
|
||||
{
|
||||
[PlcAddress("DB1.DBW0")]
|
||||
short Temperature { get; set; }
|
||||
|
||||
[PlcAddress("DB1.DBW2")]
|
||||
short Pressure { get; set; }
|
||||
|
||||
[PlcAddress("DB1.DBX4.0")]
|
||||
bool AlarmFlag { get; set; }
|
||||
}
|
||||
|
||||
// 创建代理
|
||||
var plcData = PlcInterfaceProxy.Create<IPlcData>(_plcService);
|
||||
|
||||
// 自动订阅和双向绑定
|
||||
plcData.PropertyChanged += (s, e) =>
|
||||
{
|
||||
Console.WriteLine($"{e.PropertyName} changed");
|
||||
};
|
||||
|
||||
// 写入会自动发送到PLC
|
||||
plcData.Temperature = 25;
|
||||
```
|
||||
|
||||
### 4. 自定义优化器
|
||||
|
||||
```csharp
|
||||
public class MyCustomOptimizer : IPlcOptimizer
|
||||
{
|
||||
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
|
||||
{
|
||||
// 自定义优化逻辑
|
||||
// 例如:按优先级分组、按更新频率分组等
|
||||
}
|
||||
}
|
||||
|
||||
// 注册自定义优化器
|
||||
services.AddSingleton<IPlcOptimizer, MyCustomOptimizer>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置选项
|
||||
|
||||
### PlcMonitorService 配置
|
||||
|
||||
```csharp
|
||||
public class PlcMonitorService
|
||||
{
|
||||
// 刷新率(默认 200ms)
|
||||
private readonly TimeSpan _refreshRate = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
// 最大重连次数(默认 10)
|
||||
private const int MaxReconnectAttempts = 10;
|
||||
}
|
||||
```
|
||||
|
||||
可通过继承 `PlcMonitorService` 自定义这些参数。
|
||||
|
||||
---
|
||||
|
||||
## 🔍 错误处理
|
||||
|
||||
所有操作都返回 `Result` 或 `Result<T>` 类型:
|
||||
|
||||
```csharp
|
||||
var result = await _plcService.ReadAsync<int>("DB1.DBD0");
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Console.WriteLine($"读取成功: {result.Value}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"读取失败: {result.ErrorMessage}");
|
||||
|
||||
// 也可以获取异常详情
|
||||
if (result.Exception != null)
|
||||
{
|
||||
_logger.LogError(result.Exception, "PLC读取异常");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 日志集成
|
||||
|
||||
库已集成 `Microsoft.Extensions.Logging`:
|
||||
|
||||
```csharp
|
||||
// 配置日志级别
|
||||
builder.Services.AddLogging(logging =>
|
||||
{
|
||||
logging.AddConsole();
|
||||
logging.SetMinimumLevel(LogLevel.Debug);
|
||||
});
|
||||
|
||||
// 日志输出示例
|
||||
[12:00:00 INF] Starting PLC monitor service
|
||||
[12:00:01 DBG] Subscribed to tag temp_001 at address DB1.DBW0
|
||||
[12:00:01 TRC] Optimized 10 tags into 3 batches
|
||||
[12:00:02 WRN] PLC disconnected, attempting to reconnect (attempt 1/10)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 扩展指南
|
||||
|
||||
### 实现新的PLC品牌驱动
|
||||
|
||||
#### 1. 实现 `IPlcDriver` 接口
|
||||
|
||||
```csharp
|
||||
public class ModbusDriver : IPlcDriver
|
||||
{
|
||||
public bool IsConnected { get; private set; }
|
||||
public int MaxPduSize => 253; // Modbus TCP 限制
|
||||
|
||||
public async Task<Result> ConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// 实现 Modbus TCP 连接逻辑
|
||||
}
|
||||
|
||||
public async Task<Result<byte[]>> ReadBytesAsync(
|
||||
string address,
|
||||
ushort length,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 解析 Modbus 地址格式
|
||||
// 例如: "40001" -> 寄存器地址
|
||||
// 调用 Modbus 协议读取
|
||||
}
|
||||
|
||||
// ... 其他方法
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 实现地址解析器
|
||||
|
||||
```csharp
|
||||
public class ModbusAddress : IPlcAddress
|
||||
{
|
||||
public int RegisterAddress { get; set; }
|
||||
public int ByteOffset { get; set; }
|
||||
public int BitOffset { get; set; }
|
||||
}
|
||||
|
||||
public static class ModbusAddressParser
|
||||
{
|
||||
public static ModbusAddress Parse(string address)
|
||||
{
|
||||
// 解析 Modbus 地址格式
|
||||
// 例如: "40001" -> Holding Register 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 实现优化器
|
||||
|
||||
```csharp
|
||||
public class ModbusOptimizer : IPlcOptimizer
|
||||
{
|
||||
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
|
||||
{
|
||||
// Modbus 特定优化
|
||||
// 连续寄存器合并读取
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 创建扩展方法
|
||||
|
||||
```csharp
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddModbusPlc(
|
||||
this IServiceCollection services,
|
||||
Action<ModbusPlcOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
services.AddSingleton<IPlcDriver, ModbusDriver>();
|
||||
services.AddSingleton<IPlcOptimizer, ModbusOptimizer>();
|
||||
services.AddSingleton<IDataConverter, LittleEndianConverter>();
|
||||
services.AddSingleton<IPlcService, PlcMonitorService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 设计原则
|
||||
|
||||
### 1. **依赖倒置原则 (DIP)**
|
||||
- 高层模块(PlcMonitorService)不依赖低层模块(SiemensDriver)
|
||||
- 两者都依赖抽象(IPlcDriver, IPlcOptimizer)
|
||||
|
||||
### 2. **开闭原则 (OCP)**
|
||||
- 对扩展开放:可添加新的PLC品牌驱动
|
||||
- 对修改关闭:添加新品牌无需修改核心代码
|
||||
|
||||
### 3. **单一职责原则 (SRP)**
|
||||
- **PlcMonitorService** - 只负责监控循环和订阅管理
|
||||
- **IPlcDriver** - 只负责底层通信
|
||||
- **IPlcOptimizer** - 只负责批量优化
|
||||
- **IDataConverter** - 只负责类型转换
|
||||
|
||||
### 4. **接口隔离原则 (ISP)**
|
||||
- 接口粒度合适,不强迫实现不需要的方法
|
||||
- 驱动只需实现 `IPlcDriver`,无需关心优化逻辑
|
||||
|
||||
---
|
||||
|
||||
## 📈 性能优化建议
|
||||
|
||||
### 1. 合理设置刷新率
|
||||
```csharp
|
||||
// 高频数据(100ms)
|
||||
var criticalTag = new PlcTag { Address = "...", ... };
|
||||
|
||||
// 低频数据可以单独轮询
|
||||
Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await _plcService.ReadAsync<int>("DB100.DBD0");
|
||||
await Task.Delay(5000); // 5秒轮询一次
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 减少订阅数量
|
||||
- 只订阅真正需要监控的标签
|
||||
- 一次性读取的数据使用 `ReadAsync`
|
||||
|
||||
### 3. 调整 MaxGapBytes
|
||||
```csharp
|
||||
services.AddSiemensPlc(options =>
|
||||
{
|
||||
options.MaxGapBytes = 50; // 增大间隙合并阈值
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
### 单元测试示例
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ReadAsync_ValidAddress_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var mockDriver = new Mock<IPlcDriver>();
|
||||
mockDriver.Setup(d => d.ReadBytesAsync(It.IsAny<string>(), It.IsAny<ushort>(), default))
|
||||
.ReturnsAsync(Result.Success(new byte[] { 0x00, 0x64 })); // 100
|
||||
|
||||
var service = new PlcMonitorService(
|
||||
mockDriver.Object,
|
||||
Mock.Of<IPlcOptimizer>(),
|
||||
new BigEndianConverter());
|
||||
|
||||
// Act
|
||||
var result = await service.ReadAsync<short>("DB1.DBW0");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(100, result.Value);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 常见问题
|
||||
|
||||
### Q: 如何处理连接断开?
|
||||
A: `PlcMonitorService` 内置自动重连机制,使用指数退避策略(1s, 2s, 4s, 8s, 16s, 30s)。
|
||||
|
||||
### Q: 是否支持异步属性?
|
||||
A: `PlcInterfaceProxy` 目前使用同步写入。对于异步场景,建议使用 `WriteAsync` 方法。
|
||||
|
||||
### Q: 如何优化大量标签的性能?
|
||||
A:
|
||||
1. 确保标签地址连续或接近
|
||||
2. 调整 `MaxGapBytes` 参数
|
||||
3. 增大 `PduSize`(如果PLC支持)
|
||||
4. 考虑分组订阅(按更新频率)
|
||||
|
||||
### Q: 支持哪些PLC品牌?
|
||||
A: 当前支持西门子S7系列。其他品牌需要实现对应的驱动模块。
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License - 详见 [LICENSE](LICENSE) 文件
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
### 贡献指南
|
||||
1. Fork 本仓库
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启 Pull Request
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
- 项目主页: [GitHub](https://github.com/yourcompany/plc)
|
||||
- 问题反馈: [Issues](https://github.com/yourcompany/plc/issues)
|
||||
- 文档: [Wiki](https://github.com/yourcompany/plc/wiki)
|
||||
@@ -5,54 +5,129 @@ namespace Plc.Services;
|
||||
|
||||
public class BigEndianConverter : IDataConverter
|
||||
{
|
||||
public object? Parse(byte[] buffer, int offset, Type type, int length)
|
||||
public object? Parse(byte[] buffer, int offset, Type type, int length, int bitOffset = 0)
|
||||
{
|
||||
// 为简洁起见省略了边界检查,但在生产环境中至关重要
|
||||
// 参数验证
|
||||
if (buffer == null)
|
||||
throw new ArgumentNullException(nameof(buffer));
|
||||
if (offset < 0 || offset >= buffer.Length)
|
||||
throw new ArgumentOutOfRangeException(nameof(offset), "Offset is out of buffer range");
|
||||
|
||||
// Bool类型处理(按位)
|
||||
if (type == typeof(bool))
|
||||
return buffer[offset] > 0; // 简单的检查,真实的位逻辑需要位索引
|
||||
|
||||
if (type == typeof(byte))
|
||||
return buffer[offset];
|
||||
|
||||
if (type == typeof(short))
|
||||
return BinaryPrimitives.ReadInt16BigEndian(buffer.AsSpan(offset));
|
||||
|
||||
if (type == typeof(ushort))
|
||||
return BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(offset));
|
||||
|
||||
if (type == typeof(int))
|
||||
return BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset));
|
||||
|
||||
if (type == typeof(uint))
|
||||
return BinaryPrimitives.ReadUInt32BigEndian(buffer.AsSpan(offset));
|
||||
|
||||
if (type == typeof(float))
|
||||
return BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset)));
|
||||
|
||||
if (type == typeof(double))
|
||||
return BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(buffer.AsSpan(offset)));
|
||||
|
||||
// 简单处理字符串 (假设 ASCII/S7 String)
|
||||
if (type == typeof(string))
|
||||
{
|
||||
// S7 String 前两个字节是 MaxLen 和 ActualLen,后面是字符
|
||||
// 这里假设是纯字符数组或者需要特定处理
|
||||
// 简单起见,按 ASCII 读取指定长度
|
||||
return System.Text.Encoding.ASCII.GetString(buffer, offset, length);
|
||||
if (offset >= buffer.Length)
|
||||
throw new ArgumentException("Buffer too small for bool");
|
||||
if (bitOffset < 0 || bitOffset > 7)
|
||||
throw new ArgumentOutOfRangeException(nameof(bitOffset), "Bit offset must be 0-7");
|
||||
|
||||
byte b = buffer[offset];
|
||||
return (b & (1 << bitOffset)) != 0;
|
||||
}
|
||||
|
||||
return null;
|
||||
// Byte类型
|
||||
if (type == typeof(byte))
|
||||
{
|
||||
if (offset >= buffer.Length)
|
||||
throw new ArgumentException("Buffer too small for byte");
|
||||
return buffer[offset];
|
||||
}
|
||||
|
||||
// Short类型
|
||||
if (type == typeof(short))
|
||||
{
|
||||
if (offset + 2 > buffer.Length)
|
||||
throw new ArgumentException("Buffer too small for short");
|
||||
return BinaryPrimitives.ReadInt16BigEndian(buffer.AsSpan(offset));
|
||||
}
|
||||
|
||||
// UShort类型
|
||||
if (type == typeof(ushort))
|
||||
{
|
||||
if (offset + 2 > buffer.Length)
|
||||
throw new ArgumentException("Buffer too small for ushort");
|
||||
return BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(offset));
|
||||
}
|
||||
|
||||
// Int类型
|
||||
if (type == typeof(int))
|
||||
{
|
||||
if (offset + 4 > buffer.Length)
|
||||
throw new ArgumentException("Buffer too small for int");
|
||||
return BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset));
|
||||
}
|
||||
|
||||
// UInt类型
|
||||
if (type == typeof(uint))
|
||||
{
|
||||
if (offset + 4 > buffer.Length)
|
||||
throw new ArgumentException("Buffer too small for uint");
|
||||
return BinaryPrimitives.ReadUInt32BigEndian(buffer.AsSpan(offset));
|
||||
}
|
||||
|
||||
// Float类型
|
||||
if (type == typeof(float))
|
||||
{
|
||||
if (offset + 4 > buffer.Length)
|
||||
throw new ArgumentException("Buffer too small for float");
|
||||
return BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset)));
|
||||
}
|
||||
|
||||
// Double类型
|
||||
if (type == typeof(double))
|
||||
{
|
||||
if (offset + 8 > buffer.Length)
|
||||
throw new ArgumentException("Buffer too small for double");
|
||||
return BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(buffer.AsSpan(offset)));
|
||||
}
|
||||
|
||||
// String类型(S7 String格式)
|
||||
if (type == typeof(string))
|
||||
{
|
||||
// S7 String结构:
|
||||
// 字节0: MaxLength
|
||||
// 字节1: ActualLength
|
||||
// 字节2+: 字符数据
|
||||
if (offset + 2 > buffer.Length)
|
||||
throw new ArgumentException("Buffer too small for S7 string header");
|
||||
|
||||
int maxLen = buffer[offset];
|
||||
int actualLen = buffer[offset + 1];
|
||||
|
||||
if (actualLen > maxLen)
|
||||
actualLen = maxLen; // 安全处理
|
||||
|
||||
if (offset + 2 + actualLen > buffer.Length)
|
||||
throw new ArgumentException($"Buffer too small for S7 string data (needs {actualLen} bytes)");
|
||||
|
||||
return System.Text.Encoding.ASCII.GetString(buffer, offset + 2, actualLen);
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Type {type.Name} is not supported by this converter");
|
||||
}
|
||||
|
||||
public byte[] ToBytes(object value, Type type, int length)
|
||||
public byte[] ToBytes(object value, Type type, int length, int bitOffset = 0)
|
||||
{
|
||||
if (type == typeof(bool))
|
||||
return new[] { (bool)value ? (byte)1 : (byte)0 };
|
||||
if (value == null)
|
||||
throw new ArgumentNullException(nameof(value));
|
||||
|
||||
// Bool类型(按位)
|
||||
if (type == typeof(bool))
|
||||
{
|
||||
if (bitOffset < 0 || bitOffset > 7)
|
||||
throw new ArgumentOutOfRangeException(nameof(bitOffset), "Bit offset must be 0-7");
|
||||
|
||||
byte b = 0;
|
||||
if ((bool)value)
|
||||
b = (byte)(1 << bitOffset);
|
||||
return new[] { b };
|
||||
}
|
||||
|
||||
// Byte类型
|
||||
if (type == typeof(byte))
|
||||
return new[] { (byte)value };
|
||||
|
||||
// Short类型
|
||||
if (type == typeof(short))
|
||||
{
|
||||
var bytes = new byte[2];
|
||||
@@ -60,6 +135,7 @@ public class BigEndianConverter : IDataConverter
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// UShort类型
|
||||
if (type == typeof(ushort))
|
||||
{
|
||||
var bytes = new byte[2];
|
||||
@@ -67,6 +143,7 @@ public class BigEndianConverter : IDataConverter
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Int类型
|
||||
if (type == typeof(int))
|
||||
{
|
||||
var bytes = new byte[4];
|
||||
@@ -74,6 +151,7 @@ public class BigEndianConverter : IDataConverter
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// UInt类型
|
||||
if (type == typeof(uint))
|
||||
{
|
||||
var bytes = new byte[4];
|
||||
@@ -81,6 +159,7 @@ public class BigEndianConverter : IDataConverter
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Float类型
|
||||
if (type == typeof(float))
|
||||
{
|
||||
var bytes = new byte[4];
|
||||
@@ -89,6 +168,7 @@ public class BigEndianConverter : IDataConverter
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// Double类型
|
||||
if (type == typeof(double))
|
||||
{
|
||||
var bytes = new byte[8];
|
||||
@@ -96,7 +176,23 @@ public class BigEndianConverter : IDataConverter
|
||||
BinaryPrimitives.WriteInt64BigEndian(bytes, longVal);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
return Array.Empty<byte>();
|
||||
|
||||
// String类型(S7 String格式)
|
||||
if (type == typeof(string))
|
||||
{
|
||||
var str = (string)value;
|
||||
var strBytes = System.Text.Encoding.ASCII.GetBytes(str);
|
||||
var actualLen = Math.Min(strBytes.Length, length);
|
||||
|
||||
// S7 String结构
|
||||
var result = new byte[2 + length];
|
||||
result[0] = (byte)length; // MaxLength
|
||||
result[1] = (byte)actualLen; // ActualLength
|
||||
Array.Copy(strBytes, 0, result, 2, actualLen);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Type {type.Name} is not supported by this converter");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Plc.Abstractions;
|
||||
using Plc.Models;
|
||||
using Plc.Utils;
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Plc.Services;
|
||||
|
||||
@@ -11,91 +12,197 @@ public class PlcMonitorService : IPlcService, IDisposable
|
||||
private readonly IPlcDriver _driver;
|
||||
private readonly IPlcOptimizer _optimizer;
|
||||
private readonly IDataConverter _converter;
|
||||
|
||||
private readonly ILogger<PlcMonitorService>? _logger;
|
||||
|
||||
private readonly ConcurrentDictionary<string, PlcTag> _subscriptions = new();
|
||||
private Task? _monitorTask;
|
||||
private CancellationTokenSource? _cts;
|
||||
private readonly TimeSpan _refreshRate = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
public PlcMonitorService(IPlcDriver driver, IPlcOptimizer optimizer, IDataConverter converter)
|
||||
// 重连策略
|
||||
private int _reconnectAttempts = 0;
|
||||
private const int MaxReconnectAttempts = 10;
|
||||
|
||||
public PlcMonitorService(
|
||||
IPlcDriver driver,
|
||||
IPlcOptimizer optimizer,
|
||||
IDataConverter converter,
|
||||
ILogger<PlcMonitorService>? logger = null)
|
||||
{
|
||||
_driver = driver;
|
||||
_optimizer = optimizer;
|
||||
_converter = converter;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_monitorTask != null) return;
|
||||
if (_monitorTask != null)
|
||||
{
|
||||
_logger?.LogWarning("Monitor service is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger?.LogInformation("Starting PLC monitor service");
|
||||
|
||||
var connectResult = await _driver.ConnectAsync(cancellationToken);
|
||||
if (connectResult.IsFailure)
|
||||
{
|
||||
_logger?.LogError("Failed to connect to PLC: {Error}", connectResult.ErrorMessage);
|
||||
throw new InvalidOperationException($"Cannot start monitor service: {connectResult.ErrorMessage}");
|
||||
}
|
||||
|
||||
await _driver.ConnectAsync(cancellationToken);
|
||||
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_monitorTask = Task.Run(() => MonitorLoop(_cts.Token), _cts.Token);
|
||||
|
||||
_logger?.LogInformation("PLC monitor service started successfully");
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
_logger?.LogInformation("Stopping PLC monitor service");
|
||||
|
||||
_cts?.Cancel();
|
||||
if (_monitorTask != null)
|
||||
{
|
||||
try { await _monitorTask; } catch (OperationCanceledException) { }
|
||||
try
|
||||
{
|
||||
await _monitorTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger?.LogDebug("Monitor task cancelled successfully");
|
||||
}
|
||||
}
|
||||
|
||||
await _driver.DisconnectAsync();
|
||||
_logger?.LogInformation("PLC monitor service stopped");
|
||||
}
|
||||
|
||||
public void Subscribe(PlcTag tag)
|
||||
{
|
||||
_subscriptions.TryAdd(tag.Id, tag);
|
||||
if (_subscriptions.TryAdd(tag.Id, tag))
|
||||
{
|
||||
_logger?.LogDebug("Subscribed to tag {TagId} at address {Address}", tag.Id, tag.Address);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.LogWarning("Tag {TagId} is already subscribed", tag.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public void Unsubscribe(string tagId)
|
||||
{
|
||||
_subscriptions.TryRemove(tagId, out _);
|
||||
if (_subscriptions.TryRemove(tagId, out _))
|
||||
{
|
||||
_logger?.LogDebug("Unsubscribed tag {TagId}", tagId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.LogWarning("Failed to unsubscribe tag {TagId} - not found", tagId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<T>> ReadAsync<T>(string address)
|
||||
{
|
||||
_logger?.LogDebug("Reading from address {Address}", address);
|
||||
|
||||
// 临时读取(未优化)
|
||||
int size = TypeHelper.GetSize(typeof(T));
|
||||
var result = await _driver.ReadBytesAsync(address, (ushort)size);
|
||||
|
||||
if (result.IsFailure)
|
||||
|
||||
if (result.IsFailure)
|
||||
{
|
||||
_logger?.LogError("Failed to read from {Address}: {Error}", address, result.ErrorMessage);
|
||||
return Result.Failure<T>(result.ErrorMessage ?? result.Exception?.Message ?? "Read failed");
|
||||
}
|
||||
|
||||
if (result.Value is null)
|
||||
{
|
||||
_logger?.LogError("Read from {Address} returned null data", address);
|
||||
return Result.Failure<T>("Read returned null data");
|
||||
}
|
||||
|
||||
var val = _converter.Parse(result.Value, 0, typeof(T), 1);
|
||||
if (val is null)
|
||||
{
|
||||
_logger?.LogError("Failed to convert data from {Address}", address);
|
||||
return Result.Failure<T>("Conversion failed");
|
||||
|
||||
}
|
||||
|
||||
_logger?.LogDebug("Successfully read value from {Address}", address);
|
||||
return Result.Success((T)val);
|
||||
}
|
||||
|
||||
|
||||
public async Task<Result> WriteAsync<T>(string address, T value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
_logger?.LogWarning("Attempted to write null value to {Address}", address);
|
||||
return Result.Failure("Cannot write null value");
|
||||
}
|
||||
|
||||
_logger?.LogDebug("Writing to address {Address}", address);
|
||||
|
||||
int size = TypeHelper.GetSize(typeof(T));
|
||||
var bytes = _converter.ToBytes(value, typeof(T), size);
|
||||
|
||||
if (bytes.Length == 0)
|
||||
return Result.Failure("Conversion to bytes returned empty array. Type might not be supported.");
|
||||
|
||||
return await _driver.WriteBytesAsync(address, bytes);
|
||||
if (bytes.Length == 0)
|
||||
{
|
||||
_logger?.LogError("Conversion to bytes failed for type {Type}", typeof(T).Name);
|
||||
return Result.Failure("Conversion to bytes returned empty array. Type might not be supported.");
|
||||
}
|
||||
|
||||
var result = await _driver.WriteBytesAsync(address, bytes);
|
||||
|
||||
if (result.IsFailure)
|
||||
{
|
||||
_logger?.LogError("Failed to write to {Address}: {Error}", address, result.ErrorMessage);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.LogDebug("Successfully wrote to {Address}", address);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task MonitorLoop(CancellationToken token)
|
||||
{
|
||||
_logger?.LogInformation("Monitor loop started");
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 连接检查和重试逻辑
|
||||
if (!_driver.IsConnected)
|
||||
{
|
||||
await _driver.ConnectAsync(token);
|
||||
_logger?.LogWarning("PLC disconnected, attempting to reconnect (attempt {Attempt}/{Max})",
|
||||
_reconnectAttempts + 1, MaxReconnectAttempts);
|
||||
|
||||
// 指数退避策略
|
||||
var delay = Math.Min(1000 * Math.Pow(2, _reconnectAttempts), 30000);
|
||||
await Task.Delay((int)delay, token);
|
||||
|
||||
var connectResult = await _driver.ConnectAsync(token);
|
||||
if (connectResult.IsSuccess)
|
||||
{
|
||||
_logger?.LogInformation("Successfully reconnected to PLC");
|
||||
_reconnectAttempts = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
_reconnectAttempts++;
|
||||
_logger?.LogError("Reconnection failed: {Error}", connectResult.ErrorMessage);
|
||||
|
||||
if (_reconnectAttempts >= MaxReconnectAttempts)
|
||||
{
|
||||
_logger?.LogCritical("Max reconnection attempts reached, monitor loop will continue trying");
|
||||
_reconnectAttempts = 0; // 重置以继续尝试
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var currentTags = _subscriptions.Values.ToList();
|
||||
@@ -107,6 +214,8 @@ public class PlcMonitorService : IPlcService, IDisposable
|
||||
|
||||
// 优化:计算分桶
|
||||
var batches = _optimizer.Optimize(currentTags, _driver.MaxPduSize);
|
||||
_logger?.LogTrace("Optimized {TagCount} tags into {BatchCount} batches",
|
||||
currentTags.Count, batches.Count);
|
||||
|
||||
// 执行读取
|
||||
foreach (var batch in batches)
|
||||
@@ -114,55 +223,94 @@ public class PlcMonitorService : IPlcService, IDisposable
|
||||
if (token.IsCancellationRequested) break;
|
||||
|
||||
var readResult = await _driver.ReadBytesAsync(batch.StartAddress, (ushort)batch.TotalLength, token);
|
||||
|
||||
|
||||
if (readResult.IsSuccess && readResult.Value != null)
|
||||
{
|
||||
DistributeValues(batch, readResult.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 记录错误
|
||||
_logger?.LogWarning("Batch read failed for address {Address}: {Error}",
|
||||
batch.StartAddress, readResult.ErrorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 记录异常
|
||||
_logger?.LogDebug("Monitor loop cancelled");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Error in monitor loop");
|
||||
}
|
||||
|
||||
await Task.Delay(_refreshRate, token);
|
||||
}
|
||||
|
||||
_logger?.LogInformation("Monitor loop stopped");
|
||||
}
|
||||
|
||||
private void DistributeValues(PlcReadBatch batch, byte[] data)
|
||||
{
|
||||
dynamic batchStart = batch.ParsedStartAddress!;
|
||||
if (batch.ParsedStartAddress == null)
|
||||
{
|
||||
_logger?.LogError("Batch missing parsed start address");
|
||||
return;
|
||||
}
|
||||
|
||||
var batchStart = batch.ParsedStartAddress;
|
||||
int batchStartByte = batchStart.ByteOffset;
|
||||
|
||||
foreach (var tag in batch.Tags)
|
||||
{
|
||||
dynamic tagAddr = tag.ParsedAddress!;
|
||||
if (tag.ParsedAddress == null)
|
||||
{
|
||||
_logger?.LogWarning("Tag {TagId} missing parsed address", tag.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
var tagAddr = tag.ParsedAddress;
|
||||
int tagStartByte = tagAddr.ByteOffset;
|
||||
int relativeOffset = tagStartByte - batchStartByte;
|
||||
|
||||
|
||||
// 检查边界
|
||||
int typeSize = TypeHelper.GetSize(tag.DataType);
|
||||
int typeSize = tag.DataType == typeof(string)
|
||||
? TypeHelper.GetSize(tag.DataType, tag.Length)
|
||||
: TypeHelper.GetSize(tag.DataType);
|
||||
|
||||
if (relativeOffset + typeSize <= data.Length)
|
||||
{
|
||||
var newVal = _converter.Parse(data, relativeOffset, tag.DataType, tag.Length);
|
||||
|
||||
// 变更检测
|
||||
if (!Equals(tag.Value, newVal))
|
||||
try
|
||||
{
|
||||
tag.Value = newVal;
|
||||
tag.OnChanged?.Invoke(tag, newVal);
|
||||
// 传递位偏移量以支持bool类型
|
||||
var newVal = _converter.Parse(data, relativeOffset, tag.DataType, tag.Length, tagAddr.BitOffset);
|
||||
|
||||
// 变更检测
|
||||
if (!Equals(tag.Value, newVal))
|
||||
{
|
||||
tag.Value = newVal;
|
||||
tag.OnChanged?.Invoke(tag, newVal);
|
||||
_logger?.LogTrace("Tag {TagId} value changed to {Value}", tag.Id, newVal);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Failed to parse value for tag {TagId} at address {Address}",
|
||||
tag.Id, tag.Address);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.LogWarning("Tag {TagId} offset {Offset} + size {Size} exceeds batch data length {Length}",
|
||||
tag.Id, relativeOffset, typeSize, data.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_logger?.LogDebug("Disposing PLC monitor service");
|
||||
_cts?.Cancel();
|
||||
_driver.Dispose();
|
||||
}
|
||||
|
||||
@@ -2,14 +2,46 @@ namespace Plc.Utils;
|
||||
|
||||
public static class TypeHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取PLC数据类型的字节大小
|
||||
/// </summary>
|
||||
/// <param name="type">数据类型</param>
|
||||
/// <returns>字节大小</returns>
|
||||
/// <exception cref="ArgumentException">当类型不支持或需要明确长度时抛出</exception>
|
||||
public static int GetSize(Type type)
|
||||
{
|
||||
if (type == typeof(bool)) return 1; // S7 usually treats bool as bit, but allocation often aligns to bytes or we need spec handling
|
||||
if (type == typeof(bool)) return 1; // S7中bool按字节对齐
|
||||
if (type == typeof(byte)) return 1;
|
||||
if (type == typeof(short) || type == typeof(ushort)) return 2;
|
||||
if (type == typeof(int) || type == typeof(uint) || type == typeof(float)) return 4;
|
||||
if (type == typeof(long) || type == typeof(ulong) || type == typeof(double)) return 8;
|
||||
|
||||
return 1; // Default or throw
|
||||
|
||||
// 字符串类型需要明确指定长度
|
||||
if (type == typeof(string))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"String type requires explicit length specification. Use the overload with length parameter or specify length in PlcTag.",
|
||||
nameof(type));
|
||||
}
|
||||
|
||||
throw new NotSupportedException($"Type {type.Name} is not supported. Supported types: bool, byte, short, ushort, int, uint, float, long, ulong, double, string.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取PLC数据类型的字节大小(支持字符串长度)
|
||||
/// </summary>
|
||||
/// <param name="type">数据类型</param>
|
||||
/// <param name="length">长度(对字符串类型有效)</param>
|
||||
/// <returns>字节大小</returns>
|
||||
public static int GetSize(Type type, int length)
|
||||
{
|
||||
if (type == typeof(string))
|
||||
{
|
||||
// S7 String = 2字节头部 + 实际字符数据
|
||||
return 2 + length;
|
||||
}
|
||||
|
||||
// 对于非字符串类型,忽略length参数
|
||||
return GetSize(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Common.Results;
|
||||
using Ports.Models;
|
||||
|
||||
namespace Ports.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 串口驱动接口
|
||||
/// </summary>
|
||||
public interface ISerialPortDriver : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取串口是否已打开
|
||||
/// </summary>
|
||||
bool IsOpen { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取端口名称
|
||||
/// </summary>
|
||||
string? PortName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 打开串口连接
|
||||
/// </summary>
|
||||
/// <param name="options">配置选项</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>操作结果</returns>
|
||||
Task<Result> OpenAsync(SerialPortOptions options, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 关闭串口连接
|
||||
/// </summary>
|
||||
/// <returns>操作结果</returns>
|
||||
Task<Result> CloseAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定数量的字节
|
||||
/// </summary>
|
||||
/// <param name="count">读取数量</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>读取的数据</returns>
|
||||
Task<Result<byte[]>> ReadBytesAsync(int count, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 写入字节数据
|
||||
/// </summary>
|
||||
/// <param name="data">要写入的数据</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>操作结果</returns>
|
||||
Task<Result> WriteBytesAsync(byte[] data, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 读取一行数据(使用配置的换行符)
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>读取的字符串</returns>
|
||||
Task<Result<string>> ReadLineAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 写入一行数据
|
||||
/// </summary>
|
||||
/// <param name="data">要写入的数据</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>操作结果</returns>
|
||||
Task<Result> WriteLineAsync(string data, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 读取所有可用数据
|
||||
/// </summary>
|
||||
/// <returns>读取的数据</returns>
|
||||
Task<Result<byte[]>> ReadAvailableAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 清空输入缓冲区
|
||||
/// </summary>
|
||||
void ClearInputBuffer();
|
||||
|
||||
/// <summary>
|
||||
/// 清空输出缓冲区
|
||||
/// </summary>
|
||||
void ClearOutputBuffer();
|
||||
|
||||
/// <summary>
|
||||
/// 获取输入缓冲区中待读取的字节数
|
||||
/// </summary>
|
||||
int BytesToRead { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取输出缓冲区中待发送的字节数
|
||||
/// </summary>
|
||||
int BytesToWrite { get; }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Common.Results;
|
||||
using Ports.Models;
|
||||
|
||||
namespace Ports.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 串口服务接口(高级功能)
|
||||
/// </summary>
|
||||
public interface ISerialPortService : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取连接状态
|
||||
/// </summary>
|
||||
ConnectionState State { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取端口名称
|
||||
/// </summary>
|
||||
string? PortName { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 启动监听
|
||||
/// </summary>
|
||||
/// <param name="options">配置选项</param>
|
||||
/// <param name="autoReconnect">是否自动重连</param>
|
||||
/// <returns>操作结果</returns>
|
||||
Task<Result> StartListeningAsync(SerialPortOptions options, bool autoReconnect = false);
|
||||
|
||||
/// <summary>
|
||||
/// 停止监听
|
||||
/// </summary>
|
||||
/// <returns>操作结果</returns>
|
||||
Task<Result> StopListeningAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 发送数据(队列化)
|
||||
/// </summary>
|
||||
/// <param name="data">要发送的数据</param>
|
||||
/// <returns>操作结果</returns>
|
||||
Task<Result> SendAsync(byte[] data);
|
||||
|
||||
/// <summary>
|
||||
/// 发送并等待响应(请求-响应模式)
|
||||
/// </summary>
|
||||
/// <param name="data">要发送的数据</param>
|
||||
/// <param name="timeout">超时时间</param>
|
||||
/// <returns>响应数据</returns>
|
||||
Task<Result<byte[]>> SendAndReceiveAsync(byte[] data, TimeSpan timeout);
|
||||
|
||||
/// <summary>
|
||||
/// 数据接收事件
|
||||
/// </summary>
|
||||
event EventHandler<DataReceivedEventArgs>? DataReceived;
|
||||
|
||||
/// <summary>
|
||||
/// 错误事件
|
||||
/// </summary>
|
||||
event EventHandler<Models.ErrorEventArgs>? ErrorOccurred;
|
||||
|
||||
/// <summary>
|
||||
/// 连接状态变化事件
|
||||
/// </summary>
|
||||
event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Ports.Abstractions;
|
||||
using Ports.Drivers;
|
||||
using Ports.Services;
|
||||
|
||||
namespace Ports.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// 串口模块依赖注入扩展
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 添加串口驱动(单例)
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <returns>服务集合</returns>
|
||||
public static IServiceCollection AddSerialPortDriver(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ISerialPortDriver, SerialPortDriver>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加串口驱动(瞬时)
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <returns>服务集合</returns>
|
||||
public static IServiceCollection AddSerialPortDriverTransient(this IServiceCollection services)
|
||||
{
|
||||
services.AddTransient<ISerialPortDriver, SerialPortDriver>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加串口服务(单例)
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <returns>服务集合</returns>
|
||||
public static IServiceCollection AddSerialPortService(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<ISerialPortDriver, SerialPortDriver>();
|
||||
services.AddSingleton<ISerialPortService>(sp =>
|
||||
{
|
||||
var driver = sp.GetRequiredService<ISerialPortDriver>();
|
||||
return new SerialPortService(driver);
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加串口服务(瞬时)
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <returns>服务集合</returns>
|
||||
public static IServiceCollection AddSerialPortServiceTransient(this IServiceCollection services)
|
||||
{
|
||||
services.AddTransient<ISerialPortDriver, SerialPortDriver>();
|
||||
services.AddTransient<ISerialPortService>(sp =>
|
||||
{
|
||||
var driver = sp.GetRequiredService<ISerialPortDriver>();
|
||||
return new SerialPortService(driver);
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加命名串口服务(支持多实例)
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="name">服务名称</param>
|
||||
/// <returns>服务集合</returns>
|
||||
public static IServiceCollection AddNamedSerialPortService(this IServiceCollection services, string name)
|
||||
{
|
||||
services.AddKeyedTransient<ISerialPortDriver>(name, (sp, key) => new SerialPortDriver());
|
||||
services.AddKeyedTransient<ISerialPortService>(name, (sp, key) =>
|
||||
{
|
||||
var driver = sp.GetRequiredKeyedService<ISerialPortDriver>(key);
|
||||
return new SerialPortService(driver);
|
||||
});
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.IO.Ports;
|
||||
using System.Management;
|
||||
using Common.Results;
|
||||
using Ports.Models;
|
||||
|
||||
namespace Ports.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// 串口发现与查询工具
|
||||
/// </summary>
|
||||
public static class SerialPortDiscovery
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取所有可用串口
|
||||
/// </summary>
|
||||
/// <returns>串口信息列表</returns>
|
||||
public static Result<List<SerialPortInfo>> GetAvailablePorts()
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
var portNames = SerialPort.GetPortNames();
|
||||
var portInfoList = new List<SerialPortInfo>();
|
||||
|
||||
foreach (var portName in portNames)
|
||||
{
|
||||
var portInfo = GetPortInfo(portName);
|
||||
if (portInfo.IsSuccess && portInfo.Value != null)
|
||||
{
|
||||
portInfoList.Add(portInfo.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果无法获取详细信息,至少添加基本信息
|
||||
portInfoList.Add(new SerialPortInfo
|
||||
{
|
||||
PortName = portName,
|
||||
Description = "Unknown",
|
||||
IsInUse = IsPortInUse(portName).Value
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return portInfoList.OrderBy(p => p.PortName).ToList();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查指定串口是否存在
|
||||
/// </summary>
|
||||
/// <param name="portName">端口名称</param>
|
||||
/// <returns>是否存在</returns>
|
||||
public static Result<bool> PortExists(string portName)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(portName))
|
||||
return false;
|
||||
|
||||
var availablePorts = SerialPort.GetPortNames();
|
||||
return availablePorts.Contains(portName, StringComparer.OrdinalIgnoreCase);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查串口是否被占用
|
||||
/// </summary>
|
||||
/// <param name="portName">端口名称</param>
|
||||
/// <returns>是否被占用</returns>
|
||||
public static Result<bool> IsPortInUse(string portName)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(portName))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
using var port = new SerialPort(portName);
|
||||
port.Open();
|
||||
port.Close();
|
||||
return false; // 能够打开说明没有被占用
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return true; // 无权访问说明被占用
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return true; // IO异常也可能是被占用
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false; // 其他异常认为不是被占用
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取串口详细信息
|
||||
/// </summary>
|
||||
/// <param name="portName">端口名称</param>
|
||||
/// <returns>串口信息</returns>
|
||||
public static Result<SerialPortInfo> GetPortInfo(string portName)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
var portInfo = new SerialPortInfo
|
||||
{
|
||||
PortName = portName,
|
||||
IsInUse = IsPortInUse(portName).Value
|
||||
};
|
||||
|
||||
// 仅在Windows平台获取详细信息
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
EnrichPortInfoFromWmi(portInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 如果WMI查询失败,返回基本信息
|
||||
}
|
||||
}
|
||||
|
||||
return portInfo;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取USB转串口设备
|
||||
/// </summary>
|
||||
/// <returns>USB串口列表</returns>
|
||||
public static Result<List<SerialPortInfo>> GetUsbSerialPorts()
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
var allPorts = GetAvailablePorts();
|
||||
if (allPorts.IsFailure || allPorts.Value == null)
|
||||
return new List<SerialPortInfo>();
|
||||
|
||||
return allPorts.Value
|
||||
.Where(p => p.IsUsbSerial)
|
||||
.ToList();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过WMI获取串口详细信息(仅Windows)
|
||||
/// </summary>
|
||||
private static void EnrichPortInfoFromWmi(SerialPortInfo portInfo)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
"SELECT * FROM Win32_PnPEntity WHERE Name LIKE '%(COM%'");
|
||||
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var name = obj["Name"]?.ToString() ?? string.Empty;
|
||||
|
||||
// 检查是否是当前端口
|
||||
if (!name.Contains(portInfo.PortName, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
portInfo.Description = name;
|
||||
portInfo.DeviceId = obj["DeviceID"]?.ToString() ?? string.Empty;
|
||||
portInfo.Manufacturer = obj["Manufacturer"]?.ToString() ?? string.Empty;
|
||||
|
||||
// 判断是否为USB设备
|
||||
var deviceId = portInfo.DeviceId.ToUpperInvariant();
|
||||
portInfo.IsUsbSerial = deviceId.Contains("USB");
|
||||
|
||||
// 提取VID和PID
|
||||
if (portInfo.IsUsbSerial)
|
||||
{
|
||||
ExtractVidPid(portInfo.DeviceId, out var vid, out var pid);
|
||||
portInfo.VendorId = vid;
|
||||
portInfo.ProductId = pid;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// WMI查询失败时忽略
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从设备ID中提取VID和PID
|
||||
/// </summary>
|
||||
private static void ExtractVidPid(string deviceId, out string vid, out string pid)
|
||||
{
|
||||
vid = string.Empty;
|
||||
pid = string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(deviceId))
|
||||
return;
|
||||
|
||||
// 格式示例: USB\VID_0403&PID_6001\...
|
||||
var vidIndex = deviceId.IndexOf("VID_", StringComparison.OrdinalIgnoreCase);
|
||||
var pidIndex = deviceId.IndexOf("PID_", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (vidIndex >= 0)
|
||||
{
|
||||
var vidStart = vidIndex + 4;
|
||||
var vidEnd = deviceId.IndexOf('&', vidStart);
|
||||
if (vidEnd < 0) vidEnd = deviceId.IndexOf('\\', vidStart);
|
||||
if (vidEnd < 0) vidEnd = deviceId.Length;
|
||||
|
||||
vid = deviceId.Substring(vidStart, vidEnd - vidStart);
|
||||
}
|
||||
|
||||
if (pidIndex >= 0)
|
||||
{
|
||||
var pidStart = pidIndex + 4;
|
||||
var pidEnd = deviceId.IndexOf('&', pidStart);
|
||||
if (pidEnd < 0) pidEnd = deviceId.IndexOf('\\', pidStart);
|
||||
if (pidEnd < 0) pidEnd = deviceId.Length;
|
||||
|
||||
pid = deviceId.Substring(pidStart, pidEnd - pidStart);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
using System.IO.Ports;
|
||||
using Common.Results;
|
||||
using Ports.Abstractions;
|
||||
using Ports.Models;
|
||||
|
||||
namespace Ports.Drivers;
|
||||
|
||||
/// <summary>
|
||||
/// 串口驱动实现
|
||||
/// </summary>
|
||||
public class SerialPortDriver : ISerialPortDriver
|
||||
{
|
||||
private SerialPort? _serialPort;
|
||||
private SerialPortOptions? _options;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsOpen => _serialPort?.IsOpen ?? false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? PortName => _serialPort?.PortName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int BytesToRead => _serialPort?.BytesToRead ?? 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int BytesToWrite => _serialPort?.BytesToWrite ?? 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> OpenAsync(SerialPortOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options == null)
|
||||
return Result.Failure("配置选项不能为空");
|
||||
|
||||
await _lock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (IsOpen)
|
||||
return Result.Failure("串口已经打开");
|
||||
|
||||
// 验证配置
|
||||
var validation = ValidateOptions(options);
|
||||
if (validation.IsFailure)
|
||||
return validation;
|
||||
|
||||
_options = options;
|
||||
_serialPort = new SerialPort
|
||||
{
|
||||
PortName = options.PortName,
|
||||
BaudRate = options.BaudRate,
|
||||
DataBits = options.DataBits,
|
||||
StopBits = options.StopBits,
|
||||
Parity = options.Parity,
|
||||
Handshake = options.Handshake,
|
||||
ReadTimeout = options.ReadTimeout,
|
||||
WriteTimeout = options.WriteTimeout,
|
||||
Encoding = options.Encoding,
|
||||
NewLine = options.NewLine,
|
||||
ReadBufferSize = options.ReceiveBufferSize,
|
||||
WriteBufferSize = options.TransmitBufferSize
|
||||
};
|
||||
|
||||
return await Result.TryAsync(async () =>
|
||||
{
|
||||
await Task.Run(() => _serialPort.Open(), cancellationToken);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> CloseAsync()
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!IsOpen)
|
||||
return Result.Success();
|
||||
|
||||
return await Result.TryAsync(async () =>
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
_serialPort?.Close();
|
||||
_serialPort?.Dispose();
|
||||
_serialPort = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<byte[]>> ReadBytesAsync(int count, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsOpen || _serialPort == null)
|
||||
return Result.Failure<byte[]>("串口未打开");
|
||||
|
||||
if (count <= 0)
|
||||
return Result.Failure<byte[]>("读取数量必须大于0");
|
||||
|
||||
return await Result.TryAsync(async () =>
|
||||
{
|
||||
var buffer = new byte[count];
|
||||
var totalRead = 0;
|
||||
|
||||
while (totalRead < count && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var bytesToRead = Math.Min(count - totalRead, _serialPort.BytesToRead);
|
||||
if (bytesToRead > 0)
|
||||
{
|
||||
var read = await Task.Run(() =>
|
||||
_serialPort.Read(buffer, totalRead, bytesToRead), cancellationToken);
|
||||
totalRead += read;
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.Delay(10, cancellationToken); // 短暂等待数据
|
||||
}
|
||||
}
|
||||
|
||||
if (totalRead < count)
|
||||
{
|
||||
var actualData = new byte[totalRead];
|
||||
Array.Copy(buffer, actualData, totalRead);
|
||||
return actualData;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> WriteBytesAsync(byte[] data, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsOpen || _serialPort == null)
|
||||
return Result.Failure("串口未打开");
|
||||
|
||||
if (data == null || data.Length == 0)
|
||||
return Result.Failure("数据不能为空");
|
||||
|
||||
return await Result.TryAsync(async () =>
|
||||
{
|
||||
await Task.Run(() => _serialPort.Write(data, 0, data.Length), cancellationToken);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<string>> ReadLineAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsOpen || _serialPort == null)
|
||||
return Result.Failure<string>("串口未打开");
|
||||
|
||||
return await Result.TryAsync(async () =>
|
||||
{
|
||||
return await Task.Run(() => _serialPort.ReadLine(), cancellationToken);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> WriteLineAsync(string data, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsOpen || _serialPort == null)
|
||||
return Result.Failure("串口未打开");
|
||||
|
||||
if (data == null)
|
||||
return Result.Failure("数据不能为空");
|
||||
|
||||
return await Result.TryAsync(async () =>
|
||||
{
|
||||
await Task.Run(() => _serialPort.WriteLine(data), cancellationToken);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<byte[]>> ReadAvailableAsync()
|
||||
{
|
||||
if (!IsOpen || _serialPort == null)
|
||||
return Result.Failure<byte[]>("串口未打开");
|
||||
|
||||
return await Result.TryAsync(async () =>
|
||||
{
|
||||
var count = _serialPort.BytesToRead;
|
||||
if (count == 0)
|
||||
return Array.Empty<byte>();
|
||||
|
||||
var buffer = new byte[count];
|
||||
await Task.Run(() => _serialPort.Read(buffer, 0, count));
|
||||
return buffer;
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearInputBuffer()
|
||||
{
|
||||
if (IsOpen && _serialPort != null)
|
||||
{
|
||||
_serialPort.DiscardInBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearOutputBuffer()
|
||||
{
|
||||
if (IsOpen && _serialPort != null)
|
||||
{
|
||||
_serialPort.DiscardOutBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证配置选项
|
||||
/// </summary>
|
||||
private Result ValidateOptions(SerialPortOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.PortName))
|
||||
return Result.Failure("端口名称不能为空");
|
||||
|
||||
if (options.BaudRate <= 0)
|
||||
return Result.Failure("波特率必须大于0");
|
||||
|
||||
if (options.DataBits < 5 || options.DataBits > 8)
|
||||
return Result.Failure("数据位必须在5-8之间");
|
||||
|
||||
if (options.ReadTimeout < 0)
|
||||
return Result.Failure("读取超时不能为负数");
|
||||
|
||||
if (options.WriteTimeout < 0)
|
||||
return Result.Failure("写入超时不能为负数");
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_lock.Wait();
|
||||
try
|
||||
{
|
||||
_serialPort?.Close();
|
||||
_serialPort?.Dispose();
|
||||
_serialPort = null;
|
||||
_disposed = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
_lock.Dispose();
|
||||
}
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using Ports.Discovery;
|
||||
using Ports.Drivers;
|
||||
using Ports.Models;
|
||||
using Ports.Services;
|
||||
using Ports.Utils;
|
||||
|
||||
namespace Ports.Examples;
|
||||
|
||||
/// <summary>
|
||||
/// 串口通信模块使用示例
|
||||
/// </summary>
|
||||
public class SerialPortExamples
|
||||
{
|
||||
/// <summary>
|
||||
/// 示例1:发现串口
|
||||
/// </summary>
|
||||
public static void Example01_DiscoverPorts()
|
||||
{
|
||||
Console.WriteLine("=== 示例1:发现串口 ===\n");
|
||||
|
||||
// 获取所有可用串口
|
||||
var portsResult = SerialPortDiscovery.GetAvailablePorts();
|
||||
if (portsResult.IsSuccess && portsResult.Value != null)
|
||||
{
|
||||
Console.WriteLine($"找到 {portsResult.Value.Count} 个串口:\n");
|
||||
|
||||
foreach (var port in portsResult.Value)
|
||||
{
|
||||
Console.WriteLine($"端口: {port.PortName}");
|
||||
Console.WriteLine($" 描述: {port.Description}");
|
||||
Console.WriteLine($" 制造商: {port.Manufacturer}");
|
||||
Console.WriteLine($" USB设备: {port.IsUsbSerial}");
|
||||
Console.WriteLine($" 被占用: {port.IsInUse}");
|
||||
if (port.IsUsbSerial)
|
||||
{
|
||||
Console.WriteLine($" VID: {port.VendorId}, PID: {port.ProductId}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 示例2:使用驱动层直接通信
|
||||
/// </summary>
|
||||
public static async Task Example02_UseDriver()
|
||||
{
|
||||
Console.WriteLine("=== 示例2:使用驱动层通信 ===\n");
|
||||
|
||||
using var driver = new SerialPortDriver();
|
||||
|
||||
var options = new SerialPortOptions
|
||||
{
|
||||
PortName = "COM1",
|
||||
BaudRate = 9600,
|
||||
DataBits = 8
|
||||
};
|
||||
|
||||
// 打开连接
|
||||
var openResult = await driver.OpenAsync(options);
|
||||
if (openResult.IsSuccess)
|
||||
{
|
||||
Console.WriteLine("串口打开成功");
|
||||
|
||||
// 发送数据
|
||||
var data = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
|
||||
var sendResult = await driver.WriteBytesAsync(data);
|
||||
|
||||
if (sendResult.IsSuccess)
|
||||
{
|
||||
Console.WriteLine("数据发送成功");
|
||||
|
||||
// 等待响应
|
||||
await Task.Delay(100);
|
||||
|
||||
// 读取响应
|
||||
if (driver.BytesToRead > 0)
|
||||
{
|
||||
var readResult = await driver.ReadAvailableAsync();
|
||||
if (readResult.IsSuccess && readResult.Value != null)
|
||||
{
|
||||
Console.WriteLine($"接收到 {readResult.Value.Length} 字节");
|
||||
var hexResult = SerialDataConverter.BytesToHexString(readResult.Value);
|
||||
Console.WriteLine($"数据: {hexResult.Value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await driver.CloseAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"打开串口失败: {openResult.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 示例3:使用服务层监听数据
|
||||
/// </summary>
|
||||
public static async Task Example03_UseService()
|
||||
{
|
||||
Console.WriteLine("=== 示例3:使用服务层监听 ===\n");
|
||||
|
||||
using var service = new SerialPortService(new SerialPortDriver());
|
||||
|
||||
// 订阅事件
|
||||
service.DataReceived += (sender, e) =>
|
||||
{
|
||||
Console.WriteLine($"[{e.ReceivedAt:HH:mm:ss}] 收到数据: {e.Data.Length} 字节");
|
||||
var hex = SerialDataConverter.BytesToHexString(e.Data);
|
||||
Console.WriteLine($" 数据: {hex.Value}");
|
||||
};
|
||||
|
||||
service.ErrorOccurred += (sender, e) =>
|
||||
{
|
||||
Console.WriteLine($"[错误] {e.ErrorMessage}");
|
||||
};
|
||||
|
||||
service.ConnectionStateChanged += (sender, e) =>
|
||||
{
|
||||
Console.WriteLine($"[状态] {e.PreviousState} -> {e.CurrentState}");
|
||||
};
|
||||
|
||||
var options = new SerialPortOptions
|
||||
{
|
||||
PortName = "COM1",
|
||||
BaudRate = 9600
|
||||
};
|
||||
|
||||
// 启动监听(支持自动重连)
|
||||
var startResult = await service.StartListeningAsync(options, autoReconnect: true);
|
||||
|
||||
if (startResult.IsSuccess)
|
||||
{
|
||||
Console.WriteLine("监听已启动,按任意键发送测试数据...");
|
||||
Console.ReadKey();
|
||||
|
||||
// 发送测试数据
|
||||
await service.SendAsync(new byte[] { 0x01, 0x02, 0x03 });
|
||||
|
||||
Console.WriteLine("按任意键停止监听...");
|
||||
Console.ReadKey();
|
||||
|
||||
await service.StopListeningAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 示例4:数据转换
|
||||
/// </summary>
|
||||
public static void Example04_DataConversion()
|
||||
{
|
||||
Console.WriteLine("=== 示例4:数据转换 ===\n");
|
||||
|
||||
// 整数转字节
|
||||
var int32Bytes = SerialDataConverter.Int32ToBytes(12345, isBigEndian: true);
|
||||
Console.WriteLine($"整数 12345 -> 字节: {SerialDataConverter.BytesToHexString(int32Bytes.Value!).Value}");
|
||||
|
||||
// 字节转整数
|
||||
var intValue = SerialDataConverter.BytesToInt32(int32Bytes.Value!, isBigEndian: true);
|
||||
Console.WriteLine($"字节 -> 整数: {intValue.Value}");
|
||||
|
||||
// 十六进制字符串
|
||||
var hexBytes = SerialDataConverter.HexStringToBytes("01 02 03 04");
|
||||
Console.WriteLine($"十六进制 '01 02 03 04' -> 字节数组: {hexBytes.Value!.Length} 字节");
|
||||
|
||||
// 字符串转换
|
||||
var strBytes = SerialDataConverter.StringToBytes("Hello");
|
||||
Console.WriteLine($"字符串 'Hello' -> 字节: {SerialDataConverter.BytesToHexString(strBytes.Value!).Value}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 示例5:校验计算
|
||||
/// </summary>
|
||||
public static void Example05_Checksum()
|
||||
{
|
||||
Console.WriteLine("=== 示例5:校验计算 ===\n");
|
||||
|
||||
var data = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
|
||||
|
||||
// CRC16(Modbus)
|
||||
var crc16 = SerialChecksumHelper.CalculateCrc16(data);
|
||||
Console.WriteLine($"CRC16: 0x{crc16.Value:X4}");
|
||||
|
||||
// LRC
|
||||
var lrc = SerialChecksumHelper.CalculateLrc(data);
|
||||
Console.WriteLine($"LRC: 0x{lrc.Value:X2}");
|
||||
|
||||
// XOR
|
||||
var xor = SerialChecksumHelper.CalculateXor(data);
|
||||
Console.WriteLine($"XOR: 0x{xor.Value:X2}");
|
||||
|
||||
// 累加和
|
||||
var checksum = SerialChecksumHelper.CalculateChecksum(data);
|
||||
Console.WriteLine($"Checksum: 0x{checksum.Value:X2}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 示例6:诊断工具
|
||||
/// </summary>
|
||||
public static async Task Example06_Diagnostics()
|
||||
{
|
||||
Console.WriteLine("=== 示例6:诊断工具 ===\n");
|
||||
|
||||
var options = new SerialPortOptions
|
||||
{
|
||||
PortName = "COM1",
|
||||
BaudRate = 9600
|
||||
};
|
||||
|
||||
// 测试连接
|
||||
var testResult = await SerialPortDiagnostics.TestConnectionAsync(
|
||||
"COM1",
|
||||
options,
|
||||
testData: new byte[] { 0x01, 0x02 }
|
||||
);
|
||||
|
||||
if (testResult.IsSuccess)
|
||||
{
|
||||
Console.WriteLine("连接测试结果:");
|
||||
Console.WriteLine(testResult.Value);
|
||||
}
|
||||
|
||||
// 批量测试波特率
|
||||
var baudRateResults = await SerialPortDiagnostics.TestBaudRatesAsync(
|
||||
"COM1",
|
||||
9600, 19200, 38400, 115200
|
||||
);
|
||||
|
||||
if (baudRateResults.IsSuccess)
|
||||
{
|
||||
Console.WriteLine("\n波特率测试结果:");
|
||||
foreach (var kvp in baudRateResults.Value!)
|
||||
{
|
||||
Console.WriteLine($" {kvp.Key} bps: {(kvp.Value ? "✓" : "✗")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// 全局命名空间引入
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Linq;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace Ports.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 连接状态
|
||||
/// </summary>
|
||||
public enum ConnectionState
|
||||
{
|
||||
/// <summary>
|
||||
/// 已断开
|
||||
/// </summary>
|
||||
Disconnected,
|
||||
|
||||
/// <summary>
|
||||
/// 连接中
|
||||
/// </summary>
|
||||
Connecting,
|
||||
|
||||
/// <summary>
|
||||
/// 已连接
|
||||
/// </summary>
|
||||
Connected,
|
||||
|
||||
/// <summary>
|
||||
/// 断开连接中
|
||||
/// </summary>
|
||||
Disconnecting,
|
||||
|
||||
/// <summary>
|
||||
/// 错误
|
||||
/// </summary>
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 连接状态变化事件参数
|
||||
/// </summary>
|
||||
public class ConnectionStateChangedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// 之前的状态
|
||||
/// </summary>
|
||||
public ConnectionState PreviousState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前状态
|
||||
/// </summary>
|
||||
public ConnectionState CurrentState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 变化时间
|
||||
/// </summary>
|
||||
public DateTime ChangedAt { get; init; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 端口名称
|
||||
/// </summary>
|
||||
public required string PortName { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Ports.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 数据接收事件参数
|
||||
/// </summary>
|
||||
public class DataReceivedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// 接收到的数据
|
||||
/// </summary>
|
||||
public required byte[] Data { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 接收时间
|
||||
/// </summary>
|
||||
public DateTime ReceivedAt { get; init; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 端口名称
|
||||
/// </summary>
|
||||
public required string PortName { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Ports.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 错误事件参数
|
||||
/// </summary>
|
||||
public class ErrorEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// 异常信息
|
||||
/// </summary>
|
||||
public required Exception Exception { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 错误消息
|
||||
/// </summary>
|
||||
public string ErrorMessage => Exception.Message;
|
||||
|
||||
/// <summary>
|
||||
/// 发生时间
|
||||
/// </summary>
|
||||
public DateTime OccurredAt { get; init; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 端口名称
|
||||
/// </summary>
|
||||
public required string PortName { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Ports.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 串口信息模型
|
||||
/// </summary>
|
||||
public class SerialPortInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 端口名称(如 COM1, COM2)
|
||||
/// </summary>
|
||||
public required string PortName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 端口描述
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 制造商
|
||||
/// </summary>
|
||||
public string Manufacturer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 设备ID
|
||||
/// </summary>
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否被占用
|
||||
/// </summary>
|
||||
public bool IsInUse { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 厂商ID(VID)
|
||||
/// </summary>
|
||||
public string VendorId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 产品ID(PID)
|
||||
/// </summary>
|
||||
public string ProductId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 是否为USB转串口设备
|
||||
/// </summary>
|
||||
public bool IsUsbSerial { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.IO.Ports;
|
||||
using System.Text;
|
||||
|
||||
namespace Ports.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 串口配置选项
|
||||
/// </summary>
|
||||
public class SerialPortOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 端口名称(必填)
|
||||
/// </summary>
|
||||
public required string PortName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 波特率(默认 9600)
|
||||
/// </summary>
|
||||
public int BaudRate { get; set; } = 9600;
|
||||
|
||||
/// <summary>
|
||||
/// 数据位(默认 8)
|
||||
/// </summary>
|
||||
public int DataBits { get; set; } = 8;
|
||||
|
||||
/// <summary>
|
||||
/// 停止位(默认 One)
|
||||
/// </summary>
|
||||
public StopBits StopBits { get; set; } = StopBits.One;
|
||||
|
||||
/// <summary>
|
||||
/// 校验位(默认 None)
|
||||
/// </summary>
|
||||
public Parity Parity { get; set; } = Parity.None;
|
||||
|
||||
/// <summary>
|
||||
/// 流控制(默认 None)
|
||||
/// </summary>
|
||||
public Handshake Handshake { get; set; } = Handshake.None;
|
||||
|
||||
/// <summary>
|
||||
/// 读取超时(毫秒,默认 500)
|
||||
/// </summary>
|
||||
public int ReadTimeout { get; set; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// 写入超时(毫秒,默认 500)
|
||||
/// </summary>
|
||||
public int WriteTimeout { get; set; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// 字符编码(默认 UTF8)
|
||||
/// </summary>
|
||||
public Encoding Encoding { get; set; } = Encoding.UTF8;
|
||||
|
||||
/// <summary>
|
||||
/// 换行符(默认 \r\n)
|
||||
/// </summary>
|
||||
public string NewLine { get; set; } = "\r\n";
|
||||
|
||||
/// <summary>
|
||||
/// 接收缓冲区大小(字节,默认 4096)
|
||||
/// </summary>
|
||||
public int ReceiveBufferSize { get; set; } = 4096;
|
||||
|
||||
/// <summary>
|
||||
/// 发送缓冲区大小(字节,默认 2048)
|
||||
/// </summary>
|
||||
public int TransmitBufferSize { get; set; } = 2048;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Ports</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
|
||||
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,300 @@
|
||||
# Ports - 串口通信模块
|
||||
|
||||
通用的串口通信基础设施模块,提供发现、连接、数据传输和诊断等完整能力。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 第一层:发现与查询
|
||||
- 枚举所有可用串口
|
||||
- 检查串口是否存在和被占用
|
||||
- 获取串口详细信息(描述、厂商、VID/PID)
|
||||
- 智能识别USB转串口设备
|
||||
|
||||
### 第二层:连接与通信
|
||||
- 打开/关闭串口连接
|
||||
- 读写字节数据
|
||||
- 读写行数据
|
||||
- 缓冲区管理
|
||||
- 超时控制
|
||||
|
||||
### 第三层:高级功能
|
||||
- 自动监听数据
|
||||
- 自动重连机制
|
||||
- 数据队列发送
|
||||
- 请求-响应模式
|
||||
- 事件通知(数据接收、错误、状态变化)
|
||||
|
||||
### 第四层:工具扩展
|
||||
- 数据转换(字节↔基本类型、十六进制)
|
||||
- 校验计算(CRC16、LRC、XOR、累加和)
|
||||
- 诊断工具(连接测试、流量监控、波特率测试)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 发现串口
|
||||
|
||||
```csharp
|
||||
using Ports.Discovery;
|
||||
|
||||
// 获取所有串口
|
||||
var portsResult = SerialPortDiscovery.GetAvailablePorts();
|
||||
if (portsResult.IsSuccess)
|
||||
{
|
||||
foreach (var port in portsResult.Value)
|
||||
{
|
||||
Console.WriteLine($"{port.PortName}: {port.Description}");
|
||||
Console.WriteLine($" 制造商: {port.Manufacturer}");
|
||||
Console.WriteLine($" 被占用: {port.IsInUse}");
|
||||
}
|
||||
}
|
||||
|
||||
// 获取USB串口
|
||||
var usbPortsResult = SerialPortDiscovery.GetUsbSerialPorts();
|
||||
|
||||
// 检查端口是否存在
|
||||
var existsResult = SerialPortDiscovery.PortExists("COM1");
|
||||
```
|
||||
|
||||
### 2. 使用驱动层(低级API)
|
||||
|
||||
```csharp
|
||||
using Ports.Abstractions;
|
||||
using Ports.Drivers;
|
||||
using Ports.Models;
|
||||
|
||||
// 创建驱动实例
|
||||
using var driver = new SerialPortDriver();
|
||||
|
||||
// 配置串口
|
||||
var options = new SerialPortOptions
|
||||
{
|
||||
PortName = "COM1",
|
||||
BaudRate = 9600,
|
||||
DataBits = 8,
|
||||
StopBits = StopBits.One,
|
||||
Parity = Parity.None
|
||||
};
|
||||
|
||||
// 打开连接
|
||||
var openResult = await driver.OpenAsync(options);
|
||||
if (openResult.IsSuccess)
|
||||
{
|
||||
// 发送数据
|
||||
var data = new byte[] { 0x01, 0x02, 0x03 };
|
||||
await driver.WriteBytesAsync(data);
|
||||
|
||||
// 读取数据
|
||||
var readResult = await driver.ReadBytesAsync(10);
|
||||
if (readResult.IsSuccess)
|
||||
{
|
||||
Console.WriteLine($"读取到 {readResult.Value.Length} 字节");
|
||||
}
|
||||
|
||||
// 关闭连接
|
||||
await driver.CloseAsync();
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 使用服务层(高级API)
|
||||
|
||||
```csharp
|
||||
using Ports.Services;
|
||||
|
||||
// 创建服务实例
|
||||
using var service = new SerialPortService(new SerialPortDriver());
|
||||
|
||||
// 订阅事件
|
||||
service.DataReceived += (sender, e) =>
|
||||
{
|
||||
Console.WriteLine($"接收到数据: {e.Data.Length} 字节");
|
||||
};
|
||||
|
||||
service.ErrorOccurred += (sender, e) =>
|
||||
{
|
||||
Console.WriteLine($"错误: {e.ErrorMessage}");
|
||||
};
|
||||
|
||||
service.ConnectionStateChanged += (sender, e) =>
|
||||
{
|
||||
Console.WriteLine($"状态变化: {e.PreviousState} -> {e.CurrentState}");
|
||||
};
|
||||
|
||||
// 启动监听(支持自动重连)
|
||||
var options = new SerialPortOptions { PortName = "COM1" };
|
||||
await service.StartListeningAsync(options, autoReconnect: true);
|
||||
|
||||
// 发送数据
|
||||
await service.SendAsync(new byte[] { 0x01, 0x02 });
|
||||
|
||||
// 发送并等待响应
|
||||
var response = await service.SendAndReceiveAsync(
|
||||
new byte[] { 0x01, 0x03 },
|
||||
TimeSpan.FromSeconds(5)
|
||||
);
|
||||
|
||||
// 停止监听
|
||||
await service.StopListeningAsync();
|
||||
```
|
||||
|
||||
### 4. 使用依赖注入
|
||||
|
||||
```csharp
|
||||
using Ports.DependencyInjection;
|
||||
|
||||
// 在Startup或Program.cs中注册
|
||||
services.AddSerialPortService(); // 单例模式
|
||||
|
||||
// 或注册瞬时模式
|
||||
services.AddSerialPortServiceTransient();
|
||||
|
||||
// 或注册命名服务(支持多实例)
|
||||
services.AddNamedSerialPortService("Port1");
|
||||
services.AddNamedSerialPortService("Port2");
|
||||
|
||||
// 在类中注入使用
|
||||
public class MyService
|
||||
{
|
||||
private readonly ISerialPortService _serialPort;
|
||||
|
||||
public MyService(ISerialPortService serialPort)
|
||||
{
|
||||
_serialPort = serialPort;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 数据转换
|
||||
|
||||
```csharp
|
||||
using Ports.Utils;
|
||||
|
||||
// 字节转整数
|
||||
var int32Result = SerialDataConverter.BytesToInt32(bytes, isBigEndian: true);
|
||||
|
||||
// 整数转字节
|
||||
var bytesResult = SerialDataConverter.Int32ToBytes(12345);
|
||||
|
||||
// 十六进制转换
|
||||
var hexResult = SerialDataConverter.BytesToHexString(bytes, separator: " ");
|
||||
var bytesFromHex = SerialDataConverter.HexStringToBytes("01 02 03");
|
||||
|
||||
// 字符串转换
|
||||
var strBytes = SerialDataConverter.StringToBytes("Hello", Encoding.ASCII);
|
||||
```
|
||||
|
||||
### 6. 校验计算
|
||||
|
||||
```csharp
|
||||
using Ports.Utils;
|
||||
|
||||
var data = new byte[] { 0x01, 0x02, 0x03 };
|
||||
|
||||
// CRC16(Modbus)
|
||||
var crc16 = SerialChecksumHelper.CalculateCrc16(data);
|
||||
|
||||
// LRC
|
||||
var lrc = SerialChecksumHelper.CalculateLrc(data);
|
||||
|
||||
// XOR
|
||||
var xor = SerialChecksumHelper.CalculateXor(data);
|
||||
|
||||
// 验证校验码
|
||||
var isValid = SerialChecksumHelper.VerifyCrc16(dataWithCrc);
|
||||
```
|
||||
|
||||
### 7. 诊断工具
|
||||
|
||||
```csharp
|
||||
using Ports.Utils;
|
||||
|
||||
// 测试连接
|
||||
var testResult = await SerialPortDiagnostics.TestConnectionAsync(
|
||||
"COM1",
|
||||
options,
|
||||
testData: new byte[] { 0x01 }
|
||||
);
|
||||
Console.WriteLine(testResult.Value);
|
||||
|
||||
// 监控流量
|
||||
var trafficResult = await SerialPortDiagnostics.MonitorTrafficAsync(
|
||||
"COM1",
|
||||
options,
|
||||
onData: (data, isSent) => Console.WriteLine($"{(isSent ? "发送" : "接收")}: {data.Length} 字节"),
|
||||
duration: TimeSpan.FromSeconds(10)
|
||||
);
|
||||
Console.WriteLine(trafficResult.Value);
|
||||
|
||||
// 批量测试波特率
|
||||
var baudRateResults = await SerialPortDiagnostics.TestBaudRatesAsync(
|
||||
"COM1",
|
||||
9600, 19200, 38400, 115200
|
||||
);
|
||||
```
|
||||
|
||||
## 架构设计
|
||||
|
||||
```
|
||||
Ports/
|
||||
├── Models/ # 数据模型
|
||||
│ ├── SerialPortInfo
|
||||
│ ├── SerialPortOptions
|
||||
│ └── EventArgs
|
||||
├── Abstractions/ # 接口定义
|
||||
│ ├── ISerialPortDriver
|
||||
│ └── ISerialPortService
|
||||
├── Discovery/ # 串口发现
|
||||
│ └── SerialPortDiscovery
|
||||
├── Drivers/ # 驱动层实现
|
||||
│ └── SerialPortDriver
|
||||
├── Services/ # 服务层实现
|
||||
│ └── SerialPortService
|
||||
├── Utils/ # 工具类
|
||||
│ ├── SerialDataConverter
|
||||
│ ├── SerialChecksumHelper
|
||||
│ └── SerialPortDiagnostics
|
||||
└── DependencyInjection/ # DI扩展
|
||||
└── ServiceCollectionExtensions
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用 Result 模式处理错误**
|
||||
```csharp
|
||||
var result = await driver.OpenAsync(options);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
Console.WriteLine($"错误: {result.ErrorMessage}");
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
2. **始终释放资源**
|
||||
```csharp
|
||||
using var driver = new SerialPortDriver();
|
||||
// 使用完自动释放
|
||||
```
|
||||
|
||||
3. **处理自动重连**
|
||||
```csharp
|
||||
await service.StartListeningAsync(options, autoReconnect: true);
|
||||
// 连接断开时会自动尝试重连
|
||||
```
|
||||
|
||||
4. **订阅事件监听数据**
|
||||
```csharp
|
||||
service.DataReceived += (s, e) => ProcessData(e.Data);
|
||||
service.ErrorOccurred += (s, e) => LogError(e.Exception);
|
||||
```
|
||||
|
||||
## 平台支持
|
||||
|
||||
- Windows: 完整支持(包括WMI设备信息查询)
|
||||
- Linux: 支持基础功能
|
||||
- macOS: 支持基础功能
|
||||
|
||||
## 依赖项
|
||||
|
||||
- .NET 8.0+
|
||||
- System.IO.Ports
|
||||
- System.Management (仅Windows)
|
||||
- Common (项目内部依赖)
|
||||
@@ -0,0 +1,391 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO.Ports;
|
||||
using Common.Results;
|
||||
using Ports.Abstractions;
|
||||
using Ports.Models;
|
||||
|
||||
namespace Ports.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 串口服务实现
|
||||
/// </summary>
|
||||
public class SerialPortService : ISerialPortService
|
||||
{
|
||||
private readonly ISerialPortDriver _driver;
|
||||
private SerialPortOptions? _options;
|
||||
private ConnectionState _state = ConnectionState.Disconnected;
|
||||
private bool _autoReconnect;
|
||||
private CancellationTokenSource? _listeningCts;
|
||||
private Task? _listeningTask;
|
||||
private readonly ConcurrentQueue<byte[]> _sendQueue = new();
|
||||
private readonly SemaphoreSlim _sendSemaphore = new(0);
|
||||
private Task? _sendTask;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ConnectionState State => _state;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? PortName => _driver.PortName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<DataReceivedEventArgs>? DataReceived;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<Models.ErrorEventArgs>? ErrorOccurred;
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
|
||||
public SerialPortService(ISerialPortDriver driver)
|
||||
{
|
||||
_driver = driver ?? throw new ArgumentNullException(nameof(driver));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> StartListeningAsync(SerialPortOptions options, bool autoReconnect = false)
|
||||
{
|
||||
if (_state != ConnectionState.Disconnected)
|
||||
return Result.Failure("服务已经启动");
|
||||
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_autoReconnect = autoReconnect;
|
||||
|
||||
ChangeState(ConnectionState.Connecting);
|
||||
|
||||
var openResult = await _driver.OpenAsync(options);
|
||||
if (openResult.IsFailure)
|
||||
{
|
||||
ChangeState(ConnectionState.Error);
|
||||
return openResult;
|
||||
}
|
||||
|
||||
ChangeState(ConnectionState.Connected);
|
||||
|
||||
// 启动监听任务
|
||||
_listeningCts = new CancellationTokenSource();
|
||||
_listeningTask = Task.Run(() => ListeningLoop(_listeningCts.Token));
|
||||
|
||||
// 启动发送任务
|
||||
_sendTask = Task.Run(() => SendLoop(_listeningCts.Token));
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> StopListeningAsync()
|
||||
{
|
||||
if (_state == ConnectionState.Disconnected)
|
||||
return Result.Success();
|
||||
|
||||
ChangeState(ConnectionState.Disconnecting);
|
||||
|
||||
// 停止监听和发送任务
|
||||
_listeningCts?.Cancel();
|
||||
|
||||
// 等待任务完成,忽略取消异常
|
||||
try
|
||||
{
|
||||
if (_listeningTask != null)
|
||||
await _listeningTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 任务被取消是预期行为,忽略
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_sendTask != null)
|
||||
await _sendTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 任务被取消是预期行为,忽略
|
||||
}
|
||||
|
||||
// 关闭驱动
|
||||
var closeResult = await _driver.CloseAsync();
|
||||
|
||||
_listeningCts?.Dispose();
|
||||
_listeningCts = null;
|
||||
|
||||
ChangeState(ConnectionState.Disconnected);
|
||||
|
||||
return closeResult;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Result> SendAsync(byte[] data)
|
||||
{
|
||||
if (_state != ConnectionState.Connected)
|
||||
return Task.FromResult(Result.Failure("服务未连接"));
|
||||
|
||||
if (data == null || data.Length == 0)
|
||||
return Task.FromResult(Result.Failure("数据不能为空"));
|
||||
|
||||
_sendQueue.Enqueue(data);
|
||||
_sendSemaphore.Release();
|
||||
|
||||
return Task.FromResult(Result.Success());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<byte[]>> SendAndReceiveAsync(byte[] data, TimeSpan timeout)
|
||||
{
|
||||
if (_state != ConnectionState.Connected)
|
||||
return Result.Failure<byte[]>("服务未连接");
|
||||
|
||||
var responseReceived = false;
|
||||
byte[]? responseData = null;
|
||||
var tcs = new TaskCompletionSource<byte[]>();
|
||||
|
||||
void OnDataReceived(object? sender, DataReceivedEventArgs e)
|
||||
{
|
||||
if (!responseReceived)
|
||||
{
|
||||
responseReceived = true;
|
||||
responseData = e.Data;
|
||||
tcs.TrySetResult(e.Data);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
DataReceived += OnDataReceived;
|
||||
|
||||
// 发送数据
|
||||
var sendResult = await _driver.WriteBytesAsync(data);
|
||||
if (sendResult.IsFailure)
|
||||
return Result.Failure<byte[]>(sendResult.ErrorMessage ?? "发送失败");
|
||||
|
||||
// 等待响应
|
||||
using var cts = new CancellationTokenSource(timeout);
|
||||
cts.Token.Register(() => tcs.TrySetCanceled());
|
||||
|
||||
try
|
||||
{
|
||||
responseData = await tcs.Task;
|
||||
return Result.Success(responseData);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return Result.Failure<byte[]>("等待响应超时");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataReceived -= OnDataReceived;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 监听循环
|
||||
/// </summary>
|
||||
private async Task ListeningLoop(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_driver.IsOpen)
|
||||
{
|
||||
if (_autoReconnect && _options != null)
|
||||
{
|
||||
await TryReconnectAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (_driver.BytesToRead > 0)
|
||||
{
|
||||
var result = await _driver.ReadAvailableAsync();
|
||||
if (result.IsSuccess && result.Value != null && result.Value.Length > 0)
|
||||
{
|
||||
OnDataReceived(result.Value);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await Task.Delay(10, cancellationToken); // 短暂等待
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
OnError(ex);
|
||||
|
||||
if (_autoReconnect && _options != null)
|
||||
{
|
||||
await TryReconnectAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送循环
|
||||
/// </summary>
|
||||
private async Task SendLoop(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sendSemaphore.WaitAsync(cancellationToken);
|
||||
|
||||
if (_sendQueue.TryDequeue(out var data))
|
||||
{
|
||||
var result = await _driver.WriteBytesAsync(data, cancellationToken);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
OnError(new Exception(result.ErrorMessage));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
OnError(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试重连
|
||||
/// </summary>
|
||||
private async Task TryReconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_options == null)
|
||||
return;
|
||||
|
||||
ChangeState(ConnectionState.Disconnecting);
|
||||
await _driver.CloseAsync();
|
||||
|
||||
var retryCount = 0;
|
||||
var maxRetries = 5;
|
||||
var retryDelay = TimeSpan.FromSeconds(2);
|
||||
|
||||
while (retryCount < maxRetries && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
ChangeState(ConnectionState.Connecting);
|
||||
await Task.Delay(retryDelay, cancellationToken);
|
||||
|
||||
var result = await _driver.OpenAsync(_options, cancellationToken);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
ChangeState(ConnectionState.Connected);
|
||||
return;
|
||||
}
|
||||
|
||||
retryCount++;
|
||||
retryDelay = TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, retryCount)));
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
OnError(ex);
|
||||
retryCount++;
|
||||
}
|
||||
}
|
||||
|
||||
ChangeState(ConnectionState.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 触发数据接收事件
|
||||
/// </summary>
|
||||
private void OnDataReceived(byte[] data)
|
||||
{
|
||||
if (_options == null)
|
||||
return;
|
||||
|
||||
DataReceived?.Invoke(this, new DataReceivedEventArgs
|
||||
{
|
||||
Data = data,
|
||||
PortName = _options.PortName,
|
||||
ReceivedAt = DateTime.Now
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 触发错误事件
|
||||
/// </summary>
|
||||
private void OnError(Exception exception)
|
||||
{
|
||||
if (_options == null)
|
||||
return;
|
||||
|
||||
ErrorOccurred?.Invoke(this, new Models.ErrorEventArgs
|
||||
{
|
||||
Exception = exception,
|
||||
PortName = _options.PortName,
|
||||
OccurredAt = DateTime.Now
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 改变状态
|
||||
/// </summary>
|
||||
private void ChangeState(ConnectionState newState)
|
||||
{
|
||||
if (_state == newState)
|
||||
return;
|
||||
|
||||
var oldState = _state;
|
||||
_state = newState;
|
||||
|
||||
if (_options != null)
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs
|
||||
{
|
||||
PreviousState = oldState,
|
||||
CurrentState = newState,
|
||||
PortName = _options.PortName,
|
||||
ChangedAt = DateTime.Now
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
// 使用ConfigureAwait(false).GetAwaiter().GetResult()避免死锁
|
||||
// 并捕获可能的取消异常
|
||||
StopListeningAsync().ConfigureAwait(false).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 忽略取消异常
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略其他清理异常
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_driver.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略驱动释放异常
|
||||
}
|
||||
|
||||
_sendSemaphore.Dispose();
|
||||
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using Common.Results;
|
||||
|
||||
namespace Ports.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// 串口校验工具
|
||||
/// </summary>
|
||||
public static class SerialChecksumHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// 计算CRC16(Modbus)
|
||||
/// </summary>
|
||||
/// <param name="data">数据</param>
|
||||
/// <returns>CRC16校验码</returns>
|
||||
public static Result<ushort> CalculateCrc16(byte[] data)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (data == null || data.Length == 0)
|
||||
throw new ArgumentException("数据不能为空");
|
||||
|
||||
ushort crc = 0xFFFF;
|
||||
|
||||
foreach (var b in data)
|
||||
{
|
||||
crc ^= b;
|
||||
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
if ((crc & 0x0001) != 0)
|
||||
{
|
||||
crc >>= 1;
|
||||
crc ^= 0xA001;
|
||||
}
|
||||
else
|
||||
{
|
||||
crc >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return crc;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证CRC16校验码
|
||||
/// </summary>
|
||||
/// <param name="data">数据(包含校验码)</param>
|
||||
/// <returns>是否有效</returns>
|
||||
public static Result<bool> VerifyCrc16(byte[] data)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (data == null || data.Length < 3)
|
||||
return false;
|
||||
|
||||
var dataWithoutCrc = new byte[data.Length - 2];
|
||||
Array.Copy(data, dataWithoutCrc, dataWithoutCrc.Length);
|
||||
|
||||
var calculatedCrc = CalculateCrc16(dataWithoutCrc).Value;
|
||||
var providedCrc = (ushort)(data[^2] | (data[^1] << 8));
|
||||
|
||||
return calculatedCrc == providedCrc;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算LRC(纵向冗余校验)
|
||||
/// </summary>
|
||||
/// <param name="data">数据</param>
|
||||
/// <returns>LRC校验码</returns>
|
||||
public static Result<byte> CalculateLrc(byte[] data)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (data == null || data.Length == 0)
|
||||
throw new ArgumentException("数据不能为空");
|
||||
|
||||
byte lrc = 0;
|
||||
foreach (var b in data)
|
||||
{
|
||||
lrc += b;
|
||||
}
|
||||
|
||||
return (byte)(-lrc);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证LRC校验码
|
||||
/// </summary>
|
||||
/// <param name="data">数据(包含校验码)</param>
|
||||
/// <returns>是否有效</returns>
|
||||
public static Result<bool> VerifyLrc(byte[] data)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (data == null || data.Length < 2)
|
||||
return false;
|
||||
|
||||
var dataWithoutLrc = new byte[data.Length - 1];
|
||||
Array.Copy(data, dataWithoutLrc, dataWithoutLrc.Length);
|
||||
|
||||
var calculatedLrc = CalculateLrc(dataWithoutLrc).Value;
|
||||
var providedLrc = data[^1];
|
||||
|
||||
return calculatedLrc == providedLrc;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算异或校验
|
||||
/// </summary>
|
||||
/// <param name="data">数据</param>
|
||||
/// <returns>异或校验码</returns>
|
||||
public static Result<byte> CalculateXor(byte[] data)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (data == null || data.Length == 0)
|
||||
throw new ArgumentException("数据不能为空");
|
||||
|
||||
byte xor = 0;
|
||||
foreach (var b in data)
|
||||
{
|
||||
xor ^= b;
|
||||
}
|
||||
|
||||
return xor;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证异或校验码
|
||||
/// </summary>
|
||||
/// <param name="data">数据(包含校验码)</param>
|
||||
/// <returns>是否有效</returns>
|
||||
public static Result<bool> VerifyXor(byte[] data)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (data == null || data.Length < 2)
|
||||
return false;
|
||||
|
||||
var dataWithoutXor = new byte[data.Length - 1];
|
||||
Array.Copy(data, dataWithoutXor, dataWithoutXor.Length);
|
||||
|
||||
var calculatedXor = CalculateXor(dataWithoutXor).Value;
|
||||
var providedXor = data[^1];
|
||||
|
||||
return calculatedXor == providedXor;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算简单累加和校验
|
||||
/// </summary>
|
||||
/// <param name="data">数据</param>
|
||||
/// <returns>校验和</returns>
|
||||
public static Result<byte> CalculateChecksum(byte[] data)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (data == null || data.Length == 0)
|
||||
throw new ArgumentException("数据不能为空");
|
||||
|
||||
byte sum = 0;
|
||||
foreach (var b in data)
|
||||
{
|
||||
sum += b;
|
||||
}
|
||||
|
||||
return sum;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证累加和校验码
|
||||
/// </summary>
|
||||
/// <param name="data">数据(包含校验码)</param>
|
||||
/// <returns>是否有效</returns>
|
||||
public static Result<bool> VerifyChecksum(byte[] data)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (data == null || data.Length < 2)
|
||||
return false;
|
||||
|
||||
var dataWithoutChecksum = new byte[data.Length - 1];
|
||||
Array.Copy(data, dataWithoutChecksum, dataWithoutChecksum.Length);
|
||||
|
||||
var calculatedChecksum = CalculateChecksum(dataWithoutChecksum).Value;
|
||||
var providedChecksum = data[^1];
|
||||
|
||||
return calculatedChecksum == providedChecksum;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using System.Text;
|
||||
using Common.Results;
|
||||
|
||||
namespace Ports.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// 串口数据转换工具
|
||||
/// </summary>
|
||||
public static class SerialDataConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// 字节数组转16位整数
|
||||
/// </summary>
|
||||
/// <param name="bytes">字节数组</param>
|
||||
/// <param name="isBigEndian">是否大端序</param>
|
||||
/// <returns>转换结果</returns>
|
||||
public static Result<short> BytesToInt16(byte[] bytes, bool isBigEndian = false)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (bytes == null || bytes.Length < 2)
|
||||
throw new ArgumentException("字节数组长度必须至少为2");
|
||||
|
||||
if (isBigEndian && BitConverter.IsLittleEndian)
|
||||
Array.Reverse(bytes, 0, 2);
|
||||
|
||||
return BitConverter.ToInt16(bytes, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 字节数组转32位整数
|
||||
/// </summary>
|
||||
public static Result<int> BytesToInt32(byte[] bytes, bool isBigEndian = false)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (bytes == null || bytes.Length < 4)
|
||||
throw new ArgumentException("字节数组长度必须至少为4");
|
||||
|
||||
if (isBigEndian && BitConverter.IsLittleEndian)
|
||||
Array.Reverse(bytes, 0, 4);
|
||||
|
||||
return BitConverter.ToInt32(bytes, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 字节数组转单精度浮点数
|
||||
/// </summary>
|
||||
public static Result<float> BytesToFloat(byte[] bytes, bool isBigEndian = false)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (bytes == null || bytes.Length < 4)
|
||||
throw new ArgumentException("字节数组长度必须至少为4");
|
||||
|
||||
if (isBigEndian && BitConverter.IsLittleEndian)
|
||||
Array.Reverse(bytes, 0, 4);
|
||||
|
||||
return BitConverter.ToSingle(bytes, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 16位整数转字节数组
|
||||
/// </summary>
|
||||
public static Result<byte[]> Int16ToBytes(short value, bool isBigEndian = false)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
var bytes = BitConverter.GetBytes(value);
|
||||
|
||||
if (isBigEndian && BitConverter.IsLittleEndian)
|
||||
Array.Reverse(bytes);
|
||||
|
||||
return bytes;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 32位整数转字节数组
|
||||
/// </summary>
|
||||
public static Result<byte[]> Int32ToBytes(int value, bool isBigEndian = false)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
var bytes = BitConverter.GetBytes(value);
|
||||
|
||||
if (isBigEndian && BitConverter.IsLittleEndian)
|
||||
Array.Reverse(bytes);
|
||||
|
||||
return bytes;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单精度浮点数转字节数组
|
||||
/// </summary>
|
||||
public static Result<byte[]> FloatToBytes(float value, bool isBigEndian = false)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
var bytes = BitConverter.GetBytes(value);
|
||||
|
||||
if (isBigEndian && BitConverter.IsLittleEndian)
|
||||
Array.Reverse(bytes);
|
||||
|
||||
return bytes;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 字节数组转十六进制字符串
|
||||
/// </summary>
|
||||
public static Result<string> BytesToHexString(byte[] bytes, string separator = " ")
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (bytes == null || bytes.Length == 0)
|
||||
return string.Empty;
|
||||
|
||||
return BitConverter.ToString(bytes).Replace("-", separator);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 十六进制字符串转字节数组
|
||||
/// </summary>
|
||||
public static Result<byte[]> HexStringToBytes(string hex)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hex))
|
||||
return Array.Empty<byte>();
|
||||
|
||||
// 移除空格和分隔符
|
||||
hex = hex.Replace(" ", "").Replace("-", "").Replace(":", "");
|
||||
|
||||
if (hex.Length % 2 != 0)
|
||||
throw new ArgumentException("十六进制字符串长度必须为偶数");
|
||||
|
||||
var bytes = new byte[hex.Length / 2];
|
||||
for (int i = 0; i < bytes.Length; i++)
|
||||
{
|
||||
bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 字符串转字节数组(使用指定编码)
|
||||
/// </summary>
|
||||
public static Result<byte[]> StringToBytes(string str, Encoding? encoding = null)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
encoding ??= Encoding.UTF8;
|
||||
return encoding.GetBytes(str ?? string.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 字节数组转字符串(使用指定编码)
|
||||
/// </summary>
|
||||
public static Result<string> BytesToString(byte[] bytes, Encoding? encoding = null)
|
||||
{
|
||||
return Result.Try(() =>
|
||||
{
|
||||
if (bytes == null || bytes.Length == 0)
|
||||
return string.Empty;
|
||||
|
||||
encoding ??= Encoding.UTF8;
|
||||
return encoding.GetString(bytes);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using Common.Results;
|
||||
using Ports.Abstractions;
|
||||
using Ports.Drivers;
|
||||
using Ports.Models;
|
||||
|
||||
namespace Ports.Utils;
|
||||
|
||||
/// <summary>
|
||||
/// 串口诊断工具
|
||||
/// </summary>
|
||||
public static class SerialPortDiagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// 测试串口连接
|
||||
/// </summary>
|
||||
/// <param name="portName">端口名称</param>
|
||||
/// <param name="options">配置选项</param>
|
||||
/// <param name="testData">测试数据(可选)</param>
|
||||
/// <returns>测试结果</returns>
|
||||
public static async Task<Result<string>> TestConnectionAsync(
|
||||
string portName,
|
||||
SerialPortOptions options,
|
||||
byte[]? testData = null)
|
||||
{
|
||||
return await Result.TryAsync(async () =>
|
||||
{
|
||||
using var driver = new SerialPortDriver();
|
||||
|
||||
// 测试打开连接
|
||||
var openResult = await driver.OpenAsync(options);
|
||||
if (openResult.IsFailure)
|
||||
return $"打开失败: {openResult.ErrorMessage}";
|
||||
|
||||
var report = $"端口 {portName} 连接成功\n";
|
||||
report += $"- 波特率: {options.BaudRate}\n";
|
||||
report += $"- 数据位: {options.DataBits}\n";
|
||||
report += $"- 停止位: {options.StopBits}\n";
|
||||
report += $"- 校验位: {options.Parity}\n";
|
||||
|
||||
// 如果提供了测试数据,尝试发送
|
||||
if (testData != null && testData.Length > 0)
|
||||
{
|
||||
var writeResult = await driver.WriteBytesAsync(testData);
|
||||
if (writeResult.IsSuccess)
|
||||
{
|
||||
report += $"- 测试数据发送成功 ({testData.Length} 字节)\n";
|
||||
|
||||
// 尝试读取响应(等待500ms)
|
||||
await Task.Delay(500);
|
||||
if (driver.BytesToRead > 0)
|
||||
{
|
||||
var readResult = await driver.ReadAvailableAsync();
|
||||
if (readResult.IsSuccess && readResult.Value != null)
|
||||
{
|
||||
report += $"- 接收到响应 ({readResult.Value.Length} 字节)\n";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
report += "- 未接收到响应\n";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
report += $"- 测试数据发送失败: {writeResult.ErrorMessage}\n";
|
||||
}
|
||||
}
|
||||
|
||||
await driver.CloseAsync();
|
||||
report += "- 连接关闭成功";
|
||||
|
||||
return report;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 监控串口流量
|
||||
/// </summary>
|
||||
/// <param name="portName">端口名称</param>
|
||||
/// <param name="options">配置选项</param>
|
||||
/// <param name="onData">数据回调(data, isSent)</param>
|
||||
/// <param name="duration">监控时长</param>
|
||||
/// <returns>监控结果</returns>
|
||||
public static async Task<Result<TrafficReport>> MonitorTrafficAsync(
|
||||
string portName,
|
||||
SerialPortOptions options,
|
||||
Action<byte[], bool> onData,
|
||||
TimeSpan duration)
|
||||
{
|
||||
return await Result.TryAsync(async () =>
|
||||
{
|
||||
var report = new TrafficReport
|
||||
{
|
||||
PortName = portName,
|
||||
StartTime = DateTime.Now
|
||||
};
|
||||
|
||||
using var driver = new SerialPortDriver();
|
||||
var openResult = await driver.OpenAsync(options);
|
||||
|
||||
if (openResult.IsFailure)
|
||||
throw new Exception($"打开端口失败: {openResult.ErrorMessage}");
|
||||
|
||||
var cts = new CancellationTokenSource(duration);
|
||||
|
||||
try
|
||||
{
|
||||
while (!cts.Token.IsCancellationRequested)
|
||||
{
|
||||
if (driver.BytesToRead > 0)
|
||||
{
|
||||
var readResult = await driver.ReadAvailableAsync();
|
||||
if (readResult.IsSuccess && readResult.Value != null)
|
||||
{
|
||||
report.BytesReceived += readResult.Value.Length;
|
||||
report.PacketsReceived++;
|
||||
onData?.Invoke(readResult.Value, false);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(10, cts.Token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 超时正常结束
|
||||
}
|
||||
|
||||
await driver.CloseAsync();
|
||||
|
||||
report.EndTime = DateTime.Now;
|
||||
report.Duration = report.EndTime - report.StartTime;
|
||||
|
||||
return report;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量测试多个波特率
|
||||
/// </summary>
|
||||
/// <param name="portName">端口名称</param>
|
||||
/// <param name="baudRates">要测试的波特率列表</param>
|
||||
/// <returns>测试结果</returns>
|
||||
public static async Task<Result<Dictionary<int, bool>>> TestBaudRatesAsync(
|
||||
string portName,
|
||||
params int[] baudRates)
|
||||
{
|
||||
return await Result.TryAsync(async () =>
|
||||
{
|
||||
var results = new Dictionary<int, bool>();
|
||||
|
||||
foreach (var baudRate in baudRates)
|
||||
{
|
||||
var options = new SerialPortOptions
|
||||
{
|
||||
PortName = portName,
|
||||
BaudRate = baudRate
|
||||
};
|
||||
|
||||
using var driver = new SerialPortDriver();
|
||||
var openResult = await driver.OpenAsync(options);
|
||||
|
||||
results[baudRate] = openResult.IsSuccess;
|
||||
|
||||
if (openResult.IsSuccess)
|
||||
{
|
||||
await driver.CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 流量监控报告
|
||||
/// </summary>
|
||||
public class TrafficReport
|
||||
{
|
||||
public string PortName { get; set; } = string.Empty;
|
||||
public DateTime StartTime { get; set; }
|
||||
public DateTime EndTime { get; set; }
|
||||
public TimeSpan Duration { get; set; }
|
||||
public long BytesReceived { get; set; }
|
||||
public long BytesSent { get; set; }
|
||||
public int PacketsReceived { get; set; }
|
||||
public int PacketsSent { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"流量报告 [{PortName}]\n" +
|
||||
$"监控时长: {Duration.TotalSeconds:F2}s\n" +
|
||||
$"接收: {BytesReceived} 字节 ({PacketsReceived} 包)\n" +
|
||||
$"发送: {BytesSent} 字节 ({PacketsSent} 包)";
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Plc", "Plc\Plc.csproj", "{1
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Plc.Siemens", "Plc.Siemens\Plc.Siemens.csproj", "{7C877AB8-1938-4CC7-8692-0D2253C728DF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ports", "Ports\Ports.csproj", "{BE65F468-991A-462B-B9E5-41FE4C3CF288}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -129,6 +131,10 @@ Global
|
||||
{7C877AB8-1938-4CC7-8692-0D2253C728DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7C877AB8-1938-4CC7-8692-0D2253C728DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7C877AB8-1938-4CC7-8692-0D2253C728DF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BE65F468-991A-462B-B9E5-41FE4C3CF288}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BE65F468-991A-462B-B9E5-41FE4C3CF288}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BE65F468-991A-462B-B9E5-41FE4C3CF288}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BE65F468-991A-462B-B9E5-41FE4C3CF288}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// 此代码由工具生成。
|
||||
// 运行时版本:4.0.30319.42000
|
||||
// This code was generated by a tool.
|
||||
//
|
||||
// 对此文件的更改可能会导致不正确的行为,并且如果
|
||||
// 重新生成代码,这些更改将会丢失。
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
@@ -14,7 +13,7 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Sharp.Wpf")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+53d1dd2dbf05d818528814223b58610a790fcb26")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+bc09280d4ea03ae8d0db7cd8c25ddd02d63abd84")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Sharp.Wpf")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Sharp.Wpf")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@@ -1 +1 @@
|
||||
fbdba11393faed768ef4c9b8f4eccef99aed87397a79576b900953e4aad05633
|
||||
d55a55a135e80fbfbfad99db94d1d2c02d22ff2d590de8697e3ccba5e8c5d43a
|
||||
+3
-1
@@ -6,6 +6,8 @@ build_property.ApplicationHighDpiMode =
|
||||
build_property.ApplicationUseCompatibleTextRendering =
|
||||
build_property.ApplicationVisualStyles =
|
||||
build_property.TargetFramework = net8.0-windows
|
||||
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||
build_property.TargetFrameworkVersion = v8.0
|
||||
build_property.TargetPlatformMinVersion = 7.0
|
||||
build_property.UsingMicrosoftNETSdkWeb =
|
||||
build_property.ProjectTypeGuids =
|
||||
@@ -14,7 +16,7 @@ build_property.PlatformNeutralAssembly =
|
||||
build_property.EnforceExtendedAnalyzerRules =
|
||||
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||
build_property.RootNamespace = Sharp.Wpf
|
||||
build_property.ProjectDir = D:\yuanll\sharp-basic\src\Sharp\Wpf\
|
||||
build_property.ProjectDir = D:\code\sharp-basic\src\Sharp\Wpf\
|
||||
build_property.EnableComHosting =
|
||||
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// <auto-generated/>
|
||||
global using global::System;
|
||||
global using global::System.Collections.Generic;
|
||||
global using global::System.Drawing;
|
||||
global using global::System.Linq;
|
||||
global using global::System.Threading;
|
||||
global using global::System.Threading.Tasks;
|
||||
global using global::System.Windows.Forms;
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Drawing;
|
||||
global using System.Linq;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
global using System.Windows.Forms;
|
||||
Binary file not shown.
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"format": 1,
|
||||
"restore": {
|
||||
"D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj": {}
|
||||
"D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj": {}
|
||||
},
|
||||
"projects": {
|
||||
"D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj": {
|
||||
"D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj": {
|
||||
"version": "1.0.0",
|
||||
"restore": {
|
||||
"projectUniqueName": "D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||
"projectUniqueName": "D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||
"projectName": "Sharp.Wpf",
|
||||
"projectPath": "D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||
"packagesPath": "C:\\Users\\yllyo\\.nuget\\packages\\",
|
||||
"outputPath": "D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\obj\\",
|
||||
"projectPath": "D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||
"packagesPath": "C:\\Users\\Administrator\\.nuget\\packages\\",
|
||||
"outputPath": "D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\obj\\",
|
||||
"projectStyle": "PackageReference",
|
||||
"fallbackFolders": [
|
||||
"D:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
|
||||
],
|
||||
"configFilePaths": [
|
||||
"C:\\Users\\yllyo\\AppData\\Roaming\\NuGet\\NuGet.Config",
|
||||
"C:\\Users\\Administrator\\AppData\\Roaming\\NuGet\\NuGet.Config",
|
||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
|
||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
||||
],
|
||||
@@ -44,7 +44,7 @@
|
||||
"auditLevel": "low",
|
||||
"auditMode": "direct"
|
||||
},
|
||||
"SdkAnalysisLevel": "9.0.200"
|
||||
"SdkAnalysisLevel": "10.0.100"
|
||||
},
|
||||
"frameworks": {
|
||||
"net8.0-windows7.0": {
|
||||
@@ -68,12 +68,7 @@
|
||||
"privateAssets": "none"
|
||||
}
|
||||
},
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.203/PortableRuntimeIdentifierGraph.json"
|
||||
}
|
||||
},
|
||||
"runtimes": {
|
||||
"win-x64": {
|
||||
"#import": []
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.100/PortableRuntimeIdentifierGraph.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
|
||||
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
|
||||
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
|
||||
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\yllyo\.nuget\packages\;D:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages</NuGetPackageFolders>
|
||||
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\Administrator\.nuget\packages\;D:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages</NuGetPackageFolders>
|
||||
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
|
||||
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">6.13.2</NuGetToolVersion>
|
||||
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
||||
<SourceRoot Include="C:\Users\yllyo\.nuget\packages\" />
|
||||
<SourceRoot Include="C:\Users\Administrator\.nuget\packages\" />
|
||||
<SourceRoot Include="D:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,31 +1,30 @@
|
||||
{
|
||||
"version": 3,
|
||||
"targets": {
|
||||
"net8.0-windows7.0": {},
|
||||
"net8.0-windows7.0/win-x64": {}
|
||||
"net8.0-windows7.0": {}
|
||||
},
|
||||
"libraries": {},
|
||||
"projectFileDependencyGroups": {
|
||||
"net8.0-windows7.0": []
|
||||
},
|
||||
"packageFolders": {
|
||||
"C:\\Users\\yllyo\\.nuget\\packages\\": {},
|
||||
"C:\\Users\\Administrator\\.nuget\\packages\\": {},
|
||||
"D:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages": {}
|
||||
},
|
||||
"project": {
|
||||
"version": "1.0.0",
|
||||
"restore": {
|
||||
"projectUniqueName": "D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||
"projectUniqueName": "D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||
"projectName": "Sharp.Wpf",
|
||||
"projectPath": "D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||
"packagesPath": "C:\\Users\\yllyo\\.nuget\\packages\\",
|
||||
"outputPath": "D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\obj\\",
|
||||
"projectPath": "D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||
"packagesPath": "C:\\Users\\Administrator\\.nuget\\packages\\",
|
||||
"outputPath": "D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\obj\\",
|
||||
"projectStyle": "PackageReference",
|
||||
"fallbackFolders": [
|
||||
"D:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
|
||||
],
|
||||
"configFilePaths": [
|
||||
"C:\\Users\\yllyo\\AppData\\Roaming\\NuGet\\NuGet.Config",
|
||||
"C:\\Users\\Administrator\\AppData\\Roaming\\NuGet\\NuGet.Config",
|
||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.FallbackLocation.config",
|
||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
||||
],
|
||||
@@ -52,7 +51,7 @@
|
||||
"auditLevel": "low",
|
||||
"auditMode": "direct"
|
||||
},
|
||||
"SdkAnalysisLevel": "9.0.200"
|
||||
"SdkAnalysisLevel": "10.0.100"
|
||||
},
|
||||
"frameworks": {
|
||||
"net8.0-windows7.0": {
|
||||
@@ -76,12 +75,7 @@
|
||||
"privateAssets": "none"
|
||||
}
|
||||
},
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.203/PortableRuntimeIdentifierGraph.json"
|
||||
}
|
||||
},
|
||||
"runtimes": {
|
||||
"win-x64": {
|
||||
"#import": []
|
||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.100/PortableRuntimeIdentifierGraph.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dgSpecHash": "9ZityDkLrvk=",
|
||||
"dgSpecHash": "NHqLv58UxH0=",
|
||||
"success": true,
|
||||
"projectFilePath": "D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||
"projectFilePath": "D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||
"expectedPackageFiles": [],
|
||||
"logs": []
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
"restore":{"projectUniqueName":"D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj","projectName":"Sharp.Wpf","projectPath":"D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj","outputPath":"D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\obj\\","projectStyle":"PackageReference","fallbackFolders":["D:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"],"originalTargetFrameworks":["net8.0-windows"],"sources":{"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\":{},"https://api.nuget.org/v3/index.json":{}},"frameworks":{"net8.0-windows7.0":{"targetAlias":"net8.0-windows","projectReferences":{}}},"warningProperties":{"warnAsError":["NU1605"]},"restoreAuditProperties":{"enableAudit":"true","auditLevel":"low","auditMode":"direct"},"SdkAnalysisLevel":"9.0.200"}"frameworks":{"net8.0-windows7.0":{"targetAlias":"net8.0-windows","imports":["net461","net462","net47","net471","net472","net48","net481"],"assetTargetFallback":true,"warn":true,"frameworkReferences":{"Microsoft.NETCore.App":{"privateAssets":"all"},"Microsoft.WindowsDesktop.App":{"privateAssets":"none"}},"runtimeIdentifierGraphPath":"C:\\Program Files\\dotnet\\sdk\\9.0.203/PortableRuntimeIdentifierGraph.json"}}
|
||||
"restore":{"projectUniqueName":"D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj","projectName":"Sharp.Wpf","projectPath":"D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj","outputPath":"D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\obj\\","projectStyle":"PackageReference","fallbackFolders":["D:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"],"originalTargetFrameworks":["net8.0-windows"],"sources":{"C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackages\\":{},"https://api.nuget.org/v3/index.json":{}},"frameworks":{"net8.0-windows7.0":{"targetAlias":"net8.0-windows","projectReferences":{}}},"warningProperties":{"warnAsError":["NU1605"]},"restoreAuditProperties":{"enableAudit":"true","auditLevel":"low","auditMode":"direct"},"SdkAnalysisLevel":"10.0.100"}"frameworks":{"net8.0-windows7.0":{"targetAlias":"net8.0-windows","imports":["net461","net462","net47","net471","net472","net48","net481"],"assetTargetFallback":true,"warn":true,"frameworkReferences":{"Microsoft.NETCore.App":{"privateAssets":"all"},"Microsoft.WindowsDesktop.App":{"privateAssets":"none"}},"runtimeIdentifierGraphPath":"C:\\Program Files\\dotnet\\sdk\\10.0.100/PortableRuntimeIdentifierGraph.json"}}
|
||||
@@ -1 +1 @@
|
||||
17634268840015055
|
||||
17652582922004239
|
||||
@@ -1 +1 @@
|
||||
17633637013370903
|
||||
17652582922004239
|
||||
Reference in new issue
Block a user