A:增加PLC功能和西门子PLC对接
This commit is contained in:
1 parent
bca001b90f
commit
2f3eb935dd
27 files changed
+1499
-4
No files matched your search
@@ -11,6 +11,9 @@ using Sharp.Infrastructure.Configuration;
|
||||
using Sharp.Log.Extensions;
|
||||
using Sharp.NetServer.DependencyInjection;
|
||||
using Sharp.Wpf.Services;
|
||||
using Sharp.Wpf.Abstractions;
|
||||
using Plc.Siemens.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Client
|
||||
{
|
||||
@@ -25,6 +28,57 @@ namespace Client
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -33,6 +87,8 @@ namespace Client
|
||||
|
||||
// ConfigManager and typed SystemConfig accessor
|
||||
services.AddSingleton(cm);
|
||||
// Register standard IConfiguration so that .BindConfiguration() works
|
||||
services.AddSingleton<IConfiguration>(cm.Configuration);
|
||||
services.AddSystemConfig(cm);
|
||||
|
||||
// Logging (binds from section "SharpLog")
|
||||
@@ -42,30 +98,49 @@ 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 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(_serviceProvider);
|
||||
Global.RegisterSingleton<IServiceProvider>(_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;
|
||||
|
||||
var loginWindow = _serviceProvider.GetRequiredService<LoginWindow>();
|
||||
var loginResult = loginWindow.ShowDialog();
|
||||
|
||||
if (loginResult == true)
|
||||
{
|
||||
var main = _serviceProvider.GetRequiredService<MainWindow>();
|
||||
Current.MainWindow = main;
|
||||
ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||
main.Show();
|
||||
}
|
||||
else
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<ProjectReference Include="..\NetServer\NetServer.csproj" />
|
||||
<ProjectReference Include="..\Log\Log.csproj" />
|
||||
<ProjectReference Include="..\Sharp.Infrastructure\Sharp.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\Plc.Siemens\Plc.Siemens.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.ComponentModel;
|
||||
using Plc.Attributes;
|
||||
|
||||
namespace Client.Hardware;
|
||||
|
||||
// 必须继承 INotifyPropertyChanged 才能支持自动 UI 刷新
|
||||
public interface IPackMachine : INotifyPropertyChanged
|
||||
{
|
||||
[PlcAddress("DB1.DBW0")]
|
||||
short RunningSpeed { get; set; }
|
||||
|
||||
[PlcAddress("DB1.DBW2")]
|
||||
short Temperature { get; set; }
|
||||
|
||||
// 支持 bool (比如 M 区)
|
||||
[PlcAddress("M100.0")]
|
||||
bool IsStartButtonPushed { get; set; }
|
||||
}
|
||||
@@ -47,7 +47,12 @@
|
||||
Command="{Binding ShowDialogCommand}" />
|
||||
<Button Content="触发异常"
|
||||
Padding="18,6"
|
||||
Margin="0,0,12,0"
|
||||
Command="{Binding ThrowCommand}" />
|
||||
<Button Content="PLC 调试"
|
||||
Padding="18,6"
|
||||
Style="{StaticResource ButtonPrimary}"
|
||||
Command="{Binding OpenPlcToolCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -82,6 +87,45 @@
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<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>
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Windows;
|
||||
using Client.ViewModels;
|
||||
using HandyControl.Controls;
|
||||
|
||||
namespace Client;
|
||||
|
||||
public partial class PlcWindow : GlowWindow
|
||||
{
|
||||
public PlcWindow(PlcViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ public sealed class LoginViewModel : ViewModelBase
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
|
||||
var result = await _authenticationService.LoginAsync(UserName, Password).ConfigureAwait(false);
|
||||
var result = await _authenticationService.LoginAsync(UserName, Password);
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
|
||||
@@ -5,6 +5,14 @@ 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;
|
||||
|
||||
@@ -12,23 +20,64 @@ 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;
|
||||
|
||||
public MainViewModel(IDialogService dialogService, IUserSession userSession)
|
||||
// 魔法设备对象:直接绑定到界面,无需手动写 Read/Write/Subscribe
|
||||
public IPackMachine Machine { get; }
|
||||
|
||||
public MainViewModel(IDialogService dialogService, IUserSession userSession, IDispatcher dispatcher)
|
||||
{
|
||||
_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();
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -52,6 +101,9 @@ public sealed class MainViewModel : ViewModelBase
|
||||
public IAsyncCommand ShowDialogCommand { get; }
|
||||
public IAsyncCommand RefreshDashboardCommand { get; }
|
||||
public ICommand ThrowCommand { get; }
|
||||
public ICommand OpenPlcToolCommand { get; }
|
||||
public ICommand StartMachineCommand { get; }
|
||||
public ICommand StopMachineCommand { get; }
|
||||
|
||||
private async Task ShowDialogAsync()
|
||||
{
|
||||
@@ -83,4 +135,4 @@ public sealed class MainViewModel : ViewModelBase
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
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;
|
||||
|
||||
namespace Client.ViewModels;
|
||||
|
||||
public class PlcViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IPlcService _plcService;
|
||||
private readonly SiemensPlcOptions _siemensOptions;
|
||||
|
||||
// 连接配置
|
||||
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)
|
||||
{
|
||||
_plcService = plcService;
|
||||
_siemensOptions = siemensOptions;
|
||||
|
||||
// 初始化界面值为当前配置值
|
||||
_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);
|
||||
}
|
||||
|
||||
#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; }
|
||||
|
||||
#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) =>
|
||||
{
|
||||
// 注意:UI绑定更新需要在UI线程,这里简化处理,假设WPF做了同步或者是后台线程安全集合
|
||||
t.Value = v;
|
||||
}
|
||||
};
|
||||
|
||||
_plcService.Subscribe(tag);
|
||||
MonitoredTags.Add(tag);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Plc.Abstractions;
|
||||
using Plc.Services;
|
||||
using Plc.Siemens;
|
||||
using Plc.Siemens.Options;
|
||||
|
||||
namespace Plc.Siemens.DependencyInjection;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddSiemensPlc(this IServiceCollection services, Action<SiemensPlcOptions> configureOptions)
|
||||
{
|
||||
var options = new SiemensPlcOptions();
|
||||
configureOptions(options);
|
||||
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton<IPlcDriver, SiemensDriver>();
|
||||
services.AddSingleton<IPlcOptimizer, SiemensOptimizer>();
|
||||
services.AddSingleton<IDataConverter, BigEndianConverter>();
|
||||
services.AddSingleton<IPlcService, PlcMonitorService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Plc.Siemens.Models;
|
||||
|
||||
public enum SiemensMemoryArea
|
||||
{
|
||||
Inputs = 0x81,
|
||||
Outputs = 0x82,
|
||||
Memory = 0x83,
|
||||
DataBlock = 0x84,
|
||||
Timer = 0x1D,
|
||||
Counter = 0x1C
|
||||
}
|
||||
|
||||
public class SiemensAddress
|
||||
{
|
||||
public SiemensMemoryArea Area { get; set; }
|
||||
public int DbNumber { get; set; } // Only for DataBlock
|
||||
public int ByteOffset { get; set; }
|
||||
public int BitOffset { get; set; }
|
||||
|
||||
// Helper to sort/group
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{Area}:{DbNumber}:{ByteOffset}.{BitOffset}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using S7.Net;
|
||||
|
||||
namespace Plc.Siemens.Options;
|
||||
|
||||
public class SiemensPlcOptions
|
||||
{
|
||||
public string IpAddress { get; set; } = "127.0.0.1";
|
||||
public int Port { get; set; } = 102;
|
||||
public short Rack { get; set; } = 0;
|
||||
public short Slot { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// S7-1200/1500 通常支持 960,S7-300 通常支持 240。
|
||||
/// 默认保守值设为 240。
|
||||
/// </summary>
|
||||
public int PduSize { get; set; } = 240;
|
||||
|
||||
/// <summary>
|
||||
/// CPU 类型 (例如: S71200, S71500, S7300, S7400, S7200, S7200Smart).
|
||||
/// 默认为 S71200。
|
||||
/// </summary>
|
||||
public CpuType CpuType { get; set; } = CpuType.S71200;
|
||||
}
|
||||
@@ -10,4 +10,9 @@
|
||||
<ProjectReference Include="..\Plc\Plc.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="S7netplus" Version="0.20.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,123 @@
|
||||
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;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public SiemensDriver(SiemensPlcOptions options)
|
||||
{
|
||||
_options = 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(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_plc?.Close();
|
||||
return await Task.FromResult(Result.Success());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Failure(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<byte[]>> ReadBytesAsync(string address, ushort length, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsConnected || _plc == null)
|
||||
return Result.Failure<byte[]>("PLC 未连接。");
|
||||
|
||||
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 未连接。");
|
||||
|
||||
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, null)
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_plc?.Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Plc.Abstractions;
|
||||
using Plc.Models;
|
||||
using Plc.Siemens.Models;
|
||||
using Plc.Siemens.Utils;
|
||||
using Plc.Utils;
|
||||
|
||||
namespace Plc.Siemens;
|
||||
|
||||
public class SiemensOptimizer : IPlcOptimizer
|
||||
{
|
||||
// 允许变量之间合并为一次读取的最大间隔字节数
|
||||
private const int MaxGapBytes = 20;
|
||||
|
||||
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);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 记录错误?暂时跳过。
|
||||
continue;
|
||||
}
|
||||
}
|
||||
validTags.Add(tag);
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
PlcReadBatch? currentBatch = null;
|
||||
|
||||
foreach (var tag in sortedTags)
|
||||
{
|
||||
var addr = (SiemensAddress)tag.ParsedAddress!;
|
||||
int typeSize = TypeHelper.GetSize(tag.DataType);
|
||||
int byteLength = typeSize * tag.Length; // 此标签的总字节数
|
||||
|
||||
// 如果是 bool,它可能在一个字节内,但我们在检查边界时假设它占用该字节
|
||||
|
||||
if (currentBatch == null)
|
||||
{
|
||||
currentBatch = CreateBatch(tag, addr, byteLength);
|
||||
batches.Add(currentBatch);
|
||||
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;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 开启新批次
|
||||
currentBatch = CreateBatch(tag, addr, byteLength);
|
||||
batches.Add(currentBatch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return batches;
|
||||
}
|
||||
|
||||
private PlcReadBatch CreateBatch(PlcTag tag, SiemensAddress addr, int byteLength)
|
||||
{
|
||||
return new PlcReadBatch
|
||||
{
|
||||
StartAddress = tag.Address,
|
||||
ParsedStartAddress = addr,
|
||||
TotalLength = byteLength,
|
||||
Tags = new List<PlcTag> { tag }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Plc.Siemens.Models;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Plc.Siemens.Utils;
|
||||
|
||||
public static class SiemensAddressParser
|
||||
{
|
||||
// DB匹配: DB1.DBB0, DB1.DBW2, DB1.DBD4, DB1.DBX0.0
|
||||
// Group 1: DbNumber
|
||||
// Group 2: Type (B,W,D,X) - Ignored
|
||||
// Group 3: ByteOffset
|
||||
// Group 5: BitOffset (Optional)
|
||||
private static readonly Regex DbRegex = new(@"^DB(\d+)\.DB[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
|
||||
|
||||
// M匹配: M100, MB100, MW100, MD100, MX100.0, M100.1
|
||||
// Group 1: ByteOffset
|
||||
// Group 3: BitOffset (Optional)
|
||||
private static readonly Regex MRegex = new(@"^M[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
|
||||
|
||||
// I匹配: I0.0, IB0, IW0, ID0
|
||||
private static readonly Regex IRegex = new(@"^I[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
|
||||
|
||||
// Q匹配: Q0.0, QB0, QW0, QD0
|
||||
private static readonly Regex QRegex = new(@"^Q[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
|
||||
|
||||
public static SiemensAddress Parse(string address)
|
||||
{
|
||||
address = address.Trim().ToUpper();
|
||||
|
||||
// 1. Try DB
|
||||
var dbMatch = DbRegex.Match(address);
|
||||
if (dbMatch.Success)
|
||||
{
|
||||
return new SiemensAddress
|
||||
{
|
||||
Area = SiemensMemoryArea.DataBlock,
|
||||
DbNumber = int.Parse(dbMatch.Groups[1].Value),
|
||||
ByteOffset = int.Parse(dbMatch.Groups[2].Value),
|
||||
BitOffset = dbMatch.Groups[4].Success ? int.Parse(dbMatch.Groups[4].Value) : 0
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Try M (Memory/Flags)
|
||||
var mMatch = MRegex.Match(address);
|
||||
if (mMatch.Success)
|
||||
{
|
||||
return new SiemensAddress
|
||||
{
|
||||
Area = SiemensMemoryArea.Memory,
|
||||
DbNumber = 0,
|
||||
ByteOffset = int.Parse(mMatch.Groups[1].Value),
|
||||
BitOffset = mMatch.Groups[3].Success ? int.Parse(mMatch.Groups[3].Value) : 0
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Try I (Inputs)
|
||||
var iMatch = IRegex.Match(address);
|
||||
if (iMatch.Success)
|
||||
{
|
||||
return new SiemensAddress
|
||||
{
|
||||
Area = SiemensMemoryArea.Inputs,
|
||||
DbNumber = 0,
|
||||
ByteOffset = int.Parse(iMatch.Groups[1].Value),
|
||||
BitOffset = iMatch.Groups[3].Success ? int.Parse(iMatch.Groups[3].Value) : 0
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Try Q (Outputs)
|
||||
var qMatch = QRegex.Match(address);
|
||||
if (qMatch.Success)
|
||||
{
|
||||
return new SiemensAddress
|
||||
{
|
||||
Area = SiemensMemoryArea.Outputs,
|
||||
DbNumber = 0,
|
||||
ByteOffset = int.Parse(qMatch.Groups[1].Value),
|
||||
BitOffset = qMatch.Groups[3].Success ? int.Parse(qMatch.Groups[3].Value) : 0
|
||||
};
|
||||
}
|
||||
|
||||
throw new ArgumentException($"不支持或无效的地址格式: {address}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Plc.Abstractions;
|
||||
|
||||
public interface IDataConverter
|
||||
{
|
||||
// 将原始字节(从指定偏移量开始)转换为目标类型
|
||||
object? Parse(byte[] buffer, int offset, Type type, int length);
|
||||
|
||||
// 将值转换为字节
|
||||
byte[] ToBytes(object value, Type type, int length);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Common.Results;
|
||||
|
||||
namespace Plc.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 定义 PLC 驱动的底层契约。
|
||||
/// 此接口的实现处理具体的协议细节(例如:S7, Modbus)。
|
||||
/// </summary>
|
||||
public interface IPlcDriver : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取一个值,指示驱动程序当前是否已连接到 PLC。
|
||||
/// </summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取连接支持的最大 PDU(协议数据单元)大小。
|
||||
/// 优化器使用此值来确定最大批量大小。
|
||||
/// </summary>
|
||||
int MaxPduSize { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 建立与 PLC 的连接。
|
||||
/// </summary>
|
||||
Task<Result> ConnectAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 关闭与 PLC 的连接。
|
||||
/// </summary>
|
||||
Task<Result> DisconnectAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 从指定地址读取原始字节序列。
|
||||
/// </summary>
|
||||
/// <param name="address">起始地址字符串(例如 "DB1.0")。</param>
|
||||
/// <param name="length">要读取的字节数。</param>
|
||||
Task<Result<byte[]>> ReadBytesAsync(string address, ushort length, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 将原始字节序列写入指定地址。
|
||||
/// </summary>
|
||||
/// <param name="address">起始地址字符串。</param>
|
||||
/// <param name="data">要写入的字节。</param>
|
||||
Task<Result> WriteBytesAsync(string address, byte[] data, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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>
|
||||
/// 驱动程序特定的起始地址上下文(避免重新解析)。
|
||||
/// </summary>
|
||||
public object? ParsedStartAddress { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using Common.Results;
|
||||
using Plc.Models;
|
||||
|
||||
namespace Plc.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// 用于与 PLC 交互的高层服务。
|
||||
/// 处理连接管理、批量读取和数据转换。
|
||||
/// </summary>
|
||||
public interface IPlcService
|
||||
{
|
||||
/// <summary>
|
||||
/// 启动后台监控循环。
|
||||
/// </summary>
|
||||
Task StartAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 停止后台监控循环。
|
||||
/// </summary>
|
||||
Task StopAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 对单个地址执行立即、临时的读取。
|
||||
/// </summary>
|
||||
Task<Result<T>> ReadAsync<T>(string address);
|
||||
|
||||
/// <summary>
|
||||
/// 对单个地址执行立即、临时的写入。
|
||||
/// </summary>
|
||||
Task<Result> WriteAsync<T>(string address, T value);
|
||||
|
||||
/// <summary>
|
||||
/// 订阅一个标签到监控循环。
|
||||
/// </summary>
|
||||
void Subscribe(PlcTag tag);
|
||||
|
||||
/// <summary>
|
||||
/// 从监控循环取消订阅标签。
|
||||
/// </summary>
|
||||
void Unsubscribe(string tagId);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Plc.Attributes;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public class PlcAddressAttribute : Attribute
|
||||
{
|
||||
public string Address { get; }
|
||||
public int Length { get; }
|
||||
|
||||
public PlcAddressAttribute(string address, int length = 1)
|
||||
{
|
||||
Address = address;
|
||||
Length = length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Plc.Models;
|
||||
|
||||
public enum PlcBrand
|
||||
{
|
||||
Siemens,
|
||||
Omron, // 预留
|
||||
Mitsubishi, // 预留
|
||||
ModbusTcp // 预留
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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>
|
||||
/// 内部使用:由驱动程序/优化器用于存储解析后的地址对象。
|
||||
/// 这避免了在每个周期重新解析地址字符串。
|
||||
/// </summary>
|
||||
public object? ParsedAddress { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
using Plc.Abstractions;
|
||||
using Plc.Attributes;
|
||||
using Plc.Models;
|
||||
|
||||
namespace Plc.Proxy;
|
||||
|
||||
public class PlcInterfaceProxy : DispatchProxy, INotifyPropertyChanged
|
||||
{
|
||||
private IPlcService _plcService = null!;
|
||||
private readonly Dictionary<string, PlcTag> _tags = new();
|
||||
|
||||
// 关键修改:使用私有字段手动管理委托链,避免递归调用
|
||||
private PropertyChangedEventHandler? _propertyChangedInternal;
|
||||
|
||||
// 声明为 virtual event 只是为了骗过编译器的 TypeLoadException,
|
||||
// 但我们在 Invoke 里不操作它,而是操作 _propertyChangedInternal。
|
||||
// 实际上,对于 DispatchProxy 来说,我们并不需要这个 event 真的工作,
|
||||
// 只需要它存在以满足接口签名,并允许被重写。
|
||||
public virtual event PropertyChangedEventHandler? PropertyChanged
|
||||
{
|
||||
add => _propertyChangedInternal += value;
|
||||
remove => _propertyChangedInternal -= value;
|
||||
}
|
||||
|
||||
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
||||
{
|
||||
if (targetMethod == null) return null;
|
||||
|
||||
// 1. Handle Property Getters (get_PropName)
|
||||
if (targetMethod.Name.StartsWith("get_"))
|
||||
{
|
||||
var propName = targetMethod.Name.Substring(4);
|
||||
if (_tags.TryGetValue(propName, out var tag))
|
||||
{
|
||||
return tag.Value; // Return cached value
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Handle Property Setters (set_PropName)
|
||||
if (targetMethod.Name.StartsWith("set_"))
|
||||
{
|
||||
var propName = targetMethod.Name.Substring(4);
|
||||
if (_tags.TryGetValue(propName, out var tag) && args != null && args.Length > 0)
|
||||
{
|
||||
var newValue = args[0];
|
||||
_plcService.WriteAsync(tag.Address, newValue).GetAwaiter().GetResult();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Handle INotifyPropertyChanged methods (add_PropertyChanged, remove_PropertyChanged)
|
||||
// 这里是关键:我们拦截对 add_PropertyChanged 的调用,
|
||||
// 将其重定向到我们的私有字段 _propertyChangedInternal,
|
||||
// 从而避免调用 this.PropertyChanged += ... 导致的递归。
|
||||
if (targetMethod.Name == "add_PropertyChanged" && args != null && args.Length == 1)
|
||||
{
|
||||
var handler = (PropertyChangedEventHandler)args[0]!;
|
||||
_propertyChangedInternal += handler;
|
||||
return null;
|
||||
}
|
||||
if (targetMethod.Name == "remove_PropertyChanged" && args != null && args.Length == 1)
|
||||
{
|
||||
var handler = (PropertyChangedEventHandler)args[0]!;
|
||||
_propertyChangedInternal -= handler;
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static T Create<T>(IPlcService plcService) where T : class, INotifyPropertyChanged
|
||||
{
|
||||
object proxy = Create<T, PlcInterfaceProxy>();
|
||||
((PlcInterfaceProxy)proxy).Initialize(typeof(T), plcService);
|
||||
return (T)proxy;
|
||||
}
|
||||
|
||||
private void Initialize(Type interfaceType, IPlcService plcService)
|
||||
{
|
||||
_plcService = plcService;
|
||||
|
||||
foreach (var prop in interfaceType.GetProperties())
|
||||
{
|
||||
var attr = prop.GetCustomAttribute<PlcAddressAttribute>();
|
||||
if (attr != null)
|
||||
{
|
||||
var tag = new PlcTag
|
||||
{
|
||||
Address = attr.Address,
|
||||
DataType = prop.PropertyType,
|
||||
Length = attr.Length,
|
||||
OnChanged = (t, v) =>
|
||||
{
|
||||
// 触发时,调用私有字段
|
||||
_propertyChangedInternal?.Invoke(this, new PropertyChangedEventArgs(prop.Name));
|
||||
}
|
||||
};
|
||||
|
||||
_tags[prop.Name] = tag;
|
||||
_plcService.Subscribe(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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)
|
||||
{
|
||||
// 为简洁起见省略了边界检查,但在生产环境中至关重要
|
||||
|
||||
if (type == typeof(bool))
|
||||
return buffer[offset] > 0; // 简单的检查,真实的位逻辑需要位索引
|
||||
|
||||
if (type == typeof(byte))
|
||||
return buffer[offset];
|
||||
|
||||
if (type == typeof(short))
|
||||
return BinaryPrimitives.ReadInt16BigEndian(buffer.AsSpan(offset));
|
||||
|
||||
if (type == typeof(ushort))
|
||||
return BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(offset));
|
||||
|
||||
if (type == typeof(int))
|
||||
return BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset));
|
||||
|
||||
if (type == typeof(uint))
|
||||
return BinaryPrimitives.ReadUInt32BigEndian(buffer.AsSpan(offset));
|
||||
|
||||
if (type == typeof(float))
|
||||
return BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset)));
|
||||
|
||||
if (type == typeof(double))
|
||||
return BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(buffer.AsSpan(offset)));
|
||||
|
||||
// 简单处理字符串 (假设 ASCII/S7 String)
|
||||
if (type == typeof(string))
|
||||
{
|
||||
// S7 String 前两个字节是 MaxLen 和 ActualLen,后面是字符
|
||||
// 这里假设是纯字符数组或者需要特定处理
|
||||
// 简单起见,按 ASCII 读取指定长度
|
||||
return System.Text.Encoding.ASCII.GetString(buffer, offset, length);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public byte[] ToBytes(object value, Type type, int length)
|
||||
{
|
||||
if (type == typeof(bool))
|
||||
return new[] { (bool)value ? (byte)1 : (byte)0 };
|
||||
|
||||
if (type == typeof(byte))
|
||||
return new[] { (byte)value };
|
||||
|
||||
if (type == typeof(short))
|
||||
{
|
||||
var bytes = new byte[2];
|
||||
BinaryPrimitives.WriteInt16BigEndian(bytes, (short)value);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
if (type == typeof(ushort))
|
||||
{
|
||||
var bytes = new byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(bytes, (ushort)value);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
if (type == typeof(int))
|
||||
{
|
||||
var bytes = new byte[4];
|
||||
BinaryPrimitives.WriteInt32BigEndian(bytes, (int)value);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
if (type == typeof(uint))
|
||||
{
|
||||
var bytes = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(bytes, (uint)value);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
if (type == typeof(float))
|
||||
{
|
||||
var bytes = new byte[4];
|
||||
var intVal = BitConverter.SingleToInt32Bits((float)value);
|
||||
BinaryPrimitives.WriteInt32BigEndian(bytes, intVal);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
if (type == typeof(double))
|
||||
{
|
||||
var bytes = new byte[8];
|
||||
var longVal = BitConverter.DoubleToInt64Bits((double)value);
|
||||
BinaryPrimitives.WriteInt64BigEndian(bytes, longVal);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
return Array.Empty<byte>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using Common.Results;
|
||||
using Plc.Abstractions;
|
||||
using Plc.Models;
|
||||
using Plc.Utils;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Plc.Services;
|
||||
|
||||
public class PlcMonitorService : IPlcService, IDisposable
|
||||
{
|
||||
private readonly IPlcDriver _driver;
|
||||
private readonly IPlcOptimizer _optimizer;
|
||||
private readonly IDataConverter _converter;
|
||||
|
||||
private readonly ConcurrentDictionary<string, PlcTag> _subscriptions = new();
|
||||
private Task? _monitorTask;
|
||||
private CancellationTokenSource? _cts;
|
||||
private readonly TimeSpan _refreshRate = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
public PlcMonitorService(IPlcDriver driver, IPlcOptimizer optimizer, IDataConverter converter)
|
||||
{
|
||||
_driver = driver;
|
||||
_optimizer = optimizer;
|
||||
_converter = converter;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_monitorTask != null) return;
|
||||
|
||||
await _driver.ConnectAsync(cancellationToken);
|
||||
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_monitorTask = Task.Run(() => MonitorLoop(_cts.Token), _cts.Token);
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
if (_monitorTask != null)
|
||||
{
|
||||
try { await _monitorTask; } catch (OperationCanceledException) { }
|
||||
}
|
||||
await _driver.DisconnectAsync();
|
||||
}
|
||||
|
||||
public void Subscribe(PlcTag tag)
|
||||
{
|
||||
_subscriptions.TryAdd(tag.Id, tag);
|
||||
}
|
||||
|
||||
public void Unsubscribe(string tagId)
|
||||
{
|
||||
_subscriptions.TryRemove(tagId, out _);
|
||||
}
|
||||
|
||||
public async Task<Result<T>> ReadAsync<T>(string address)
|
||||
{
|
||||
// 临时读取(未优化)
|
||||
int size = TypeHelper.GetSize(typeof(T));
|
||||
var result = await _driver.ReadBytesAsync(address, (ushort)size);
|
||||
|
||||
if (result.IsFailure)
|
||||
return Result.Failure<T>(result.ErrorMessage ?? result.Exception?.Message ?? "Read failed");
|
||||
|
||||
if (result.Value is null)
|
||||
return Result.Failure<T>("Read returned null data");
|
||||
|
||||
var val = _converter.Parse(result.Value, 0, typeof(T), 1);
|
||||
if (val is null)
|
||||
return Result.Failure<T>("Conversion failed");
|
||||
|
||||
return Result.Success((T)val);
|
||||
}
|
||||
|
||||
public async Task<Result> WriteAsync<T>(string address, T value)
|
||||
{
|
||||
if (value == null)
|
||||
return Result.Failure("Cannot write null value");
|
||||
|
||||
int size = TypeHelper.GetSize(typeof(T));
|
||||
var bytes = _converter.ToBytes(value, typeof(T), size);
|
||||
|
||||
if (bytes.Length == 0)
|
||||
return Result.Failure("Conversion to bytes returned empty array. Type might not be supported.");
|
||||
|
||||
return await _driver.WriteBytesAsync(address, bytes);
|
||||
}
|
||||
|
||||
private async Task MonitorLoop(CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_driver.IsConnected)
|
||||
{
|
||||
await _driver.ConnectAsync(token);
|
||||
}
|
||||
|
||||
var currentTags = _subscriptions.Values.ToList();
|
||||
if (currentTags.Count == 0)
|
||||
{
|
||||
await Task.Delay(1000, token);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 优化:计算分桶
|
||||
var batches = _optimizer.Optimize(currentTags, _driver.MaxPduSize);
|
||||
|
||||
// 执行读取
|
||||
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
|
||||
{
|
||||
// 记录错误
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 记录异常
|
||||
}
|
||||
|
||||
await Task.Delay(_refreshRate, token);
|
||||
}
|
||||
}
|
||||
|
||||
private void DistributeValues(PlcReadBatch batch, byte[] data)
|
||||
{
|
||||
dynamic batchStart = batch.ParsedStartAddress!;
|
||||
int batchStartByte = batchStart.ByteOffset;
|
||||
|
||||
foreach (var tag in batch.Tags)
|
||||
{
|
||||
dynamic tagAddr = tag.ParsedAddress!;
|
||||
int tagStartByte = tagAddr.ByteOffset;
|
||||
int relativeOffset = tagStartByte - batchStartByte;
|
||||
|
||||
// 检查边界
|
||||
int typeSize = TypeHelper.GetSize(tag.DataType);
|
||||
if (relativeOffset + typeSize <= data.Length)
|
||||
{
|
||||
var newVal = _converter.Parse(data, relativeOffset, tag.DataType, tag.Length);
|
||||
|
||||
// 变更检测
|
||||
if (!Equals(tag.Value, newVal))
|
||||
{
|
||||
tag.Value = newVal;
|
||||
tag.OnChanged?.Invoke(tag, newVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_driver.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Plc.Utils;
|
||||
|
||||
public static class TypeHelper
|
||||
{
|
||||
public static int GetSize(Type type)
|
||||
{
|
||||
if (type == typeof(bool)) return 1; // S7 usually treats bool as bit, but allocation often aligns to bytes or we need spec handling
|
||||
if (type == typeof(byte)) return 1;
|
||||
if (type == typeof(short) || type == typeof(ushort)) return 2;
|
||||
if (type == typeof(int) || type == typeof(uint) || type == typeof(float)) return 4;
|
||||
if (type == typeof(long) || type == typeof(ulong) || type == typeof(double)) return 8;
|
||||
|
||||
return 1; // Default or throw
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user