Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1d059ed29 | ||
|
|
2739a71134 | ||
|
|
dd993e5b45 | ||
|
|
e334e65c2d | ||
|
|
1d412bba33 |
No files matched your search
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -10,3 +10,7 @@
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ using System;
|
||||
using System.Windows;
|
||||
using Client.Services;
|
||||
using Client.ViewModels;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Common;
|
||||
using Config;
|
||||
@@ -10,10 +12,8 @@ using NetClient.DependencyInjection;
|
||||
using Sharp.Infrastructure.Configuration;
|
||||
using Sharp.Log.Extensions;
|
||||
using Sharp.NetServer.DependencyInjection;
|
||||
using Sharp.Wpf.Exceptions;
|
||||
using Sharp.Wpf.Services;
|
||||
using Sharp.Wpf.Abstractions;
|
||||
using Plc.Siemens.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Client
|
||||
{
|
||||
@@ -27,58 +27,7 @@ namespace Client
|
||||
protected override void OnStartup(StartupEventArgs 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)
|
||||
var cm = ConfigBuilder.BuildFromFiles(new[] { "appsettings.json" }, reloadOnChange: true, includeEnvironment: true, basePath: AppContext.BaseDirectory);
|
||||
|
||||
@@ -87,8 +36,7 @@ namespace Client
|
||||
|
||||
// ConfigManager and typed SystemConfig accessor
|
||||
services.AddSingleton(cm);
|
||||
// Register standard IConfiguration so that .BindConfiguration() works
|
||||
services.AddSingleton<IConfiguration>(cm.Configuration);
|
||||
services.TryAddSingleton<IConfiguration>(cm.Configuration); // Register IConfiguration for BindConfiguration
|
||||
services.AddSystemConfig(cm);
|
||||
|
||||
// Logging (binds from section "SharpLog")
|
||||
@@ -98,53 +46,47 @@ namespace Client
|
||||
services.AddNetClient(cm);
|
||||
services.AddNetServer(cm);
|
||||
|
||||
// PLC
|
||||
services.AddSiemensPlc(options =>
|
||||
{
|
||||
options.IpAddress = "127.0.0.1";
|
||||
options.CpuType = S7.Net.CpuType.S71200;
|
||||
});
|
||||
// WPF exception handler
|
||||
services.AddSingleton<IUiExceptionHandler, UiExceptionHandler>();
|
||||
|
||||
// WPF app services
|
||||
services.AddSingleton<IDialogService, DialogService>();
|
||||
services.AddSingleton<IAuthenticationService, DemoAuthenticationService>();
|
||||
services.AddSingleton<IUserSession, UserSession>();
|
||||
// Register DispatcherService with the current UI dispatcher
|
||||
services.AddSingleton<IDispatcher>(new DispatcherService(Dispatcher));
|
||||
|
||||
services.AddTransient<LoginViewModel>();
|
||||
services.AddTransient<MainViewModel>();
|
||||
services.AddTransient<PlcViewModel>();
|
||||
|
||||
services.AddTransient<LoginWindow>();
|
||||
services.AddTransient<MainWindow>();
|
||||
services.AddTransient<PlcWindow>();
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
|
||||
// 3) Register global facades for convenience
|
||||
Global.RegisterSingleton<IServiceProvider>(_serviceProvider);
|
||||
// 3) Activate global WPF exception handler
|
||||
var exceptionHandler = _serviceProvider.GetRequiredService<IUiExceptionHandler>();
|
||||
exceptionHandler.Register();
|
||||
|
||||
// 4) Register global facades for convenience
|
||||
Global.RegisterSingleton(_serviceProvider);
|
||||
Global.RegisterSingleton(cm);
|
||||
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
|
||||
// Explicitly set ShutdownMode to prevent app from closing when LoginWindow closes
|
||||
ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
|
||||
// 5) Show login window first, then main shell if authenticated
|
||||
var logger = _serviceProvider.GetRequiredService<ILogger<App>>();
|
||||
logger.LogInformation("Showing login window");
|
||||
var loginWindow = _serviceProvider.GetRequiredService<LoginWindow>();
|
||||
var loginResult = loginWindow.ShowDialog();
|
||||
|
||||
logger.LogInformation("Login window closed with result: {LoginResult}", loginResult);
|
||||
|
||||
if (loginResult == true)
|
||||
{
|
||||
logger.LogInformation("Login successful, showing main window");
|
||||
var main = _serviceProvider.GetRequiredService<MainWindow>();
|
||||
Current.MainWindow = main;
|
||||
ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||
main.Show();
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Login cancelled or failed, shutting down application");
|
||||
Shutdown();
|
||||
}
|
||||
}
|
||||
@@ -155,4 +97,4 @@ namespace Client
|
||||
base.OnExit(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,6 @@
|
||||
<ProjectReference Include="..\NetServer\NetServer.csproj" />
|
||||
<ProjectReference Include="..\Log\Log.csproj" />
|
||||
<ProjectReference Include="..\Sharp.Infrastructure\Sharp.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\Plc.Siemens\Plc.Siemens.csproj" />
|
||||
<ProjectReference Include="..\Ports\Ports.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,18 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,23 +1,27 @@
|
||||
using System;
|
||||
using HandyControl.Controls;
|
||||
using Client.ViewModels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Client;
|
||||
|
||||
public partial class LoginWindow : GlowWindow
|
||||
{
|
||||
private readonly LoginViewModel _viewModel;
|
||||
private readonly ILogger<LoginWindow>? _logger;
|
||||
|
||||
public LoginWindow(LoginViewModel viewModel)
|
||||
public LoginWindow(LoginViewModel viewModel, ILogger<LoginWindow>? logger = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
_viewModel = viewModel;
|
||||
_logger = logger;
|
||||
DataContext = viewModel;
|
||||
_viewModel.LoginSucceeded += HandleLoginSucceeded;
|
||||
}
|
||||
|
||||
private void HandleLoginSucceeded(object? sender, EventArgs e)
|
||||
{
|
||||
_logger?.LogInformation("HandleLoginSucceeded called, setting DialogResult = true");
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,17 +47,7 @@
|
||||
Command="{Binding ShowDialogCommand}" />
|
||||
<Button Content="触发异常"
|
||||
Padding="18,6"
|
||||
Margin="0,0,12,0"
|
||||
Command="{Binding ThrowCommand}" />
|
||||
<Button Content="串口调试"
|
||||
Padding="18,6"
|
||||
Margin="0,0,12,0"
|
||||
Style="{StaticResource ButtonInfo}"
|
||||
Command="{Binding OpenSerialPortToolCommand}" />
|
||||
<Button Content="PLC 调试"
|
||||
Padding="18,6"
|
||||
Style="{StaticResource ButtonPrimary}"
|
||||
Command="{Binding OpenPlcToolCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -92,45 +82,6 @@
|
||||
<TextBlock Text="{Binding Greeting}" Margin="0,8,0,0" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</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">
|
||||
<StackPanel>
|
||||
<TextBlock Text="提示" FontWeight="SemiBold" />
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<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"/>
|
||||
<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>
|
||||
@@ -1,14 +0,0 @@
|
||||
using System.Windows;
|
||||
using Client.ViewModels;
|
||||
using HandyControl.Controls;
|
||||
|
||||
namespace Client;
|
||||
|
||||
public partial class PlcWindow : GlowWindow
|
||||
{
|
||||
public PlcWindow(PlcViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
<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>
|
||||
@@ -1,644 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
# 串口通信调试工具使用说明
|
||||
|
||||
## 功能概览
|
||||
|
||||
串口通信调试工具集成了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(基础功能)
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Client.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Sharp.Wpf.Commands;
|
||||
using Sharp.Wpf.Mvvm;
|
||||
|
||||
@@ -10,6 +11,7 @@ public sealed class LoginViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IAuthenticationService _authenticationService;
|
||||
private readonly IUserSession _userSession;
|
||||
private readonly ILogger<LoginViewModel> _logger;
|
||||
private readonly IAsyncCommand _loginCommand;
|
||||
|
||||
private string _userName = "admin";
|
||||
@@ -18,10 +20,11 @@ public sealed class LoginViewModel : ViewModelBase
|
||||
|
||||
public event EventHandler? LoginSucceeded;
|
||||
|
||||
public LoginViewModel(IAuthenticationService authenticationService, IUserSession userSession)
|
||||
public LoginViewModel(IAuthenticationService authenticationService, IUserSession userSession, ILogger<LoginViewModel> logger)
|
||||
{
|
||||
_authenticationService = authenticationService;
|
||||
_userSession = userSession;
|
||||
_logger = logger;
|
||||
_loginCommand = new AsyncCommand(LoginAsync);
|
||||
}
|
||||
|
||||
@@ -67,6 +70,7 @@ public sealed class LoginViewModel : ViewModelBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Login attempt for user: {UserName}", UserName);
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
|
||||
@@ -74,13 +78,21 @@ public sealed class LoginViewModel : ViewModelBase
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
_logger.LogWarning("Login failed for user: {UserName}, Error: {Error}", UserName, result.Error);
|
||||
ErrorMessage = result.Error;
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Login succeeded for user: {UserName}", UserName);
|
||||
_userSession.SignIn(UserName, result.DisplayName);
|
||||
_logger.LogInformation("Invoking LoginSucceeded event");
|
||||
LoginSucceeded?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Login failed for user: {UserName}", UserName);
|
||||
ErrorMessage = "An unexpected error occurred.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
|
||||
@@ -5,14 +5,6 @@ using Client.Services;
|
||||
using Sharp.Wpf.Commands;
|
||||
using Sharp.Wpf.Mvvm;
|
||||
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;
|
||||
|
||||
@@ -20,70 +12,23 @@ public sealed class MainViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IDialogService _dialogService;
|
||||
private readonly IUserSession _userSession;
|
||||
private readonly IDispatcher _dispatcher;
|
||||
|
||||
private string _greeting = "欢迎使用 Sharp";
|
||||
private string _statusMessage = "系统就绪";
|
||||
private DateTime _lastSyncTime = DateTime.Now;
|
||||
|
||||
// 魔法设备对象:直接绑定到界面,无需手动写 Read/Write/Subscribe
|
||||
public IPackMachine Machine { get; }
|
||||
|
||||
public MainViewModel(IDialogService dialogService, IUserSession userSession, IDispatcher dispatcher)
|
||||
public MainViewModel(IDialogService dialogService, IUserSession userSession)
|
||||
{
|
||||
_dialogService = dialogService;
|
||||
_userSession = userSession;
|
||||
_dispatcher = dispatcher;
|
||||
_greeting = $"欢迎回来,{UserDisplayName}";
|
||||
_statusMessage = "准备就绪";
|
||||
|
||||
ShowDialogCommand = new AsyncCommand(ShowDialogAsync);
|
||||
RefreshDashboardCommand = new AsyncCommand(RefreshDashboardAsync);
|
||||
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 Greeting
|
||||
@@ -107,10 +52,6 @@ public sealed class MainViewModel : ViewModelBase
|
||||
public IAsyncCommand ShowDialogCommand { get; }
|
||||
public IAsyncCommand RefreshDashboardCommand { 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()
|
||||
{
|
||||
@@ -142,4 +83,4 @@ public sealed class MainViewModel : ViewModelBase
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
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";
|
||||
|
||||
public PlcViewModel(
|
||||
IPlcService plcService,
|
||||
SiemensPlcOptions siemensOptions,
|
||||
IDispatcher dispatcher)
|
||||
{
|
||||
_plcService = plcService;
|
||||
_siemensOptions = siemensOptions;
|
||||
_dispatcher = dispatcher;
|
||||
|
||||
// 初始化界面值为当前配置值
|
||||
_ipAddress = _siemensOptions.IpAddress;
|
||||
_selectedCpuType = _siemensOptions.CpuType;
|
||||
|
||||
ConnectCommand = new AsyncCommand(ConnectAsync);
|
||||
DisconnectCommand = new AsyncCommand(DisconnectAsync);
|
||||
ReadCommand = new AsyncCommand(ReadAsync);
|
||||
WriteCommand = new AsyncCommand(WriteAsync);
|
||||
AddMonitorCommand = new RelayCommand(AddMonitor);
|
||||
RemoveMonitorCommand = new RelayCommand<PlcTag>(RemoveMonitor, tag => tag != null);
|
||||
}
|
||||
|
||||
#region Properties
|
||||
|
||||
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 ObservableCollection<PlcBrand> Brands { get; } = new(Enum.GetValues<PlcBrand>());
|
||||
public ObservableCollection<CpuType> CpuTypes { get; } = new(Enum.GetValues<CpuType>());
|
||||
public ObservableCollection<PlcTag> 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 = typeof(short),
|
||||
OnChanged = (t, v) =>
|
||||
{
|
||||
// 使用Dispatcher在UI线程更新值,避免跨线程访问异常
|
||||
_dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
t.Value = v;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_plcService.Subscribe(tag);
|
||||
|
||||
// 在UI线程添加到集合
|
||||
_dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
MonitoredTags.Add(tag);
|
||||
});
|
||||
}
|
||||
|
||||
private void RemoveMonitor(PlcTag? tag)
|
||||
{
|
||||
if (tag == null) return;
|
||||
|
||||
// 从PLC服务取消订阅
|
||||
_plcService.Unsubscribe(tag.Id);
|
||||
|
||||
// 从UI集合移除
|
||||
_dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
MonitoredTags.Remove(tag);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "0048F04EA9C102506BBFAC3803E4711F700CF3ED"
|
||||
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "A01D87CBC408FE1E9934F8938867D92F17E3A2CF"
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
@@ -46,7 +46,7 @@ namespace Client {
|
||||
/// InitializeComponent
|
||||
/// </summary>
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.0.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
|
||||
public void InitializeComponent() {
|
||||
if (_contentLoaded) {
|
||||
return;
|
||||
@@ -66,7 +66,7 @@ namespace Client {
|
||||
/// </summary>
|
||||
[System.STAThreadAttribute()]
|
||||
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.0.0")]
|
||||
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
|
||||
public static void Main() {
|
||||
Client.App app = new Client.App();
|
||||
app.InitializeComponent();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <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>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
@@ -13,7 +14,7 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Client")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+bc09280d4ea03ae8d0db7cd8c25ddd02d63abd84")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+f7660c49e81d5fe2747f6514545ceb961c2066d3")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Client")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Client")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@@ -1 +1 @@
|
||||
8b7343fa73644682ba621a0a8c61df82ebb0d6cdd1d8d63cf26b3522a08f12ee
|
||||
eeea53cf7db257ee716efdd2fdcdb5f1a810b49927811d410a05e1ff19899369
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
is_global = true
|
||||
build_property.TargetFramework = net8.0-windows
|
||||
build_property.TargetFrameworkIdentifier = .NETCoreApp
|
||||
build_property.TargetFrameworkVersion = v8.0
|
||||
build_property.TargetPlatformMinVersion = 7.0
|
||||
build_property.UsingMicrosoftNETSdkWeb =
|
||||
build_property.ProjectTypeGuids =
|
||||
@@ -10,7 +8,7 @@ build_property.PlatformNeutralAssembly =
|
||||
build_property.EnforceExtendedAnalyzerRules =
|
||||
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||
build_property.RootNamespace = Client
|
||||
build_property.ProjectDir = D:\code\sharp-basic\src\Sharp\Client\
|
||||
build_property.ProjectDir = D:\yuanll\sharp-basic\src\Sharp\Client\
|
||||
build_property.EnableComHosting =
|
||||
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#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,503 @@
|
||||
using System.Data;
|
||||
using System.Data.OleDb;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Sharp.Data.Access.Connections;
|
||||
using Sharp.Data.Access.Initialization;
|
||||
using Sharp.Data.Access.Internal;
|
||||
using Sharp.Data.Access.Options;
|
||||
using Sharp.Data.Access.Security;
|
||||
|
||||
namespace Sharp.Data.Access;
|
||||
|
||||
/// <summary>
|
||||
/// Access 数据库事务实现
|
||||
/// </summary>
|
||||
internal class AccessTransaction : IAccessTransaction
|
||||
{
|
||||
private readonly OleDbConnection _connection;
|
||||
private readonly OleDbTransaction _transaction;
|
||||
private readonly ILogger? _logger;
|
||||
private readonly bool _ownsConnection;
|
||||
private bool _disposed;
|
||||
|
||||
public AccessTransaction(OleDbConnection connection, bool ownsConnection, ILogger? logger)
|
||||
{
|
||||
_connection = connection;
|
||||
_ownsConnection = ownsConnection;
|
||||
_transaction = connection.BeginTransaction();
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public int Execute(string sql, params OleDbParameter[] parameters)
|
||||
{
|
||||
using var command = new OleDbCommand(sql, _connection, _transaction);
|
||||
if (parameters.Length > 0)
|
||||
command.Parameters.AddRange(parameters);
|
||||
|
||||
return command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public List<T> Query<T>(string sql, params OleDbParameter[] parameters) where T : new()
|
||||
{
|
||||
using var command = new OleDbCommand(sql, _connection, _transaction);
|
||||
if (parameters.Length > 0)
|
||||
command.Parameters.AddRange(parameters);
|
||||
|
||||
var results = new List<T>();
|
||||
using var reader = command.ExecuteReader();
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
var item = MapReaderToObject<T>(reader);
|
||||
results.Add(item);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public object? ExecuteScalar(string sql, params OleDbParameter[] parameters)
|
||||
{
|
||||
using var command = new OleDbCommand(sql, _connection, _transaction);
|
||||
if (parameters.Length > 0)
|
||||
command.Parameters.AddRange(parameters);
|
||||
|
||||
return command.ExecuteScalar();
|
||||
}
|
||||
|
||||
public void Commit()
|
||||
{
|
||||
_transaction.Commit();
|
||||
_logger?.LogDebug("事务已提交");
|
||||
}
|
||||
|
||||
public void Rollback()
|
||||
{
|
||||
_transaction.Rollback();
|
||||
_logger?.LogDebug("事务已回滚");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
try
|
||||
{
|
||||
_transaction.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_ownsConnection)
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private static T MapReaderToObject<T>(IDataReader reader) where T : new()
|
||||
{
|
||||
var obj = new T();
|
||||
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
// 创建字段名到索引的映射(大小写不敏感)
|
||||
var fieldMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
var fieldName = reader.GetName(i);
|
||||
fieldMap[fieldName] = i;
|
||||
|
||||
// Access 列名有时包含末尾空格,提前做一次修剪以便映射
|
||||
var trimmed = fieldName.Trim();
|
||||
if (!fieldMap.ContainsKey(trimmed))
|
||||
{
|
||||
fieldMap[trimmed] = i;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
if (!property.CanWrite) continue;
|
||||
|
||||
try
|
||||
{
|
||||
// 尝试查找匹配的字段(大小写不敏感)
|
||||
if (fieldMap.TryGetValue(property.Name, out int ordinal))
|
||||
{
|
||||
var value = reader.GetValue(ordinal);
|
||||
|
||||
if (value != DBNull.Value && value != null)
|
||||
{
|
||||
// 尝试类型转换
|
||||
if (property.PropertyType == value.GetType())
|
||||
{
|
||||
property.SetValue(obj, value);
|
||||
}
|
||||
else if (property.PropertyType == typeof(string))
|
||||
{
|
||||
property.SetValue(obj, value.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
// 尝试使用 Convert.ChangeType
|
||||
var convertedValue = Convert.ChangeType(value, property.PropertyType);
|
||||
property.SetValue(obj, convertedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 字段不存在或类型转换失败,保持默认值
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Access 数据服务实现(基于 System.Data.OleDb)
|
||||
/// </summary>
|
||||
public class AccessDataService : IAccessDataService
|
||||
{
|
||||
private readonly AccessDatabaseOptions _options;
|
||||
private readonly IAccessConnectionStringFactory _connectionFactory;
|
||||
private readonly IAccessDatabaseInitializer _initializer;
|
||||
private readonly IAccessPasswordDecryptor _decryptor;
|
||||
private readonly ILogger<AccessDataService>? _logger;
|
||||
private readonly string _connectionString;
|
||||
|
||||
public AccessDataService(
|
||||
AccessDatabaseOptions options,
|
||||
IAccessConnectionStringFactory? connectionFactory = null,
|
||||
IAccessDatabaseInitializer? initializer = null,
|
||||
IAccessPasswordDecryptor? decryptor = null,
|
||||
ILogger<AccessDataService>? logger = null)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_options.Validate();
|
||||
|
||||
_connectionFactory = connectionFactory ?? new AccessConnectionStringFactory(decryptor ?? new PassthroughAccessPasswordDecryptor());
|
||||
_initializer = initializer ?? new AccessDatabaseInitializer();
|
||||
_decryptor = decryptor ?? new PassthroughAccessPasswordDecryptor();
|
||||
_logger = logger;
|
||||
|
||||
try
|
||||
{
|
||||
// 确保数据库存在(如果配置了模板复制)
|
||||
_initializer.EnsureDatabase(_options, NullServiceProvider.Instance);
|
||||
|
||||
// 构建连接字符串
|
||||
_connectionString = _connectionFactory.Create(_options, NullServiceProvider.Instance);
|
||||
|
||||
_logger?.LogInformation("AccessDataService 已初始化,数据库: {DatabaseFile}, Provider: {Provider}",
|
||||
_options.DatabaseFile, _options.ProviderName);
|
||||
_logger?.LogDebug("实际连接字符串: {ConnectionString}", _connectionString);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "AccessDataService 初始化失败");
|
||||
|
||||
// 提供详细的错误诊断信息
|
||||
if (ex.Message.Contains("找不到") || ex is FileNotFoundException)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"找不到 Access 数据库文件: {_options.DatabaseFile}。" +
|
||||
$"请确保文件存在或配置 TemplateFile 以自动创建。", ex);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public List<T> Query<T>(string sql, params OleDbParameter[] parameters) where T : new()
|
||||
{
|
||||
_logger?.LogDebug("执行查询: {Sql}", sql);
|
||||
|
||||
return ExecuteWithRetry(() =>
|
||||
{
|
||||
using var connection = CreateConnection();
|
||||
connection.Open();
|
||||
|
||||
using var command = new OleDbCommand(sql, connection);
|
||||
if (parameters.Length > 0)
|
||||
{
|
||||
// 克隆参数以避免重试时的"已包含在另一个集合中"错误
|
||||
var clonedParams = parameters.Select(p => CloneParameter(p)).ToArray();
|
||||
command.Parameters.AddRange(clonedParams);
|
||||
}
|
||||
|
||||
var results = new List<T>();
|
||||
using var reader = command.ExecuteReader();
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
var item = MapReaderToObject<T>(reader);
|
||||
results.Add(item);
|
||||
}
|
||||
|
||||
_logger?.LogDebug("查询返回 {Count} 条记录", results.Count);
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
public T? QueryFirstOrDefault<T>(string sql, params OleDbParameter[] parameters) where T : new()
|
||||
{
|
||||
_logger?.LogDebug("执行单条查询: {Sql}", sql);
|
||||
|
||||
return ExecuteWithRetry(() =>
|
||||
{
|
||||
using var connection = CreateConnection();
|
||||
connection.Open();
|
||||
|
||||
using var command = new OleDbCommand(sql, connection);
|
||||
if (parameters.Length > 0)
|
||||
{
|
||||
// 克隆参数以避免重试时的"已包含在另一个集合中"错误
|
||||
var clonedParams = parameters.Select(p => CloneParameter(p)).ToArray();
|
||||
command.Parameters.AddRange(clonedParams);
|
||||
}
|
||||
|
||||
using var reader = command.ExecuteReader();
|
||||
|
||||
if (reader.Read())
|
||||
{
|
||||
return MapReaderToObject<T>(reader);
|
||||
}
|
||||
|
||||
return default;
|
||||
});
|
||||
}
|
||||
|
||||
public object? ExecuteScalar(string sql, params OleDbParameter[] parameters)
|
||||
{
|
||||
_logger?.LogDebug("执行标量查询: {Sql}", sql);
|
||||
|
||||
return ExecuteWithRetry(() =>
|
||||
{
|
||||
using var connection = CreateConnection();
|
||||
connection.Open();
|
||||
|
||||
using var command = new OleDbCommand(sql, connection);
|
||||
if (parameters.Length > 0)
|
||||
{
|
||||
// 克隆参数以避免重试时的"已包含在另一个集合中"错误
|
||||
var clonedParams = parameters.Select(p => CloneParameter(p)).ToArray();
|
||||
command.Parameters.AddRange(clonedParams);
|
||||
}
|
||||
|
||||
var result = command.ExecuteScalar();
|
||||
_logger?.LogDebug("标量查询返回: {Result}", result);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
public int Execute(string sql, params OleDbParameter[] parameters)
|
||||
{
|
||||
_logger?.LogDebug("执行命令: {Sql}", sql);
|
||||
|
||||
return ExecuteWithRetry(() =>
|
||||
{
|
||||
using var connection = CreateConnection();
|
||||
connection.Open();
|
||||
|
||||
using var command = new OleDbCommand(sql, connection);
|
||||
if (parameters.Length > 0)
|
||||
{
|
||||
// 克隆参数以避免重试时的"已包含在另一个集合中"错误
|
||||
var clonedParams = parameters.Select(p => CloneParameter(p)).ToArray();
|
||||
command.Parameters.AddRange(clonedParams);
|
||||
}
|
||||
|
||||
var rowsAffected = command.ExecuteNonQuery();
|
||||
_logger?.LogDebug("命令影响了 {RowsAffected} 行", rowsAffected);
|
||||
return rowsAffected;
|
||||
});
|
||||
}
|
||||
|
||||
public IAccessTransaction BeginTransaction()
|
||||
{
|
||||
_logger?.LogDebug("开始事务");
|
||||
var connection = CreateConnection();
|
||||
connection.Open();
|
||||
return new AccessTransaction(connection, ownsConnection: true, _logger);
|
||||
}
|
||||
|
||||
public void ExecuteInTransaction(Action<IAccessTransaction> action)
|
||||
{
|
||||
_logger?.LogDebug("在事务中执行操作");
|
||||
|
||||
using var transaction = BeginTransaction();
|
||||
try
|
||||
{
|
||||
action(transaction);
|
||||
transaction.Commit();
|
||||
_logger?.LogDebug("事务执行成功");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "事务执行失败,正在回滚");
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TestConnection()
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger?.LogDebug("测试数据库连接");
|
||||
using var connection = CreateConnection();
|
||||
connection.Open();
|
||||
_logger?.LogDebug("数据库连接成功");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "数据库连接失败");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private OleDbConnection CreateConnection()
|
||||
{
|
||||
return new OleDbConnection(_connectionString);
|
||||
}
|
||||
|
||||
private T ExecuteWithRetry<T>(Func<T> operation)
|
||||
{
|
||||
var maxRetries = _options.MaxRetryCount;
|
||||
var retryDelay = _options.RetryDelayMilliseconds;
|
||||
|
||||
for (int attempt = 0; attempt <= maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return operation();
|
||||
}
|
||||
catch (OleDbException ex) when (attempt < maxRetries && IsRetriableError(ex))
|
||||
{
|
||||
_logger?.LogWarning(ex, "数据库操作失败,正在重试 ({Attempt}/{MaxRetries})...", attempt + 1, maxRetries);
|
||||
Thread.Sleep(retryDelay);
|
||||
}
|
||||
catch (OleDbException ex)
|
||||
{
|
||||
// 提供详细的错误诊断信息
|
||||
var errorMessage = $"Access 数据库操作失败: {ex.Message}";
|
||||
|
||||
// 检查是否是 Provider 相关的错误
|
||||
if (ex.Message.Contains("找不到") || ex.Message.Contains("未注册") ||
|
||||
ex.Message.Contains("OLE DB") || ex.Message.Contains("多步"))
|
||||
{
|
||||
errorMessage += $"\n\n可能的原因:" +
|
||||
$"\n1. 未安装 Access Database Engine:当前使用 Provider = {_options.ProviderName}" +
|
||||
$"\n - 如果使用 16.0,需要安装 Access Database Engine 2016 或 Office 2016+" +
|
||||
$"\n - 如果使用 12.0,需要安装 Access Database Engine 2010 或 Office 2010+" +
|
||||
$"\n - 建议:优先使用 12.0 版本,兼容性最好" +
|
||||
$"\n2. 32位/64位不匹配:进程架构必须与 Access Driver 架构一致" +
|
||||
$"\n3. 数据库文件损坏或被其他程序锁定" +
|
||||
$"\n4. 数据库文件路径不正确:{_options.DatabaseFile}";
|
||||
|
||||
_logger?.LogError(ex, "数据库连接失败,Provider: {Provider}", _options.ProviderName);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(errorMessage, ex);
|
||||
}
|
||||
}
|
||||
|
||||
// 不会到这里,因为最后一次尝试会抛出异常
|
||||
throw new InvalidOperationException("重试逻辑错误");
|
||||
}
|
||||
|
||||
private static bool IsRetriableError(OleDbException ex)
|
||||
{
|
||||
// Access 数据库常见的可重试错误
|
||||
// -2147467259: 数据库被锁定
|
||||
// -2147217865: 无法打开数据库(可能正在被其他进程使用)
|
||||
return ex.ErrorCode == -2147467259 || ex.ErrorCode == -2147217865;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 克隆 OleDbParameter 以避免重试时参数被重复添加的问题
|
||||
/// </summary>
|
||||
private static OleDbParameter CloneParameter(OleDbParameter original)
|
||||
{
|
||||
var clone = new OleDbParameter
|
||||
{
|
||||
ParameterName = original.ParameterName,
|
||||
Value = original.Value,
|
||||
OleDbType = original.OleDbType,
|
||||
Size = original.Size,
|
||||
Direction = original.Direction,
|
||||
IsNullable = original.IsNullable,
|
||||
Precision = original.Precision,
|
||||
Scale = original.Scale,
|
||||
SourceColumn = original.SourceColumn,
|
||||
SourceVersion = original.SourceVersion
|
||||
};
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static T MapReaderToObject<T>(IDataReader reader) where T : new()
|
||||
{
|
||||
var obj = new T();
|
||||
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
// 创建字段名到索引的映射(大小写不敏感)
|
||||
var fieldMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
for (int i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
var fieldName = reader.GetName(i);
|
||||
fieldMap[fieldName] = i;
|
||||
|
||||
// Access 列名有时包含末尾空格,提前做一次修剪以便映射
|
||||
var trimmed = fieldName.Trim();
|
||||
if (!fieldMap.ContainsKey(trimmed))
|
||||
{
|
||||
fieldMap[trimmed] = i;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
if (!property.CanWrite) continue;
|
||||
|
||||
try
|
||||
{
|
||||
// 尝试查找匹配的字段(大小写不敏感)
|
||||
if (fieldMap.TryGetValue(property.Name, out int ordinal))
|
||||
{
|
||||
var value = reader.GetValue(ordinal);
|
||||
|
||||
if (value != DBNull.Value && value != null)
|
||||
{
|
||||
// 尝试类型转换
|
||||
if (property.PropertyType == value.GetType())
|
||||
{
|
||||
property.SetValue(obj, value);
|
||||
}
|
||||
else if (property.PropertyType == typeof(string))
|
||||
{
|
||||
property.SetValue(obj, value.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
// 尝试使用 Convert.ChangeType
|
||||
var convertedValue = Convert.ChangeType(value, property.PropertyType);
|
||||
property.SetValue(obj, convertedValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 字段不存在或类型转换失败,保持默认值
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Sharp.Data.Access.Options;
|
||||
using Sharp.Data.Access.Security;
|
||||
using Sharp.Data.Access.Connections;
|
||||
using Sharp.Data.Access.Initialization;
|
||||
|
||||
namespace Sharp.Data.Access;
|
||||
|
||||
/// <summary>
|
||||
/// Access 数据服务的依赖注入扩展
|
||||
/// </summary>
|
||||
public static class AccessDataServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 添加 Access 数据服务(最简单方式 - 仅指定数据库路径)
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="databasePath">数据库文件路径(绝对或相对路径)</param>
|
||||
/// <param name="password">数据库密码(可选)</param>
|
||||
/// <param name="provider">OLE DB 提供程序,默认:Microsoft.ACE.OLEDB.16.0</param>
|
||||
/// <returns>服务集合</returns>
|
||||
public static IServiceCollection AddAccessDataService(
|
||||
this IServiceCollection services,
|
||||
string databasePath,
|
||||
string? password = null,
|
||||
string? provider = null)
|
||||
{
|
||||
return services.AddAccessDataService(options =>
|
||||
{
|
||||
options.DatabaseFile = databasePath;
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
options.Password = password;
|
||||
if (!string.IsNullOrEmpty(provider))
|
||||
options.ProviderName = provider;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加 Access 数据服务(使用配置委托)
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="configureOptions">配置委托</param>
|
||||
/// <returns>服务集合</returns>
|
||||
public static IServiceCollection AddAccessDataService(
|
||||
this IServiceCollection services,
|
||||
Action<AccessDatabaseOptions> configureOptions)
|
||||
{
|
||||
if (services == null) throw new ArgumentNullException(nameof(services));
|
||||
if (configureOptions == null) throw new ArgumentNullException(nameof(configureOptions));
|
||||
|
||||
var options = new AccessDatabaseOptions();
|
||||
configureOptions(options);
|
||||
options.Validate();
|
||||
|
||||
// 注册核心服务
|
||||
services.TryAddSingleton<IAccessPasswordDecryptor, PassthroughAccessPasswordDecryptor>();
|
||||
services.TryAddSingleton<IAccessConnectionStringFactory>(sp =>
|
||||
new AccessConnectionStringFactory(sp.GetRequiredService<IAccessPasswordDecryptor>()));
|
||||
services.TryAddSingleton<IAccessDatabaseInitializer, AccessDatabaseInitializer>();
|
||||
|
||||
// 注册配置
|
||||
services.AddSingleton(options);
|
||||
|
||||
// 注册 AccessDataService
|
||||
services.AddSingleton<IAccessDataService>(sp =>
|
||||
new AccessDataService(
|
||||
options,
|
||||
sp.GetRequiredService<IAccessConnectionStringFactory>(),
|
||||
sp.GetRequiredService<IAccessDatabaseInitializer>(),
|
||||
sp.GetRequiredService<IAccessPasswordDecryptor>(),
|
||||
sp.GetService<Microsoft.Extensions.Logging.ILogger<AccessDataService>>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加 Access 数据服务(使用配置对象)
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="options">配置选项</param>
|
||||
/// <returns>服务集合</returns>
|
||||
public static IServiceCollection AddAccessDataService(
|
||||
this IServiceCollection services,
|
||||
AccessDatabaseOptions options)
|
||||
{
|
||||
if (options == null) throw new ArgumentNullException(nameof(options));
|
||||
|
||||
return services.AddAccessDataService(_ => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册自定义密码解密器
|
||||
/// </summary>
|
||||
/// <typeparam name="TDecryptor">解密器类型</typeparam>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <returns>服务集合</returns>
|
||||
public static IServiceCollection AddAccessPasswordDecryptor<TDecryptor>(this IServiceCollection services)
|
||||
where TDecryptor : class, IAccessPasswordDecryptor
|
||||
{
|
||||
services.AddSingleton<IAccessPasswordDecryptor, TDecryptor>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册自定义密码解密器(使用工厂方法)
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="implementationFactory">解密器工厂</param>
|
||||
/// <returns>服务集合</returns>
|
||||
public static IServiceCollection AddAccessPasswordDecryptor(
|
||||
this IServiceCollection services,
|
||||
Func<IServiceProvider, IAccessPasswordDecryptor> implementationFactory)
|
||||
{
|
||||
services.AddSingleton(implementationFactory);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Data.OleDb;
|
||||
using Sharp.Data.Access.Internal;
|
||||
using Sharp.Data.Access.Options;
|
||||
using Sharp.Data.Access.Security;
|
||||
|
||||
namespace Sharp.Data.Access.Connections;
|
||||
|
||||
internal sealed class AccessConnectionStringFactory : IAccessConnectionStringFactory
|
||||
{
|
||||
private readonly IAccessPasswordDecryptor _decryptor;
|
||||
|
||||
public AccessConnectionStringFactory(IAccessPasswordDecryptor decryptor)
|
||||
{
|
||||
_decryptor = decryptor ?? throw new ArgumentNullException(nameof(decryptor));
|
||||
}
|
||||
|
||||
public string Create(AccessDatabaseOptions options, IServiceProvider services)
|
||||
{
|
||||
var configuration = AccessDatabaseConfiguration.FromOptions(options, services);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuration.ConnectionString))
|
||||
{
|
||||
return configuration.ConnectionString;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configuration.DatabaseFile))
|
||||
{
|
||||
throw new InvalidOperationException("Access 数据库需要提供 DatabaseFile 或 ConnectionString。");
|
||||
}
|
||||
|
||||
// 基础连接字符串
|
||||
// 注意:Connect Timeout 参数在某些 ACE 版本中不稳定,已移除
|
||||
var builder = new OleDbConnectionStringBuilder
|
||||
{
|
||||
["Provider"] = configuration.ProviderName,
|
||||
["Data Source"] = AccessPathResolver.Resolve(configuration.DatabaseFile, configuration.DataDirectory)
|
||||
};
|
||||
|
||||
var password = configuration.Password;
|
||||
if (string.IsNullOrEmpty(password) && !string.IsNullOrWhiteSpace(configuration.EncryptedPassword))
|
||||
{
|
||||
password = _decryptor.Decrypt(configuration.EncryptedPassword);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(password))
|
||||
{
|
||||
builder["Jet OLEDB:Database Password"] = password;
|
||||
}
|
||||
|
||||
foreach (var parameter in configuration.AdditionalParameters)
|
||||
{
|
||||
builder[parameter.Key] = parameter.Value;
|
||||
}
|
||||
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using Sharp.Data.Access.Options;
|
||||
|
||||
namespace Sharp.Data.Access.Connections;
|
||||
|
||||
/// <summary>
|
||||
/// 构建 Access 连接字符串的工厂。
|
||||
/// </summary>
|
||||
public interface IAccessConnectionStringFactory
|
||||
{
|
||||
string Create(AccessDatabaseOptions options, IServiceProvider services);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Sharp.Data.Access</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EntityFrameworkCore.Jet" Version="8.0.0-preview1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
|
||||
<PackageReference Include="System.Data.OleDb" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Data\Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Sharp.Data.Access.Connections;
|
||||
using Sharp.Data.Access.Initialization;
|
||||
using Sharp.Data.Access.Internal;
|
||||
using Sharp.Data.Access.Options;
|
||||
using Sharp.Data.Access.Security;
|
||||
using Sharp.Data.Context;
|
||||
|
||||
namespace Sharp.Data.Access.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Access 模块的依赖注入扩展。
|
||||
/// </summary>
|
||||
public static class AccessServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAccessDataContext<TContext>(this IServiceCollection services,
|
||||
Action<AccessContextOptions<TContext>> configure,
|
||||
bool registerDataContext = true,
|
||||
ServiceLifetime dataContextLifetime = ServiceLifetime.Scoped)
|
||||
where TContext : DbContext
|
||||
{
|
||||
if (configure == null) throw new ArgumentNullException(nameof(configure));
|
||||
|
||||
return services.AddAccessDataContext(_ =>
|
||||
{
|
||||
var options = new AccessContextOptions<TContext>();
|
||||
configure(options);
|
||||
return options;
|
||||
}, registerDataContext, dataContextLifetime);
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAccessDataContext<TContext>(this IServiceCollection services,
|
||||
Func<IServiceProvider, AccessContextOptions<TContext>> configure,
|
||||
bool registerDataContext = true,
|
||||
ServiceLifetime dataContextLifetime = ServiceLifetime.Scoped)
|
||||
where TContext : DbContext
|
||||
{
|
||||
if (services == null) throw new ArgumentNullException(nameof(services));
|
||||
if (configure == null) throw new ArgumentNullException(nameof(configure));
|
||||
|
||||
services.TryAddSingleton<IAccessPasswordDecryptor, PassthroughAccessPasswordDecryptor>();
|
||||
services.TryAddSingleton<IAccessConnectionStringFactory>(sp =>
|
||||
new AccessConnectionStringFactory(sp.GetRequiredService<IAccessPasswordDecryptor>()));
|
||||
services.TryAddSingleton<IAccessDatabaseInitializer, AccessDatabaseInitializer>();
|
||||
|
||||
services.TryAddSingleton(provider =>
|
||||
{
|
||||
var options = configure(provider) ?? throw new InvalidOperationException("AccessContextOptions 不能为空。");
|
||||
options.Validate();
|
||||
return options;
|
||||
});
|
||||
|
||||
services.AddScoped<IAccessDataContextFactory<TContext>>(sp =>
|
||||
new AccessDataContextFactory<TContext>(
|
||||
sp,
|
||||
sp.GetRequiredService<AccessContextOptions<TContext>>(),
|
||||
sp.GetRequiredService<IAccessConnectionStringFactory>(),
|
||||
sp.GetRequiredService<IAccessDatabaseInitializer>()));
|
||||
|
||||
if (registerDataContext)
|
||||
{
|
||||
services.Add(new ServiceDescriptor(typeof(IDataContext),
|
||||
sp => sp.GetRequiredService<IAccessDataContextFactory<TContext>>().Create(),
|
||||
dataContextLifetime));
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using EntityFrameworkCore.Jet;
|
||||
using EntityFrameworkCore.Jet.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Sharp.Data.Access.Connections;
|
||||
using Sharp.Data.Access.Initialization;
|
||||
using Sharp.Data.Access.Internal;
|
||||
using Sharp.Data.Access.Options;
|
||||
using Sharp.Data.Access.Security;
|
||||
using Sharp.Data;
|
||||
|
||||
namespace Sharp.Data.Access.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 手动使用 DataContextBuilder 时的 Access Helper。
|
||||
/// </summary>
|
||||
public static class AccessDataContextBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 通过现成的连接字符串配置 Access。
|
||||
/// </summary>
|
||||
public static DataContextBuilder<TContext> UseAccessDatabase<TContext>(
|
||||
this DataContextBuilder<TContext> builder,
|
||||
string connectionString,
|
||||
Action<JetDbContextOptionsBuilder>? configure = null)
|
||||
where TContext : DbContext
|
||||
{
|
||||
if (builder == null) throw new ArgumentNullException(nameof(builder));
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
throw new ArgumentException("连接字符串不能为空。", nameof(connectionString));
|
||||
}
|
||||
|
||||
builder.ConfigureDbContext(options => options.UseJet(connectionString, configure));
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 AccessDatabaseOptions 配置 Access。
|
||||
/// </summary>
|
||||
public static DataContextBuilder<TContext> UseAccessDatabase<TContext>(
|
||||
this DataContextBuilder<TContext> builder,
|
||||
Action<AccessDatabaseOptions> configureOptions,
|
||||
IServiceProvider? services = null,
|
||||
IAccessPasswordDecryptor? decryptor = null,
|
||||
Action<JetDbContextOptionsBuilder>? configure = null)
|
||||
where TContext : DbContext
|
||||
{
|
||||
if (builder == null) throw new ArgumentNullException(nameof(builder));
|
||||
if (configureOptions == null) throw new ArgumentNullException(nameof(configureOptions));
|
||||
|
||||
var options = new AccessDatabaseOptions();
|
||||
configureOptions(options);
|
||||
options.Validate();
|
||||
|
||||
var serviceProvider = services ?? NullServiceProvider.Instance;
|
||||
var initializer = new AccessDatabaseInitializer();
|
||||
var connectionFactory = new AccessConnectionStringFactory(decryptor ?? new PassthroughAccessPasswordDecryptor());
|
||||
|
||||
initializer.EnsureDatabase(options, serviceProvider);
|
||||
var connectionString = connectionFactory.Create(options, serviceProvider);
|
||||
builder.ConfigureDbContext(o => o.UseJet(connectionString, configure));
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Sharp.Data.Context;
|
||||
|
||||
namespace Sharp.Data.Access;
|
||||
|
||||
/// <summary>
|
||||
/// Access 数据上下文工厂,避免直接依赖具体实现。
|
||||
/// </summary>
|
||||
public interface IAccessDataContextFactory<TContext> where TContext : DbContext
|
||||
{
|
||||
IDataContext Create();
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Data.OleDb;
|
||||
|
||||
namespace Sharp.Data.Access;
|
||||
|
||||
/// <summary>
|
||||
/// Access 数据库事务接口
|
||||
/// </summary>
|
||||
public interface IAccessTransaction : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行非查询命令(INSERT, UPDATE, DELETE)
|
||||
/// </summary>
|
||||
int Execute(string sql, params OleDbParameter[] parameters);
|
||||
|
||||
/// <summary>
|
||||
/// 查询多条记录
|
||||
/// </summary>
|
||||
List<T> Query<T>(string sql, params OleDbParameter[] parameters) where T : new();
|
||||
|
||||
/// <summary>
|
||||
/// 执行标量查询(COUNT, SUM等)
|
||||
/// </summary>
|
||||
object? ExecuteScalar(string sql, params OleDbParameter[] parameters);
|
||||
|
||||
/// <summary>
|
||||
/// 提交事务
|
||||
/// </summary>
|
||||
void Commit();
|
||||
|
||||
/// <summary>
|
||||
/// 回滚事务
|
||||
/// </summary>
|
||||
void Rollback();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Access 数据服务接口(基于 System.Data.OleDb)
|
||||
/// </summary>
|
||||
public interface IAccessDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询多条记录
|
||||
/// </summary>
|
||||
List<T> Query<T>(string sql, params OleDbParameter[] parameters) where T : new();
|
||||
|
||||
/// <summary>
|
||||
/// 查询单条记录
|
||||
/// </summary>
|
||||
T? QueryFirstOrDefault<T>(string sql, params OleDbParameter[] parameters) where T : new();
|
||||
|
||||
/// <summary>
|
||||
/// 执行标量查询(COUNT, SUM等)
|
||||
/// </summary>
|
||||
object? ExecuteScalar(string sql, params OleDbParameter[] parameters);
|
||||
|
||||
/// <summary>
|
||||
/// 执行非查询命令(INSERT, UPDATE, DELETE)
|
||||
/// </summary>
|
||||
int Execute(string sql, params OleDbParameter[] parameters);
|
||||
|
||||
/// <summary>
|
||||
/// 开始事务
|
||||
/// </summary>
|
||||
IAccessTransaction BeginTransaction();
|
||||
|
||||
/// <summary>
|
||||
/// 在事务中执行操作(自动提交/回滚)
|
||||
/// </summary>
|
||||
void ExecuteInTransaction(Action<IAccessTransaction> action);
|
||||
|
||||
/// <summary>
|
||||
/// 测试数据库连接
|
||||
/// </summary>
|
||||
bool TestConnection();
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Sharp.Data.Access.Internal;
|
||||
using Sharp.Data.Access.Options;
|
||||
|
||||
namespace Sharp.Data.Access.Initialization;
|
||||
|
||||
internal sealed class AccessDatabaseInitializer : IAccessDatabaseInitializer
|
||||
{
|
||||
public void EnsureDatabase(AccessDatabaseOptions options, IServiceProvider services)
|
||||
{
|
||||
var configuration = AccessDatabaseConfiguration.FromOptions(options, services);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(configuration.ConnectionString) ||
|
||||
string.IsNullOrWhiteSpace(configuration.DatabaseFile))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var databasePath = AccessPathResolver.Resolve(configuration.DatabaseFile, configuration.DataDirectory);
|
||||
var directory = Path.GetDirectoryName(databasePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
if (File.Exists(databasePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (configuration.CopyTemplateWhenMissing && !string.IsNullOrWhiteSpace(configuration.TemplateFile))
|
||||
{
|
||||
var templatePath = AccessPathResolver.Resolve(configuration.TemplateFile, configuration.DataDirectory);
|
||||
if (!File.Exists(templatePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Access 模板文件 \"{templatePath}\" 不存在。", templatePath);
|
||||
}
|
||||
|
||||
File.Copy(templatePath, databasePath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (configuration.ThrowIfMissing)
|
||||
{
|
||||
throw new FileNotFoundException($"Access 数据库 \"{databasePath}\" 不存在。", databasePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using Sharp.Data.Access.Options;
|
||||
|
||||
namespace Sharp.Data.Access.Initialization;
|
||||
|
||||
/// <summary>
|
||||
/// Access 数据库初始化器(确保目录/数据库文件准备就绪)。
|
||||
/// </summary>
|
||||
public interface IAccessDatabaseInitializer
|
||||
{
|
||||
void EnsureDatabase(AccessDatabaseOptions options, IServiceProvider services);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using EntityFrameworkCore.Jet;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Sharp.Data;
|
||||
using Sharp.Data.Access.Connections;
|
||||
using Sharp.Data.Access.Initialization;
|
||||
using Sharp.Data.Access.Options;
|
||||
using Sharp.Data.Context;
|
||||
|
||||
namespace Sharp.Data.Access.Internal;
|
||||
|
||||
internal sealed class AccessDataContextFactory<TContext> : IAccessDataContextFactory<TContext>
|
||||
where TContext : DbContext
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly AccessContextOptions<TContext> _options;
|
||||
private readonly IAccessConnectionStringFactory _connectionFactory;
|
||||
private readonly IAccessDatabaseInitializer _initializer;
|
||||
|
||||
public AccessDataContextFactory(
|
||||
IServiceProvider services,
|
||||
AccessContextOptions<TContext> options,
|
||||
IAccessConnectionStringFactory connectionFactory,
|
||||
IAccessDatabaseInitializer initializer)
|
||||
{
|
||||
_services = services;
|
||||
_options = options;
|
||||
_connectionFactory = connectionFactory;
|
||||
_initializer = initializer;
|
||||
}
|
||||
|
||||
public IDataContext Create()
|
||||
{
|
||||
var builder = new DataContextBuilder<TContext>();
|
||||
|
||||
_initializer.EnsureDatabase(_options.Database, _services);
|
||||
|
||||
var connectionString = _connectionFactory.Create(_options.Database, _services);
|
||||
builder.ConfigureDbContext(options =>
|
||||
{
|
||||
options.UseJet(connectionString, jet => _options.ConfigureJet?.Invoke(jet));
|
||||
});
|
||||
|
||||
_options.ConfigureDataContext?.Invoke(_services, builder);
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Sharp.Data.Access.Options;
|
||||
|
||||
namespace Sharp.Data.Access.Internal;
|
||||
|
||||
internal sealed record AccessDatabaseConfiguration(
|
||||
string? ConnectionString,
|
||||
string? DatabaseFile,
|
||||
string ProviderName,
|
||||
string? Password,
|
||||
string? EncryptedPassword,
|
||||
string? TemplateFile,
|
||||
string? DataDirectory,
|
||||
int ConnectionTimeout,
|
||||
int MaxRetryCount,
|
||||
int RetryDelayMilliseconds,
|
||||
IReadOnlyDictionary<string, string> AdditionalParameters,
|
||||
bool CopyTemplateWhenMissing,
|
||||
bool ThrowIfMissing)
|
||||
{
|
||||
public static AccessDatabaseConfiguration FromOptions(AccessDatabaseOptions options, IServiceProvider services)
|
||||
{
|
||||
if (options == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
var connectionString = Resolve(options.ConnectionString, options.ConnectionStringFactory, services);
|
||||
var databaseFile = Resolve(options.DatabaseFile, options.DatabaseFileFactory, services);
|
||||
var templateFile = Resolve(options.TemplateFile, options.TemplateFileFactory, services);
|
||||
var dataDirectory = Resolve(options.DataDirectory, options.DataDirectoryFactory, services);
|
||||
var password = Resolve(options.Password, options.PasswordFactory, services);
|
||||
var encryptedPassword = Resolve(options.EncryptedPassword, options.EncryptedPasswordFactory, services);
|
||||
|
||||
return new AccessDatabaseConfiguration(
|
||||
connectionString,
|
||||
databaseFile,
|
||||
string.IsNullOrWhiteSpace(options.ProviderName) ? AccessProviderNames.AceOleDb16 : options.ProviderName,
|
||||
password,
|
||||
encryptedPassword,
|
||||
templateFile,
|
||||
dataDirectory,
|
||||
options.ConnectionTimeout,
|
||||
options.MaxRetryCount,
|
||||
options.RetryDelayMilliseconds,
|
||||
new Dictionary<string, string>(options.AdditionalParameters, StringComparer.OrdinalIgnoreCase),
|
||||
options.CopyTemplateWhenMissing,
|
||||
options.ThrowIfMissing);
|
||||
}
|
||||
|
||||
private static string? Resolve(string? value, Func<IServiceProvider, string?>? factory, IServiceProvider services)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return factory?.Invoke(services);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace Sharp.Data.Access.Internal;
|
||||
|
||||
internal static class AccessPathResolver
|
||||
{
|
||||
public static string Resolve(string path, string? baseDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new InvalidOperationException("Access 数据库路径不能为空。");
|
||||
}
|
||||
|
||||
if (Path.IsPathFullyQualified(path))
|
||||
{
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
|
||||
var root = string.IsNullOrWhiteSpace(baseDirectory)
|
||||
? AppContext.BaseDirectory
|
||||
: baseDirectory!;
|
||||
|
||||
return Path.GetFullPath(Path.Combine(root, path));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
|
||||
namespace Sharp.Data.Access.Internal;
|
||||
|
||||
internal sealed class NullServiceProvider : IServiceProvider
|
||||
{
|
||||
public static readonly NullServiceProvider Instance = new();
|
||||
|
||||
private NullServiceProvider()
|
||||
{
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using EntityFrameworkCore.Jet.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Sharp.Data;
|
||||
|
||||
namespace Sharp.Data.Access.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Access 数据上下文注册配置。
|
||||
/// </summary>
|
||||
/// <typeparam name="TContext">EF Core DbContext 类型。</typeparam>
|
||||
public sealed class AccessContextOptions<TContext> where TContext : DbContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Access 数据库配置(连接方式、密码等)。
|
||||
/// </summary>
|
||||
public AccessDatabaseOptions Database { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 允许调用方在 Build 之前继续配置 DataContextBuilder(例如启用审计、软删除、当前用户等)。
|
||||
/// </summary>
|
||||
public Action<IServiceProvider, DataContextBuilder<TContext>>? ConfigureDataContext { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 细化 Jet Provider 选项。
|
||||
/// </summary>
|
||||
public Action<JetDbContextOptionsBuilder>? ConfigureJet { get; set; }
|
||||
|
||||
internal void Validate() => Database.Validate();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Sharp.Data.Access.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Access 数据库统一配置项,同时支持 OleDb 和 EF Core 两种方式。
|
||||
/// </summary>
|
||||
public class AccessDatabaseOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 完整的连接字符串,可选。如果提供则优先使用,不再重新构建。
|
||||
/// </summary>
|
||||
public string? ConnectionString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通过依赖注入动态生成连接字符串。
|
||||
/// </summary>
|
||||
public Func<IServiceProvider, string?>? ConnectionStringFactory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Access 数据库文件路径(相对或绝对)。
|
||||
/// </summary>
|
||||
public string? DatabaseFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通过依赖注入动态解析数据库文件路径。
|
||||
/// </summary>
|
||||
public Func<IServiceProvider, string?>? DatabaseFileFactory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数据文件的基础目录(如果数据库路径为相对路径则会拼接)。
|
||||
/// 默认使用 <see cref="AppContext.BaseDirectory" />。
|
||||
/// </summary>
|
||||
public string? DataDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通过依赖注入动态解析数据目录。
|
||||
/// </summary>
|
||||
public Func<IServiceProvider, string?>? DataDirectoryFactory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 明文密码,可选。用于访问加密的 Access 数据库。
|
||||
/// </summary>
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通过依赖注入动态解析明文密码。
|
||||
/// </summary>
|
||||
public Func<IServiceProvider, string?>? PasswordFactory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 加密后的密码,可选。将通过 <see cref="Security.IAccessPasswordDecryptor" /> 解密。
|
||||
/// </summary>
|
||||
public string? EncryptedPassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通过依赖注入动态解析加密密码。
|
||||
/// </summary>
|
||||
public Func<IServiceProvider, string?>? EncryptedPasswordFactory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数据库模板(*.mdb / *.accdb),在文件不存在时复制一份。
|
||||
/// </summary>
|
||||
public string? TemplateFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通过依赖注入动态解析模板文件路径。
|
||||
/// </summary>
|
||||
public Func<IServiceProvider, string?>? TemplateFileFactory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当数据库文件缺失时是否尝试从模板复制。
|
||||
/// </summary>
|
||||
public bool CopyTemplateWhenMissing { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 当数据库文件缺失(且无法复制)时是否抛出异常。
|
||||
/// </summary>
|
||||
public bool ThrowIfMissing { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Provider 名称,默认为 ACE OLE DB 16.0(功能最完整)。
|
||||
/// 可选值:
|
||||
/// - AccessProviderNames.AceOleDb16 (Microsoft.ACE.OLEDB.16.0) - 推荐,需要 Office 2016+
|
||||
/// - AccessProviderNames.AceOleDb12 (Microsoft.ACE.OLEDB.12.0) - 需要 Office 2010+,不支持 Connect Timeout
|
||||
/// - AccessProviderNames.JetOleDb4 (Microsoft.Jet.OLEDB.4.0) - 仅支持 32 位和旧版 Access
|
||||
/// </summary>
|
||||
public string ProviderName { get; set; } = AccessProviderNames.AceOleDb16;
|
||||
|
||||
/// <summary>
|
||||
/// 连接超时时间(秒),默认 15 秒。
|
||||
/// </summary>
|
||||
public int ConnectionTimeout { get; set; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// 最大重试次数,默认 3 次。用于增强稳健性。
|
||||
/// </summary>
|
||||
public int MaxRetryCount { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 重试延迟(毫秒),默认 100 毫秒。
|
||||
/// </summary>
|
||||
public int RetryDelayMilliseconds { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// 额外的 OLE DB 参数,按需添加。
|
||||
/// </summary>
|
||||
public IDictionary<string, string> AdditionalParameters { get; } =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ConnectionString) &&
|
||||
ConnectionStringFactory is null &&
|
||||
string.IsNullOrWhiteSpace(DatabaseFile) &&
|
||||
DatabaseFileFactory is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"AccessDatabaseOptions 需要连接字符串或数据库文件路径。");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ProviderName))
|
||||
{
|
||||
throw new InvalidOperationException("必须指定 Access Provider。");
|
||||
}
|
||||
|
||||
if (ConnectionTimeout <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("ConnectionTimeout 必须大于 0。");
|
||||
}
|
||||
|
||||
if (MaxRetryCount < 0)
|
||||
{
|
||||
throw new InvalidOperationException("MaxRetryCount 不能小于 0。");
|
||||
}
|
||||
|
||||
if (RetryDelayMilliseconds < 0)
|
||||
{
|
||||
throw new InvalidOperationException("RetryDelayMilliseconds 不能小于 0。");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Sharp.Data.Access.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 常用 Access 数据库 Provider 名称。
|
||||
/// </summary>
|
||||
public static class AccessProviderNames
|
||||
{
|
||||
/// <summary>
|
||||
/// Microsoft Access Database Engine 2016 (推荐)。
|
||||
/// </summary>
|
||||
public const string AceOleDb16 = "Microsoft.ACE.OLEDB.16.0";
|
||||
|
||||
/// <summary>
|
||||
/// Microsoft Access Database Engine 2010。
|
||||
/// </summary>
|
||||
public const string AceOleDb12 = "Microsoft.ACE.OLEDB.12.0";
|
||||
|
||||
/// <summary>
|
||||
/// 老旧的 Jet 4.0 Provider(仅限 32 位进程)。
|
||||
/// </summary>
|
||||
public const string JetOleDb4 = "Microsoft.Jet.OLEDB.4.0";
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
# AccessDataService 简化 API 使用指南
|
||||
|
||||
## 🎯 设计目标
|
||||
|
||||
Data.Access 模块的核心目标是**让调用者使用起来更简单**,将连接管理、SQL 执行、参数处理、事务管理等复杂度封装起来。
|
||||
|
||||
## 📊 代码对比
|
||||
|
||||
### ❌ 之前:直接使用 OleDb(复杂)
|
||||
|
||||
```csharp
|
||||
// 每次都要手动创建连接
|
||||
private OleDbConnection CreateConnection()
|
||||
{
|
||||
var dbPath = Path.GetFullPath("dbDwDmsSetup.accdb");
|
||||
var connectionString = $"Provider=Microsoft.ACE.OLEDB.12.0;Data Source={dbPath}";
|
||||
return new OleDbConnection(connectionString);
|
||||
}
|
||||
|
||||
// 查询用户 - 需要写大量样板代码
|
||||
private async Task<List<UserInfoDto>> LoadUsersAsync()
|
||||
{
|
||||
using var connection = CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
|
||||
using var command = new OleDbCommand("SELECT UserName, Password FROM UserInfo", connection);
|
||||
using var reader = await Task.Run(() => command.ExecuteReader());
|
||||
|
||||
var users = new List<UserInfoDto>();
|
||||
while (await Task.Run(() => reader.Read()))
|
||||
{
|
||||
var user = new UserInfoDto
|
||||
{
|
||||
UserName = reader["UserName"]?.ToString() ?? "",
|
||||
Password = reader["Password"]?.ToString() ?? ""
|
||||
};
|
||||
users.Add(user);
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
// 删除用户 - 需要手动管理事务
|
||||
private async Task DeleteUserAsync(string userName)
|
||||
{
|
||||
using var connection = CreateConnection();
|
||||
await connection.OpenAsync();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
using (var deletePermCmd = new OleDbCommand(
|
||||
"DELETE FROM UserPermission WHERE UserInfo_UserName = ?", connection, transaction))
|
||||
{
|
||||
deletePermCmd.Parameters.AddWithValue("@UserName", userName);
|
||||
await Task.Run(() => deletePermCmd.ExecuteNonQuery());
|
||||
}
|
||||
|
||||
using (var deleteUserCmd = new OleDbCommand(
|
||||
"DELETE FROM UserInfo WHERE UserName = ?", connection, transaction))
|
||||
{
|
||||
deleteUserCmd.Parameters.AddWithValue("@UserName", userName);
|
||||
await Task.Run(() => deleteUserCmd.ExecuteNonQuery());
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- 大量重复的连接管理代码
|
||||
- 手动映射查询结果到对象
|
||||
- 手动管理事务的提交和回滚
|
||||
- 代码冗长,不易维护
|
||||
|
||||
---
|
||||
|
||||
### ✅ 现在:使用 AccessDataService(简洁)
|
||||
|
||||
```csharp
|
||||
// 注入服务,无需手动创建连接
|
||||
private readonly IAccessDataService _accessDataService;
|
||||
|
||||
// 查询用户 - 一行代码!自动映射!
|
||||
private async Task<List<UserInfoDto>> LoadUsersAsync()
|
||||
{
|
||||
return await _accessDataService.QueryAsync<UserInfoDto>(
|
||||
"SELECT UserName, [Password] FROM UserInfo");
|
||||
}
|
||||
|
||||
// 删除用户 - 自动管理事务!
|
||||
private async Task DeleteUserAsync(string userName)
|
||||
{
|
||||
await _accessDataService.ExecuteInTransactionAsync(async transaction =>
|
||||
{
|
||||
await transaction.ExecuteAsync(
|
||||
"DELETE FROM UserPermission WHERE UserInfo_UserName = ?",
|
||||
new OleDbParameter("@UserName", userName));
|
||||
|
||||
await transaction.ExecuteAsync(
|
||||
"DELETE FROM UserInfo WHERE UserName = ?",
|
||||
new OleDbParameter("@UserName", userName));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**优势**:
|
||||
- ✅ 无需手动管理连接
|
||||
- ✅ 自动泛型映射查询结果
|
||||
- ✅ 自动管理事务(提交/回滚)
|
||||
- ✅ 代码简洁,易于维护
|
||||
- ✅ 从 60+ 行减少到 10 行左右
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 初始化服务
|
||||
|
||||
在 `App.xaml.cs` 中初始化:
|
||||
|
||||
```csharp
|
||||
private void InitializeAccessDataService()
|
||||
{
|
||||
var databasePath = Path.GetFullPath("dbDwDmsSetup.accdb");
|
||||
var options = new AccessDataServiceOptions
|
||||
{
|
||||
DatabasePath = databasePath
|
||||
};
|
||||
|
||||
AccessDataService = new AccessDataService(options, LoggerFactory.CreateLogger<AccessDataService>());
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 在窗口中使用
|
||||
|
||||
```csharp
|
||||
public partial class AccessDatabaseWindowOleDb : Window
|
||||
{
|
||||
private readonly IAccessDataService _accessDataService;
|
||||
|
||||
public AccessDatabaseWindowOleDb()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
var app = (App)Application.Current;
|
||||
_accessDataService = app.AccessDataService;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 API 使用示例
|
||||
|
||||
### 1. 查询多条记录
|
||||
|
||||
```csharp
|
||||
// 自动映射到对象
|
||||
var users = await _accessDataService.QueryAsync<UserInfoDto>(
|
||||
"SELECT UserName, [Password] FROM UserInfo");
|
||||
|
||||
// 带参数的查询
|
||||
var users = await _accessDataService.QueryAsync<UserInfoDto>(
|
||||
"SELECT UserName, [Password] FROM UserInfo WHERE UserName LIKE ?",
|
||||
new OleDbParameter("@Pattern", "%admin%"));
|
||||
```
|
||||
|
||||
### 2. 查询单条记录
|
||||
|
||||
```csharp
|
||||
var user = await _accessDataService.QueryFirstOrDefaultAsync<UserInfoDto>(
|
||||
"SELECT UserName, [Password] FROM UserInfo WHERE UserName = ?",
|
||||
new OleDbParameter("@UserName", "admin"));
|
||||
|
||||
if (user != null)
|
||||
{
|
||||
Console.WriteLine($"找到用户: {user.UserName}");
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 执行标量查询
|
||||
|
||||
```csharp
|
||||
// 查询数量
|
||||
var count = await _accessDataService.ExecuteScalarAsync(
|
||||
"SELECT COUNT(*) FROM UserInfo WHERE UserName = ?",
|
||||
new OleDbParameter("@UserName", "admin"));
|
||||
|
||||
int userCount = Convert.ToInt32(count);
|
||||
```
|
||||
|
||||
### 4. 插入数据
|
||||
|
||||
```csharp
|
||||
var rowsAffected = await _accessDataService.ExecuteAsync(
|
||||
"INSERT INTO UserInfo (UserName, [Password]) VALUES (?, ?)",
|
||||
new OleDbParameter("@UserName", "newuser"),
|
||||
new OleDbParameter("@Password", "password123"));
|
||||
|
||||
Console.WriteLine($"插入了 {rowsAffected} 行");
|
||||
```
|
||||
|
||||
### 5. 更新数据
|
||||
|
||||
```csharp
|
||||
var rowsAffected = await _accessDataService.ExecuteAsync(
|
||||
"UPDATE UserInfo SET [Password] = ? WHERE UserName = ?",
|
||||
new OleDbParameter("@Password", "newpassword"),
|
||||
new OleDbParameter("@UserName", "admin"));
|
||||
|
||||
Console.WriteLine($"更新了 {rowsAffected} 行");
|
||||
```
|
||||
|
||||
### 6. 删除数据
|
||||
|
||||
```csharp
|
||||
var rowsAffected = await _accessDataService.ExecuteAsync(
|
||||
"DELETE FROM UserInfo WHERE UserName = ?",
|
||||
new OleDbParameter("@UserName", "testuser"));
|
||||
|
||||
Console.WriteLine($"删除了 {rowsAffected} 行");
|
||||
```
|
||||
|
||||
### 7. 使用事务(自动提交/回滚)
|
||||
|
||||
```csharp
|
||||
// 推荐:使用 ExecuteInTransactionAsync(自动管理)
|
||||
await _accessDataService.ExecuteInTransactionAsync(async transaction =>
|
||||
{
|
||||
// 删除权限
|
||||
await transaction.ExecuteAsync(
|
||||
"DELETE FROM UserPermission WHERE UserInfo_UserName = ?",
|
||||
new OleDbParameter("@UserName", userName));
|
||||
|
||||
// 删除用户
|
||||
await transaction.ExecuteAsync(
|
||||
"DELETE FROM UserInfo WHERE UserName = ?",
|
||||
new OleDbParameter("@UserName", userName));
|
||||
|
||||
// 自动提交!如果有异常,自动回滚!
|
||||
});
|
||||
```
|
||||
|
||||
### 8. 手动管理事务
|
||||
|
||||
```csharp
|
||||
// 如果需要手动控制
|
||||
using var transaction = _accessDataService.BeginTransaction();
|
||||
try
|
||||
{
|
||||
await transaction.ExecuteAsync("INSERT INTO ...", ...);
|
||||
await transaction.ExecuteAsync("UPDATE ...", ...);
|
||||
|
||||
transaction.Commit(); // 手动提交
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback(); // 手动回滚
|
||||
throw;
|
||||
}
|
||||
```
|
||||
|
||||
### 9. 测试连接
|
||||
|
||||
```csharp
|
||||
var success = await _accessDataService.TestConnectionAsync();
|
||||
if (success)
|
||||
{
|
||||
Console.WriteLine("数据库连接成功!");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 完整示例
|
||||
|
||||
```csharp
|
||||
public class UserService
|
||||
{
|
||||
private readonly IAccessDataService _accessDataService;
|
||||
|
||||
public UserService(IAccessDataService accessDataService)
|
||||
{
|
||||
_accessDataService = accessDataService;
|
||||
}
|
||||
|
||||
// 获取所有用户
|
||||
public async Task<List<UserInfoDto>> GetAllUsersAsync()
|
||||
{
|
||||
return await _accessDataService.QueryAsync<UserInfoDto>(
|
||||
"SELECT UserName, [Password] FROM UserInfo");
|
||||
}
|
||||
|
||||
// 获取用户权限数量
|
||||
public async Task<int> GetUserPermissionCountAsync(string userName)
|
||||
{
|
||||
var count = await _accessDataService.ExecuteScalarAsync(
|
||||
"SELECT COUNT(*) FROM UserPermission WHERE UserInfo_UserName = ?",
|
||||
new OleDbParameter("@UserName", userName));
|
||||
|
||||
return Convert.ToInt32(count);
|
||||
}
|
||||
|
||||
// 添加用户
|
||||
public async Task<bool> AddUserAsync(string userName, string password)
|
||||
{
|
||||
// 检查是否已存在
|
||||
var exists = await _accessDataService.ExecuteScalarAsync(
|
||||
"SELECT COUNT(*) FROM UserInfo WHERE UserName = ?",
|
||||
new OleDbParameter("@UserName", userName));
|
||||
|
||||
if (Convert.ToInt32(exists) > 0)
|
||||
return false;
|
||||
|
||||
// 插入用户
|
||||
await _accessDataService.ExecuteAsync(
|
||||
"INSERT INTO UserInfo (UserName, [Password]) VALUES (?, ?)",
|
||||
new OleDbParameter("@UserName", userName),
|
||||
new OleDbParameter("@Password", password));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 删除用户(包含权限)
|
||||
public async Task DeleteUserAsync(string userName)
|
||||
{
|
||||
await _accessDataService.ExecuteInTransactionAsync(async transaction =>
|
||||
{
|
||||
// 先删除权限
|
||||
await transaction.ExecuteAsync(
|
||||
"DELETE FROM UserPermission WHERE UserInfo_UserName = ?",
|
||||
new OleDbParameter("@UserName", userName));
|
||||
|
||||
// 再删除用户
|
||||
await transaction.ExecuteAsync(
|
||||
"DELETE FROM UserInfo WHERE UserName = ?",
|
||||
new OleDbParameter("@UserName", userName));
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 核心优势总结
|
||||
|
||||
| 特性 | 直接使用 OleDb | 使用 AccessDataService |
|
||||
|------|---------------|----------------------|
|
||||
| 连接管理 | 手动创建、打开、释放 | ✅ 自动管理 |
|
||||
| 结果映射 | 手动读取 DataReader | ✅ 自动泛型映射 |
|
||||
| 事务管理 | 手动 BeginTransaction/Commit/Rollback | ✅ 自动管理 |
|
||||
| 代码量 | 60+ 行 | ✅ 10 行左右 |
|
||||
| 可维护性 | 低(大量重复代码) | ✅ 高 |
|
||||
| 易用性 | 低(需要理解 ADO.NET) | ✅ 高 |
|
||||
|
||||
---
|
||||
|
||||
## 📖 参考文件
|
||||
|
||||
- **核心实现**: `Data.Access/AccessDataService.cs`
|
||||
- **接口定义**: `Data.Access/IAccessDataService.cs`
|
||||
- **配置选项**: `Data.Access/AccessDataServiceOptions.cs`
|
||||
- **使用示例**: `Server/Views/AccessDatabaseWindowOleDb.xaml.cs`
|
||||
|
||||
---
|
||||
|
||||
**总结**:Data.Access 模块通过封装 OleDb 的复杂度,提供了简洁优雅的 API,让调用者可以用更少的代码完成相同的功能!
|
||||
@@ -0,0 +1,115 @@
|
||||
# Sharp.Data.Access
|
||||
|
||||
基于 EF Core + Access (Jet) 的数据库基础设施。遵循 “依赖大于配置” 的理念,提供易于集成的连接串构建、密码解密、数据库初始化以及依赖注入支持,让其他项目只专注于提供配置即可。
|
||||
|
||||
> **定位**:仅提供 Access 数据层能力(连接、仓储、上下文构建),不含任何业务逻辑。
|
||||
|
||||
## 特性
|
||||
|
||||
- ✅ **DI 集成** – 一行 `AddAccessDataContext<TContext>()` 注册,其他模块通过构造函数获取 `IDataContext`。
|
||||
- ✅ **最小配置** – 只需要提供数据库文件或连接字符串即可完成集成。
|
||||
- ✅ **密码解密** – 内置 `IAccessPasswordDecryptor`,可注入自定义实现来解密加密密码。
|
||||
- ✅ **数据库初始化** – 自动创建目录、可选基于模板复制空库文件,避免运行期错误。
|
||||
- ✅ **灵活扩展** – 支持通过委托访问 `IServiceProvider`,从任意配置中心或密钥管理系统读取参数。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Sharp.Data.Access.DependencyInjection;
|
||||
using Sharp.Data.Context;
|
||||
|
||||
builder.Services.AddAccessDataContext<AppDbContext>(options =>
|
||||
{
|
||||
options.Database.DatabaseFile = "data/app.accdb";
|
||||
options.Database.EncryptedPassword = configuration["Access:EncryptedPassword"];
|
||||
// 配置 Data 模块能力
|
||||
options.ConfigureDataContext = (sp, ctx) =>
|
||||
{
|
||||
ctx.UseAudit()
|
||||
.UseSoftDelete()
|
||||
.UseCurrentUserProvider(() => sp.GetService<ICurrentUser>()?.Id);
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
随后只需要注入 `IDataContext` 或 `IAccessDataContextFactory<AppDbContext>` 即可使用:
|
||||
|
||||
```csharp
|
||||
public class UserService
|
||||
{
|
||||
private readonly IDataContext _db;
|
||||
|
||||
public UserService(IDataContext db) => _db = db;
|
||||
|
||||
public Task<List<User>> GetUsersAsync() => _db.Repository<User>().GetAllAsync();
|
||||
}
|
||||
```
|
||||
|
||||
## 依赖注入示例(带模板/密码)
|
||||
|
||||
```csharp
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSingleton<ISecretProvider, SecretProvider>(); // 自定义的密钥服务
|
||||
|
||||
services.AddAccessDataContext<AppDbContext>(options =>
|
||||
{
|
||||
options.Database.DatabaseFile = "data/app.accdb";
|
||||
options.Database.TemplateFile = "data/template.accdb";
|
||||
options.Database.CopyTemplateWhenMissing = true;
|
||||
options.Database.EncryptedPasswordFactory = sp =>
|
||||
sp.GetRequiredService<ISecretProvider>().Get("AccessDbPassword");
|
||||
|
||||
options.ConfigureDataContext = (sp, ctx) =>
|
||||
{
|
||||
ctx.UseAudit()
|
||||
.UseSoftDelete()
|
||||
.UseCurrentUserProvider(() => sp.GetRequiredService<ICurrentUserAccessor>().Id);
|
||||
};
|
||||
});
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var factory = provider.GetRequiredService<IAccessDataContextFactory<AppDbContext>>();
|
||||
await using var dataContext = factory.Create();
|
||||
```
|
||||
|
||||
> 在实际业务项目中可以把 `IAccessDataContextFactory<TContext>` 注入到需要“短生命周期”数据库连接的服务中,或直接注入 `IDataContext`(作用域作用域内共享同一实例)。
|
||||
|
||||
## 处理加密密码
|
||||
|
||||
实现 `IAccessPasswordDecryptor` 并注册到 DI:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddSingleton<IAccessPasswordDecryptor, AesPasswordDecryptor>();
|
||||
```
|
||||
|
||||
`AccessDatabaseOptions.EncryptedPassword` 会在构建连接字符串前自动调用该服务完成解密,也可以直接提供明文 `Password`。
|
||||
|
||||
## 模板与初始化
|
||||
|
||||
- `CopyTemplateWhenMissing = true`(默认)时,如果指定了 `TemplateFile`,将在首次运行时复制模板数据库。
|
||||
- `ThrowIfMissing = true` 可在缺少数据库文件时立即抛出异常,便于早期发现部署问题。
|
||||
- 所有路径都支持相对路径(基于 `AppContext.BaseDirectory`),也可以通过 `DataDirectoryFactory` 自定义。
|
||||
|
||||
## 手动使用(无依赖注入)
|
||||
|
||||
```csharp
|
||||
var builder = new DataContextBuilder<AppDbContext>();
|
||||
builder.UseAccessDatabase(options =>
|
||||
{
|
||||
options.DatabaseFile = "data/app.accdb";
|
||||
options.Password = "secret";
|
||||
});
|
||||
|
||||
var db = builder.Build();
|
||||
```
|
||||
|
||||
## 可扩展点
|
||||
|
||||
- `IAccessPasswordDecryptor`:自定义密码加解密。
|
||||
- `IAccessConnectionStringFactory`:自定义连接串构建逻辑。
|
||||
- `IAccessDatabaseInitializer`:自定义数据库初始化策略(检查锁、备份等)。
|
||||
- `AccessContextOptions.ConfigureDataContext`:复用 Data 模块的审计、软删除、自动迁移等能力。
|
||||
|
||||
通过这些基础设施,Access 支持可在任何项目中复用,同时保持代码简洁、低耦合。
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Sharp.Data.Access.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Access 密码解密接口,调用方可替换以实现自定义加解密策略。
|
||||
/// </summary>
|
||||
public interface IAccessPasswordDecryptor
|
||||
{
|
||||
/// <summary>
|
||||
/// 解密 Access 数据库密码。
|
||||
/// </summary>
|
||||
/// <param name="encryptedPassword">加密后的密码。</param>
|
||||
/// <returns>返回明文密码,如果无法解密则返回 null。</returns>
|
||||
string? Decrypt(string? encryptedPassword);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Sharp.Data.Access.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 默认的密码解密器,直接返回原始值。
|
||||
/// </summary>
|
||||
public sealed class PassthroughAccessPasswordDecryptor : IAccessPasswordDecryptor
|
||||
{
|
||||
public string? Decrypt(string? encryptedPassword) => encryptedPassword;
|
||||
}
|
||||
@@ -294,9 +294,20 @@ public class HttpServer
|
||||
var controller = _serviceProvider.GetService(controllerType);
|
||||
if (controller != null)
|
||||
return controller;
|
||||
|
||||
// 使用 ActivatorUtilities 创建实例(可以解析构造函数参数)
|
||||
try
|
||||
{
|
||||
return ActivatorUtilities.CreateInstance(_serviceProvider, controllerType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "Failed to create controller {ControllerType} with DI", controllerType.Name);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用激活器创建
|
||||
// 没有 DI 容器时,使用默认激活器创建(需要无参构造)
|
||||
return Activator.CreateInstance(controllerType)!;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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}";
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<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>
|
||||
@@ -1,700 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,138 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace Plc.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 定义PLC地址的通用接口,用于不同品牌的地址解析结果
|
||||
/// </summary>
|
||||
public interface IPlcAddress
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取字节偏移量
|
||||
/// </summary>
|
||||
int ByteOffset { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取位偏移量(仅对bool类型有效)
|
||||
/// </summary>
|
||||
int BitOffset { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取地址的字符串表示
|
||||
/// </summary>
|
||||
string ToString();
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Plc.Models;
|
||||
|
||||
public enum PlcBrand
|
||||
{
|
||||
Siemens,
|
||||
Omron, // 预留
|
||||
Mitsubishi, // 预留
|
||||
ModbusTcp // 预留
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<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>
|
||||
@@ -1,106 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
# Plc - 工业PLC通信核心库
|
||||
|
||||
[](https://dotnet.microsoft.com/)
|
||||
[](LICENSE)
|
||||
|
||||
## 📖 概述
|
||||
|
||||
Plc 是一个通用的工业PLC通信抽象层库,提供了统一的接口来与不同品牌的PLC设备进行通信。该库采用分层架构设计,将协议细节抽象化,使得上层应用无需关心具体的PLC品牌和协议实现。
|
||||
|
||||
### 核心特性
|
||||
|
||||
- ✅ **品牌无关** - 抽象接口设计,支持多种PLC品牌(西门子、欧姆龙、三菱等)
|
||||
- ✅ **高性能优化** - 智能批量读取优化,减少网络往返次数
|
||||
- ✅ **实时监控** - 支持标签订阅模式,自动轮询和变更通知
|
||||
- ✅ **类型安全** - 强类型数据转换,支持多种基础数据类型
|
||||
- ✅ **连接管理** - 自动重连、指数退避策略
|
||||
- ✅ **完整日志** - 集成 Microsoft.Extensions.Logging
|
||||
- ✅ **依赖注入** - 原生支持 .NET DI 容器
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 架构设计
|
||||
|
||||
### 分层架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 应用层 (Application Layer) │
|
||||
│ - ViewModel/Controller │
|
||||
│ - Business Logic │
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────▼────────────────────────┐
|
||||
│ 服务层 (Service Layer) │
|
||||
│ - PlcMonitorService │ ← 核心服务
|
||||
│ - 订阅管理、批量优化 │
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────▼────────────────────────┐
|
||||
│ 抽象层 (Abstraction Layer) │
|
||||
│ - IPlcDriver │ ← 驱动接口
|
||||
│ - IPlcOptimizer │ ← 优化器接口
|
||||
│ - IDataConverter │ ← 转换器接口
|
||||
└────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────▼────────────────────────┐
|
||||
│ 实现层 (Implementation Layer) │
|
||||
│ - SiemensDriver │ ← 品牌驱动
|
||||
│ - OmronDriver │
|
||||
│ - ModbusDriver │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 核心组件
|
||||
|
||||
#### 1. **IPlcService** - PLC服务接口
|
||||
提供统一的PLC通信入口,包括:
|
||||
- 连接管理(`StartAsync`, `StopAsync`)
|
||||
- 即时读写(`ReadAsync<T>`, `WriteAsync<T>`)
|
||||
- 订阅监控(`Subscribe`, `Unsubscribe`)
|
||||
|
||||
#### 2. **IPlcDriver** - PLC驱动接口
|
||||
定义底层通信协议的契约:
|
||||
- 连接/断开(`ConnectAsync`, `DisconnectAsync`)
|
||||
- 字节级读写(`ReadBytesAsync`, `WriteBytesAsync`)
|
||||
- 状态查询(`IsConnected`, `MaxPduSize`)
|
||||
|
||||
#### 3. **IPlcOptimizer** - 批量优化器接口
|
||||
负责优化多标签读取策略:
|
||||
- 地址排序和分组
|
||||
- 间隙合并(减少通信次数)
|
||||
- PDU大小限制
|
||||
|
||||
#### 4. **IDataConverter** - 数据转换器接口
|
||||
处理PLC字节序和.NET类型转换:
|
||||
- 大端/小端转换
|
||||
- 基础类型(bool, byte, short, int, float, double, string)
|
||||
- 位偏移支持(bool类型)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 安装核心库
|
||||
dotnet add package YourCompany.Plc
|
||||
|
||||
# 安装西门子驱动
|
||||
dotnet add package YourCompany.Plc.Siemens
|
||||
```
|
||||
|
||||
### 基本使用
|
||||
|
||||
#### 1. 注册服务
|
||||
|
||||
```csharp
|
||||
using Plc.Siemens.DependencyInjection;
|
||||
|
||||
// 在 Program.cs 或 Startup.cs 中
|
||||
services.AddSiemensPlc(options =>
|
||||
{
|
||||
options.IpAddress = "192.168.1.100";
|
||||
options.CpuType = S7.Net.CpuType.S71200;
|
||||
options.Port = 102;
|
||||
options.PduSize = 960;
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. 使用服务
|
||||
|
||||
```csharp
|
||||
public class MyController
|
||||
{
|
||||
private readonly IPlcService _plcService;
|
||||
|
||||
public MyController(IPlcService plcService)
|
||||
{
|
||||
_plcService = plcService;
|
||||
}
|
||||
|
||||
public async Task Example()
|
||||
{
|
||||
// 启动PLC服务
|
||||
await _plcService.StartAsync();
|
||||
|
||||
// 读取数据
|
||||
var result = await _plcService.ReadAsync<short>("DB1.DBW0");
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Console.WriteLine($"Value: {result.Value}");
|
||||
}
|
||||
|
||||
// 写入数据
|
||||
await _plcService.WriteAsync("DB1.DBW0", (short)1234);
|
||||
|
||||
// 停止服务
|
||||
await _plcService.StopAsync();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 核心概念
|
||||
|
||||
### PlcTag - 标签模型
|
||||
|
||||
`PlcTag` 表示PLC中的一个数据点:
|
||||
|
||||
```csharp
|
||||
var tag = new PlcTag
|
||||
{
|
||||
Id = "temperature_sensor",
|
||||
Address = "DB1.DBD0",
|
||||
DataType = typeof(float),
|
||||
Length = 1,
|
||||
OnChanged = (tag, newValue) =>
|
||||
{
|
||||
Console.WriteLine($"温度变化: {newValue}°C");
|
||||
}
|
||||
};
|
||||
|
||||
_plcService.Subscribe(tag);
|
||||
```
|
||||
|
||||
**属性说明:**
|
||||
- `Id` - 唯一标识符
|
||||
- `Address` - PLC地址(格式取决于驱动)
|
||||
- `DataType` - .NET 类型
|
||||
- `Length` - 数组长度(字符串的最大长度)
|
||||
- `OnChanged` - 值变化回调
|
||||
- `Value` - 当前值(只读)
|
||||
|
||||
### 支持的数据类型
|
||||
|
||||
| C# 类型 | PLC 类型 | 字节数 | 说明 |
|
||||
|---------|---------|--------|------|
|
||||
| `bool` | BOOL | 1 | 支持位偏移 |
|
||||
| `byte` | BYTE | 1 | 无符号8位 |
|
||||
| `short` | INT | 2 | 有符号16位 |
|
||||
| `ushort` | UINT | 2 | 无符号16位 |
|
||||
| `int` | DINT | 4 | 有符号32位 |
|
||||
| `uint` | UDINT | 4 | 无符号32位 |
|
||||
| `float` | REAL | 4 | 单精度浮点 |
|
||||
| `double` | LREAL | 8 | 双精度浮点 |
|
||||
| `string` | STRING | 2+N | S7格式字符串 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 高级功能
|
||||
|
||||
### 1. 标签订阅与监控
|
||||
|
||||
```csharp
|
||||
// 订阅多个标签
|
||||
var tags = new[]
|
||||
{
|
||||
new PlcTag { Address = "DB1.DBW0", DataType = typeof(short) },
|
||||
new PlcTag { Address = "DB1.DBW2", DataType = typeof(short) },
|
||||
new PlcTag { Address = "DB1.DBD4", DataType = typeof(float) }
|
||||
};
|
||||
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
_plcService.Subscribe(tag);
|
||||
}
|
||||
|
||||
// 服务会自动批量优化读取
|
||||
await _plcService.StartAsync();
|
||||
```
|
||||
|
||||
### 2. 批量优化原理
|
||||
|
||||
优化器会自动:
|
||||
1. **按区域分组** - DB1、M、I、Q 等分开处理
|
||||
2. **按地址排序** - 确保连续性
|
||||
3. **间隙合并** - 小于 `MaxGapBytes` 的间隙合并为一次读取
|
||||
4. **PDU限制** - 单次读取不超过 `MaxPduSize`
|
||||
|
||||
**示例:**
|
||||
```
|
||||
标签1: DB1.DBW0 (2 bytes)
|
||||
标签2: DB1.DBW4 (2 bytes) ← 间隙 2 bytes
|
||||
标签3: DB1.DBW8 (2 bytes) ← 间隙 2 bytes
|
||||
|
||||
优化后: 读取 DB1.DBW0, 10 bytes
|
||||
包含所有三个标签和中间的间隙
|
||||
```
|
||||
|
||||
### 3. 使用特性绑定
|
||||
|
||||
```csharp
|
||||
using Plc.Attributes;
|
||||
using Plc.Proxy;
|
||||
|
||||
public interface IPlcData : INotifyPropertyChanged
|
||||
{
|
||||
[PlcAddress("DB1.DBW0")]
|
||||
short Temperature { get; set; }
|
||||
|
||||
[PlcAddress("DB1.DBW2")]
|
||||
short Pressure { get; set; }
|
||||
|
||||
[PlcAddress("DB1.DBX4.0")]
|
||||
bool AlarmFlag { get; set; }
|
||||
}
|
||||
|
||||
// 创建代理
|
||||
var plcData = PlcInterfaceProxy.Create<IPlcData>(_plcService);
|
||||
|
||||
// 自动订阅和双向绑定
|
||||
plcData.PropertyChanged += (s, e) =>
|
||||
{
|
||||
Console.WriteLine($"{e.PropertyName} changed");
|
||||
};
|
||||
|
||||
// 写入会自动发送到PLC
|
||||
plcData.Temperature = 25;
|
||||
```
|
||||
|
||||
### 4. 自定义优化器
|
||||
|
||||
```csharp
|
||||
public class MyCustomOptimizer : IPlcOptimizer
|
||||
{
|
||||
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
|
||||
{
|
||||
// 自定义优化逻辑
|
||||
// 例如:按优先级分组、按更新频率分组等
|
||||
}
|
||||
}
|
||||
|
||||
// 注册自定义优化器
|
||||
services.AddSingleton<IPlcOptimizer, MyCustomOptimizer>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 配置选项
|
||||
|
||||
### PlcMonitorService 配置
|
||||
|
||||
```csharp
|
||||
public class PlcMonitorService
|
||||
{
|
||||
// 刷新率(默认 200ms)
|
||||
private readonly TimeSpan _refreshRate = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
// 最大重连次数(默认 10)
|
||||
private const int MaxReconnectAttempts = 10;
|
||||
}
|
||||
```
|
||||
|
||||
可通过继承 `PlcMonitorService` 自定义这些参数。
|
||||
|
||||
---
|
||||
|
||||
## 🔍 错误处理
|
||||
|
||||
所有操作都返回 `Result` 或 `Result<T>` 类型:
|
||||
|
||||
```csharp
|
||||
var result = await _plcService.ReadAsync<int>("DB1.DBD0");
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Console.WriteLine($"读取成功: {result.Value}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"读取失败: {result.ErrorMessage}");
|
||||
|
||||
// 也可以获取异常详情
|
||||
if (result.Exception != null)
|
||||
{
|
||||
_logger.LogError(result.Exception, "PLC读取异常");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 日志集成
|
||||
|
||||
库已集成 `Microsoft.Extensions.Logging`:
|
||||
|
||||
```csharp
|
||||
// 配置日志级别
|
||||
builder.Services.AddLogging(logging =>
|
||||
{
|
||||
logging.AddConsole();
|
||||
logging.SetMinimumLevel(LogLevel.Debug);
|
||||
});
|
||||
|
||||
// 日志输出示例
|
||||
[12:00:00 INF] Starting PLC monitor service
|
||||
[12:00:01 DBG] Subscribed to tag temp_001 at address DB1.DBW0
|
||||
[12:00:01 TRC] Optimized 10 tags into 3 batches
|
||||
[12:00:02 WRN] PLC disconnected, attempting to reconnect (attempt 1/10)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 扩展指南
|
||||
|
||||
### 实现新的PLC品牌驱动
|
||||
|
||||
#### 1. 实现 `IPlcDriver` 接口
|
||||
|
||||
```csharp
|
||||
public class ModbusDriver : IPlcDriver
|
||||
{
|
||||
public bool IsConnected { get; private set; }
|
||||
public int MaxPduSize => 253; // Modbus TCP 限制
|
||||
|
||||
public async Task<Result> ConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// 实现 Modbus TCP 连接逻辑
|
||||
}
|
||||
|
||||
public async Task<Result<byte[]>> ReadBytesAsync(
|
||||
string address,
|
||||
ushort length,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 解析 Modbus 地址格式
|
||||
// 例如: "40001" -> 寄存器地址
|
||||
// 调用 Modbus 协议读取
|
||||
}
|
||||
|
||||
// ... 其他方法
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 实现地址解析器
|
||||
|
||||
```csharp
|
||||
public class ModbusAddress : IPlcAddress
|
||||
{
|
||||
public int RegisterAddress { get; set; }
|
||||
public int ByteOffset { get; set; }
|
||||
public int BitOffset { get; set; }
|
||||
}
|
||||
|
||||
public static class ModbusAddressParser
|
||||
{
|
||||
public static ModbusAddress Parse(string address)
|
||||
{
|
||||
// 解析 Modbus 地址格式
|
||||
// 例如: "40001" -> Holding Register 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 实现优化器
|
||||
|
||||
```csharp
|
||||
public class ModbusOptimizer : IPlcOptimizer
|
||||
{
|
||||
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
|
||||
{
|
||||
// Modbus 特定优化
|
||||
// 连续寄存器合并读取
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 创建扩展方法
|
||||
|
||||
```csharp
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddModbusPlc(
|
||||
this IServiceCollection services,
|
||||
Action<ModbusPlcOptions> configureOptions)
|
||||
{
|
||||
services.Configure(configureOptions);
|
||||
services.AddSingleton<IPlcDriver, ModbusDriver>();
|
||||
services.AddSingleton<IPlcOptimizer, ModbusOptimizer>();
|
||||
services.AddSingleton<IDataConverter, LittleEndianConverter>();
|
||||
services.AddSingleton<IPlcService, PlcMonitorService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 设计原则
|
||||
|
||||
### 1. **依赖倒置原则 (DIP)**
|
||||
- 高层模块(PlcMonitorService)不依赖低层模块(SiemensDriver)
|
||||
- 两者都依赖抽象(IPlcDriver, IPlcOptimizer)
|
||||
|
||||
### 2. **开闭原则 (OCP)**
|
||||
- 对扩展开放:可添加新的PLC品牌驱动
|
||||
- 对修改关闭:添加新品牌无需修改核心代码
|
||||
|
||||
### 3. **单一职责原则 (SRP)**
|
||||
- **PlcMonitorService** - 只负责监控循环和订阅管理
|
||||
- **IPlcDriver** - 只负责底层通信
|
||||
- **IPlcOptimizer** - 只负责批量优化
|
||||
- **IDataConverter** - 只负责类型转换
|
||||
|
||||
### 4. **接口隔离原则 (ISP)**
|
||||
- 接口粒度合适,不强迫实现不需要的方法
|
||||
- 驱动只需实现 `IPlcDriver`,无需关心优化逻辑
|
||||
|
||||
---
|
||||
|
||||
## 📈 性能优化建议
|
||||
|
||||
### 1. 合理设置刷新率
|
||||
```csharp
|
||||
// 高频数据(100ms)
|
||||
var criticalTag = new PlcTag { Address = "...", ... };
|
||||
|
||||
// 低频数据可以单独轮询
|
||||
Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await _plcService.ReadAsync<int>("DB100.DBD0");
|
||||
await Task.Delay(5000); // 5秒轮询一次
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 减少订阅数量
|
||||
- 只订阅真正需要监控的标签
|
||||
- 一次性读取的数据使用 `ReadAsync`
|
||||
|
||||
### 3. 调整 MaxGapBytes
|
||||
```csharp
|
||||
services.AddSiemensPlc(options =>
|
||||
{
|
||||
options.MaxGapBytes = 50; // 增大间隙合并阈值
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
### 单元测试示例
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task ReadAsync_ValidAddress_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var mockDriver = new Mock<IPlcDriver>();
|
||||
mockDriver.Setup(d => d.ReadBytesAsync(It.IsAny<string>(), It.IsAny<ushort>(), default))
|
||||
.ReturnsAsync(Result.Success(new byte[] { 0x00, 0x64 })); // 100
|
||||
|
||||
var service = new PlcMonitorService(
|
||||
mockDriver.Object,
|
||||
Mock.Of<IPlcOptimizer>(),
|
||||
new BigEndianConverter());
|
||||
|
||||
// Act
|
||||
var result = await service.ReadAsync<short>("DB1.DBW0");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(100, result.Value);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 常见问题
|
||||
|
||||
### Q: 如何处理连接断开?
|
||||
A: `PlcMonitorService` 内置自动重连机制,使用指数退避策略(1s, 2s, 4s, 8s, 16s, 30s)。
|
||||
|
||||
### Q: 是否支持异步属性?
|
||||
A: `PlcInterfaceProxy` 目前使用同步写入。对于异步场景,建议使用 `WriteAsync` 方法。
|
||||
|
||||
### Q: 如何优化大量标签的性能?
|
||||
A:
|
||||
1. 确保标签地址连续或接近
|
||||
2. 调整 `MaxGapBytes` 参数
|
||||
3. 增大 `PduSize`(如果PLC支持)
|
||||
4. 考虑分组订阅(按更新频率)
|
||||
|
||||
### Q: 支持哪些PLC品牌?
|
||||
A: 当前支持西门子S7系列。其他品牌需要实现对应的驱动模块。
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT License - 详见 [LICENSE](LICENSE) 文件
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
### 贡献指南
|
||||
1. Fork 本仓库
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启 Pull Request
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
- 项目主页: [GitHub](https://github.com/yourcompany/plc)
|
||||
- 问题反馈: [Issues](https://github.com/yourcompany/plc/issues)
|
||||
- 文档: [Wiki](https://github.com/yourcompany/plc/wiki)
|
||||
@@ -1,198 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
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 ? "✓" : "✗")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// 全局命名空间引入
|
||||
global using System;
|
||||
global using System.Collections.Generic;
|
||||
global using System.Linq;
|
||||
global using System.Threading;
|
||||
global using System.Threading.Tasks;
|
||||
@@ -1,58 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<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>
|
||||
@@ -1,300 +0,0 @@
|
||||
# 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 (项目内部依赖)
|
||||
@@ -1,391 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
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} 包)";
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Windows;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Config;
|
||||
using Sharp.Log;
|
||||
using Sharp.Data;
|
||||
using Sharp.Data.Context;
|
||||
using Sharp.Data.Access;
|
||||
using Sharp.Data.Access.Options;
|
||||
using Server.Config;
|
||||
using Server.Services;
|
||||
using Server.Data;
|
||||
@@ -20,6 +23,7 @@ namespace Server
|
||||
public ILoggerFactory? LoggerFactory { get; private set; }
|
||||
public ServerService? ServerService { get; private set; }
|
||||
public IDataContext? DataContext { get; private set; }
|
||||
public IAccessDataService? AccessDataService { get; private set; }
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
@@ -36,7 +40,10 @@ namespace Server
|
||||
// 3. 初始化数据库
|
||||
InitializeDatabase();
|
||||
|
||||
// 4. 初始化服务器服务
|
||||
// 4. 初始化 Access 数据服务
|
||||
InitializeAccessDataService();
|
||||
|
||||
// 5. 初始化服务器服务
|
||||
InitializeServerService();
|
||||
|
||||
var logger = LoggerFactory?.CreateLogger<App>();
|
||||
@@ -74,7 +81,7 @@ namespace Server
|
||||
options.Enrich.Application ??= "SharpServer";
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private void InitializeDatabase()
|
||||
{
|
||||
if (LoggerFactory == null)
|
||||
@@ -104,6 +111,62 @@ namespace Server
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeAccessDataService()
|
||||
{
|
||||
if (LoggerFactory == null)
|
||||
{
|
||||
throw new InvalidOperationException("日志系统未初始化");
|
||||
}
|
||||
|
||||
var logger = LoggerFactory.CreateLogger<App>();
|
||||
logger.LogInformation("正在初始化 Access 数据服务...");
|
||||
|
||||
try
|
||||
{
|
||||
// 诊断:输出当前目录信息
|
||||
logger.LogInformation("AppContext.BaseDirectory: {BaseDirectory}", AppContext.BaseDirectory);
|
||||
logger.LogInformation("Environment.CurrentDirectory: {CurrentDirectory}", Environment.CurrentDirectory);
|
||||
logger.LogInformation("数据库文件在输出目录: {DbPath}",
|
||||
Path.Combine(AppContext.BaseDirectory, "dbDwDmsSetup.accdb"));
|
||||
logger.LogInformation("数据库文件是否存在: {Exists}",
|
||||
File.Exists(Path.Combine(AppContext.BaseDirectory, "dbDwDmsSetup.accdb")));
|
||||
|
||||
// 配置 Access 数据库选项
|
||||
var options = new AccessDatabaseOptions
|
||||
{
|
||||
DatabaseFile = "dbDwDmsSetup.accdb",
|
||||
|
||||
// 使用 ACE OLE DB 16.0(功能最完整,支持所有连接字符串参数)
|
||||
// 注意:12.0 不支持 Connect Timeout 参数,会导致 0x80040E21 错误
|
||||
ProviderName = AccessProviderNames.AceOleDb16,
|
||||
|
||||
// 如果数据库有密码,可以在这里设置:
|
||||
// Password = "your_password",
|
||||
|
||||
// 或者使用加密密码(需要先注册自定义解密器):
|
||||
// EncryptedPassword = "encrypted_password",
|
||||
|
||||
// 可选:调整稳健性配置
|
||||
// MaxRetryCount = 3, // 最大重试次数
|
||||
// RetryDelayMilliseconds = 100, // 重试延迟
|
||||
// ConnectionTimeout = 15, // 连接超时(秒)
|
||||
};
|
||||
|
||||
// 创建 Access 数据服务
|
||||
AccessDataService = new AccessDataService(
|
||||
options,
|
||||
logger: LoggerFactory.CreateLogger<AccessDataService>());
|
||||
|
||||
logger.LogInformation("Access 数据服务初始化成功");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Access 数据服务初始化失败");
|
||||
// 不抛出异常,因为 Access 数据库是可选的
|
||||
logger.LogWarning("Access 数据服务初始化失败,但应用程序将继续运行");
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeServerService()
|
||||
{
|
||||
|
||||
@@ -23,13 +23,13 @@ namespace Server.Controllers
|
||||
public async Task GetAll(HttpContext context)
|
||||
{
|
||||
var items = _dataStore.Values.ToList();
|
||||
var result = ResultExtensions.HttpSuccess(new
|
||||
var data = new
|
||||
{
|
||||
Count = items.Count,
|
||||
Items = items
|
||||
});
|
||||
};
|
||||
|
||||
await WriteSuccessResponseAsync(context, result);
|
||||
await WriteSuccessResponseAsync(context, data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -49,8 +49,7 @@ namespace Server.Controllers
|
||||
|
||||
if (_dataStore.TryGetValue(id, out var item))
|
||||
{
|
||||
var result = ResultExtensions.HttpSuccess(item);
|
||||
await WriteSuccessResponseAsync(context, result);
|
||||
await WriteSuccessResponseAsync(context, item);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -87,8 +86,7 @@ namespace Server.Controllers
|
||||
|
||||
_dataStore[id] = item;
|
||||
|
||||
var result = ResultExtensions.HttpSuccess(item, "Data created successfully");
|
||||
await WriteSuccessResponseAsync(context, result);
|
||||
await WriteSuccessResponseAsync(context, item, "Data created successfully");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -129,8 +127,7 @@ namespace Server.Controllers
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
var result = ResultExtensions.HttpSuccess(item, "Data updated successfully");
|
||||
await WriteSuccessResponseAsync(context, result);
|
||||
await WriteSuccessResponseAsync(context, item, "Data updated successfully");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -150,8 +147,7 @@ namespace Server.Controllers
|
||||
|
||||
if (_dataStore.TryRemove(id, out var item))
|
||||
{
|
||||
var result = ResultExtensions.HttpSuccess(item, "Data deleted successfully");
|
||||
await WriteSuccessResponseAsync(context, result);
|
||||
await WriteSuccessResponseAsync(context, item, "Data deleted successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -169,8 +165,8 @@ namespace Server.Controllers
|
||||
var count = _dataStore.Count;
|
||||
_dataStore.Clear();
|
||||
|
||||
var result = ResultExtensions.HttpSuccess(new { DeletedCount = count }, "All data cleared");
|
||||
await WriteSuccessResponseAsync(context, result);
|
||||
var data = new { DeletedCount = count };
|
||||
await WriteSuccessResponseAsync(context, data, "All data cleared");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,14 +19,14 @@ namespace Server.Controllers
|
||||
[HttpGet]
|
||||
public async Task Check(HttpContext context)
|
||||
{
|
||||
var result = ResultExtensions.HttpSuccess(new
|
||||
var data = new
|
||||
{
|
||||
Status = "Healthy",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Uptime = DateTime.UtcNow - Process.GetCurrentProcess().StartTime
|
||||
});
|
||||
};
|
||||
|
||||
await WriteSuccessResponseAsync(context, result);
|
||||
await WriteSuccessResponseAsync(context, data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -37,7 +37,7 @@ namespace Server.Controllers
|
||||
public async Task DetailedCheck(HttpContext context)
|
||||
{
|
||||
var process = Process.GetCurrentProcess();
|
||||
var result = ResultExtensions.HttpSuccess(new
|
||||
var data = new
|
||||
{
|
||||
Status = "Healthy",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
@@ -60,9 +60,9 @@ namespace Server.Controllers
|
||||
},
|
||||
Threads = process.Threads.Count
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
await WriteSuccessResponseAsync(context, result);
|
||||
await WriteSuccessResponseAsync(context, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace Server.Controllers
|
||||
[HttpGet("/info")]
|
||||
public async Task GetSystemInfo(HttpContext context)
|
||||
{
|
||||
var result = ResultExtensions.HttpSuccess(new
|
||||
var data = new
|
||||
{
|
||||
MachineName = Environment.MachineName,
|
||||
UserName = Environment.UserName,
|
||||
@@ -30,9 +30,9 @@ namespace Server.Controllers
|
||||
CurrentDirectory = Environment.CurrentDirectory,
|
||||
TickCount = Environment.TickCount64,
|
||||
Version = Environment.Version.ToString()
|
||||
});
|
||||
};
|
||||
|
||||
await WriteSuccessResponseAsync(context, result);
|
||||
await WriteSuccessResponseAsync(context, data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -59,7 +59,7 @@ namespace Server.Controllers
|
||||
return;
|
||||
}
|
||||
|
||||
var successResult = ResultExtensions.HttpSuccess(new { Name = name, Value = value });
|
||||
var successResult = new { Name = name, Value = value };
|
||||
await WriteSuccessResponseAsync(context, successResult);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ namespace Server.Controllers
|
||||
var now = DateTime.Now;
|
||||
var utcNow = DateTime.UtcNow;
|
||||
|
||||
var result = ResultExtensions.HttpSuccess(new
|
||||
var data = new
|
||||
{
|
||||
Local = new
|
||||
{
|
||||
@@ -89,9 +89,9 @@ namespace Server.Controllers
|
||||
Time = utcNow.TimeOfDay,
|
||||
UnixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
await WriteSuccessResponseAsync(context, result);
|
||||
await WriteSuccessResponseAsync(context, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Server.Data.Access;
|
||||
|
||||
/// <summary>
|
||||
/// Access 数据库上下文
|
||||
/// </summary>
|
||||
public class AccessDbContext : DbContext
|
||||
{
|
||||
public AccessDbContext(DbContextOptions<AccessDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<UserInfo> UserInfos => Set<UserInfo>();
|
||||
public DbSet<UserPermission> UserPermissions => Set<UserPermission>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<UserInfo>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.UserName);
|
||||
entity.Property(e => e.UserName).IsRequired().HasMaxLength(255);
|
||||
entity.Property(e => e.Password).IsRequired().HasMaxLength(255);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<UserPermission>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.UserInfo_UserName, e.Permission_Code });
|
||||
entity.Property(e => e.UserInfo_UserName).IsRequired().HasMaxLength(255);
|
||||
entity.Property(e => e.Permission_Code).IsRequired().HasMaxLength(255);
|
||||
|
||||
entity.HasOne(e => e.UserInfo)
|
||||
.WithMany(u => u.Permissions)
|
||||
.HasForeignKey(e => e.UserInfo_UserName)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Server.Data.Access;
|
||||
|
||||
/// <summary>
|
||||
/// UserInfo 表实体
|
||||
/// </summary>
|
||||
[Table("UserInfo")]
|
||||
public class UserInfo
|
||||
{
|
||||
[Key]
|
||||
[Required]
|
||||
[MaxLength(255)]
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(255)]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
public ICollection<UserPermission> Permissions { get; set; } = new List<UserPermission>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UserPermission 表实体
|
||||
/// </summary>
|
||||
[Table("UserPermission")]
|
||||
public class UserPermission
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(255)]
|
||||
public string UserInfo_UserName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(255)]
|
||||
public string Permission_Code { get; set; } = string.Empty;
|
||||
|
||||
[ForeignKey(nameof(UserInfo_UserName))]
|
||||
public UserInfo? UserInfo { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
# Access 数据库 CRUD 完整示例
|
||||
|
||||
使用 **System.Data.OleDb** 对 Access 数据库进行增删改查操作。
|
||||
|
||||
## 📦 连接字符串
|
||||
|
||||
```csharp
|
||||
var connectionString = "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=dbDwDmsSetup.accdb";
|
||||
using var connection = new OleDbConnection(connectionString);
|
||||
connection.Open();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1️⃣ 查询(SELECT)
|
||||
|
||||
### 查询所有用户
|
||||
|
||||
```csharp
|
||||
using var connection = new OleDbConnection(connectionString);
|
||||
connection.Open();
|
||||
|
||||
var users = new List<UserInfo>();
|
||||
using var command = new OleDbCommand("SELECT UserName, Password FROM UserInfo", connection);
|
||||
using var reader = command.ExecuteReader();
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
var user = new UserInfo
|
||||
{
|
||||
UserName = reader["UserName"]?.ToString() ?? "",
|
||||
Password = reader["Password"]?.ToString() ?? ""
|
||||
};
|
||||
users.Add(user);
|
||||
}
|
||||
|
||||
Console.WriteLine($"查询到 {users.Count} 个用户");
|
||||
```
|
||||
|
||||
### 条件查询
|
||||
|
||||
```csharp
|
||||
// 根据用户名查询
|
||||
using var command = new OleDbCommand(
|
||||
"SELECT UserName, Password FROM UserInfo WHERE UserName = ?", connection);
|
||||
command.Parameters.AddWithValue("@UserName", "admin");
|
||||
|
||||
using var reader = command.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
var userName = reader["UserName"]?.ToString();
|
||||
var password = reader["Password"]?.ToString();
|
||||
Console.WriteLine($"找到用户: {userName}");
|
||||
}
|
||||
```
|
||||
|
||||
### 统计数量
|
||||
|
||||
```csharp
|
||||
using var command = new OleDbCommand(
|
||||
"SELECT COUNT(*) FROM UserPermission WHERE UserInfo_UserName = ?", connection);
|
||||
command.Parameters.AddWithValue("@UserName", "admin");
|
||||
|
||||
var count = Convert.ToInt32(command.ExecuteScalar());
|
||||
Console.WriteLine($"该用户有 {count} 个权限");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2️⃣ 添加(INSERT)
|
||||
|
||||
### 添加用户
|
||||
|
||||
```csharp
|
||||
using var connection = new OleDbConnection(connectionString);
|
||||
connection.Open();
|
||||
|
||||
// 1. 先检查用户是否已存在
|
||||
using (var checkCmd = new OleDbCommand(
|
||||
"SELECT COUNT(*) FROM UserInfo WHERE UserName = ?", connection))
|
||||
{
|
||||
checkCmd.Parameters.AddWithValue("@UserName", "newuser");
|
||||
var exists = Convert.ToInt32(checkCmd.ExecuteScalar()) > 0;
|
||||
|
||||
if (exists)
|
||||
{
|
||||
Console.WriteLine("用户已存在!");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 插入新用户
|
||||
using (var insertCmd = new OleDbCommand(
|
||||
"INSERT INTO UserInfo (UserName, Password) VALUES (?, ?)", connection))
|
||||
{
|
||||
insertCmd.Parameters.AddWithValue("@UserName", "newuser");
|
||||
insertCmd.Parameters.AddWithValue("@Password", "password123");
|
||||
|
||||
var rowsAffected = insertCmd.ExecuteNonQuery();
|
||||
Console.WriteLine($"成功添加 {rowsAffected} 个用户");
|
||||
}
|
||||
```
|
||||
|
||||
### 添加权限
|
||||
|
||||
```csharp
|
||||
using var insertCmd = new OleDbCommand(
|
||||
"INSERT INTO UserPermission (UserInfo_UserName, Permission_Code) VALUES (?, ?)", connection);
|
||||
insertCmd.Parameters.AddWithValue("@UserName", "newuser");
|
||||
insertCmd.Parameters.AddWithValue("@PermissionCode", "READ_DATA");
|
||||
|
||||
var rowsAffected = insertCmd.ExecuteNonQuery();
|
||||
Console.WriteLine($"成功添加权限: {rowsAffected} 条");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3️⃣ 修改(UPDATE)
|
||||
|
||||
### 修改用户密码
|
||||
|
||||
```csharp
|
||||
using var connection = new OleDbConnection(connectionString);
|
||||
connection.Open();
|
||||
|
||||
using var updateCmd = new OleDbCommand(
|
||||
"UPDATE UserInfo SET Password = ? WHERE UserName = ?", connection);
|
||||
updateCmd.Parameters.AddWithValue("@Password", "newpassword");
|
||||
updateCmd.Parameters.AddWithValue("@UserName", "admin");
|
||||
|
||||
var rowsAffected = updateCmd.ExecuteNonQuery();
|
||||
|
||||
if (rowsAffected > 0)
|
||||
{
|
||||
Console.WriteLine("密码修改成功!");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("用户不存在!");
|
||||
}
|
||||
```
|
||||
|
||||
### 批量更新
|
||||
|
||||
```csharp
|
||||
// 将所有用户的密码更新为默认密码
|
||||
using var updateCmd = new OleDbCommand(
|
||||
"UPDATE UserInfo SET Password = ? WHERE Password IS NULL OR Password = ''", connection);
|
||||
updateCmd.Parameters.AddWithValue("@Password", "default123");
|
||||
|
||||
var rowsAffected = updateCmd.ExecuteNonQuery();
|
||||
Console.WriteLine($"更新了 {rowsAffected} 个用户的密码");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4️⃣ 删除(DELETE)
|
||||
|
||||
### 删除用户(含事务)
|
||||
|
||||
```csharp
|
||||
using var connection = new OleDbConnection(connectionString);
|
||||
connection.Open();
|
||||
|
||||
// 使用事务确保数据一致性
|
||||
using var transaction = connection.BeginTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 先删除用户的所有权限
|
||||
using (var deletePermCmd = new OleDbCommand(
|
||||
"DELETE FROM UserPermission WHERE UserInfo_UserName = ?", connection, transaction))
|
||||
{
|
||||
deletePermCmd.Parameters.AddWithValue("@UserName", "testuser");
|
||||
var permCount = deletePermCmd.ExecuteNonQuery();
|
||||
Console.WriteLine($"删除了 {permCount} 个权限");
|
||||
}
|
||||
|
||||
// 2. 再删除用户
|
||||
using (var deleteUserCmd = new OleDbCommand(
|
||||
"DELETE FROM UserInfo WHERE UserName = ?", connection, transaction))
|
||||
{
|
||||
deleteUserCmd.Parameters.AddWithValue("@UserName", "testuser");
|
||||
var userCount = deleteUserCmd.ExecuteNonQuery();
|
||||
|
||||
if (userCount == 0)
|
||||
{
|
||||
transaction.Rollback();
|
||||
Console.WriteLine("用户不存在!");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"删除了 {userCount} 个用户");
|
||||
}
|
||||
|
||||
// 3. 提交事务
|
||||
transaction.Commit();
|
||||
Console.WriteLine("删除成功!");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 回滚事务
|
||||
transaction.Rollback();
|
||||
Console.WriteLine($"删除失败: {ex.Message}");
|
||||
}
|
||||
```
|
||||
|
||||
### 删除权限
|
||||
|
||||
```csharp
|
||||
using var deleteCmd = new OleDbCommand(
|
||||
"DELETE FROM UserPermission WHERE UserInfo_UserName = ? AND Permission_Code = ?", connection);
|
||||
deleteCmd.Parameters.AddWithValue("@UserName", "admin");
|
||||
deleteCmd.Parameters.AddWithValue("@PermissionCode", "DELETE_DATA");
|
||||
|
||||
var rowsAffected = deleteCmd.ExecuteNonQuery();
|
||||
Console.WriteLine($"删除了 {rowsAffected} 个权限");
|
||||
```
|
||||
|
||||
### 批量删除
|
||||
|
||||
```csharp
|
||||
// 删除所有没有权限的用户
|
||||
using var deleteCmd = new OleDbCommand(@"
|
||||
DELETE FROM UserInfo
|
||||
WHERE UserName NOT IN (
|
||||
SELECT DISTINCT UserInfo_UserName FROM UserPermission
|
||||
)", connection);
|
||||
|
||||
var rowsAffected = deleteCmd.ExecuteNonQuery();
|
||||
Console.WriteLine($"删除了 {rowsAffected} 个无权限用户");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔥 高级操作
|
||||
|
||||
### 1. JOIN 查询
|
||||
|
||||
```csharp
|
||||
// 查询用户及其所有权限
|
||||
using var command = new OleDbCommand(@"
|
||||
SELECT u.UserName, u.Password, p.Permission_Code
|
||||
FROM UserInfo u
|
||||
LEFT JOIN UserPermission p ON u.UserName = p.UserInfo_UserName
|
||||
ORDER BY u.UserName", connection);
|
||||
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var userName = reader["UserName"]?.ToString();
|
||||
var permission = reader["Permission_Code"]?.ToString();
|
||||
Console.WriteLine($"{userName}: {permission ?? "(无权限)"}");
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 分组统计
|
||||
|
||||
```csharp
|
||||
// 统计每个用户的权限数量
|
||||
using var command = new OleDbCommand(@"
|
||||
SELECT u.UserName, COUNT(p.Permission_Code) AS PermCount
|
||||
FROM UserInfo u
|
||||
LEFT JOIN UserPermission p ON u.UserName = p.UserInfo_UserName
|
||||
GROUP BY u.UserName
|
||||
ORDER BY PermCount DESC", connection);
|
||||
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var userName = reader["UserName"]?.ToString();
|
||||
var count = Convert.ToInt32(reader["PermCount"]);
|
||||
Console.WriteLine($"{userName}: {count} 个权限");
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 参数化批量插入
|
||||
|
||||
```csharp
|
||||
using var connection = new OleDbConnection(connectionString);
|
||||
connection.Open();
|
||||
|
||||
var users = new[]
|
||||
{
|
||||
("user1", "pass1"),
|
||||
("user2", "pass2"),
|
||||
("user3", "pass3")
|
||||
};
|
||||
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
using var insertCmd = new OleDbCommand(
|
||||
"INSERT INTO UserInfo (UserName, Password) VALUES (?, ?)", connection, transaction);
|
||||
|
||||
foreach (var (userName, password) in users)
|
||||
{
|
||||
insertCmd.Parameters.Clear();
|
||||
insertCmd.Parameters.AddWithValue("@UserName", userName);
|
||||
insertCmd.Parameters.AddWithValue("@Password", password);
|
||||
insertCmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
Console.WriteLine($"成功添加 {users.Length} 个用户");
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 存在性检查
|
||||
|
||||
```csharp
|
||||
// 检查用户是否存在
|
||||
public bool UserExists(string userName)
|
||||
{
|
||||
using var connection = new OleDbConnection(connectionString);
|
||||
connection.Open();
|
||||
|
||||
using var command = new OleDbCommand(
|
||||
"SELECT COUNT(*) FROM UserInfo WHERE UserName = ?", connection);
|
||||
command.Parameters.AddWithValue("@UserName", userName);
|
||||
|
||||
return Convert.ToInt32(command.ExecuteScalar()) > 0;
|
||||
}
|
||||
|
||||
// 使用
|
||||
if (UserExists("admin"))
|
||||
{
|
||||
Console.WriteLine("用户存在");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 最佳实践
|
||||
|
||||
### 1. 使用 using 语句
|
||||
|
||||
```csharp
|
||||
// ✅ 推荐:自动释放资源
|
||||
using var connection = new OleDbConnection(connectionString);
|
||||
using var command = new OleDbCommand(sql, connection);
|
||||
using var reader = command.ExecuteReader();
|
||||
|
||||
// ❌ 不推荐:需要手动释放
|
||||
var connection = new OleDbConnection(connectionString);
|
||||
try
|
||||
{
|
||||
// ...
|
||||
}
|
||||
finally
|
||||
{
|
||||
connection.Dispose();
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 使用参数化查询(防止 SQL 注入)
|
||||
|
||||
```csharp
|
||||
// ✅ 推荐:参数化查询
|
||||
var sql = "SELECT * FROM UserInfo WHERE UserName = ?";
|
||||
command.Parameters.AddWithValue("@UserName", userName);
|
||||
|
||||
// ❌ 危险:字符串拼接(SQL 注入风险)
|
||||
var sql = $"SELECT * FROM UserInfo WHERE UserName = '{userName}'";
|
||||
```
|
||||
|
||||
### 3. 使用事务保证数据一致性
|
||||
|
||||
```csharp
|
||||
// ✅ 推荐:使用事务
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
// 多个操作
|
||||
command1.ExecuteNonQuery();
|
||||
command2.ExecuteNonQuery();
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 异常处理
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
using var connection = new OleDbConnection(connectionString);
|
||||
connection.Open();
|
||||
// 数据库操作
|
||||
}
|
||||
catch (OleDbException ex)
|
||||
{
|
||||
Console.WriteLine($"数据库错误: {ex.Message}");
|
||||
// 记录日志
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"未知错误: {ex.Message}");
|
||||
// 记录日志
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 完整示例项目
|
||||
|
||||
完整的 CRUD 操作已集成到 **Server 项目**中:
|
||||
|
||||
**文件位置**:
|
||||
- UI: `Server/Views/AccessDatabaseWindowOleDb.xaml`
|
||||
- 代码: `Server/Views/AccessDatabaseWindowOleDb.xaml.cs`
|
||||
|
||||
**功能列表**:
|
||||
- ✅ 查询所有用户
|
||||
- ✅ 添加新用户
|
||||
- ✅ 修改用户密码
|
||||
- ✅ 删除用户(含级联删除权限)
|
||||
- ✅ 添加用户权限
|
||||
- ✅ 删除用户权限
|
||||
- ✅ 事务支持
|
||||
- ✅ 异常处理
|
||||
- ✅ 异步操作(不阻塞 UI)
|
||||
|
||||
**运行方式**:
|
||||
1. 启动 Server 项目
|
||||
2. 点击 "Access数据库" 按钮
|
||||
3. 尽情测试增删改查功能!
|
||||
|
||||
数据库随意修改,不用担心!🎉
|
||||
@@ -0,0 +1,186 @@
|
||||
# Access 数据库访问方案对比
|
||||
|
||||
## 方案选择
|
||||
|
||||
### ✅ 方案 A:System.Data.OleDb(推荐)
|
||||
|
||||
**适用场景**:
|
||||
- 简单的增删改查操作
|
||||
- 性能要求高
|
||||
- 需要稳定可靠的方案
|
||||
- 不需要复杂的 LINQ 查询
|
||||
|
||||
**实现文件**:
|
||||
- `Views/AccessDatabaseWindowOleDb.xaml`
|
||||
- `Views/AccessDatabaseWindowOleDb.xaml.cs`
|
||||
|
||||
**示例代码**:
|
||||
```csharp
|
||||
// 1. 创建连接
|
||||
var connectionString = "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=dbDwDmsSetup.accdb";
|
||||
using var connection = new OleDbConnection(connectionString);
|
||||
connection.Open();
|
||||
|
||||
// 2. 查询数据
|
||||
using var command = new OleDbCommand("SELECT UserName, Password FROM UserInfo", connection);
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var userName = reader["UserName"]?.ToString();
|
||||
var password = reader["Password"]?.ToString();
|
||||
}
|
||||
|
||||
// 3. 插入数据
|
||||
using var insertCmd = new OleDbCommand(
|
||||
"INSERT INTO UserInfo (UserName, Password) VALUES (?, ?)", connection);
|
||||
insertCmd.Parameters.AddWithValue("@UserName", "newuser");
|
||||
insertCmd.Parameters.AddWithValue("@Password", "password");
|
||||
insertCmd.ExecuteNonQuery();
|
||||
|
||||
// 4. 更新数据
|
||||
using var updateCmd = new OleDbCommand(
|
||||
"UPDATE UserInfo SET Password = ? WHERE UserName = ?", connection);
|
||||
updateCmd.Parameters.AddWithValue("@Password", "newpassword");
|
||||
updateCmd.Parameters.AddWithValue("@UserName", "username");
|
||||
updateCmd.ExecuteNonQuery();
|
||||
|
||||
// 5. 删除数据
|
||||
using var deleteCmd = new OleDbCommand(
|
||||
"DELETE FROM UserInfo WHERE UserName = ?", connection);
|
||||
deleteCmd.Parameters.AddWithValue("@UserName", "username");
|
||||
deleteCmd.ExecuteNonQuery();
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 微软官方支持
|
||||
- ✅ 无需额外 NuGet 包
|
||||
- ✅ 性能最佳
|
||||
- ✅ 稳定可靠
|
||||
- ✅ 适用于所有 .NET 版本
|
||||
|
||||
**缺点**:
|
||||
- ❌ 需要手写 SQL
|
||||
- ❌ 没有 LINQ 支持
|
||||
- ❌ 没有自动实体映射
|
||||
|
||||
---
|
||||
|
||||
### 方案 B:EntityFrameworkCore.Jet
|
||||
|
||||
**适用场景**:
|
||||
- 需要复杂的 LINQ 查询
|
||||
- 需要实体关系映射
|
||||
- 代码优雅度要求高
|
||||
- 可以接受第三方库
|
||||
|
||||
**实现文件**:
|
||||
- `Views/AccessDatabaseWindow.xaml`
|
||||
- `Views/AccessDatabaseWindow.xaml.cs`
|
||||
- `Data/Access/AccessEntities.cs`
|
||||
- `Data/Access/AccessDbContext.cs`
|
||||
|
||||
**示例代码**:
|
||||
```csharp
|
||||
// 1. 创建 DbContext
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AccessDbContext>();
|
||||
optionsBuilder.UseJet("Provider=Microsoft.ACE.OLEDB.12.0;Data Source=dbDwDmsSetup.accdb");
|
||||
using var dbContext = new AccessDbContext(optionsBuilder.Options);
|
||||
|
||||
// 2. LINQ 查询
|
||||
var users = await dbContext.UserInfos
|
||||
.Include(u => u.Permissions)
|
||||
.Where(u => u.UserName.Contains("admin"))
|
||||
.ToListAsync();
|
||||
|
||||
// 3. 插入数据
|
||||
var newUser = new UserInfo { UserName = "newuser", Password = "password" };
|
||||
dbContext.UserInfos.Add(newUser);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// 4. 更新数据
|
||||
var user = await dbContext.UserInfos.FindAsync("username");
|
||||
if (user != null)
|
||||
{
|
||||
user.Password = "newpassword";
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// 5. 删除数据
|
||||
var userToDelete = await dbContext.UserInfos.FindAsync("username");
|
||||
if (userToDelete != null)
|
||||
{
|
||||
dbContext.UserInfos.Remove(userToDelete);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ LINQ 查询支持
|
||||
- ✅ 自动实体映射
|
||||
- ✅ 代码更优雅
|
||||
- ✅ 支持导航属性
|
||||
|
||||
**缺点**:
|
||||
- ❌ 第三方库(EntityFrameworkCore.Jet)
|
||||
- ❌ 版本兼容性问题
|
||||
- ❌ 性能开销
|
||||
- ❌ 可能与其他 EF Core 提供程序冲突
|
||||
|
||||
---
|
||||
|
||||
## 推荐方案
|
||||
|
||||
### 对于简单的增删改查:
|
||||
|
||||
**推荐使用方案 A(System.Data.OleDb)**
|
||||
|
||||
理由:
|
||||
1. 稳定可靠,微软官方支持
|
||||
2. 性能最佳
|
||||
3. 无依赖问题
|
||||
4. 代码简单直接
|
||||
|
||||
### 对于复杂的业务逻辑:
|
||||
|
||||
可以考虑方案 B(EntityFrameworkCore.Jet),但需要:
|
||||
1. 接受第三方库的风险
|
||||
2. 处理版本兼容性问题
|
||||
3. 确保不与其他数据库提供程序冲突
|
||||
|
||||
---
|
||||
|
||||
## 当前 Server 项目使用的方案
|
||||
|
||||
当前使用:**方案 A(System.Data.OleDb)** ✅
|
||||
|
||||
主窗口按钮 "Access数据库" 打开的是 `AccessDatabaseWindowOleDb` 窗口。
|
||||
|
||||
如需切换到方案 B,修改 `MainWindow.xaml.cs` 中的 `AccessDbButton_Click` 方法:
|
||||
|
||||
```csharp
|
||||
// 方案 A(当前)
|
||||
var window = new AccessDatabaseWindowOleDb { Owner = this };
|
||||
|
||||
// 方案 B
|
||||
var window = new AccessDatabaseWindow { Owner = this };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 性能对比
|
||||
|
||||
| 操作 | OleDb | EF Core Jet |
|
||||
|------|-------|-------------|
|
||||
| 简单查询 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
|
||||
| 复杂查询 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| 插入操作 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
|
||||
| 批量操作 | ⭐⭐⭐⭐⭐ | ⭐⭐ |
|
||||
| 内存占用 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
对于你的需求(简单的增删改查),**强烈推荐使用 System.Data.OleDb(方案 A)**。
|
||||
|
||||
它是最稳定、性能最好、最简单的方案。
|
||||
@@ -55,6 +55,8 @@
|
||||
Background="#FF9800" Foreground="White" BorderThickness="0" FontWeight="Bold" Cursor="Hand" IsEnabled="False"/>
|
||||
<Button x:Name="DataManagementButton" Content="数据管理" Width="100" Height="32" Margin="0,0,10,0" Click="DataManagementButton_Click"
|
||||
Background="#673AB7" Foreground="White" BorderThickness="0" FontWeight="Bold" Cursor="Hand"/>
|
||||
<Button x:Name="AccessDbButton" Content="Access数据库" Width="110" Height="32" Margin="0,0,10,0" Click="AccessDbButton_Click"
|
||||
Background="#009688" Foreground="White" BorderThickness="0" FontWeight="Bold" Cursor="Hand"/>
|
||||
<Button x:Name="ClearLogButton" Content="清空日志" Width="100" Height="32" Click="ClearLogButton_Click"
|
||||
Background="#9E9E9E" Foreground="White" BorderThickness="0" FontWeight="Bold" Cursor="Hand"/>
|
||||
</StackPanel>
|
||||
|
||||
@@ -155,6 +155,26 @@ namespace Server
|
||||
}
|
||||
}
|
||||
|
||||
private void AccessDbButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger?.LogInformation("打开 Access 数据库管理窗口 (OleDb)");
|
||||
var window = new AccessDatabaseWindowOleDb
|
||||
{
|
||||
Owner = this
|
||||
};
|
||||
window.ShowDialog();
|
||||
_logger?.LogInformation("Access 数据库管理窗口已关闭");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "打开 Access 数据库管理窗口失败");
|
||||
AppendLog($"打开 Access 数据库管理窗口失败: {ex.Message}");
|
||||
MessageBox.Show($"打开 Access 数据库管理窗口失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnServerStatusChanged(object? sender, string status)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
|
||||
<!-- 强制使用 x64 架构,与已安装的 64 位 ACE 驱动匹配 -->
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
@@ -22,6 +25,7 @@
|
||||
<ProjectReference Include="..\Wpf\Wpf.csproj" />
|
||||
<ProjectReference Include="..\Common\Common.csproj" />
|
||||
<ProjectReference Include="..\Data\Data.csproj" />
|
||||
<ProjectReference Include="..\Data.Access\Data.Access.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -34,6 +38,9 @@
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="dbDwDmsSetup.accdb">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,54 @@
|
||||
<Window x:Class="Server.Views.AccessDatabaseWindow"
|
||||
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"
|
||||
mc:Ignorable="d"
|
||||
Title="Access 数据库管理" Height="600" Width="900"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Grid Margin="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="200"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- 标题和操作栏 -->
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<TextBlock Text="Access 数据库管理" FontSize="20" FontWeight="Bold" VerticalAlignment="Center"/>
|
||||
<TextBlock Text=" (dbDwDmsSetup.accdb)" FontSize="14" Foreground="Gray" VerticalAlignment="Center" Margin="5,0,0,0"/>
|
||||
<Button Content="刷新数据" Click="RefreshButton_Click" Margin="20,0,0,0" Padding="10,5"/>
|
||||
<Button Content="测试连接" Click="TestConnectionButton_Click" Margin="10,0,0,0" Padding="10,5"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<GroupBox Grid.Row="1" Header="用户列表" Margin="0,0,0,10">
|
||||
<DataGrid x:Name="UsersDataGrid"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
SelectionMode="Single"
|
||||
SelectionChanged="UsersDataGrid_SelectionChanged">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="用户名" Binding="{Binding UserName}" Width="200"/>
|
||||
<DataGridTextColumn Header="密码" Binding="{Binding Password}" Width="*"/>
|
||||
<DataGridTextColumn Header="权限数量" Binding="{Binding Permissions.Count}" Width="100"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</GroupBox>
|
||||
|
||||
<!-- 分隔符 -->
|
||||
<GridSplitter Grid.Row="2" Height="5" HorizontalAlignment="Stretch" Background="LightGray"/>
|
||||
|
||||
<!-- 权限列表 -->
|
||||
<GroupBox Grid.Row="3" Header="选中用户的权限">
|
||||
<DataGrid x:Name="PermissionsDataGrid"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="权限代码" Binding="{Binding Permission_Code}" Width="*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</GroupBox>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Data.OleDb;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using EntityFrameworkCore.Jet;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Sharp.Data.Access.Options;
|
||||
using Server.Data.Access;
|
||||
|
||||
namespace Server.Views;
|
||||
|
||||
public partial class AccessDatabaseWindow : Window
|
||||
{
|
||||
private readonly ILogger<AccessDatabaseWindow>? _logger;
|
||||
private const string DatabaseFile = "dbDwDmsSetup.accdb";
|
||||
|
||||
public AccessDatabaseWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
try
|
||||
{
|
||||
var app = (App)Application.Current;
|
||||
_logger = app.LoggerFactory?.CreateLogger<AccessDatabaseWindow>();
|
||||
|
||||
Loaded += AccessDatabaseWindow_Loaded;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"初始化失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async void AccessDatabaseWindow_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async void RefreshButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async void TestConnectionButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger?.LogInformation("测试 Access 数据库连接...");
|
||||
|
||||
using var dbContext = CreateDbContext();
|
||||
var canConnect = await dbContext.Database.CanConnectAsync();
|
||||
|
||||
if (canConnect)
|
||||
{
|
||||
_logger?.LogInformation("数据库连接成功!");
|
||||
MessageBox.Show("数据库连接成功!", "成功", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger?.LogWarning("无法连接到数据库");
|
||||
MessageBox.Show("无法连接到数据库", "警告", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "测试连接失败");
|
||||
MessageBox.Show($"测试连接失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task LoadDataAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger?.LogInformation("开始加载 Access 数据库数据...");
|
||||
|
||||
using var dbContext = CreateDbContext();
|
||||
|
||||
// 查询所有用户及其权限
|
||||
var users = await dbContext.UserInfos
|
||||
.Include(u => u.Permissions)
|
||||
.ToListAsync();
|
||||
|
||||
_logger?.LogInformation($"加载了 {users.Count} 个用户");
|
||||
|
||||
// 更新 UI
|
||||
UsersDataGrid.ItemsSource = users;
|
||||
PermissionsDataGrid.ItemsSource = null;
|
||||
|
||||
MessageBox.Show($"成功加载 {users.Count} 个用户!", "成功", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "加载数据失败");
|
||||
MessageBox.Show($"加载数据失败: {ex.Message}\n\n详细信息:\n{ex.ToString()}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void UsersDataGrid_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
||||
{
|
||||
if (UsersDataGrid.SelectedItem is UserInfo selectedUser)
|
||||
{
|
||||
_logger?.LogInformation($"选中用户: {selectedUser.UserName}, 权限数: {selectedUser.Permissions.Count}");
|
||||
PermissionsDataGrid.ItemsSource = selectedUser.Permissions;
|
||||
}
|
||||
else
|
||||
{
|
||||
PermissionsDataGrid.ItemsSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建 Access 数据库上下文(使用新的统一配置体系)
|
||||
/// </summary>
|
||||
private AccessDbContext CreateDbContext()
|
||||
{
|
||||
// 获取数据库文件的完整路径
|
||||
var dbPath = Path.GetFullPath(DatabaseFile);
|
||||
|
||||
if (!File.Exists(dbPath))
|
||||
{
|
||||
throw new FileNotFoundException($"找不到 Access 数据库文件: {dbPath}");
|
||||
}
|
||||
|
||||
// 使用统一的配置方式构建连接字符串
|
||||
var builder = new OleDbConnectionStringBuilder
|
||||
{
|
||||
["Provider"] = AccessProviderNames.AceOleDb16, // 使用 16.0
|
||||
["Data Source"] = dbPath
|
||||
// Connect Timeout 在某些情况下不稳定,已移除
|
||||
};
|
||||
|
||||
// 如果数据库有密码,可以在这里设置:
|
||||
// builder["Jet OLEDB:Database Password"] = "your_password";
|
||||
|
||||
var connectionString = builder.ConnectionString;
|
||||
|
||||
// 创建 DbContext 选项
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AccessDbContext>();
|
||||
optionsBuilder.UseJet(connectionString);
|
||||
|
||||
return new AccessDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<Window x:Class="Server.Views.AccessDatabaseWindowOleDb"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Access 数据库管理 (OleDb方式)" Height="700" Width="1000"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Grid Margin="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="200"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- 标题和操作栏 -->
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,10">
|
||||
<TextBlock Text="Access 数据库管理" FontSize="20" FontWeight="Bold" VerticalAlignment="Center"/>
|
||||
<TextBlock Text=" (System.Data.OleDb)" FontSize="14" Foreground="Green" VerticalAlignment="Center" Margin="5,0,0,0"/>
|
||||
<Button Content="刷新数据" Click="RefreshButton_Click" Margin="20,0,0,0" Padding="10,5"/>
|
||||
<Button Content="测试连接" Click="TestConnectionButton_Click" Margin="10,0,0,0" Padding="10,5"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 用户操作区 -->
|
||||
<GroupBox Grid.Row="1" Header="用户操作" Margin="0,0,0,10">
|
||||
<StackPanel Orientation="Horizontal" Margin="5">
|
||||
<TextBlock Text="用户名:" VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||
<TextBox x:Name="UserNameTextBox" Width="150" Margin="0,0,10,0"/>
|
||||
<TextBlock Text="密码:" VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||
<TextBox x:Name="PasswordTextBox" Width="150" Margin="0,0,20,0"/>
|
||||
<Button Content="添加用户" Click="AddUserButton_Click" Padding="10,5" Margin="0,0,5,0" Background="#4CAF50" Foreground="White"/>
|
||||
<Button Content="修改密码" Click="UpdatePasswordButton_Click" Padding="10,5" Margin="0,0,5,0" Background="#FF9800" Foreground="White"/>
|
||||
<Button Content="删除用户" Click="DeleteUserButton_Click" Padding="10,5" Background="#F44336" Foreground="White"/>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
|
||||
<!-- 用户列表 -->
|
||||
<GroupBox Grid.Row="2" Header="用户列表" Margin="0,0,0,10">
|
||||
<DataGrid x:Name="UsersDataGrid"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
SelectionMode="Single"
|
||||
SelectionChanged="UsersDataGrid_SelectionChanged">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="用户名" Binding="{Binding UserName}" Width="200"/>
|
||||
<DataGridTextColumn Header="密码" Binding="{Binding Password}" Width="*"/>
|
||||
<DataGridTextColumn Header="权限数量" Binding="{Binding PermissionCount}" Width="100"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</GroupBox>
|
||||
|
||||
<!-- 分隔符 -->
|
||||
<GridSplitter Grid.Row="3" Height="5" HorizontalAlignment="Stretch" Background="LightGray"/>
|
||||
|
||||
<!-- 权限列表 -->
|
||||
<GroupBox Grid.Row="4" Header="选中用户的权限">
|
||||
<DataGrid x:Name="PermissionsDataGrid"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="权限代码" Binding="{Binding Permission_Code}" Width="*"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</GroupBox>
|
||||
|
||||
<!-- 权限操作区 -->
|
||||
<GroupBox Grid.Row="5" Header="权限操作" Margin="0,10,0,0">
|
||||
<StackPanel Orientation="Horizontal" Margin="5">
|
||||
<TextBlock Text="权限代码:" VerticalAlignment="Center" Margin="0,0,5,0"/>
|
||||
<TextBox x:Name="PermissionCodeTextBox" Width="200" Margin="0,0,20,0"/>
|
||||
<Button Content="添加权限" Click="AddPermissionButton_Click" Padding="10,5" Margin="0,0,5,0" Background="#4CAF50" Foreground="White"/>
|
||||
<Button Content="删除权限" Click="DeletePermissionButton_Click" Padding="10,5" Background="#F44336" Foreground="White"/>
|
||||
</StackPanel>
|
||||
</GroupBox>
|
||||
</Grid>
|
||||
</Window>
|
||||
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user