Compare commits
6
Commits
my-main
...
de1a29c522
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de1a29c522 | ||
|
|
6ed0d3f1c2 | ||
|
|
d9e5a5483c | ||
|
|
bc09280d4e | ||
|
|
2f3eb935dd | ||
|
|
bca001b90f |
No files matched your search
@@ -11,6 +11,9 @@ using Sharp.Infrastructure.Configuration;
|
|||||||
using Sharp.Log.Extensions;
|
using Sharp.Log.Extensions;
|
||||||
using Sharp.NetServer.DependencyInjection;
|
using Sharp.NetServer.DependencyInjection;
|
||||||
using Sharp.Wpf.Services;
|
using Sharp.Wpf.Services;
|
||||||
|
using Sharp.Wpf.Abstractions;
|
||||||
|
using Plc.Siemens.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace Client
|
namespace Client
|
||||||
{
|
{
|
||||||
@@ -25,6 +28,57 @@ namespace Client
|
|||||||
{
|
{
|
||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
|
|
||||||
|
// Global Exception Handling
|
||||||
|
DispatcherUnhandledException += (s, args) =>
|
||||||
|
{
|
||||||
|
// Safe logging: handle case where DI failed
|
||||||
|
if (_serviceProvider != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logger = _serviceProvider.GetService<ILogger<App>>();
|
||||||
|
logger?.LogError(args.Exception, "UI Thread Unhandled Exception");
|
||||||
|
}
|
||||||
|
catch { /* Ignore logging failure during crash */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageBox.Show($"发生未处理异常: {args.Exception.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
args.Handled = true; // Prevent crash
|
||||||
|
};
|
||||||
|
|
||||||
|
TaskScheduler.UnobservedTaskException += (s, args) =>
|
||||||
|
{
|
||||||
|
if (_serviceProvider != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logger = _serviceProvider.GetService<ILogger<App>>();
|
||||||
|
logger?.LogError(args.Exception, "Task Unobserved Exception");
|
||||||
|
}
|
||||||
|
catch { /* Ignore */ }
|
||||||
|
}
|
||||||
|
args.SetObserved();
|
||||||
|
};
|
||||||
|
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += (s, args) =>
|
||||||
|
{
|
||||||
|
var ex = args.ExceptionObject as Exception;
|
||||||
|
if (_serviceProvider != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logger = _serviceProvider.GetService<ILogger<App>>();
|
||||||
|
logger?.LogCritical(ex, "AppDomain Unhandled Exception (IsTerminating: {IsTerminating})", args.IsTerminating);
|
||||||
|
}
|
||||||
|
catch { /* Ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.IsTerminating)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"发生严重错误,程序即将退出: {ex?.Message}", "严重错误", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 1) Build configuration manager from appsettings.json (+ reload on change + env vars)
|
// 1) Build configuration manager from appsettings.json (+ reload on change + env vars)
|
||||||
var cm = ConfigBuilder.BuildFromFiles(new[] { "appsettings.json" }, reloadOnChange: true, includeEnvironment: true, basePath: AppContext.BaseDirectory);
|
var cm = ConfigBuilder.BuildFromFiles(new[] { "appsettings.json" }, reloadOnChange: true, includeEnvironment: true, basePath: AppContext.BaseDirectory);
|
||||||
|
|
||||||
@@ -33,6 +87,8 @@ namespace Client
|
|||||||
|
|
||||||
// ConfigManager and typed SystemConfig accessor
|
// ConfigManager and typed SystemConfig accessor
|
||||||
services.AddSingleton(cm);
|
services.AddSingleton(cm);
|
||||||
|
// Register standard IConfiguration so that .BindConfiguration() works
|
||||||
|
services.AddSingleton<IConfiguration>(cm.Configuration);
|
||||||
services.AddSystemConfig(cm);
|
services.AddSystemConfig(cm);
|
||||||
|
|
||||||
// Logging (binds from section "SharpLog")
|
// Logging (binds from section "SharpLog")
|
||||||
@@ -42,30 +98,49 @@ namespace Client
|
|||||||
services.AddNetClient(cm);
|
services.AddNetClient(cm);
|
||||||
services.AddNetServer(cm);
|
services.AddNetServer(cm);
|
||||||
|
|
||||||
|
// PLC
|
||||||
|
services.AddSiemensPlc(options =>
|
||||||
|
{
|
||||||
|
options.IpAddress = "127.0.0.1";
|
||||||
|
options.CpuType = S7.Net.CpuType.S71200;
|
||||||
|
});
|
||||||
|
|
||||||
// WPF app services
|
// WPF app services
|
||||||
services.AddSingleton<IDialogService, DialogService>();
|
services.AddSingleton<IDialogService, DialogService>();
|
||||||
services.AddSingleton<IAuthenticationService, DemoAuthenticationService>();
|
services.AddSingleton<IAuthenticationService, DemoAuthenticationService>();
|
||||||
services.AddSingleton<IUserSession, UserSession>();
|
services.AddSingleton<IUserSession, UserSession>();
|
||||||
|
// Register DispatcherService with the current UI dispatcher
|
||||||
|
services.AddSingleton<IDispatcher>(new DispatcherService(Dispatcher));
|
||||||
|
|
||||||
services.AddTransient<LoginViewModel>();
|
services.AddTransient<LoginViewModel>();
|
||||||
services.AddTransient<MainViewModel>();
|
services.AddTransient<MainViewModel>();
|
||||||
|
services.AddTransient<PlcViewModel>();
|
||||||
|
|
||||||
services.AddTransient<LoginWindow>();
|
services.AddTransient<LoginWindow>();
|
||||||
services.AddTransient<MainWindow>();
|
services.AddTransient<MainWindow>();
|
||||||
|
services.AddTransient<PlcWindow>();
|
||||||
|
|
||||||
_serviceProvider = services.BuildServiceProvider();
|
_serviceProvider = services.BuildServiceProvider();
|
||||||
|
|
||||||
// 3) Register global facades for convenience
|
// 3) Register global facades for convenience
|
||||||
Global.RegisterSingleton(_serviceProvider);
|
Global.RegisterSingleton<IServiceProvider>(_serviceProvider);
|
||||||
Global.RegisterSingleton(cm);
|
Global.RegisterSingleton(cm);
|
||||||
Global.RegisterSingleton(_serviceProvider.GetRequiredService<ISystemConfigAccessor>());
|
Global.RegisterSingleton(_serviceProvider.GetRequiredService<ISystemConfigAccessor>());
|
||||||
|
// Also register IDispatcher in Global if it's needed by static contexts (though injection is preferred)
|
||||||
|
Global.RegisterSingleton(_serviceProvider.GetRequiredService<IDispatcher>());
|
||||||
|
|
||||||
// 4) Show login window first, then main shell if authenticated
|
// 4) Show login window first, then main shell if authenticated
|
||||||
|
// Explicitly set ShutdownMode to prevent app from closing when LoginWindow closes
|
||||||
|
ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||||
|
|
||||||
var loginWindow = _serviceProvider.GetRequiredService<LoginWindow>();
|
var loginWindow = _serviceProvider.GetRequiredService<LoginWindow>();
|
||||||
var loginResult = loginWindow.ShowDialog();
|
var loginResult = loginWindow.ShowDialog();
|
||||||
|
|
||||||
if (loginResult == true)
|
if (loginResult == true)
|
||||||
{
|
{
|
||||||
var main = _serviceProvider.GetRequiredService<MainWindow>();
|
var main = _serviceProvider.GetRequiredService<MainWindow>();
|
||||||
|
Current.MainWindow = main;
|
||||||
|
ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||||
main.Show();
|
main.Show();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
<ProjectReference Include="..\NetServer\NetServer.csproj" />
|
<ProjectReference Include="..\NetServer\NetServer.csproj" />
|
||||||
<ProjectReference Include="..\Log\Log.csproj" />
|
<ProjectReference Include="..\Log\Log.csproj" />
|
||||||
<ProjectReference Include="..\Sharp.Infrastructure\Sharp.Infrastructure.csproj" />
|
<ProjectReference Include="..\Sharp.Infrastructure\Sharp.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\Plc.Siemens\Plc.Siemens.csproj" />
|
||||||
|
<ProjectReference Include="..\Ports\Ports.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using Plc.Attributes;
|
||||||
|
|
||||||
|
namespace Client.Hardware;
|
||||||
|
|
||||||
|
// 必须继承 INotifyPropertyChanged 才能支持自动 UI 刷新
|
||||||
|
public interface IPackMachine : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
[PlcAddress("DB1.DBW0")]
|
||||||
|
short RunningSpeed { get; set; }
|
||||||
|
|
||||||
|
[PlcAddress("DB1.DBW2")]
|
||||||
|
short Temperature { get; set; }
|
||||||
|
|
||||||
|
// 支持 bool (比如 M 区)
|
||||||
|
[PlcAddress("M100.0")]
|
||||||
|
bool IsStartButtonPushed { get; set; }
|
||||||
|
}
|
||||||
@@ -47,7 +47,17 @@
|
|||||||
Command="{Binding ShowDialogCommand}" />
|
Command="{Binding ShowDialogCommand}" />
|
||||||
<Button Content="触发异常"
|
<Button Content="触发异常"
|
||||||
Padding="18,6"
|
Padding="18,6"
|
||||||
|
Margin="0,0,12,0"
|
||||||
Command="{Binding ThrowCommand}" />
|
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}"
|
||||||
|
Command="{Binding OpenPlcToolCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -82,6 +92,45 @@
|
|||||||
<TextBlock Text="{Binding Greeting}" Margin="0,8,0,0" TextWrapping="Wrap" />
|
<TextBlock Text="{Binding Greeting}" Margin="0,8,0,0" TextWrapping="Wrap" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
<Border Background="{DynamicResource PopupBrush}" Margin="0,0,16,16" Padding="20" CornerRadius="12">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="包装机状态 (PLC)" FontWeight="SemiBold" />
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
|
||||||
|
<TextBlock Text="速度: " Foreground="{DynamicResource SecondaryTextBrush}"/>
|
||||||
|
<TextBlock Text="{Binding Machine.RunningSpeed}" FontWeight="Bold" />
|
||||||
|
<TextBlock Text=" rpm" Foreground="{DynamicResource SecondaryTextBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
|
||||||
|
<TextBlock Text="温度: " Foreground="{DynamicResource SecondaryTextBrush}"/>
|
||||||
|
<TextBlock Text="{Binding Machine.Temperature}" FontWeight="Bold" />
|
||||||
|
<TextBlock Text=" ℃" Foreground="{DynamicResource SecondaryTextBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- 交互控制演示 -->
|
||||||
|
<Separator Margin="0,10" Background="{DynamicResource BorderBrush}"/>
|
||||||
|
<TextBlock Text="控制面板" FontWeight="SemiBold" Margin="0,0,0,5"/>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="设定速度:" VerticalAlignment="Center"/>
|
||||||
|
<Slider Grid.Column="1" Minimum="0" Maximum="2000" Value="{Binding Machine.RunningSpeed}" Margin="10,0" IsMoveToPointEnabled="True"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding Machine.RunningSpeed}" Width="40" TextAlignment="Right"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
|
||||||
|
<Button Content="启动机器" Command="{Binding StartMachineCommand}" Margin="0,0,10,0" Style="{StaticResource ButtonSuccess}"/>
|
||||||
|
<Button Content="停止机器" Command="{Binding StopMachineCommand}" Style="{StaticResource ButtonDanger}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
|
||||||
|
<CheckBox IsChecked="{Binding Machine.IsStartButtonPushed}" Content="启动按钮状态 (双向绑定)" IsHitTestVisible="False"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
<Border Background="{DynamicResource PopupBrush}" Margin="0,0,16,16" Padding="20" CornerRadius="12">
|
<Border Background="{DynamicResource PopupBrush}" Margin="0,0,16,16" Padding="20" CornerRadius="12">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<TextBlock Text="提示" FontWeight="SemiBold" />
|
<TextBlock Text="提示" FontWeight="SemiBold" />
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<hc:GlowWindow x:Class="Client.PlcWindow"
|
||||||
|
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"
|
||||||
|
xmlns:conv="clr-namespace:Sharp.Wpf.Converters;assembly=Sharp.Wpf"
|
||||||
|
mc:Ignorable="d"
|
||||||
|
Title="PLC 调试工具" Height="500" Width="800"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
<hc:GlowWindow.Resources>
|
||||||
|
<conv:BoolToVisibilityConverter x:Key="Bool2Vis" />
|
||||||
|
</hc:GlowWindow.Resources>
|
||||||
|
|
||||||
|
<Grid Margin="20">
|
||||||
|
<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">
|
||||||
|
<StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
|
||||||
|
<TextBlock Text="品牌:" VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||||
|
<ComboBox ItemsSource="{Binding Brands}" SelectedItem="{Binding SelectedBrand}" Width="120" Margin="0,0,15,0"/>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Visibility="{Binding IsSiemensOptionsVisible, Converter={StaticResource Bool2Vis}}">
|
||||||
|
<TextBlock Text="型号:" VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||||
|
<ComboBox ItemsSource="{Binding CpuTypes}" SelectedItem="{Binding SelectedCpuType}" Width="120" Margin="0,0,15,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBox hc:InfoElement.Placeholder="IP 地址" hc:InfoElement.Title="IP" Text="{Binding IpAddress}" Width="200" Margin="0,0,10,0"/>
|
||||||
|
<Button Content="连接 / 启动服务" Command="{Binding ConnectCommand}" Margin="0,0,10,0" Style="{StaticResource ButtonPrimary}"/>
|
||||||
|
<Button Content="断开" Command="{Binding DisconnectCommand}"/>
|
||||||
|
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Margin="20,0,0,0" FontWeight="Bold" Foreground="{DynamicResource PrimaryTextBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- 单次读写 -->
|
||||||
|
<GroupBox Header="单次读写测试 (默认 Short)" Grid.Row="1" Margin="0,0,0,15">
|
||||||
|
<Grid Margin="10">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<TextBlock Text="读取地址:" VerticalAlignment="Center"/>
|
||||||
|
<TextBox Grid.Column="1" Text="{Binding ReadAddress}" Margin="5,0"/>
|
||||||
|
<Button Grid.Column="2" Content="读取" Command="{Binding ReadCommand}" Margin="5,0"/>
|
||||||
|
<TextBlock Grid.Column="3" Text="{Binding ReadValue}" VerticalAlignment="Center" Margin="5,0" FontWeight="Bold"/>
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="1" Text="写入地址:" VerticalAlignment="Center" Margin="0,10,0,0"/>
|
||||||
|
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding WriteAddress}" Margin="5,10,0,0"/>
|
||||||
|
<TextBox Grid.Row="1" Grid.Column="3" Text="{Binding WriteValue}" Margin="5,10,0,0"/>
|
||||||
|
<Button Grid.Row="1" Grid.Column="4" Content="写入" Command="{Binding WriteCommand}" Margin="5,10,0,0"/>
|
||||||
|
</Grid>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<!-- 监控 -->
|
||||||
|
<GroupBox Header="实时监控 (Add Watch)" Grid.Row="2" Margin="0,0,0,15">
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="10">
|
||||||
|
<TextBox hc:InfoElement.Placeholder="地址 (如 DB1.DBW0)" Text="{Binding MonitorAddress}" Width="200" Margin="0,0,10,0"/>
|
||||||
|
<ComboBox ItemsSource="{Binding SupportedMonitorTypes}" SelectedItem="{Binding SelectedMonitorType}" Width="120" Margin="0,0,10,0" DisplayMemberPath="Name"/>
|
||||||
|
<Button Content="添加监控" Command="{Binding AddMonitorCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</GroupBox>
|
||||||
|
|
||||||
|
<!-- 监控列表 -->
|
||||||
|
<DataGrid Grid.Row="3" ItemsSource="{Binding MonitoredTags}" AutoGenerateColumns="False" IsReadOnly="True" HeadersVisibility="Column">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="ID" Binding="{Binding Id}" Width="100"/>
|
||||||
|
<DataGridTextColumn Header="地址" Binding="{Binding Address}" Width="150"/>
|
||||||
|
<DataGridTextColumn Header="类型" Binding="{Binding DataType.Name}" Width="80"/>
|
||||||
|
<DataGridTextColumn Header="当前值" Binding="{Binding Value}" Width="*"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</Grid>
|
||||||
|
</hc:GlowWindow>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using Client.ViewModels;
|
||||||
|
using HandyControl.Controls;
|
||||||
|
|
||||||
|
namespace Client;
|
||||||
|
|
||||||
|
public partial class PlcWindow : GlowWindow
|
||||||
|
{
|
||||||
|
public PlcWindow(PlcViewModel viewModel)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = viewModel;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(基础功能)
|
||||||
@@ -70,7 +70,7 @@ public sealed class LoginViewModel : ViewModelBase
|
|||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
ErrorMessage = null;
|
ErrorMessage = null;
|
||||||
|
|
||||||
var result = await _authenticationService.LoginAsync(UserName, Password).ConfigureAwait(false);
|
var result = await _authenticationService.LoginAsync(UserName, Password);
|
||||||
|
|
||||||
if (!result.Succeeded)
|
if (!result.Succeeded)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ using Client.Services;
|
|||||||
using Sharp.Wpf.Commands;
|
using Sharp.Wpf.Commands;
|
||||||
using Sharp.Wpf.Mvvm;
|
using Sharp.Wpf.Mvvm;
|
||||||
using Sharp.Wpf.Services;
|
using Sharp.Wpf.Services;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Common;
|
||||||
|
using Client.Hardware;
|
||||||
|
using Plc.Proxy;
|
||||||
|
using Plc.Abstractions;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
using Sharp.Wpf.Abstractions;
|
||||||
|
|
||||||
namespace Client.ViewModels;
|
namespace Client.ViewModels;
|
||||||
|
|
||||||
@@ -12,23 +20,70 @@ public sealed class MainViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
private readonly IDialogService _dialogService;
|
private readonly IDialogService _dialogService;
|
||||||
private readonly IUserSession _userSession;
|
private readonly IUserSession _userSession;
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
|
||||||
private string _greeting = "欢迎使用 Sharp";
|
private string _greeting = "欢迎使用 Sharp";
|
||||||
private string _statusMessage = "系统就绪";
|
private string _statusMessage = "系统就绪";
|
||||||
private DateTime _lastSyncTime = DateTime.Now;
|
private DateTime _lastSyncTime = DateTime.Now;
|
||||||
|
|
||||||
public MainViewModel(IDialogService dialogService, IUserSession userSession)
|
// 魔法设备对象:直接绑定到界面,无需手动写 Read/Write/Subscribe
|
||||||
|
public IPackMachine Machine { get; }
|
||||||
|
|
||||||
|
public MainViewModel(IDialogService dialogService, IUserSession userSession, IDispatcher dispatcher)
|
||||||
{
|
{
|
||||||
_dialogService = dialogService;
|
_dialogService = dialogService;
|
||||||
_userSession = userSession;
|
_userSession = userSession;
|
||||||
|
_dispatcher = dispatcher;
|
||||||
_greeting = $"欢迎回来,{UserDisplayName}";
|
_greeting = $"欢迎回来,{UserDisplayName}";
|
||||||
_statusMessage = "准备就绪";
|
_statusMessage = "准备就绪";
|
||||||
|
|
||||||
ShowDialogCommand = new AsyncCommand(ShowDialogAsync);
|
ShowDialogCommand = new AsyncCommand(ShowDialogAsync);
|
||||||
RefreshDashboardCommand = new AsyncCommand(RefreshDashboardAsync);
|
RefreshDashboardCommand = new AsyncCommand(RefreshDashboardAsync);
|
||||||
ThrowCommand = new RelayCommand(() => throw new InvalidOperationException("演示:未处理异常将被全局处理"));
|
ThrowCommand = new RelayCommand(() => throw new InvalidOperationException("演示:未处理异常将被全局处理"));
|
||||||
|
|
||||||
|
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>();
|
||||||
|
|
||||||
|
// 2. 创建代理
|
||||||
|
Machine = PlcInterfaceProxy.Create<IPackMachine>(plcService);
|
||||||
|
|
||||||
|
// 3. 代码监听演示:监听属性变化
|
||||||
|
Machine.PropertyChanged += OnMachinePropertyChanged;
|
||||||
|
|
||||||
|
// 4. 控制命令
|
||||||
|
StartMachineCommand = new RelayCommand(() => Machine.IsStartButtonPushed = true);
|
||||||
|
StopMachineCommand = new RelayCommand(() => Machine.IsStartButtonPushed = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnMachinePropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
// 演示:当速度变化时,在日志或状态栏显示
|
||||||
|
if (e.PropertyName == nameof(IPackMachine.RunningSpeed))
|
||||||
|
{
|
||||||
|
_dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (Machine.RunningSpeed > 1000)
|
||||||
|
{
|
||||||
|
StatusMessage = $"警告:速度过高 ({Machine.RunningSpeed})";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//...
|
||||||
|
|
||||||
public string UserDisplayName => _userSession.DisplayName ?? _userSession.UserName ?? "访客";
|
public string UserDisplayName => _userSession.DisplayName ?? _userSession.UserName ?? "访客";
|
||||||
|
|
||||||
public string Greeting
|
public string Greeting
|
||||||
@@ -52,6 +107,10 @@ public sealed class MainViewModel : ViewModelBase
|
|||||||
public IAsyncCommand ShowDialogCommand { get; }
|
public IAsyncCommand ShowDialogCommand { get; }
|
||||||
public IAsyncCommand RefreshDashboardCommand { get; }
|
public IAsyncCommand RefreshDashboardCommand { get; }
|
||||||
public ICommand ThrowCommand { get; }
|
public ICommand ThrowCommand { get; }
|
||||||
|
public ICommand OpenPlcToolCommand { get; }
|
||||||
|
public ICommand OpenSerialPortToolCommand { get; }
|
||||||
|
public ICommand StartMachineCommand { get; }
|
||||||
|
public ICommand StopMachineCommand { get; }
|
||||||
|
|
||||||
private async Task ShowDialogAsync()
|
private async Task ShowDialogAsync()
|
||||||
{
|
{
|
||||||
@@ -83,4 +142,4 @@ public sealed class MainViewModel : ViewModelBase
|
|||||||
IsBusy = false;
|
IsBusy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Plc.Models;
|
||||||
|
using Sharp.Wpf.Mvvm;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Client.ViewModels;
|
||||||
|
|
||||||
|
public class PlcTagViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly PlcTag _tag;
|
||||||
|
|
||||||
|
public PlcTagViewModel(PlcTag tag)
|
||||||
|
{
|
||||||
|
_tag = tag ?? throw new ArgumentNullException(nameof(tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Id => _tag.Id;
|
||||||
|
|
||||||
|
public string Address => _tag.Address;
|
||||||
|
|
||||||
|
public Type DataType => _tag.DataType;
|
||||||
|
|
||||||
|
public object? Value => _tag.Value;
|
||||||
|
|
||||||
|
public PlcTag Tag => _tag;
|
||||||
|
|
||||||
|
public void UpdateValue()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(Value));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using Sharp.Wpf.Mvvm;
|
||||||
|
using Sharp.Wpf.Commands;
|
||||||
|
using Plc.Abstractions;
|
||||||
|
using Plc.Models;
|
||||||
|
using Plc.Siemens.Options;
|
||||||
|
using S7.Net;
|
||||||
|
using System.Windows;
|
||||||
|
using Sharp.Wpf.Abstractions;
|
||||||
|
|
||||||
|
namespace Client.ViewModels;
|
||||||
|
|
||||||
|
public class PlcViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private readonly IPlcService _plcService;
|
||||||
|
private readonly SiemensPlcOptions _siemensOptions;
|
||||||
|
private readonly IDispatcher _dispatcher;
|
||||||
|
|
||||||
|
// 连接配置
|
||||||
|
private PlcBrand _selectedBrand = PlcBrand.Siemens;
|
||||||
|
private CpuType _selectedCpuType = CpuType.S71200;
|
||||||
|
private string _ipAddress = "127.0.0.1";
|
||||||
|
private string _status = "未连接";
|
||||||
|
|
||||||
|
// 读写测试
|
||||||
|
private string _readAddress = "DB1.DBW0";
|
||||||
|
private string _readValue = "-";
|
||||||
|
private string _writeAddress = "DB1.DBW0";
|
||||||
|
private string _writeValue = "0";
|
||||||
|
|
||||||
|
// 监控
|
||||||
|
private string _monitorAddress = "DB1.DBW0";
|
||||||
|
private Type _selectedMonitorType = typeof(short);
|
||||||
|
|
||||||
|
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<PlcTagViewModel>(RemoveMonitor, tag => tag != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Properties
|
||||||
|
|
||||||
|
public PlcBrand SelectedBrand
|
||||||
|
{
|
||||||
|
get => _selectedBrand;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _selectedBrand, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsSiemensOptionsVisible));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public CpuType SelectedCpuType
|
||||||
|
{
|
||||||
|
get => _selectedCpuType;
|
||||||
|
set => SetProperty(ref _selectedCpuType, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsSiemensOptionsVisible => SelectedBrand == PlcBrand.Siemens;
|
||||||
|
|
||||||
|
public string IpAddress
|
||||||
|
{
|
||||||
|
get => _ipAddress;
|
||||||
|
set => SetProperty(ref _ipAddress, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Status
|
||||||
|
{
|
||||||
|
get => _status;
|
||||||
|
set => SetProperty(ref _status, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ReadAddress
|
||||||
|
{
|
||||||
|
get => _readAddress;
|
||||||
|
set => SetProperty(ref _readAddress, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ReadValue
|
||||||
|
{
|
||||||
|
get => _readValue;
|
||||||
|
set => SetProperty(ref _readValue, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string WriteAddress
|
||||||
|
{
|
||||||
|
get => _writeAddress;
|
||||||
|
set => SetProperty(ref _writeAddress, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string WriteValue
|
||||||
|
{
|
||||||
|
get => _writeValue;
|
||||||
|
set => SetProperty(ref _writeValue, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string MonitorAddress
|
||||||
|
{
|
||||||
|
get => _monitorAddress;
|
||||||
|
set => SetProperty(ref _monitorAddress, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Type SelectedMonitorType
|
||||||
|
{
|
||||||
|
get => _selectedMonitorType;
|
||||||
|
set => SetProperty(ref _selectedMonitorType, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据源
|
||||||
|
public ObservableCollection<PlcBrand> Brands { get; } = new(Enum.GetValues<PlcBrand>());
|
||||||
|
public ObservableCollection<CpuType> CpuTypes { get; } = new(Enum.GetValues<CpuType>());
|
||||||
|
public ObservableCollection<Type> SupportedMonitorTypes { get; } = new()
|
||||||
|
{
|
||||||
|
typeof(bool),
|
||||||
|
typeof(byte),
|
||||||
|
typeof(short),
|
||||||
|
typeof(ushort),
|
||||||
|
typeof(int),
|
||||||
|
typeof(uint),
|
||||||
|
typeof(long),
|
||||||
|
typeof(ulong),
|
||||||
|
typeof(float),
|
||||||
|
typeof(double),
|
||||||
|
typeof(string)
|
||||||
|
};
|
||||||
|
public ObservableCollection<PlcTagViewModel> MonitoredTags { get; } = new();
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Commands
|
||||||
|
|
||||||
|
public IAsyncCommand ConnectCommand { get; }
|
||||||
|
public IAsyncCommand DisconnectCommand { get; }
|
||||||
|
public IAsyncCommand ReadCommand { get; }
|
||||||
|
public IAsyncCommand WriteCommand { get; }
|
||||||
|
public ICommand AddMonitorCommand { get; }
|
||||||
|
public ICommand RemoveMonitorCommand { get; }
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
private async Task ConnectAsync()
|
||||||
|
{
|
||||||
|
if (SelectedBrand != PlcBrand.Siemens)
|
||||||
|
{
|
||||||
|
Status = $"暂不支持 {SelectedBrand},请等待后续更新";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Status = "正在连接...";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. 更新配置 (因为 SiemensDriver 注册为单例,且引用了 SiemensPlcOptions 单例)
|
||||||
|
// 修改这里的 Options 会直接影响 Driver 下一次 ConnectAsync 的行为
|
||||||
|
_siemensOptions.IpAddress = IpAddress;
|
||||||
|
_siemensOptions.CpuType = SelectedCpuType;
|
||||||
|
// 端口、机架号等暂时保持默认,也可以暴露在界面上
|
||||||
|
|
||||||
|
// 2. 启动服务 (内部会调用 Driver.ConnectAsync)
|
||||||
|
await _plcService.StartAsync();
|
||||||
|
Status = $"已连接 ({SelectedBrand} - {SelectedCpuType})";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Status = $"连接失败: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DisconnectAsync()
|
||||||
|
{
|
||||||
|
await _plcService.StopAsync();
|
||||||
|
Status = "已断开";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReadAsync()
|
||||||
|
{
|
||||||
|
var result = await _plcService.ReadAsync<short>(ReadAddress);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
ReadValue = result.Value.ToString();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ReadValue = $"Error: {result.ErrorMessage}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteAsync()
|
||||||
|
{
|
||||||
|
if (short.TryParse(WriteValue, out var val))
|
||||||
|
{
|
||||||
|
var result = await _plcService.WriteAsync(WriteAddress, val);
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
Status = $"写入失败: {result.ErrorMessage}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Status = "写入成功";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Status = "输入值无效 (仅支持 short)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddMonitor()
|
||||||
|
{
|
||||||
|
var tag = new PlcTag
|
||||||
|
{
|
||||||
|
Address = MonitorAddress,
|
||||||
|
DataType = SelectedMonitorType
|
||||||
|
};
|
||||||
|
|
||||||
|
var vm = new PlcTagViewModel(tag);
|
||||||
|
|
||||||
|
tag.OnChanged = (t, v) =>
|
||||||
|
{
|
||||||
|
// 使用Dispatcher在UI线程更新值,避免跨线程访问异常
|
||||||
|
_dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
vm.UpdateValue();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
_plcService.Subscribe(tag);
|
||||||
|
|
||||||
|
// 在UI线程添加到集合
|
||||||
|
_dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
MonitoredTags.Add(vm);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveMonitor(PlcTagViewModel? tagVm)
|
||||||
|
{
|
||||||
|
if (tagVm == null) return;
|
||||||
|
|
||||||
|
// 从PLC服务取消订阅
|
||||||
|
_plcService.Unsubscribe(tagVm.Id);
|
||||||
|
|
||||||
|
// 从UI集合移除
|
||||||
|
_dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
MonitoredTags.Remove(tagVm);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "A01D87CBC408FE1E9934F8938867D92F17E3A2CF"
|
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "0048F04EA9C102506BBFAC3803E4711F700CF3ED"
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
// <auto-generated>
|
// <auto-generated>
|
||||||
// This code was generated by a tool.
|
// This code was generated by a tool.
|
||||||
@@ -46,7 +46,7 @@ namespace Client {
|
|||||||
/// InitializeComponent
|
/// InitializeComponent
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
|
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.0.0")]
|
||||||
public void InitializeComponent() {
|
public void InitializeComponent() {
|
||||||
if (_contentLoaded) {
|
if (_contentLoaded) {
|
||||||
return;
|
return;
|
||||||
@@ -66,7 +66,7 @@ namespace Client {
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[System.STAThreadAttribute()]
|
[System.STAThreadAttribute()]
|
||||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
|
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.0.0")]
|
||||||
public static void Main() {
|
public static void Main() {
|
||||||
Client.App app = new Client.App();
|
Client.App app = new Client.App();
|
||||||
app.InitializeComponent();
|
app.InitializeComponent();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Client")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("Client")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[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("Client")]
|
[assembly: System.Reflection.AssemblyProductAttribute("Client")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Client")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Client")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
f8321da7c6a777fc7770c7234548af806317c95cd5f1beb1c1743888aa1bde11
|
8b7343fa73644682ba621a0a8c61df82ebb0d6cdd1d8d63cf26b3522a08f12ee
|
||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
is_global = true
|
is_global = true
|
||||||
build_property.TargetFramework = net8.0-windows
|
build_property.TargetFramework = net8.0-windows
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkVersion = v8.0
|
||||||
build_property.TargetPlatformMinVersion = 7.0
|
build_property.TargetPlatformMinVersion = 7.0
|
||||||
build_property.UsingMicrosoftNETSdkWeb =
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
build_property.ProjectTypeGuids =
|
build_property.ProjectTypeGuids =
|
||||||
@@ -8,7 +10,7 @@ build_property.PlatformNeutralAssembly =
|
|||||||
build_property.EnforceExtendedAnalyzerRules =
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
build_property._SupportedPlatformList = Linux,macOS,Windows
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
build_property.RootNamespace = Client
|
build_property.RootNamespace = Client
|
||||||
build_property.ProjectDir = D:\yuanll\sharp-basic\src\Sharp\Client\
|
build_property.ProjectDir = D:\code\sharp-basic\src\Sharp\Client\
|
||||||
build_property.EnableComHosting =
|
build_property.EnableComHosting =
|
||||||
build_property.EnableGeneratedComInterfaceComImportInterop =
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
#pragma checksum "..\..\..\MainWindow.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "9E813BA3492F961B4CE0E3DE4E78408D972934B6"
|
|
||||||
//------------------------------------------------------------------------------
|
|
||||||
// <auto-generated>
|
|
||||||
// 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>
|
|
||||||
//------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
using HandyControl.Controls;
|
|
||||||
using HandyControl.Data;
|
|
||||||
using HandyControl.Expression.Media;
|
|
||||||
using HandyControl.Expression.Shapes;
|
|
||||||
using HandyControl.Interactivity;
|
|
||||||
using HandyControl.Media.Animation;
|
|
||||||
using HandyControl.Media.Effects;
|
|
||||||
using HandyControl.Properties.Langs;
|
|
||||||
using HandyControl.Themes;
|
|
||||||
using HandyControl.Tools;
|
|
||||||
using HandyControl.Tools.Converter;
|
|
||||||
using HandyControl.Tools.Extension;
|
|
||||||
using Sharp.Wpf.Converters;
|
|
||||||
using Sharp.Wpf.Windowing;
|
|
||||||
using System;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Windows;
|
|
||||||
using System.Windows.Automation;
|
|
||||||
using System.Windows.Controls;
|
|
||||||
using System.Windows.Controls.Primitives;
|
|
||||||
using System.Windows.Controls.Ribbon;
|
|
||||||
using System.Windows.Data;
|
|
||||||
using System.Windows.Documents;
|
|
||||||
using System.Windows.Forms.Integration;
|
|
||||||
using System.Windows.Ink;
|
|
||||||
using System.Windows.Input;
|
|
||||||
using System.Windows.Markup;
|
|
||||||
using System.Windows.Media;
|
|
||||||
using System.Windows.Media.Animation;
|
|
||||||
using System.Windows.Media.Effects;
|
|
||||||
using System.Windows.Media.Imaging;
|
|
||||||
using System.Windows.Media.Media3D;
|
|
||||||
using System.Windows.Media.TextFormatting;
|
|
||||||
using System.Windows.Navigation;
|
|
||||||
using System.Windows.Shapes;
|
|
||||||
using System.Windows.Shell;
|
|
||||||
|
|
||||||
|
|
||||||
namespace Client {
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// MainWindow
|
|
||||||
/// </summary>
|
|
||||||
public partial class MainWindow : HandyControl.Controls.GlowWindow, System.Windows.Markup.IComponentConnector {
|
|
||||||
|
|
||||||
private bool _contentLoaded;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// InitializeComponent
|
|
||||||
/// </summary>
|
|
||||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
|
||||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
|
|
||||||
public void InitializeComponent() {
|
|
||||||
if (_contentLoaded) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_contentLoaded = true;
|
|
||||||
System.Uri resourceLocater = new System.Uri("/Client;component/mainwindow.xaml", System.UriKind.Relative);
|
|
||||||
|
|
||||||
#line 1 "..\..\..\MainWindow.xaml"
|
|
||||||
System.Windows.Application.LoadComponent(this, resourceLocater);
|
|
||||||
|
|
||||||
#line default
|
|
||||||
#line hidden
|
|
||||||
}
|
|
||||||
|
|
||||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
|
||||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
|
|
||||||
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
|
|
||||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")]
|
|
||||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")]
|
|
||||||
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1800:DoNotCastUnnecessarily")]
|
|
||||||
void System.Windows.Markup.IComponentConnector.Connect(int connectionId, object target) {
|
|
||||||
this._contentLoaded = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Plc.Abstractions;
|
||||||
|
using Plc.Services;
|
||||||
|
using Plc.Siemens;
|
||||||
|
using Plc.Siemens.Options;
|
||||||
|
|
||||||
|
namespace Plc.Siemens.DependencyInjection;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 添加西门子PLC服务到DI容器
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="services">服务集合</param>
|
||||||
|
/// <param name="configureOptions">配置选项的委托</param>
|
||||||
|
/// <returns>服务集合</returns>
|
||||||
|
public static IServiceCollection AddSiemensPlc(
|
||||||
|
this IServiceCollection services,
|
||||||
|
Action<SiemensPlcOptions> configureOptions)
|
||||||
|
{
|
||||||
|
// 使用标准的 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Plc.Abstractions;
|
||||||
|
|
||||||
|
namespace Plc.Siemens.Models;
|
||||||
|
|
||||||
|
public enum SiemensMemoryArea
|
||||||
|
{
|
||||||
|
Inputs = 0x81,
|
||||||
|
Outputs = 0x82,
|
||||||
|
Memory = 0x83,
|
||||||
|
DataBlock = 0x84,
|
||||||
|
Timer = 0x1D,
|
||||||
|
Counter = 0x1C
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
return $"{Area}:{DbNumber}:{ByteOffset}.{BitOffset}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using S7.Net;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace Plc.Siemens.Options;
|
||||||
|
|
||||||
|
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>
|
||||||
|
/// CPU 类型 (例如: S71200, S71500, S7300, S7400, S7200, S7200Smart).
|
||||||
|
/// 默认为 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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Plc\Plc.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using Common.Results;
|
||||||
|
using Plc.Abstractions;
|
||||||
|
using Plc.Siemens.Models;
|
||||||
|
using Plc.Siemens.Options;
|
||||||
|
using Plc.Siemens.Utils;
|
||||||
|
using S7.Net;
|
||||||
|
|
||||||
|
namespace Plc.Siemens;
|
||||||
|
|
||||||
|
public class SiemensDriver : IPlcDriver
|
||||||
|
{
|
||||||
|
private readonly SiemensPlcOptions _options;
|
||||||
|
private S7.Net.Plc? _plc;
|
||||||
|
|
||||||
|
public SiemensDriver(SiemensPlcOptions options)
|
||||||
|
{
|
||||||
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsConnected => _plc?.IsConnected ?? false;
|
||||||
|
|
||||||
|
public int MaxPduSize => _options.PduSize;
|
||||||
|
|
||||||
|
public async Task<Result> ConnectAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Close existing connection if any
|
||||||
|
if (_plc != null)
|
||||||
|
{
|
||||||
|
_plc.Close();
|
||||||
|
_plc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new instance with latest options
|
||||||
|
_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($"Connection failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Result> DisconnectAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_plc?.Close();
|
||||||
|
return Task.FromResult(Result.Success());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
return Result.Success(bytes);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure<byte[]>($"Read failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> WriteBytesAsync(string address, byte[] data, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!IsConnected || _plc == null)
|
||||||
|
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);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Result.Failure($"Write failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DataType MapToS7DataType(SiemensMemoryArea area)
|
||||||
|
{
|
||||||
|
return area switch
|
||||||
|
{
|
||||||
|
SiemensMemoryArea.DataBlock => DataType.DataBlock,
|
||||||
|
SiemensMemoryArea.Memory => DataType.Memory,
|
||||||
|
SiemensMemoryArea.Inputs => DataType.Input,
|
||||||
|
SiemensMemoryArea.Outputs => DataType.Output,
|
||||||
|
SiemensMemoryArea.Timer => DataType.Timer,
|
||||||
|
SiemensMemoryArea.Counter => DataType.Counter,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(area), area, $"Unsupported memory area: {area}")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_plc?.Close();
|
||||||
|
_plc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using Plc.Abstractions;
|
||||||
|
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 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)
|
||||||
|
{
|
||||||
|
var batches = new List<PlcReadBatch>();
|
||||||
|
|
||||||
|
// 1. 准备标签(解析地址)
|
||||||
|
var validTags = new List<PlcTag>();
|
||||||
|
foreach (var tag in tags)
|
||||||
|
{
|
||||||
|
if (tag.ParsedAddress == null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
tag.ParsedAddress = SiemensAddressParser.Parse(tag.Address);
|
||||||
|
_logger?.LogTrace("Parsed address {Address} for tag {TagId}", tag.Address, tag.Id);
|
||||||
|
}
|
||||||
|
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 =>
|
||||||
|
{
|
||||||
|
var addr = (SiemensAddress)t.ParsedAddress!;
|
||||||
|
return new { addr.Area, addr.DbNumber };
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
// 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 = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
var batchStartAddr = (SiemensAddress)currentBatch.ParsedStartAddress!;
|
||||||
|
int currentBatchEnd = batchStartAddr.ByteOffset + currentBatch.TotalLength;
|
||||||
|
|
||||||
|
// 计算间隔 (Gap)
|
||||||
|
int gap = addr.ByteOffset - currentBatchEnd;
|
||||||
|
|
||||||
|
// 计算潜在的新长度
|
||||||
|
// 新标签地址 + 其长度 - 批次起始位置
|
||||||
|
int newTotalLength = (addr.ByteOffset + byteLength) - batchStartAddr.ByteOffset;
|
||||||
|
|
||||||
|
// 合并条件:
|
||||||
|
// 1. 标签在当前结束之后(或位置相同)开始(因为已排序,所以是肯定的)
|
||||||
|
// 2. 间隔足够小
|
||||||
|
// 3. 总大小适合 PDU
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlcReadBatch CreateBatch(PlcTag tag, SiemensAddress addr, int byteLength)
|
||||||
|
{
|
||||||
|
return new PlcReadBatch
|
||||||
|
{
|
||||||
|
StartAddress = tag.Address,
|
||||||
|
ParsedStartAddress = addr,
|
||||||
|
TotalLength = byteLength,
|
||||||
|
Tags = new List<PlcTag> { tag }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Plc.Siemens.Models;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Plc.Siemens.Utils;
|
||||||
|
|
||||||
|
public static class SiemensAddressParser
|
||||||
|
{
|
||||||
|
// DB匹配: DB1.DBB0, DB1.DBW2, DB1.DBD4, DB1.DBX0.0
|
||||||
|
// Group 1: DbNumber
|
||||||
|
// Group 2: Type (B,W,D,X) - Ignored
|
||||||
|
// Group 3: ByteOffset
|
||||||
|
// Group 5: BitOffset (Optional)
|
||||||
|
private static readonly Regex DbRegex = new(@"^DB(\d+)\.DB[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// M匹配: M100, MB100, MW100, MD100, MX100.0, M100.1
|
||||||
|
// Group 1: ByteOffset
|
||||||
|
// Group 3: BitOffset (Optional)
|
||||||
|
private static readonly Regex MRegex = new(@"^M[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// I匹配: I0.0, IB0, IW0, ID0
|
||||||
|
private static readonly Regex IRegex = new(@"^I[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// Q匹配: Q0.0, QB0, QW0, QD0
|
||||||
|
private static readonly Regex QRegex = new(@"^Q[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
public static SiemensAddress Parse(string address)
|
||||||
|
{
|
||||||
|
address = address.Trim().ToUpper();
|
||||||
|
|
||||||
|
// 1. Try DB
|
||||||
|
var dbMatch = DbRegex.Match(address);
|
||||||
|
if (dbMatch.Success)
|
||||||
|
{
|
||||||
|
return new SiemensAddress
|
||||||
|
{
|
||||||
|
Area = SiemensMemoryArea.DataBlock,
|
||||||
|
DbNumber = int.Parse(dbMatch.Groups[1].Value),
|
||||||
|
ByteOffset = int.Parse(dbMatch.Groups[2].Value),
|
||||||
|
BitOffset = dbMatch.Groups[4].Success ? int.Parse(dbMatch.Groups[4].Value) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try M (Memory/Flags)
|
||||||
|
var mMatch = MRegex.Match(address);
|
||||||
|
if (mMatch.Success)
|
||||||
|
{
|
||||||
|
return new SiemensAddress
|
||||||
|
{
|
||||||
|
Area = SiemensMemoryArea.Memory,
|
||||||
|
DbNumber = 0,
|
||||||
|
ByteOffset = int.Parse(mMatch.Groups[1].Value),
|
||||||
|
BitOffset = mMatch.Groups[3].Success ? int.Parse(mMatch.Groups[3].Value) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Try I (Inputs)
|
||||||
|
var iMatch = IRegex.Match(address);
|
||||||
|
if (iMatch.Success)
|
||||||
|
{
|
||||||
|
return new SiemensAddress
|
||||||
|
{
|
||||||
|
Area = SiemensMemoryArea.Inputs,
|
||||||
|
DbNumber = 0,
|
||||||
|
ByteOffset = int.Parse(iMatch.Groups[1].Value),
|
||||||
|
BitOffset = iMatch.Groups[3].Success ? int.Parse(iMatch.Groups[3].Value) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Try Q (Outputs)
|
||||||
|
var qMatch = QRegex.Match(address);
|
||||||
|
if (qMatch.Success)
|
||||||
|
{
|
||||||
|
return new SiemensAddress
|
||||||
|
{
|
||||||
|
Area = SiemensMemoryArea.Outputs,
|
||||||
|
DbNumber = 0,
|
||||||
|
ByteOffset = int.Parse(qMatch.Groups[1].Value),
|
||||||
|
BitOffset = qMatch.Groups[3].Success ? int.Parse(qMatch.Groups[3].Value) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentException($"不支持或无效的地址格式: {address}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Plc.Abstractions;
|
||||||
|
|
||||||
|
public interface IDataConverter
|
||||||
|
{
|
||||||
|
/// <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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Common.Results;
|
||||||
|
|
||||||
|
namespace Plc.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定义 PLC 驱动的底层契约。
|
||||||
|
/// 此接口的实现处理具体的协议细节(例如:S7, Modbus)。
|
||||||
|
/// </summary>
|
||||||
|
public interface IPlcDriver : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取一个值,指示驱动程序当前是否已连接到 PLC。
|
||||||
|
/// </summary>
|
||||||
|
bool IsConnected { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取连接支持的最大 PDU(协议数据单元)大小。
|
||||||
|
/// 优化器使用此值来确定最大批量大小。
|
||||||
|
/// </summary>
|
||||||
|
int MaxPduSize { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 建立与 PLC 的连接。
|
||||||
|
/// </summary>
|
||||||
|
Task<Result> ConnectAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 关闭与 PLC 的连接。
|
||||||
|
/// </summary>
|
||||||
|
Task<Result> DisconnectAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从指定地址读取原始字节序列。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="address">起始地址字符串(例如 "DB1.0")。</param>
|
||||||
|
/// <param name="length">要读取的字节数。</param>
|
||||||
|
Task<Result<byte[]>> ReadBytesAsync(string address, ushort length, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将原始字节序列写入指定地址。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="address">起始地址字符串。</param>
|
||||||
|
/// <param name="data">要写入的字节。</param>
|
||||||
|
Task<Result> WriteBytesAsync(string address, byte[] data, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Plc.Models;
|
||||||
|
|
||||||
|
namespace Plc.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 负责分析请求的标签列表,并将它们分组为优化的批量请求。
|
||||||
|
/// </summary>
|
||||||
|
public interface IPlcOptimizer
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 基于地址位置和 PDU 大小,将单个标签分组为高效的读取批次。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tags">活动订阅列表。</param>
|
||||||
|
/// <param name="maxPduSize">单次请求中可读取的最大字节数。</param>
|
||||||
|
/// <returns>批量请求列表。</returns>
|
||||||
|
List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示覆盖多个标签的单个优化读取操作。
|
||||||
|
/// </summary>
|
||||||
|
public class PlcReadBatch
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 此批量读取的起始地址。
|
||||||
|
/// </summary>
|
||||||
|
public string StartAddress { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 要读取的总字节数。
|
||||||
|
/// </summary>
|
||||||
|
public int TotalLength { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 包含在此批量范围内的标签列表。
|
||||||
|
/// </summary>
|
||||||
|
public List<PlcTag> Tags { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 驱动程序特定的起始地址上下文(避免重新解析)。
|
||||||
|
/// 使用 IPlcAddress 接口以支持类型安全访问。
|
||||||
|
/// </summary>
|
||||||
|
public IPlcAddress? ParsedStartAddress { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Common.Results;
|
||||||
|
using Plc.Models;
|
||||||
|
|
||||||
|
namespace Plc.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用于与 PLC 交互的高层服务。
|
||||||
|
/// 处理连接管理、批量读取和数据转换。
|
||||||
|
/// </summary>
|
||||||
|
public interface IPlcService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 启动后台监控循环。
|
||||||
|
/// </summary>
|
||||||
|
Task StartAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 停止后台监控循环。
|
||||||
|
/// </summary>
|
||||||
|
Task StopAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 对单个地址执行立即、临时的读取。
|
||||||
|
/// </summary>
|
||||||
|
Task<Result<T>> ReadAsync<T>(string address);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 对单个地址执行立即、临时的写入。
|
||||||
|
/// </summary>
|
||||||
|
Task<Result> WriteAsync<T>(string address, T value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 订阅一个标签到监控循环。
|
||||||
|
/// </summary>
|
||||||
|
void Subscribe(PlcTag tag);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从监控循环取消订阅标签。
|
||||||
|
/// </summary>
|
||||||
|
void Unsubscribe(string tagId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Plc.Attributes;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Property)]
|
||||||
|
public class PlcAddressAttribute : Attribute
|
||||||
|
{
|
||||||
|
public string Address { get; }
|
||||||
|
public int Length { get; }
|
||||||
|
|
||||||
|
public PlcAddressAttribute(string address, int length = 1)
|
||||||
|
{
|
||||||
|
Address = address;
|
||||||
|
Length = length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Plc.Models;
|
||||||
|
|
||||||
|
public enum PlcBrand
|
||||||
|
{
|
||||||
|
Siemens,
|
||||||
|
Omron, // 预留
|
||||||
|
Mitsubishi, // 预留
|
||||||
|
ModbusTcp // 预留
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Plc.Abstractions;
|
||||||
|
|
||||||
|
namespace Plc.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示用户想要监控或访问的 PLC 中的特定数据点。
|
||||||
|
/// </summary>
|
||||||
|
public class PlcTag
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 此标签订阅的唯一标识符。
|
||||||
|
/// </summary>
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PLC 地址字符串(例如 "DB1.DBW0", "M100.1")。
|
||||||
|
/// </summary>
|
||||||
|
public required string Address { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 此数据表示的 .NET 类型(例如 typeof(int), typeof(float))。
|
||||||
|
/// </summary>
|
||||||
|
public required Type DataType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 数据长度(主要用于字符串或数组)。默认为 1。
|
||||||
|
/// </summary>
|
||||||
|
public int Length { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 此标签的最后已知值。
|
||||||
|
/// </summary>
|
||||||
|
public object? Value { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当值更改(或刷新)时调用的操作。
|
||||||
|
/// 回调接收 Tag 实例和新值。
|
||||||
|
/// </summary>
|
||||||
|
public Action<PlcTag, object?>? OnChanged { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 内部使用:由驱动程序/优化器用于存储解析后的地址对象。
|
||||||
|
/// 这避免了在每个周期重新解析地址字符串。
|
||||||
|
/// 使用 IPlcAddress 接口以支持类型安全访问。
|
||||||
|
/// </summary>
|
||||||
|
public IPlcAddress? ParsedAddress { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Common\Common.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Reflection;
|
||||||
|
using Plc.Abstractions;
|
||||||
|
using Plc.Attributes;
|
||||||
|
using Plc.Models;
|
||||||
|
|
||||||
|
namespace Plc.Proxy;
|
||||||
|
|
||||||
|
public class PlcInterfaceProxy : DispatchProxy, INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private IPlcService _plcService = null!;
|
||||||
|
private readonly Dictionary<string, PlcTag> _tags = new();
|
||||||
|
|
||||||
|
// 关键修改:使用私有字段手动管理委托链,避免递归调用
|
||||||
|
private PropertyChangedEventHandler? _propertyChangedInternal;
|
||||||
|
|
||||||
|
// 声明为 virtual event 只是为了骗过编译器的 TypeLoadException,
|
||||||
|
// 但我们在 Invoke 里不操作它,而是操作 _propertyChangedInternal。
|
||||||
|
// 实际上,对于 DispatchProxy 来说,我们并不需要这个 event 真的工作,
|
||||||
|
// 只需要它存在以满足接口签名,并允许被重写。
|
||||||
|
public virtual event PropertyChangedEventHandler? PropertyChanged
|
||||||
|
{
|
||||||
|
add => _propertyChangedInternal += value;
|
||||||
|
remove => _propertyChangedInternal -= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||||
|
{
|
||||||
|
if (targetMethod == null) return null;
|
||||||
|
|
||||||
|
// 1. Handle Property Getters (get_PropName)
|
||||||
|
if (targetMethod.Name.StartsWith("get_"))
|
||||||
|
{
|
||||||
|
var propName = targetMethod.Name.Substring(4);
|
||||||
|
if (_tags.TryGetValue(propName, out var tag))
|
||||||
|
{
|
||||||
|
return tag.Value; // Return cached value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Handle Property Setters (set_PropName)
|
||||||
|
if (targetMethod.Name.StartsWith("set_"))
|
||||||
|
{
|
||||||
|
var propName = targetMethod.Name.Substring(4);
|
||||||
|
if (_tags.TryGetValue(propName, out var tag) && args != null && args.Length > 0)
|
||||||
|
{
|
||||||
|
var newValue = args[0];
|
||||||
|
_plcService.WriteAsync(tag.Address, newValue).GetAwaiter().GetResult();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Handle INotifyPropertyChanged methods (add_PropertyChanged, remove_PropertyChanged)
|
||||||
|
// 这里是关键:我们拦截对 add_PropertyChanged 的调用,
|
||||||
|
// 将其重定向到我们的私有字段 _propertyChangedInternal,
|
||||||
|
// 从而避免调用 this.PropertyChanged += ... 导致的递归。
|
||||||
|
if (targetMethod.Name == "add_PropertyChanged" && args != null && args.Length == 1)
|
||||||
|
{
|
||||||
|
var handler = (PropertyChangedEventHandler)args[0]!;
|
||||||
|
_propertyChangedInternal += handler;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (targetMethod.Name == "remove_PropertyChanged" && args != null && args.Length == 1)
|
||||||
|
{
|
||||||
|
var handler = (PropertyChangedEventHandler)args[0]!;
|
||||||
|
_propertyChangedInternal -= handler;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static T Create<T>(IPlcService plcService) where T : class, INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
object proxy = Create<T, PlcInterfaceProxy>();
|
||||||
|
((PlcInterfaceProxy)proxy).Initialize(typeof(T), plcService);
|
||||||
|
return (T)proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Initialize(Type interfaceType, IPlcService plcService)
|
||||||
|
{
|
||||||
|
_plcService = plcService;
|
||||||
|
|
||||||
|
foreach (var prop in interfaceType.GetProperties())
|
||||||
|
{
|
||||||
|
var attr = prop.GetCustomAttribute<PlcAddressAttribute>();
|
||||||
|
if (attr != null)
|
||||||
|
{
|
||||||
|
var tag = new PlcTag
|
||||||
|
{
|
||||||
|
Address = attr.Address,
|
||||||
|
DataType = prop.PropertyType,
|
||||||
|
Length = attr.Length,
|
||||||
|
OnChanged = (t, v) =>
|
||||||
|
{
|
||||||
|
// 触发时,调用私有字段
|
||||||
|
_propertyChangedInternal?.Invoke(this, new PropertyChangedEventArgs(prop.Name));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_tags[prop.Name] = tag;
|
||||||
|
_plcService.Subscribe(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
# 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格式字符串 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 核心通信优化机制
|
||||||
|
|
||||||
|
本项目采用了**分桶(Batching/Bucketing)**优化策略,这是高性能 PLC 通信的关键。
|
||||||
|
|
||||||
|
### 1. 为什么需要优化?
|
||||||
|
PLC 通信通常基于 TCP/IP 协议,每次读写请求都包含网络握手、协议头封装和 PLC 内部处理时间。如果用户订阅了 100 个离散点位,如果发送 100 次网络请求,延迟将极高(例如 100 * 20ms = 2000ms),无法满足实时监控需求。
|
||||||
|
|
||||||
|
### 2. 优化原理
|
||||||
|
系统通过 `IPlcOptimizer`(如 `SiemensOptimizer`)在内存中动态重组读写请求,将多个小的、地址相邻的请求合并为一个大的网络请求。
|
||||||
|
|
||||||
|
#### 处理流程(伪代码)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[原始订阅Tags] --> B[1. 分组 Grouping]
|
||||||
|
B --> C[2. 排序 Sorting]
|
||||||
|
C --> D[3. 合并 Merging]
|
||||||
|
D --> E[执行网络请求]
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **分组 (Grouping)**: 根据内存区域(如 DB1, DB2, M区, I区)将 Tag 分组。不同区域无法合并。
|
||||||
|
2. **排序 (Sorting)**: 在同一组内,按字节偏移量(Byte Offset)从小到大排序。
|
||||||
|
3. **合并 (Merging)**:
|
||||||
|
* 遍历排序后的 Tags。
|
||||||
|
* 计算两个 Tag 之间的**间隙 (Gap)**。
|
||||||
|
* 如果 `Gap <= MaxGapBytes` (默认20字节) 且 `TotalLength <= MaxPduSize` (最大包长),则合并。
|
||||||
|
* 否则,创建一个新的**批次 (Batch)**。
|
||||||
|
|
||||||
|
### 3. 实际案例分析
|
||||||
|
|
||||||
|
假设用户订阅了以下点位(西门子 S7):
|
||||||
|
- Tag A: `DB1.DBW0` (2 bytes)
|
||||||
|
- Tag B: `DB1.DBW4` (2 bytes)
|
||||||
|
- Tag C: `DB1.DBW1000` (2 bytes)
|
||||||
|
|
||||||
|
**处理逻辑:**
|
||||||
|
1. **Tag A 和 Tag B**:
|
||||||
|
* Tag A 结束于 byte 2,Tag B 开始于 byte 4。
|
||||||
|
* 间隙 Gap = 4 - 2 = 2 bytes。
|
||||||
|
* 2 bytes < 20 bytes (阈值),且总长未超 PDU。
|
||||||
|
* **结果**: 合并为一个请求:`Start: 0, Length: 6`。虽然读回了无用的 byte 2-3,但节省了一次网络请求。
|
||||||
|
2. **Tag B 和 Tag C**:
|
||||||
|
* Tag B 结束于 byte 6,Tag C 开始于 byte 1000。
|
||||||
|
* 间隙 Gap = 994 bytes。
|
||||||
|
* 994 bytes > 20 bytes。
|
||||||
|
* **结果**: 断开,Tag C 放入新的批次。
|
||||||
|
|
||||||
|
**最终网络请求**: 2 次(而不是 3 次)。
|
||||||
|
|
||||||
|
### 4. 极限情况处理
|
||||||
|
|
||||||
|
* **只订阅 1 个点位**: 优化器生成 1 个批次,包含 1 个 Tag。无额外开销。
|
||||||
|
* **连续订阅 1000 个点位 (例如 DB1.DBW0 - DB1.DBW2000)**:
|
||||||
|
* 总长度 2000 字节。
|
||||||
|
* 假设 PLC PDU 限制为 960 字节。
|
||||||
|
* 优化器会自动切分为 3 个批次:
|
||||||
|
* Batch 1: 0 - 960
|
||||||
|
* Batch 2: 960 - 1920
|
||||||
|
* Batch 3: 1920 - 2000
|
||||||
|
* 系统自动处理分包,上层业务无感。
|
||||||
|
|
||||||
|
### 5. 未来扩展性 (适配欧姆龙/三菱)
|
||||||
|
|
||||||
|
本架构设计之初就考虑了多品牌支持。适配新品牌只需实现以下接口,**无需修改 Client/ViewModel 代码**:
|
||||||
|
|
||||||
|
1. **实现 `IPlcDriver`**: 处理底层的 Socket/串口 通信(如 FINS, MC 协议)。
|
||||||
|
2. **实现 `IPlcAddress`**: 定义新品牌的地址格式(如欧姆龙的 `D100`, `W0.00`)。
|
||||||
|
3. **实现 `IPlcOptimizer`**: 针对新协议调整优化参数。
|
||||||
|
|
||||||
|
#### 扩展示例(伪代码)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 欧姆龙 FINS 协议优化器示例
|
||||||
|
public class OmronOptimizer : IPlcOptimizer
|
||||||
|
{
|
||||||
|
// 欧姆龙按 Word (2byte) 寻址,且单次读取限制不同
|
||||||
|
private const int MaxWordCount = 2000;
|
||||||
|
|
||||||
|
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
|
||||||
|
{
|
||||||
|
// 1. 按内存区 (DM, CIO, WR) 分组
|
||||||
|
// 2. 按 Word 地址排序
|
||||||
|
// 3. 合并逻辑:如果两个地址相差 < 10 个 Words,则合并读取
|
||||||
|
// 4. 返回适用于 FINS 协议的 ReadRequest 列表
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 高级功能
|
||||||
|
|
||||||
|
### 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();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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)
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
using System.Buffers.Binary;
|
||||||
|
using Plc.Abstractions;
|
||||||
|
|
||||||
|
namespace Plc.Services;
|
||||||
|
|
||||||
|
public class BigEndianConverter : IDataConverter
|
||||||
|
{
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, int bitOffset = 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];
|
||||||
|
BinaryPrimitives.WriteInt16BigEndian(bytes, (short)value);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UShort类型
|
||||||
|
if (type == typeof(ushort))
|
||||||
|
{
|
||||||
|
var bytes = new byte[2];
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(bytes, (ushort)value);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Int类型
|
||||||
|
if (type == typeof(int))
|
||||||
|
{
|
||||||
|
var bytes = new byte[4];
|
||||||
|
BinaryPrimitives.WriteInt32BigEndian(bytes, (int)value);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UInt类型
|
||||||
|
if (type == typeof(uint))
|
||||||
|
{
|
||||||
|
var bytes = new byte[4];
|
||||||
|
BinaryPrimitives.WriteUInt32BigEndian(bytes, (uint)value);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Float类型
|
||||||
|
if (type == typeof(float))
|
||||||
|
{
|
||||||
|
var bytes = new byte[4];
|
||||||
|
var intVal = BitConverter.SingleToInt32Bits((float)value);
|
||||||
|
BinaryPrimitives.WriteInt32BigEndian(bytes, intVal);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double类型
|
||||||
|
if (type == typeof(double))
|
||||||
|
{
|
||||||
|
var bytes = new byte[8];
|
||||||
|
var longVal = BitConverter.DoubleToInt64Bits((double)value);
|
||||||
|
BinaryPrimitives.WriteInt64BigEndian(bytes, longVal);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
using Common.Results;
|
||||||
|
using Plc.Abstractions;
|
||||||
|
using Plc.Models;
|
||||||
|
using Plc.Utils;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Plc.Services;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 重连策略
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
_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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_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)
|
||||||
|
{
|
||||||
|
_logger?.LogDebug("Monitor task cancelled successfully");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_monitorTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_cts != null)
|
||||||
|
{
|
||||||
|
_cts.Dispose();
|
||||||
|
_cts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _driver.DisconnectAsync();
|
||||||
|
_logger?.LogInformation("PLC monitor service stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Subscribe(PlcTag 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)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
_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)
|
||||||
|
{
|
||||||
|
_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)
|
||||||
|
{
|
||||||
|
_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();
|
||||||
|
if (currentTags.Count == 0)
|
||||||
|
{
|
||||||
|
await Task.Delay(1000, token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优化:计算分桶
|
||||||
|
var batches = _optimizer.Optimize(currentTags, _driver.MaxPduSize);
|
||||||
|
_logger?.LogTrace("Optimized {TagCount} tags into {BatchCount} batches",
|
||||||
|
currentTags.Count, batches.Count);
|
||||||
|
|
||||||
|
// 执行读取
|
||||||
|
foreach (var batch in batches)
|
||||||
|
{
|
||||||
|
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 (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)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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 = tag.DataType == typeof(string)
|
||||||
|
? TypeHelper.GetSize(tag.DataType, tag.Length)
|
||||||
|
: TypeHelper.GetSize(tag.DataType);
|
||||||
|
|
||||||
|
if (relativeOffset + typeSize <= data.Length)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 传递位偏移量以支持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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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中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;
|
||||||
|
|
||||||
|
// 字符串类型需要明确指定长度
|
||||||
|
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} 包)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data", "Data\Data.csproj",
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data.Test", "..\..\test\Data.Test\Data.Test.csproj", "{8EEC8BF4-CF9A-473D-B7A0-005E151C645D}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data.Test", "..\..\test\Data.Test\Data.Test.csproj", "{8EEC8BF4-CF9A-473D-B7A0-005E151C645D}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Plc", "Plc\Plc.csproj", "{12FA0DCD-A69B-4862-901C-A8CF8694B9E3}"
|
||||||
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -117,6 +123,18 @@ Global
|
|||||||
{8EEC8BF4-CF9A-473D-B7A0-005E151C645D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{8EEC8BF4-CF9A-473D-B7A0-005E151C645D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{8EEC8BF4-CF9A-473D-B7A0-005E151C645D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{8EEC8BF4-CF9A-473D-B7A0-005E151C645D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{8EEC8BF4-CF9A-473D-B7A0-005E151C645D}.Release|Any CPU.Build.0 = Release|Any CPU
|
{8EEC8BF4-CF9A-473D-B7A0-005E151C645D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{12FA0DCD-A69B-4862-901C-A8CF8694B9E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{12FA0DCD-A69B-4862-901C-A8CF8694B9E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{12FA0DCD-A69B-4862-901C-A8CF8694B9E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{12FA0DCD-A69B-4862-901C-A8CF8694B9E3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7C877AB8-1938-4CC7-8692-0D2253C728DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
// <auto-generated>
|
// <auto-generated>
|
||||||
// 此代码由工具生成。
|
// This code was generated by a tool.
|
||||||
// 运行时版本:4.0.30319.42000
|
|
||||||
//
|
//
|
||||||
// 对此文件的更改可能会导致不正确的行为,并且如果
|
// Changes to this file may cause incorrect behavior and will be lost if
|
||||||
// 重新生成代码,这些更改将会丢失。
|
// the code is regenerated.
|
||||||
// </auto-generated>
|
// </auto-generated>
|
||||||
//------------------------------------------------------------------------------
|
//------------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -14,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Sharp.Wpf")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("Sharp.Wpf")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[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.AssemblyProductAttribute("Sharp.Wpf")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Sharp.Wpf")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Sharp.Wpf")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[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.ApplicationUseCompatibleTextRendering =
|
||||||
build_property.ApplicationVisualStyles =
|
build_property.ApplicationVisualStyles =
|
||||||
build_property.TargetFramework = net8.0-windows
|
build_property.TargetFramework = net8.0-windows
|
||||||
|
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||||
|
build_property.TargetFrameworkVersion = v8.0
|
||||||
build_property.TargetPlatformMinVersion = 7.0
|
build_property.TargetPlatformMinVersion = 7.0
|
||||||
build_property.UsingMicrosoftNETSdkWeb =
|
build_property.UsingMicrosoftNETSdkWeb =
|
||||||
build_property.ProjectTypeGuids =
|
build_property.ProjectTypeGuids =
|
||||||
@@ -14,7 +16,7 @@ build_property.PlatformNeutralAssembly =
|
|||||||
build_property.EnforceExtendedAnalyzerRules =
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
build_property._SupportedPlatformList = Linux,macOS,Windows
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
build_property.RootNamespace = Sharp.Wpf
|
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.EnableComHosting =
|
||||||
build_property.EnableGeneratedComInterfaceComImportInterop =
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// <auto-generated/>
|
// <auto-generated/>
|
||||||
global using global::System;
|
global using System;
|
||||||
global using global::System.Collections.Generic;
|
global using System.Collections.Generic;
|
||||||
global using global::System.Drawing;
|
global using System.Drawing;
|
||||||
global using global::System.Linq;
|
global using System.Linq;
|
||||||
global using global::System.Threading;
|
global using System.Threading;
|
||||||
global using global::System.Threading.Tasks;
|
global using System.Threading.Tasks;
|
||||||
global using global::System.Windows.Forms;
|
global using System.Windows.Forms;
|
||||||
Binary file not shown.
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"format": 1,
|
"format": 1,
|
||||||
"restore": {
|
"restore": {
|
||||||
"D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj": {}
|
"D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj": {}
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj": {
|
"D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"restore": {
|
"restore": {
|
||||||
"projectUniqueName": "D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
"projectUniqueName": "D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||||
"projectName": "Sharp.Wpf",
|
"projectName": "Sharp.Wpf",
|
||||||
"projectPath": "D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
"projectPath": "D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||||
"packagesPath": "C:\\Users\\yllyo\\.nuget\\packages\\",
|
"packagesPath": "C:\\Users\\Administrator\\.nuget\\packages\\",
|
||||||
"outputPath": "D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\obj\\",
|
"outputPath": "D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\obj\\",
|
||||||
"projectStyle": "PackageReference",
|
"projectStyle": "PackageReference",
|
||||||
"fallbackFolders": [
|
"fallbackFolders": [
|
||||||
"D:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
|
"D:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
|
||||||
],
|
],
|
||||||
"configFilePaths": [
|
"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.FallbackLocation.config",
|
||||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
||||||
],
|
],
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"auditLevel": "low",
|
"auditLevel": "low",
|
||||||
"auditMode": "direct"
|
"auditMode": "direct"
|
||||||
},
|
},
|
||||||
"SdkAnalysisLevel": "9.0.200"
|
"SdkAnalysisLevel": "10.0.100"
|
||||||
},
|
},
|
||||||
"frameworks": {
|
"frameworks": {
|
||||||
"net8.0-windows7.0": {
|
"net8.0-windows7.0": {
|
||||||
@@ -68,12 +68,7 @@
|
|||||||
"privateAssets": "none"
|
"privateAssets": "none"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.203/PortableRuntimeIdentifierGraph.json"
|
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.100/PortableRuntimeIdentifierGraph.json"
|
||||||
}
|
|
||||||
},
|
|
||||||
"runtimes": {
|
|
||||||
"win-x64": {
|
|
||||||
"#import": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
|
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
|
||||||
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
|
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
|
||||||
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
|
<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>
|
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
|
||||||
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">6.13.2</NuGetToolVersion>
|
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">7.0.0</NuGetToolVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
|
<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\" />
|
<SourceRoot Include="D:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,31 +1,30 @@
|
|||||||
{
|
{
|
||||||
"version": 3,
|
"version": 3,
|
||||||
"targets": {
|
"targets": {
|
||||||
"net8.0-windows7.0": {},
|
"net8.0-windows7.0": {}
|
||||||
"net8.0-windows7.0/win-x64": {}
|
|
||||||
},
|
},
|
||||||
"libraries": {},
|
"libraries": {},
|
||||||
"projectFileDependencyGroups": {
|
"projectFileDependencyGroups": {
|
||||||
"net8.0-windows7.0": []
|
"net8.0-windows7.0": []
|
||||||
},
|
},
|
||||||
"packageFolders": {
|
"packageFolders": {
|
||||||
"C:\\Users\\yllyo\\.nuget\\packages\\": {},
|
"C:\\Users\\Administrator\\.nuget\\packages\\": {},
|
||||||
"D:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages": {}
|
"D:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages": {}
|
||||||
},
|
},
|
||||||
"project": {
|
"project": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"restore": {
|
"restore": {
|
||||||
"projectUniqueName": "D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
"projectUniqueName": "D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||||
"projectName": "Sharp.Wpf",
|
"projectName": "Sharp.Wpf",
|
||||||
"projectPath": "D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
"projectPath": "D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||||
"packagesPath": "C:\\Users\\yllyo\\.nuget\\packages\\",
|
"packagesPath": "C:\\Users\\Administrator\\.nuget\\packages\\",
|
||||||
"outputPath": "D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\obj\\",
|
"outputPath": "D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\obj\\",
|
||||||
"projectStyle": "PackageReference",
|
"projectStyle": "PackageReference",
|
||||||
"fallbackFolders": [
|
"fallbackFolders": [
|
||||||
"D:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
|
"D:\\Program Files (x86)\\Microsoft Visual Studio\\Shared\\NuGetPackages"
|
||||||
],
|
],
|
||||||
"configFilePaths": [
|
"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.FallbackLocation.config",
|
||||||
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
"C:\\Program Files (x86)\\NuGet\\Config\\Microsoft.VisualStudio.Offline.config"
|
||||||
],
|
],
|
||||||
@@ -52,7 +51,7 @@
|
|||||||
"auditLevel": "low",
|
"auditLevel": "low",
|
||||||
"auditMode": "direct"
|
"auditMode": "direct"
|
||||||
},
|
},
|
||||||
"SdkAnalysisLevel": "9.0.200"
|
"SdkAnalysisLevel": "10.0.100"
|
||||||
},
|
},
|
||||||
"frameworks": {
|
"frameworks": {
|
||||||
"net8.0-windows7.0": {
|
"net8.0-windows7.0": {
|
||||||
@@ -76,12 +75,7 @@
|
|||||||
"privateAssets": "none"
|
"privateAssets": "none"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\9.0.203/PortableRuntimeIdentifierGraph.json"
|
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\10.0.100/PortableRuntimeIdentifierGraph.json"
|
||||||
}
|
|
||||||
},
|
|
||||||
"runtimes": {
|
|
||||||
"win-x64": {
|
|
||||||
"#import": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"dgSpecHash": "9ZityDkLrvk=",
|
"dgSpecHash": "NHqLv58UxH0=",
|
||||||
"success": true,
|
"success": true,
|
||||||
"projectFilePath": "D:\\yuanll\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
"projectFilePath": "D:\\code\\sharp-basic\\src\\Sharp\\Wpf\\Wpf.csproj",
|
||||||
"expectedPackageFiles": [],
|
"expectedPackageFiles": [],
|
||||||
"logs": []
|
"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