Compare commits

...
2 Commits
Author SHA1 Message Date
jimmy 2f3eb935dd A:增加PLC功能和西门子PLC对接 2025-12-09 09:40:50 +08:00
jimmy bca001b90f A:初始提交 2025-12-08 14:05:44 +08:00
34 changed files with 1545 additions and 99 deletions

No files matched your search

+76 -1
View File
@@ -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
+1
View File
@@ -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>
+18
View File
@@ -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; }
}
+44
View File
@@ -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" />
+90
View File
@@ -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>
+14
View File
@@ -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)
{
+54 -2
View File
@@ -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;
}
}
}
}
+212
View File
@@ -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);
}
}
@@ -1,4 +1,4 @@
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "A01D87CBC408FE1E9934F8938867D92F17E3A2CF"
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "0048F04EA9C102506BBFAC3803E4711F700CF3ED"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
@@ -46,7 +46,7 @@ namespace Client {
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.0.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
@@ -66,7 +66,7 @@ namespace Client {
/// </summary>
[System.STAThreadAttribute()]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.0.0")]
public static void Main() {
Client.App app = new Client.App();
app.InitializeComponent();
@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Client")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+53d1dd2dbf05d818528814223b58610a790fcb26")]
[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 @@
f8321da7c6a777fc7770c7234548af806317c95cd5f1beb1c1743888aa1bde11
eeea53cf7db257ee716efdd2fdcdb5f1a810b49927811d410a05e1ff19899369
@@ -1,5 +1,7 @@
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 =
@@ -8,7 +10,7 @@ build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = Client
build_property.ProjectDir = D:\yuanll\sharp-basic\src\Sharp\Client\
build_property.ProjectDir = D:\code\sharp-basic\src\Sharp\Client\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.CsWinRTUseWindowsUIXamlProjections = false
@@ -1,89 +0,0 @@
#pragma checksum "..\..\..\MainWindow.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "9E813BA3492F961B4CE0E3DE4E78408D972934B6"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using HandyControl.Controls;
using HandyControl.Data;
using HandyControl.Expression.Media;
using HandyControl.Expression.Shapes;
using HandyControl.Interactivity;
using HandyControl.Media.Animation;
using HandyControl.Media.Effects;
using HandyControl.Properties.Langs;
using HandyControl.Themes;
using HandyControl.Tools;
using HandyControl.Tools.Converter;
using HandyControl.Tools.Extension;
using Sharp.Wpf.Converters;
using Sharp.Wpf.Windowing;
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Controls.Ribbon;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Forms.Integration;
using System.Windows.Ink;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Media.TextFormatting;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Shell;
namespace Client {
/// <summary>
/// MainWindow
/// </summary>
public partial class MainWindow : HandyControl.Controls.GlowWindow, System.Windows.Markup.IComponentConnector {
private bool _contentLoaded;
/// <summary>
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
}
_contentLoaded = true;
System.Uri resourceLocater = new System.Uri("/Client;component/mainwindow.xaml", System.UriKind.Relative);
#line 1 "..\..\..\MainWindow.xaml"
System.Windows.Application.LoadComponent(this, resourceLocater);
#line default
#line hidden
}
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1800:DoNotCastUnnecessarily")]
void System.Windows.Markup.IComponentConnector.Connect(int connectionId, object target) {
this._contentLoaded = true;
}
}
}
@@ -0,0 +1,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;
}
+18
View File
@@ -0,0 +1,18 @@
<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="S7netplus" Version="0.20.0" />
</ItemGroup>
</Project>
+123
View File
@@ -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();
}
}
+107
View File
@@ -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);
}
+45
View File
@@ -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; }
}
+41
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
namespace Plc.Models;
public enum PlcBrand
{
Siemens,
Omron, // 预留
Mitsubishi, // 预留
ModbusTcp // 预留
}
+44
View File
@@ -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; }
}
+13
View File
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>
</Project>
+106
View File
@@ -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>();
}
}
+169
View File
@@ -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();
}
}
+15
View File
@@ -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
}
}
+12
View File
@@ -39,6 +39,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data", "Data\Data.csproj",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data.Test", "..\..\test\Data.Test\Data.Test.csproj", "{8EEC8BF4-CF9A-473D-B7A0-005E151C645D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Plc", "Plc\Plc.csproj", "{12FA0DCD-A69B-4862-901C-A8CF8694B9E3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Plc.Siemens", "Plc.Siemens\Plc.Siemens.csproj", "{7C877AB8-1938-4CC7-8692-0D2253C728DF}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -117,6 +121,14 @@ Global
{8EEC8BF4-CF9A-473D-B7A0-005E151C645D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8EEC8BF4-CF9A-473D-B7A0-005E151C645D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8EEC8BF4-CF9A-473D-B7A0-005E151C645D}.Release|Any CPU.Build.0 = Release|Any CPU
{12FA0DCD-A69B-4862-901C-A8CF8694B9E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{12FA0DCD-A69B-4862-901C-A8CF8694B9E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{12FA0DCD-A69B-4862-901C-A8CF8694B9E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{12FA0DCD-A69B-4862-901C-A8CF8694B9E3}.Release|Any CPU.Build.0 = Release|Any CPU
{7C877AB8-1938-4CC7-8692-0D2253C728DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7C877AB8-1938-4CC7-8692-0D2253C728DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7C877AB8-1938-4CC7-8692-0D2253C728DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C877AB8-1938-4CC7-8692-0D2253C728DF}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE