Compare commits

..
5 Commits
Author SHA1 Message Date
jimmy e1d059ed29 A:调整部分日志;增加Client对于依赖注入的支持 2025-11-20 16:35:48 +08:00
jimmy 2739a71134 A:Access接口增加可加密选项 2025-11-19 18:12:11 +08:00
jimmy dd993e5b45 A:增加Access数据库支持 2025-11-19 14:04:25 +08:00
jimmy e334e65c2d A:增加Access数据库 2025-11-19 08:43:25 +08:00
jimmy 1d412bba33 A:Access数据库功能 2025-11-19 08:42:28 +08:00
142 changed files with 4716 additions and 7687 deletions

No files matched your search

-29
View File
@@ -1,29 +0,0 @@
# Visual Studio
.vs/
bin/
obj/
[Dd]ebug/
[Rr]elease/
*.user
*.suo
*.userprefs
*.usertasks
*.sln.docstates
*.dll
*.pdb
*.exe
!src/**/bin/**/*.dll
!src/**/bin/**/*.pdb
!src/**/bin/**/*.exe
# Rider
.idea/
# Dotnet
project.lock.json
project.fragment.lock.json
artifacts/
# Other
Thumbs.db
Desktop.ini
-222
View File
@@ -1,222 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Sharp-Basic is a modular C# .NET 8 framework for building enterprise applications with reusable components. It provides a comprehensive toolkit with emphasis on:
- **Modularity**: Independent, reusable libraries that can be used selectively
- **Desktop Applications**: WPF-based UI toolkit with MVVM infrastructure
- **Industrial IoT**: PLC integration (Siemens) and serial port communication
- **Configuration as Code**: Multi-format (JSON/XML/INI/TOML) with hot-reload
- **Functional Programming**: Result<T> pattern for railway-oriented error handling
**Language**: All comments and documentation are in Chinese (中文).
## Build Commands
### Build the entire solution
```bash
cd src/Sharp
dotnet build Sharp.sln
```
### Build specific project
```bash
cd src/Sharp
dotnet build Client/Client.csproj # WPF demo application
dotnet build Common/Common.csproj # Core utilities
dotnet build Config/Config.csproj # Configuration management
```
### Run tests
```bash
cd src/Sharp
dotnet test Sharp.sln # All tests
dotnet test ../../test/Common.Test # Specific test project
```
### Run the WPF Client application
```bash
cd src/Sharp
dotnet run --project Client/Client.csproj
```
### Clean build artifacts
```bash
cd src/Sharp
dotnet clean Sharp.sln
```
## Architecture Overview
### Project Structure
```
src/Sharp/ # All source projects
├── Common/ # Core foundation (Result<T>, Global container, utilities)
├── Config/ # Multi-format configuration with hot-reload
├── Sharp.Infrastructure/ # Infrastructure abstractions
├── Wpf/ # WPF UI toolkit (MVVM, dialogs, converters)
├── Data/ # EF Core wrapper with repository pattern
├── Repository/ # Repository implementations
├── Log/ # Serilog-based structured logging
├── NetClient/ # HTTP client with Polly resilience
├── NetServer/ # Lightweight HTTP server with middleware
├── Ports/ # Serial port abstraction (RS232/RS485)
├── Plc/ # PLC abstraction interface
├── Plc.Siemens/ # Siemens S7 PLC implementation (S7.NET+)
├── EventBus/ # Event pub/sub (planned)
├── Client/ # WPF demo application
└── Server/ # WPF server example
test/ # MSTest test projects
├── Common.Test/
├── Config.Test/
├── Data.Test/
├── Log.Test/
├── NetClient.Test/
└── NetServer.Test/
```
### Key Architectural Patterns
#### 1. Result<T> Pattern (Railway-Oriented Programming)
- Eliminates exceptions as control flow
- Provides chainable operations: `Map`, `Bind`, `Match`, `Where`, `OnSuccess`, `OnFailure`
- Synchronous and async wrappers: `Result.Try()`, `Result.TryAsync()`
- Example usage:
```csharp
Result.Try(() => int.Parse(input))
.Map(x => x * 2)
.Where(v => v > 100, "Value too small")
.OnSuccess(v => Console.WriteLine(v))
.OnFailure((ex, msg) => logger.LogError(msg));
```
#### 2. Dependency Injection (Hybrid Approach)
- **Primary**: Microsoft.Extensions.DependencyInjection for standard DI
- **Fallback**: Global service locator (Common.Global) for legacy/convenience
- Services registered in `DependencyInjection` folders within each module
- Client app startup (App.xaml.cs) orchestrates all service registration
#### 3. Configuration Management
- Multi-source configuration builder supporting JSON/XML/INI/TOML
- Hot-reload with change notifications
- Strong typing with DataAnnotations validation
- IOptions<T> pattern integration
- Environment variable overrides with optional prefix
#### 4. Repository Pattern with EF Core
- Generic `IRepository<TEntity>` abstraction
- Built-in entity types: `Entity`, `AuditableEntity`, `ISoftDeletable`
- Auto-audit tracking (CreatedAt, CreatedBy, UpdatedAt, UpdatedBy)
- Multi-database support: SQL Server, SQLite, PostgreSQL, MySQL, In-Memory
#### 5. MVVM for WPF
- `ViewModelBase` with INotifyPropertyChanged and IsBusy tracking
- `RelayCommand` and `AsyncCommand` implementations
- Services: `IDialogService`, `IFileDialogService`, `IDispatcherService`
- Converters: BoolToVisibility, InverseBooleanConverter, EnumToDescriptionConverter
- Global exception handling for UI/Task/AppDomain exceptions
#### 6. Middleware Pattern
- Custom HTTP server (NetServer) with ASP.NET Core-style pipeline
- Middleware: Authentication, CORS, Rate Limiting, Caching, Logging, Exception Handling
- Attribute-based routing with `RouteAttribute`
- Custom `IMiddleware` interface for extensibility
## Module Dependencies
**Core Layer** (no external dependencies):
- Common → (no dependencies)
**Configuration Layer**:
- Config → Microsoft.Extensions.Configuration.*, Tomlyn (TOML support)
**Data Layer**:
- Data → EF Core, Common
- Repository → Data
**Network Layer**:
- NetClient → Polly, Microsoft.Extensions.Http, Common
- NetServer → Common, Log
**WPF Layer**:
- Wpf → HandyControl (optional UI controls)
- Client → Wpf, Config, NetClient, NetServer, Log, Plc.Siemens, Ports
**Industrial IoT**:
- Ports → (serial port communication)
- Plc → (abstraction only)
- Plc.Siemens → Plc, S7.NET+ library
**Logging**:
- Log → Serilog, Microsoft.Extensions.Logging
## Common Development Workflows
### Adding a new module/library
1. Create project under `src/Sharp/`
2. Target `net8.0` (or `net8.0-windows` for WPF)
3. Enable nullable reference types: `<Nullable>enable</Nullable>`
4. Create `DependencyInjection` folder for service registration extensions
5. Add test project under `test/` with MSTest
6. Add to Sharp.sln: `dotnet sln add <ProjectPath>`
### Working with Result<T>
- Use `Result.Try()` to wrap exception-prone code
- Chain operations with `Map()` for transformations
- Use `Bind()` for operations that return Result<T>
- Extract values with `GetOrDefault()`, `GetOrThrow()`, or `Match()`
- Always handle both success and failure cases
### Using the Global container
- Register singletons: `Global.RegisterSingleton<T>(instance)` or `Global.RegisterSingleton<T>(() => new T())`
- Register transients: `Global.RegisterTransient<T>(() => new T())`
- Subscribe to changes: `Global.Subscribe<T>(e => { /* handle change */ })`
- Retrieve services: `Global.Get<T>()`
- Prefer standard DI (IServiceCollection) for new code
### Configuration workflow
1. Add config files to project root (appsettings.json, etc.)
2. Create strongly-typed options class with DataAnnotations
3. Build ConfigManager in App.xaml.cs OnStartup
4. Register with DI: `services.AddSingleton(cfg)` and `services.AddSingleton(cfg.Configuration)`
5. Use hot-reload callbacks: `cfg.OnReload(() => { /* refresh */ })`
### WPF Client startup flow
The Client application (App.xaml.cs) orchestrates initialization:
1. Register global exception handlers (UI, Task, AppDomain)
2. Build ConfigManager from appsettings.json
3. Setup ServiceCollection and register all services
4. Create ServiceProvider
5. Show LoginWindow (modal)
6. On successful login, show MainWindow
### Working with PLC (Siemens)
- Interface: `IPLC` in Plc project
- Implementation: Plc.Siemens using S7.NET+ library
- Supports: S7-1200, S7-1500, S7-300, S7-400
- Register with DI: Add Siemens PLC service with connection options
- Methods: Read/Write memory areas, data blocks, etc.
### Serial Port Communication
- Service: `ISerialPortService` in Ports module
- Discovery: `SerialPortDiscovery` for auto-detection
- Event-based data reception: `DataReceivedEventArgs`
- Configured with: baud rate, parity, stop bits, data bits
## Code Style
- **Target Framework**: .NET 8 (net8.0 / net8.0-windows)
- **Nullable**: Enabled (`#nullable enable`)
- **Implicit Usings**: Enabled
- **Namespace Convention**: `Sharp.*` (Common will migrate to `Sharp.Common`)
- **Language**: All comments, docs, and strings in Chinese
- **Backwards Compatibility**: Maintain for minor versions; breaking changes only in major versions
- **Testing**: MSTest with parallel execution enabled
## License
GNU AGPLv3 - Network services that use this framework must open source modifications. Contact author for alternative commercial licensing.
+266 -60
View File
@@ -1,81 +1,287 @@
# Sharp Basic
# sharp-basic
一个基于 .NET 8 的现代化工业自动化与企业级应用开发套件,采用模块化架构设计,提供了从底层通信、基础设施到上层 UI 的完整解决方案。
C# 公共基础核心(模块化、多项目可复用)
## 🏗️ 架构概览
sharp-basic 旨在将常用基础能力拆分为可独立复用的模块(库项目),帮助业务项目即取即用,减少样板代码与重复实现。
本项目包含以下核心模块,旨在实现高内聚、低耦合的开发体验:
当前仓库采用多项目结构,包含 Common、Config 等核心模块,以及 Logging、EventBus、NetClient/NetServer 等待实现或扩展的模块,同时提供 WPF/Client 示例项目便于演示与调试。
### 🔌 核心基础设施
- **[Sharp.Common](Common/README.md)**: 基础工具库。提供轻量级 DI 容器 (`Global`)、函数式结果模式 (`Result<T>`)、以及丰富的扩展方法(String, DateTime, Encryption, Reflection)。
- **[Sharp.Config](Config/README.md)**: 强大的配置管理。支持 JSON/XML/INI/TOML 多源聚合、强类型绑定和数据注解验证。
- **[Sharp.Log](Log/README.md)**: 统一日志门面。基于 Serilog 封装,支持一行代码接入,提供结构化日志和文件滚动策略。
- **[Sharp.EventBus](EventBus/README.md)**: (开发中) 轻量级事件总线。
## 功能概览
### 💾 数据与存储
- **[Sharp.Data](Data/README.md)**: 极简数据访问层。基于 EF Core,提供开箱即用的仓储模式、自动审计(创建人/时间)、软删除和分页支持。
- **[Sharp.Repository](Repository/README.md)**: 数据库集成模块。通过配置切换 SQL Server/MySQL/PostgreSQL/Sqlite,支持自动迁移。
- 通用工具(Sharp.Common)
- 结果类型 Result/Result<T>:统一成功/失败表达、错误码/异常、Map/Bind/LINQ 风格链式操作、同步/异步 Try 包装
- 轻量服务容器 Global:线程安全注册与获取(Singleton/Transient)、实例变更通知(订阅发布)、批量管理
- 实用工具与扩展:JSON 序列化选项与 IPAddress 转换、端口探测、管理员权限检测、字符串解析等
- 配置中心(Sharp.Config)
- 统一构建器:支持 Json/Ini/Xml/Toml 多源叠加、可选/必选、基路径/文件提供器、环境变量、热重载
- 强类型绑定与验证:DataAnnotations 递归验证、TryGet/异常详情、对象保存与读取(多格式序列化)
- 扩展钩子:自定义 IConfigurationBuilder 管道(ConfigureBuilderHook)
- 日志(Sharp.Log)【规划中】
- 基于 Microsoft.Extensions.Logging 的统一抽象,适配 Console/File/Serilog/NLog,结构化日志与上下文
- 事件总线(Sharp.EventBus)【规划中】
- 进程内发布订阅、异步/顺序/粘性事件,支持 WPF 弱订阅;外部 Broker(Redis/RabbitMQ/Kafka)适配扩展
- 网络(Sharp.NetClient / Sharp.NetServer)【规划中】
- HttpClientFactory 封装、重试熔断超时、请求签名、统一 Result<T> 返回;最小 API Host 与中间件
- 桌面工具(Sharp.Wpf / Sharp.Client)
- WPF 示例/样板,用于演示配置热更、事件订阅、日志等集成的参考
### 🌐 网络与通信
- **[Sharp.NetServer](NetServer/README.md)**: 轻量级 Web 框架。基于 `HttpListener`,提供类似 ASP.NET Core 的 Controller/Route/Middleware 开发体验,适合嵌入式或桌面应用内嵌 Web 服务。
- **[Sharp.NetClient](NetClient/README.md)**: 强类型 HTTP 客户端。集成 Polly 重试策略、Bearer Token 认证和 multipart 上传。
- **[Sharp.Ports](Ports/README.md)**: 串口通信库。提供串口发现、连接管理、数据监听和诊断工具。
## 目录结构
### 🏭 工业互联 (IIoT)
- **[Sharp.Plc](Plc/README.md)**: PLC 通信抽象层。定义了统一的读写、订阅接口。
- **[Sharp.Plc.Siemens](Plc.Siemens/README.md)**: 西门子驱动实现。支持 S7-200/300/400/1200/1500,内置智能批量读取优化算法(自动合并相邻地址请求)。
### 🖥️ 桌面应用 UI
- **[Sharp.Wpf](Wpf/README.md)**: WPF 基础库。提供 MVVM 基类、全局异常捕获、常用转换器、对话框服务和行为扩展。
- **[Sharp.Client](Client/README.md)**: 综合客户端示例。集成 PLC 监控、串口调试和用户登录功能的工业控制台应用。
- **[Sharp.Server](Server/README.md)**: 服务器控制台示例。展示了如何在一个 WPF 应用中托管 NetServer Web 服务,并提供可视化管理界面。
## 🚀 快速开始
### 环境要求
- .NET 8.0 SDK
- Visual Studio 2022 或 VS Code
### 构建项目
```bash
# 还原依赖
dotnet restore
# 编译解决方案
dotnet build Sharp.sln -c Debug
```
src/Sharp/
Common/ # 通用能力(Result、Global、Utils、扩展)
Config/ # 配置集中管理与序列化
Log/ # 日志抽象(规划中)
EventBus/ # 事件总线(规划中)
NetClient/ # 客户端网络(规划中)
NetServer/ # 服务端网络(规划中)
Wpf/ # WPF 示例
Client/ # WPF 示例(另一个样板)
test/
Common.Test/ # Common 核心用例(MSTest)
```
### 运行示例
目标框架:.NET 8(net8.0 / net8.0-windows)。
**1. 运行 WPF 客户端:**
```bash
dotnet run --project Client/Client.csproj
## 快速开始
> 当前未发布 NuGet 包,可在解决方案内直接引用相关项目,或将模块作为子模块引入你的解决方案。
### 使用 Result 统一返回
```csharp
using Common.Results;
Result<int> r1 = Result.Success(123);
Result r2 = Result.Failure("something wrong");
// Try 模式(同步/异步)
var r3 = Result.Try(() => int.Parse("456"));
var r4 = await Result.TryAsync(async () => { await Task.Delay(10); return 789; });
// Map/Bind 链式管道
var r5 = r3.Map(x => x * 2).Where(v => v > 500, "too small");
// 提取值(或异常)
int value = r5.GetOrDefault(0); // 成功取值,否则默认
int must = r5.GetOrThrow(); // 失败抛出异常
```
**2. 运行嵌入式 Web 服务器:**
```bash
dotnet run --project Server/Server.csproj
### 使用 Global 注册与订阅
```csharp
using Common;
// 注册单例(实例/工厂),与瞬态
Global.RegisterSingleton(new AppConfig { AppName = "MyApp", Version = "1.0" });
Global.RegisterSingleton(() => new CacheService());
Global.RegisterTransient(() => new RequestContext());
// 订阅服务变更
Global.Subscribe<AppConfig>(e =>
{
Console.WriteLine($"Config changed: {e.OldValue?.Version} -> {e.NewValue.Version}");
});
// 更新将触发订阅回调
Global.Update(new AppConfig { AppName = "MyApp", Version = "1.1" });
// 获取
var config = Global.Get<AppConfig>();
```
**3. 运行 NetServer 纯控制台示例:**
```bash
dotnet run --project NetServer/Examples
示例类型(自定义)
```csharp
public sealed class AppConfig { public string AppName { get; set; } = ""; public string Version { get; set; } = ""; }
public sealed class CacheService { }
public sealed class RequestContext { public string RequestId { get; set; } = Guid.NewGuid().ToString(); }
```
## 🧪 测试
### 配置管理(多源叠加/强类型/验证/保存)
项目包含完整的单元测试,位于解决方案同级的 `test` 目录中。
```csharp
using Sharp.Config;
```bash
# 运行所有测试
dotnet test Sharp.sln
// 1) 从多个文件构建(支持 Json/Ini/Xml/Toml),并启用环境变量与热重载
var cfg = ConfigBuilder.Build(opt =>
{
opt.BasePath = AppContext.BaseDirectory;
opt.ReloadOnChange = true;
opt.IncludeEnvironmentVariables = true;
opt.EnvironmentPrefix = "MYAPP_"; // 可选
opt.Sources.Add(new ConfigSource { Path = "appsettings.json", Format = ConfigFormat.Json, Optional = false });
opt.Sources.Add(new ConfigSource { Path = "appsettings.Development.json", Format = ConfigFormat.Json, Optional = true });
});
// 2) 强类型绑定 + 验证(DataAnnotations)
var options = cfg.Get<MyOptions>(validate: true);
// 3) 热重载回调
using var _ = cfg.OnReload(() => Console.WriteLine("Configuration reloaded"));
// 4) 保存/读取对象(跨格式序列化)
cfg.Save(options, path: "out/appsettings.toml", format: ConfigFormat.Toml);
var reloaded = cfg.LoadFromFile<MyOptions>("out/appsettings.toml", ConfigFormat.Toml);
public sealed class MyOptions
{
[System.ComponentModel.DataAnnotations.Required]
public string Name { get; set; } = string.Empty;
public int Port { get; set; } = 8080;
}
```
## 🤝 贡献指南
欢迎提交 Pull Request 或 Issue。请确保:
1. 代码风格符合 .NET 规范。
2. 新增功能包含对应的单元测试。
3. 提交信息简洁明了。
示例 appsettings.json
## 📄 许可证
MIT License
```json
{
"name": "demo",
"port": 5000
}
```
## 测试
- 测试框架:MSTest(并行执行)。
- 运行:
```bash
dotnet test
```
核心用例位于 `test/Common.Test`,覆盖 Result 与 Global 的主要功能与边界场景。
## WPF 工具包(Sharp.Wpf)
提供即插即用的 WPF 常用基础能力:
- 全局异常处理:统一捕获 UI/后台/未观察任务异常,支持弹窗与自定义日志
- MVVM 基础:`ViewModelBase`(INPC/IsBusy)、`RelayCommand`、`AsyncCommand`
- Dispatcher 封装:`IDispatcher`/`DispatcherService`,提供 UI 线程调用统一入口
- 对话与文件对话:`IDialogService`、`IFileDialogService`
- 常用转换器:Bool/NullOrEmpty 可见性、布尔取反、枚举描述
- 附加行为:EventToCommand(事件转 ICommand)
- 窗口工具:`WindowPlacement` 窗口位置/大小/状态持久化
### 引用
在你的 WPF 项目中添加项目引用:
```xml
<!-- Client/Client.csproj -->
<ItemGroup>
<ProjectReference Include="..\Wpf\Wpf.csproj" />
<!-- 或通过 NuGet: <PackageReference Include="Sharp.Wpf" Version="x.y.z" /> -->
</ItemGroup>
```
### 全局异常处理(App 启动)
```csharp
// App.Startup.cs
using System.Windows;
using Sharp.Wpf.Exceptions;
public partial class App : Application
{
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
new UiExceptionHandler().Register(new ExceptionHandlingOptions
{
ShowDialog = true,
DialogTitle = "应用程序错误"
});
}
}
```
### XAML 集成(窗口位置、行为、转换器)
```xml
<Window x:Class="Client.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:b="clr-namespace:Sharp.Wpf.Behaviors;assembly=Sharp.Wpf"
xmlns:w="clr-namespace:Sharp.Wpf.Windowing;assembly=Sharp.Wpf"
xmlns:conv="clr-namespace:Sharp.Wpf.Converters;assembly=Sharp.Wpf"
Title="MainWindow"
w:WindowPlacement.RememberPlacement="True"
w:WindowPlacement.PlacementId="MainWindow">
<Window.Resources>
<conv:BoolToVisibilityConverter x:Key="Bool2Vis" />
</Window.Resources>
<StackPanel Margin="16">
<Button Content="点击弹窗"
b:EventToCommand.EventName="Click"
b:EventToCommand.Command="{Binding ShowDialogCommand}" />
<Button Content="触发异常"
b:EventToCommand.EventName="Click"
b:EventToCommand.Command="{Binding ThrowCommand}" />
<TextBlock Text="正在处理..."
Visibility="{Binding IsBusy, Converter={StaticResource Bool2Vis}}"/>
</StackPanel>
</Window>
```
### ViewModel 与命令
```csharp
using System;
using System.Threading.Tasks;
using Sharp.Wpf.Commands;
using Sharp.Wpf.Mvvm;
using Sharp.Wpf.Services;
public sealed class MainViewModel : ViewModelBase
{
private readonly IDialogService _dialog = new DialogService();
public string Greeting { get; set; } = "欢迎使用 Sharp.Wpf";
public IAsyncCommand ShowDialogCommand { get; }
public RelayCommand ThrowCommand { get; }
public MainViewModel()
{
ShowDialogCommand = new AsyncCommand(async () =>
{
try { IsBusy = true; await Task.Delay(300); await _dialog.ShowInfoAsync("Hello!"); }
finally { IsBusy = false; }
});
ThrowCommand = new RelayCommand(() => throw new InvalidOperationException("演示未处理异常"));
}
}
```
### 文件对话
```csharp
var files = await new FileDialogService().OpenFilesAsync("Images|*.png;*.jpg");
var folder = await new FileDialogService().SelectFolderAsync();
```
## 路线图(Roadmap)
- P0(最优先)
- Logging 抽象与 Serilog 适配;EventBus 进程内 Typed 事件;Config 对接 IOptions 与变更广播
- P1
- Net.Client:HttpClientFactory 封装 + Result<T> 模式;WPF Toolkit:MVVM/事件聚合/通知与主题
- P2
- 可靠性(Polly)、OpenTelemetry、外部 Broker 适配(Redis/RabbitMQ/Kafka)、敏感字段加密
- P3
- 文档/示例完善、CI/CD(自动打包发布)、更多单测与性能基准
## 贡献与规范
- 欢迎通过 Issue/PR 参与贡献。
- 代码风格:.NET 8、可空启用、简化 `using`,命名空间建议统一为 `Sharp.*`(Common 模块后续将统一为 `Sharp.Common`)。
- 变更需保持向后兼容(次版本),破坏式变更请在主版本发布并注明迁移指南。
## 许可
本项目采用 GNU AGPLv3 许可证,详见根目录 `LICENSE`。
如需在私有环境或商业场景中使用且不希望开源网络服务端改动代码,请联系作者洽谈其他授权方式。
+4
View File
@@ -10,3 +10,7 @@
</ResourceDictionary>
</Application.Resources>
</Application>
+21 -82
View File
@@ -2,7 +2,9 @@ using System;
using System.Windows;
using Client.Services;
using Client.ViewModels;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Common;
using Config;
@@ -10,10 +12,8 @@ using NetClient.DependencyInjection;
using Sharp.Infrastructure.Configuration;
using Sharp.Log.Extensions;
using Sharp.NetServer.DependencyInjection;
using Sharp.Wpf.Exceptions;
using Sharp.Wpf.Services;
using Sharp.Wpf.Abstractions;
using Plc.Siemens.DependencyInjection;
using Microsoft.Extensions.Configuration;
namespace Client
{
@@ -27,58 +27,7 @@ namespace Client
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// Global Exception Handling
DispatcherUnhandledException += (s, args) =>
{
// Safe logging: handle case where DI failed
if (_serviceProvider != null)
{
try
{
var logger = _serviceProvider.GetService<ILogger<App>>();
logger?.LogError(args.Exception, "UI Thread Unhandled Exception");
}
catch { /* Ignore logging failure during crash */ }
}
MessageBox.Show($"发生未处理异常: {args.Exception.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
args.Handled = true; // Prevent crash
};
TaskScheduler.UnobservedTaskException += (s, args) =>
{
if (_serviceProvider != null)
{
try
{
var logger = _serviceProvider.GetService<ILogger<App>>();
logger?.LogError(args.Exception, "Task Unobserved Exception");
}
catch { /* Ignore */ }
}
args.SetObserved();
};
AppDomain.CurrentDomain.UnhandledException += (s, args) =>
{
var ex = args.ExceptionObject as Exception;
if (_serviceProvider != null)
{
try
{
var logger = _serviceProvider.GetService<ILogger<App>>();
logger?.LogCritical(ex, "AppDomain Unhandled Exception (IsTerminating: {IsTerminating})", args.IsTerminating);
}
catch { /* Ignore */ }
}
if (args.IsTerminating)
{
MessageBox.Show($"发生严重错误,程序即将退出: {ex?.Message}", "严重错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
};
// 1) Build configuration manager from appsettings.json (+ reload on change + env vars)
var cm = ConfigBuilder.BuildFromFiles(new[] { "appsettings.json" }, reloadOnChange: true, includeEnvironment: true, basePath: AppContext.BaseDirectory);
@@ -87,8 +36,7 @@ namespace Client
// ConfigManager and typed SystemConfig accessor
services.AddSingleton(cm);
// Register standard IConfiguration so that .BindConfiguration() works
services.AddSingleton<IConfiguration>(cm.Configuration);
services.TryAddSingleton<IConfiguration>(cm.Configuration); // Register IConfiguration for BindConfiguration
services.AddSystemConfig(cm);
// Logging (binds from section "SharpLog")
@@ -98,56 +46,47 @@ namespace Client
services.AddNetClient(cm);
services.AddNetServer(cm);
// PLC
services.AddSiemensPlc(options =>
{
options.IpAddress = "127.0.0.1";
options.CpuType = S7.Net.CpuType.S71200;
// 配置监控循环频率 (单位: 毫秒)
// 默认 200ms。如果设为 100ms 则频率更高,但需注意网络负载。
options.MonitorInterval = 200;
});
// WPF exception handler
services.AddSingleton<IUiExceptionHandler, UiExceptionHandler>();
// WPF app services
services.AddSingleton<IDialogService, DialogService>();
services.AddSingleton<IAuthenticationService, DemoAuthenticationService>();
services.AddSingleton<IUserSession, UserSession>();
// Register DispatcherService with the current UI dispatcher
services.AddSingleton<IDispatcher>(new DispatcherService(Dispatcher));
services.AddTransient<LoginViewModel>();
services.AddTransient<MainViewModel>();
services.AddTransient<PlcViewModel>();
services.AddTransient<LoginWindow>();
services.AddTransient<MainWindow>();
services.AddTransient<PlcWindow>();
_serviceProvider = services.BuildServiceProvider();
// 3) Register global facades for convenience
Global.RegisterSingleton<IServiceProvider>(_serviceProvider);
// 3) Activate global WPF exception handler
var exceptionHandler = _serviceProvider.GetRequiredService<IUiExceptionHandler>();
exceptionHandler.Register();
// 4) Register global facades for convenience
Global.RegisterSingleton(_serviceProvider);
Global.RegisterSingleton(cm);
Global.RegisterSingleton(_serviceProvider.GetRequiredService<ISystemConfigAccessor>());
// Also register IDispatcher in Global if it's needed by static contexts (though injection is preferred)
Global.RegisterSingleton(_serviceProvider.GetRequiredService<IDispatcher>());
// 4) Show login window first, then main shell if authenticated
// Explicitly set ShutdownMode to prevent app from closing when LoginWindow closes
ShutdownMode = ShutdownMode.OnExplicitShutdown;
// 5) Show login window first, then main shell if authenticated
var logger = _serviceProvider.GetRequiredService<ILogger<App>>();
logger.LogInformation("Showing login window");
var loginWindow = _serviceProvider.GetRequiredService<LoginWindow>();
var loginResult = loginWindow.ShowDialog();
logger.LogInformation("Login window closed with result: {LoginResult}", loginResult);
if (loginResult == true)
{
logger.LogInformation("Login successful, showing main window");
var main = _serviceProvider.GetRequiredService<MainWindow>();
Current.MainWindow = main;
ShutdownMode = ShutdownMode.OnMainWindowClose;
main.Show();
}
else
{
logger.LogInformation("Login cancelled or failed, shutting down application");
Shutdown();
}
}
@@ -158,4 +97,4 @@ namespace Client
base.OnExit(e);
}
}
}
}
-2
View File
@@ -21,8 +21,6 @@
<ProjectReference Include="..\NetServer\NetServer.csproj" />
<ProjectReference Include="..\Log\Log.csproj" />
<ProjectReference Include="..\Sharp.Infrastructure\Sharp.Infrastructure.csproj" />
<ProjectReference Include="..\Plc.Siemens\Plc.Siemens.csproj" />
<ProjectReference Include="..\Ports\Ports.csproj" />
</ItemGroup>
</Project>
-18
View File
@@ -1,18 +0,0 @@
using System.ComponentModel;
using Plc.Attributes;
namespace Client.Hardware;
// 必须继承 INotifyPropertyChanged 才能支持自动 UI 刷新
public interface IPackMachine : INotifyPropertyChanged
{
[PlcAddress("DB1.DBW0")]
short RunningSpeed { get; set; }
[PlcAddress("DB1.DBW2")]
short Temperature { get; set; }
// 支持 bool (比如 M 区)
[PlcAddress("M100.0")]
bool IsStartButtonPushed { get; set; }
}
+5 -1
View File
@@ -1,23 +1,27 @@
using System;
using HandyControl.Controls;
using Client.ViewModels;
using Microsoft.Extensions.Logging;
namespace Client;
public partial class LoginWindow : GlowWindow
{
private readonly LoginViewModel _viewModel;
private readonly ILogger<LoginWindow>? _logger;
public LoginWindow(LoginViewModel viewModel)
public LoginWindow(LoginViewModel viewModel, ILogger<LoginWindow>? logger = null)
{
InitializeComponent();
_viewModel = viewModel;
_logger = logger;
DataContext = viewModel;
_viewModel.LoginSucceeded += HandleLoginSucceeded;
}
private void HandleLoginSucceeded(object? sender, EventArgs e)
{
_logger?.LogInformation("HandleLoginSucceeded called, setting DialogResult = true");
DialogResult = true;
}
-49
View File
@@ -47,17 +47,7 @@
Command="{Binding ShowDialogCommand}" />
<Button Content="触发异常"
Padding="18,6"
Margin="0,0,12,0"
Command="{Binding ThrowCommand}" />
<Button Content="串口调试"
Padding="18,6"
Margin="0,0,12,0"
Style="{StaticResource ButtonInfo}"
Command="{Binding OpenSerialPortToolCommand}" />
<Button Content="PLC 调试"
Padding="18,6"
Style="{StaticResource ButtonPrimary}"
Command="{Binding OpenPlcToolCommand}" />
</StackPanel>
</Grid>
</Border>
@@ -92,45 +82,6 @@
<TextBlock Text="{Binding Greeting}" Margin="0,8,0,0" TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Background="{DynamicResource PopupBrush}" Margin="0,0,16,16" Padding="20" CornerRadius="12">
<StackPanel>
<TextBlock Text="包装机状态 (PLC)" FontWeight="SemiBold" />
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
<TextBlock Text="速度: " Foreground="{DynamicResource SecondaryTextBrush}"/>
<TextBlock Text="{Binding Machine.RunningSpeed}" FontWeight="Bold" />
<TextBlock Text=" rpm" Foreground="{DynamicResource SecondaryTextBrush}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<TextBlock Text="温度: " Foreground="{DynamicResource SecondaryTextBrush}"/>
<TextBlock Text="{Binding Machine.Temperature}" FontWeight="Bold" />
<TextBlock Text=" ℃" Foreground="{DynamicResource SecondaryTextBrush}"/>
</StackPanel>
<!-- 交互控制演示 -->
<Separator Margin="0,10" Background="{DynamicResource BorderBrush}"/>
<TextBlock Text="控制面板" FontWeight="SemiBold" Margin="0,0,0,5"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="设定速度:" VerticalAlignment="Center"/>
<Slider Grid.Column="1" Minimum="0" Maximum="2000" Value="{Binding Machine.RunningSpeed}" Margin="10,0" IsMoveToPointEnabled="True"/>
<TextBlock Grid.Column="2" Text="{Binding Machine.RunningSpeed}" Width="40" TextAlignment="Right"/>
</Grid>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<Button Content="启动机器" Command="{Binding StartMachineCommand}" Margin="0,0,10,0" Style="{StaticResource ButtonSuccess}"/>
<Button Content="停止机器" Command="{Binding StopMachineCommand}" Style="{StaticResource ButtonDanger}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
<CheckBox IsChecked="{Binding Machine.IsStartButtonPushed}" Content="启动按钮状态 (双向绑定)" IsHitTestVisible="False"/>
</StackPanel>
</StackPanel>
</Border>
<Border Background="{DynamicResource PopupBrush}" Margin="0,0,16,16" Padding="20" CornerRadius="12">
<StackPanel>
<TextBlock Text="提示" FontWeight="SemiBold" />
-91
View File
@@ -1,91 +0,0 @@
<hc:GlowWindow x:Class="Client.PlcWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:hc="https://handyorg.github.io/handycontrol"
xmlns:conv="clr-namespace:Sharp.Wpf.Converters;assembly=Sharp.Wpf"
mc:Ignorable="d"
Title="PLC 调试工具" Height="500" Width="800"
WindowStartupLocation="CenterOwner">
<hc:GlowWindow.Resources>
<conv:BoolToVisibilityConverter x:Key="Bool2Vis" />
</hc:GlowWindow.Resources>
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 连接控制 -->
<Border Background="{DynamicResource RegionBrush}" Padding="15" CornerRadius="8" Margin="0,0,0,15">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="品牌:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox ItemsSource="{Binding Brands}" SelectedItem="{Binding SelectedBrand}" Width="120" Margin="0,0,15,0"/>
<StackPanel Orientation="Horizontal" Visibility="{Binding IsSiemensOptionsVisible, Converter={StaticResource Bool2Vis}}">
<TextBlock Text="型号:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox ItemsSource="{Binding CpuTypes}" SelectedItem="{Binding SelectedCpuType}" Width="120" Margin="0,0,15,0"/>
</StackPanel>
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBox hc:InfoElement.Placeholder="IP 地址" hc:InfoElement.Title="IP" Text="{Binding IpAddress}" Width="200" Margin="0,0,10,0"/>
<Button Content="连接 / 启动服务" Command="{Binding ConnectCommand}" Margin="0,0,10,0" Style="{StaticResource ButtonPrimary}"/>
<Button Content="断开" Command="{Binding DisconnectCommand}"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Margin="20,0,0,0" FontWeight="Bold" Foreground="{DynamicResource PrimaryTextBrush}"/>
</StackPanel>
</StackPanel>
</Border>
<!-- 单次读写 -->
<GroupBox Header="单次读写测试 (默认 Short)" Grid.Row="1" Margin="0,0,0,15">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="读取地址:" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding ReadAddress}" Margin="5,0"/>
<Button Grid.Column="2" Content="读取" Command="{Binding ReadCommand}" Margin="5,0"/>
<TextBlock Grid.Column="3" Text="{Binding ReadValue}" VerticalAlignment="Center" Margin="5,0" FontWeight="Bold"/>
<TextBlock Grid.Row="1" Text="写入地址:" VerticalAlignment="Center" Margin="0,10,0,0"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding WriteAddress}" Margin="5,10,0,0"/>
<TextBox Grid.Row="1" Grid.Column="3" Text="{Binding WriteValue}" Margin="5,10,0,0"/>
<Button Grid.Row="1" Grid.Column="4" Content="写入" Command="{Binding WriteCommand}" Margin="5,10,0,0"/>
</Grid>
</GroupBox>
<!-- 监控 -->
<GroupBox Header="实时监控 (Add Watch)" Grid.Row="2" Margin="0,0,0,15">
<StackPanel Orientation="Horizontal" Margin="10">
<TextBox hc:InfoElement.Placeholder="地址 (如 DB1.DBW0)" Text="{Binding MonitorAddress}" Width="200" Margin="0,0,10,0"/>
<ComboBox ItemsSource="{Binding SupportedMonitorTypes}" SelectedValue="{Binding SelectedMonitorType}" SelectedValuePath="Type" DisplayMemberPath="Name" Width="120" 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 DataTypeName}" Width="80"/>
<DataGridTextColumn Header="当前值" Binding="{Binding Value}" Width="*"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
</hc:GlowWindow>
-14
View File
@@ -1,14 +0,0 @@
using System.Windows;
using Client.ViewModels;
using HandyControl.Controls;
namespace Client;
public partial class PlcWindow : GlowWindow
{
public PlcWindow(PlcViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
-44
View File
@@ -1,44 +0,0 @@
# Sharp.Client
基于 WPF 的工业客户端应用程序示例,展示了如何集成套件中的各个模块(PLC、串口、配置、日志、MVVM)。
## 功能概览
- **用户登录**:基于 `Sharp.Data` 的用户验证。
- **PLC 监控**:展示 `Plc.Siemens` 模块的使用,实时读取/写入 PLC 数据。
- **串口调试**:集成 `Ports` 模块,提供串口发现、连接与数据收发界面。
- **MVVM 架构**:使用 `Sharp.Wpf` 提供的 ViewModelBase、Command 和 DialogService。
## 关键集成点
### 1. 启动初始化 (App.xaml.cs)
展示了如何按顺序初始化基础设施:
- 加载配置 (`Sharp.Config`)
- 初始化日志 (`Sharp.Log`)
- 配置数据库 (`Sharp.Data`)
- 注册服务到 DI 容器 (`Sharp.Common.Global`)
### 2. PLC 交互 (PlcWindow)
- 演示了 `IPlcService` 的使用。
- 订阅模式(Subscribe) vs 即时读取(ReadAsync)。
- UI 绑定与数据转换。
### 3. 串口通信 (SerialPortWindow)
- 串口列表刷新与选择。
- 16进制与 ASCII 模式切换。
- 数据收发日志。
## 开发指南
1. 确保已安装 .NET 8 SDK。
2. 运行项目:
```bash
dotnet run --project Client.csproj
```
3. 默认登录账号(如使用示例数据库):`admin` / `123456`(需自行在初始化时播种)。
## 目录结构
- `Hardware/`: 硬件抽象层(如扫码枪、打印机接口)。
- `Services/`: 客户端特定服务(如 `LoginService`)。
- `ViewModels/`: MVVM 视图模型。
- `Views/`: XAML 窗口与控件。
-301
View File
@@ -1,301 +0,0 @@
<hc:GlowWindow x:Class="Client.SerialPortWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:hc="https://handyorg.github.io/handycontrol"
mc:Ignorable="d"
Title="串口通信调试工具" Height="750" Width="1200"
WindowStartupLocation="CenterOwner">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TabControl Grid.Row="0" Grid.RowSpan="2" Style="{StaticResource TabControlCapsule}">
<!-- Tab 1: 串口发现 -->
<TabItem Header="串口发现">
<Grid Margin="15">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Background="{DynamicResource RegionBrush}" Padding="15" CornerRadius="8" Margin="0,0,0,15">
<StackPanel Orientation="Horizontal">
<Button Content="刷新串口列表" Click="RefreshPorts_Click" Style="{StaticResource ButtonPrimary}" Margin="0,0,10,0"/>
<Button Content="仅显示USB串口" Click="ShowUsbPorts_Click" Margin="0,0,10,0"/>
<Button Content="测试连接" Click="TestConnection_Click" Margin="0,0,10,0"/>
<TextBlock x:Name="PortCountText" Text="发现 0 个串口" VerticalAlignment="Center" Margin="20,0,0,0" FontWeight="Bold"/>
</StackPanel>
</Border>
<DataGrid x:Name="PortsDataGrid" Grid.Row="1" AutoGenerateColumns="False" IsReadOnly="True"
SelectionMode="Single" HeadersVisibility="Column" CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="端口" Binding="{Binding PortName}" Width="100"/>
<DataGridTextColumn Header="描述" Binding="{Binding Description}" Width="200"/>
<DataGridTextColumn Header="制造商" Binding="{Binding Manufacturer}" Width="150"/>
<DataGridCheckBoxColumn Header="USB设备" Binding="{Binding IsUsbSerial}" Width="80"/>
<DataGridTextColumn Header="VID" Binding="{Binding VendorId}" Width="80"/>
<DataGridTextColumn Header="PID" Binding="{Binding ProductId}" Width="80"/>
<DataGridCheckBoxColumn Header="被占用" Binding="{Binding IsInUse}" Width="80"/>
<DataGridTextColumn Header="设备ID" Binding="{Binding DeviceId}" Width="*"/>
</DataGrid.Columns>
</DataGrid>
<TextBlock Grid.Row="2" x:Name="DiscoveryStatusText" Text="点击'刷新串口列表'开始"
Foreground="{DynamicResource SecondaryTextBrush}" Margin="0,10,0,0"/>
</Grid>
</TabItem>
<!-- Tab 2: 基础通信 -->
<TabItem Header="基础通信">
<Grid Margin="15">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 连接配置 -->
<Border Background="{DynamicResource RegionBrush}" Padding="15" CornerRadius="8" Margin="0,0,0,15">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal">
<TextBlock Text="端口:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="PortNameCombo" Width="100" Margin="0,0,15,0"/>
<TextBlock Text="波特率:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="BaudRateCombo" Width="100" Margin="0,0,15,0" SelectedIndex="3">
<ComboBoxItem Content="4800"/>
<ComboBoxItem Content="9600"/>
<ComboBoxItem Content="19200"/>
<ComboBoxItem Content="38400"/>
<ComboBoxItem Content="57600"/>
<ComboBoxItem Content="115200"/>
</ComboBox>
<TextBlock Text="数据位:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="DataBitsCombo" Width="60" Margin="0,0,15,0" SelectedIndex="3">
<ComboBoxItem Content="5"/>
<ComboBoxItem Content="6"/>
<ComboBoxItem Content="7"/>
<ComboBoxItem Content="8"/>
</ComboBox>
<TextBlock Text="校验位:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="ParityCombo" Width="80" Margin="0,0,15,0" SelectedIndex="0">
<ComboBoxItem Content="None"/>
<ComboBoxItem Content="Odd"/>
<ComboBoxItem Content="Even"/>
<ComboBoxItem Content="Mark"/>
<ComboBoxItem Content="Space"/>
</ComboBox>
<TextBlock Text="停止位:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="StopBitsCombo" Width="80" Margin="0,0,0,0" SelectedIndex="1">
<ComboBoxItem Content="None"/>
<ComboBoxItem Content="One"/>
<ComboBoxItem Content="Two"/>
</ComboBox>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,10,0,0">
<Button x:Name="OpenButton" Content="打开串口" Click="OpenPort_Click" Style="{StaticResource ButtonSuccess}" Margin="0,0,10,0"/>
<Button x:Name="CloseButton" Content="关闭串口" Click="ClosePort_Click" Style="{StaticResource ButtonDanger}" Margin="0,0,10,0" IsEnabled="False"/>
<TextBlock x:Name="ConnectionStatusText" Text="未连接" VerticalAlignment="Center" Margin="20,0,0,0" FontWeight="Bold"/>
</StackPanel>
</Grid>
</Border>
<!-- 发送区域 -->
<GroupBox Header="发送数据" Grid.Row="1" Margin="0,0,0,15">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal">
<TextBox x:Name="SendDataTextBox" Width="400" hc:InfoElement.Placeholder="输入要发送的数据 (十六进制,如: 01 02 03)" Margin="0,0,10,0"/>
<Button Content="发送 (HEX)" Click="SendHex_Click" Margin="0,0,10,0"/>
<Button Content="发送 (文本)" Click="SendText_Click" Margin="0,0,10,0"/>
<Button Content="清空" Click="ClearSend_Click"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,10,0,0">
<CheckBox x:Name="AutoAddNewLineCheckBox" Content="自动添加换行符" IsChecked="True" Margin="0,0,15,0"/>
<TextBlock Text="编码:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="EncodingCombo" Width="100" SelectedIndex="0">
<ComboBoxItem Content="UTF-8"/>
<ComboBoxItem Content="ASCII"/>
<ComboBoxItem Content="GBK"/>
</ComboBox>
</StackPanel>
</Grid>
</GroupBox>
<!-- 接收区域 -->
<GroupBox Header="接收数据" Grid.Row="2" Margin="0,0,0,10">
<StackPanel Orientation="Horizontal" Margin="10">
<CheckBox x:Name="HexDisplayCheckBox" Content="HEX显示" IsChecked="True" Margin="0,0,15,0"/>
<CheckBox x:Name="AutoScrollCheckBox" Content="自动滚动" IsChecked="True" Margin="0,0,15,0"/>
<Button Content="清空接收区" Click="ClearReceive_Click" Margin="0,0,10,0"/>
<TextBlock x:Name="ReceivedBytesText" Text="已接收: 0 字节" VerticalAlignment="Center" Margin="10,0,0,0"/>
</StackPanel>
</GroupBox>
<TextBox x:Name="ReceiveTextBox" Grid.Row="3" IsReadOnly="True" VerticalScrollBarVisibility="Auto"
TextWrapping="Wrap" FontFamily="Consolas" Background="{DynamicResource RegionBrush}"/>
</Grid>
</TabItem>
<!-- Tab 3: 高级监听 -->
<TabItem Header="高级监听">
<Grid Margin="15">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border Background="{DynamicResource RegionBrush}" Padding="15" CornerRadius="8" Margin="0,0,0,15">
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<TextBlock Text="端口:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="ServicePortCombo" Width="100" Margin="0,0,15,0"/>
<TextBlock Text="波特率:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="ServiceBaudRateCombo" Width="100" Margin="0,0,15,0" SelectedIndex="3">
<ComboBoxItem Content="9600"/>
<ComboBoxItem Content="19200"/>
<ComboBoxItem Content="38400"/>
<ComboBoxItem Content="57600"/>
<ComboBoxItem Content="115200"/>
</ComboBox>
<CheckBox x:Name="AutoReconnectCheckBox" Content="自动重连" IsChecked="True" Margin="0,0,15,0" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal">
<Button x:Name="StartListeningButton" Content="启动监听" Click="StartListening_Click" Style="{StaticResource ButtonSuccess}" Margin="0,0,10,0"/>
<Button x:Name="StopListeningButton" Content="停止监听" Click="StopListening_Click" Style="{StaticResource ButtonDanger}" Margin="0,0,10,0" IsEnabled="False"/>
<TextBlock x:Name="ServiceStatusText" Text="未启动" VerticalAlignment="Center" Margin="20,0,0,0" FontWeight="Bold"/>
</StackPanel>
</StackPanel>
</Border>
<DataGrid x:Name="EventLogDataGrid" Grid.Row="1" AutoGenerateColumns="False" IsReadOnly="True"
HeadersVisibility="Column" CanUserResizeColumns="True" Margin="0,0,0,15">
<DataGrid.Columns>
<DataGridTextColumn Header="时间" Binding="{Binding Time, StringFormat=HH:mm:ss.fff}" Width="100"/>
<DataGridTextColumn Header="类型" Binding="{Binding EventType}" Width="100"/>
<DataGridTextColumn Header="数据/消息" Binding="{Binding Data}" Width="*"/>
</DataGrid.Columns>
</DataGrid>
<StackPanel Grid.Row="2" Orientation="Horizontal">
<Button Content="清空日志" Click="ClearEventLog_Click" Margin="0,0,10,0"/>
<TextBlock x:Name="EventCountText" Text="事件数: 0" VerticalAlignment="Center" Margin="10,0,0,0"/>
</StackPanel>
</Grid>
</TabItem>
<!-- Tab 4: 数据工具 -->
<TabItem Header="数据工具">
<Grid Margin="15">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 数据转换 -->
<GroupBox Header="数据转换" Margin="0,0,0,15">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal">
<TextBox x:Name="ConvertInputTextBox" Width="300" hc:InfoElement.Placeholder="输入数据" Margin="0,0,10,0"/>
<ComboBox x:Name="ConvertTypeCombo" Width="120" Margin="0,0,10,0" SelectedIndex="0">
<ComboBoxItem Content="HEX -> 字节"/>
<ComboBoxItem Content="字节 -> HEX"/>
<ComboBoxItem Content="字节 -> Int16"/>
<ComboBoxItem Content="字节 -> Int32"/>
<ComboBoxItem Content="字节 -> Float"/>
<ComboBoxItem Content="Int32 -> 字节"/>
</ComboBox>
<CheckBox x:Name="BigEndianCheckBox" Content="大端序" Margin="0,0,10,0" VerticalAlignment="Center"/>
<Button Content="转换" Click="ConvertData_Click"/>
</StackPanel>
<TextBlock Grid.Row="1" Text="转换结果:" Margin="0,10,0,5"/>
<TextBox Grid.Row="2" x:Name="ConvertResultTextBox" IsReadOnly="True" Background="{DynamicResource RegionBrush}"/>
</Grid>
</GroupBox>
<!-- 校验计算 -->
<GroupBox Header="校验计算" Grid.Row="1" Margin="0,0,0,15">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal">
<TextBox x:Name="ChecksumInputTextBox" Width="400" hc:InfoElement.Placeholder="输入十六进制数据 (如: 01 02 03)" Margin="0,0,10,0"/>
<Button Content="CRC16" Click="CalculateCrc16_Click" Margin="0,0,5,0"/>
<Button Content="LRC" Click="CalculateLrc_Click" Margin="0,0,5,0"/>
<Button Content="XOR" Click="CalculateXor_Click" Margin="0,0,5,0"/>
<Button Content="累加和" Click="CalculateChecksum_Click"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,10,0,0">
<TextBlock Text="校验结果:" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBox x:Name="ChecksumResultTextBox" Width="200" IsReadOnly="True" Background="{DynamicResource RegionBrush}"/>
</StackPanel>
</Grid>
</GroupBox>
<!-- 诊断工具 -->
<GroupBox Header="诊断工具" Grid.Row="2" Margin="0,0,0,15">
<Grid Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Orientation="Horizontal">
<TextBlock Text="端口:" VerticalAlignment="Center" Margin="0,0,5,0"/>
<ComboBox x:Name="DiagPortCombo" Width="100" Margin="0,0,10,0"/>
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal">
<Button Content="测试连接" Click="DiagTestConnection_Click" Margin="0,0,5,0"/>
<Button Content="波特率扫描" Click="ScanBaudRates_Click" Margin="0,0,5,0"/>
<Button Content="流量监控(10s)" Click="MonitorTraffic_Click"/>
</StackPanel>
</Grid>
</GroupBox>
<!-- 诊断结果 -->
<TextBox x:Name="DiagResultTextBox" Grid.Row="3" IsReadOnly="True" VerticalScrollBarVisibility="Auto"
TextWrapping="Wrap" FontFamily="Consolas" Background="{DynamicResource RegionBrush}"/>
</Grid>
</TabItem>
</TabControl>
</Grid>
</hc:GlowWindow>
-644
View File
@@ -1,644 +0,0 @@
using System.Collections.ObjectModel;
using System.IO.Ports;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using HandyControl.Controls;
using Ports.Abstractions;
using Ports.Discovery;
using Ports.Drivers;
using Ports.Models;
using Ports.Services;
using Ports.Utils;
namespace Client;
public partial class SerialPortWindow
{
private ISerialPortDriver? _driver;
private ISerialPortService? _service;
private int _totalReceivedBytes;
private readonly ObservableCollection<EventLogItem> _eventLog = new();
public SerialPortWindow()
{
InitializeComponent();
InitializeComboBoxes();
EventLogDataGrid.ItemsSource = _eventLog;
}
private void InitializeComboBoxes()
{
// 初始化波特率下拉框
BaudRateCombo.SelectedItem = BaudRateCombo.Items[4]; // 115200
// 加载串口列表
RefreshPortsList();
}
private void RefreshPortsList()
{
var result = SerialPortDiscovery.GetAvailablePorts();
if (result.IsSuccess && result.Value != null)
{
var portNames = result.Value.Select(p => p.PortName).ToList();
PortNameCombo.ItemsSource = portNames;
ServicePortCombo.ItemsSource = portNames;
DiagPortCombo.ItemsSource = portNames;
if (portNames.Count > 0)
{
PortNameCombo.SelectedIndex = 0;
ServicePortCombo.SelectedIndex = 0;
DiagPortCombo.SelectedIndex = 0;
}
}
}
#region Tab 1: 串口发现
private void RefreshPorts_Click(object sender, RoutedEventArgs e)
{
var result = SerialPortDiscovery.GetAvailablePorts();
if (result.IsSuccess && result.Value != null)
{
PortsDataGrid.ItemsSource = result.Value;
PortCountText.Text = $"发现 {result.Value.Count} 个串口";
DiscoveryStatusText.Text = $"最后刷新: {DateTime.Now:HH:mm:ss}";
RefreshPortsList();
}
else
{
DiscoveryStatusText.Text = $"错误: {result.ErrorMessage}";
}
}
private void ShowUsbPorts_Click(object sender, RoutedEventArgs e)
{
var result = SerialPortDiscovery.GetUsbSerialPorts();
if (result.IsSuccess && result.Value != null)
{
PortsDataGrid.ItemsSource = result.Value;
PortCountText.Text = $"发现 {result.Value.Count} 个USB串口";
DiscoveryStatusText.Text = $"仅显示USB设备";
}
else
{
DiscoveryStatusText.Text = $"错误: {result.ErrorMessage}";
}
}
private async void TestConnection_Click(object sender, RoutedEventArgs e)
{
var selected = PortsDataGrid.SelectedItem as SerialPortInfo;
if (selected == null)
{
HandyControl.Controls.MessageBox.Show("请先选择一个串口", "提示");
return;
}
DiscoveryStatusText.Text = $"正在测试 {selected.PortName}...";
var options = new SerialPortOptions { PortName = selected.PortName };
var result = await SerialPortDiagnostics.TestConnectionAsync(selected.PortName, options);
if (result.IsSuccess)
{
DiscoveryStatusText.Text = "测试完成";
HandyControl.Controls.MessageBox.Show(result.Value, "测试结果");
}
else
{
DiscoveryStatusText.Text = $"测试失败: {result.ErrorMessage}";
}
}
#endregion
#region Tab 2: 基础通信
private async void OpenPort_Click(object sender, RoutedEventArgs e)
{
if (PortNameCombo.SelectedItem == null)
{
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
return;
}
var options = new SerialPortOptions
{
PortName = PortNameCombo.SelectedItem.ToString()!,
BaudRate = int.Parse(((ComboBoxItem)BaudRateCombo.SelectedItem).Content.ToString()!),
DataBits = int.Parse(((ComboBoxItem)DataBitsCombo.SelectedItem).Content.ToString()!),
Parity = Enum.Parse<Parity>(((ComboBoxItem)ParityCombo.SelectedItem).Content.ToString()!),
StopBits = Enum.Parse<StopBits>(((ComboBoxItem)StopBitsCombo.SelectedItem).Content.ToString()!)
};
_driver = new SerialPortDriver();
var result = await _driver.OpenAsync(options);
if (result.IsSuccess)
{
ConnectionStatusText.Text = "已连接";
OpenButton.IsEnabled = false;
CloseButton.IsEnabled = true;
_totalReceivedBytes = 0;
// 启动接收线程
_ = Task.Run(ReceiveLoop);
}
else
{
ConnectionStatusText.Text = $"连接失败: {result.ErrorMessage}";
HandyControl.Controls.MessageBox.Show(result.ErrorMessage ?? "连接失败", "错误");
}
}
private async void ClosePort_Click(object sender, RoutedEventArgs e)
{
if (_driver != null)
{
await _driver.CloseAsync();
_driver.Dispose();
_driver = null;
}
ConnectionStatusText.Text = "未连接";
OpenButton.IsEnabled = true;
CloseButton.IsEnabled = false;
}
private async void SendHex_Click(object sender, RoutedEventArgs e)
{
if (_driver == null || !_driver.IsOpen)
{
HandyControl.Controls.MessageBox.Show("请先打开串口", "提示");
return;
}
var hexResult = SerialDataConverter.HexStringToBytes(SendDataTextBox.Text);
if (hexResult.IsFailure)
{
HandyControl.Controls.MessageBox.Show(hexResult.ErrorMessage ?? "数据格式错误", "错误");
return;
}
var result = await _driver.WriteBytesAsync(hexResult.Value!);
if (result.IsFailure)
{
HandyControl.Controls.MessageBox.Show(result.ErrorMessage ?? "发送失败", "错误");
}
}
private async void SendText_Click(object sender, RoutedEventArgs e)
{
if (_driver == null || !_driver.IsOpen)
{
HandyControl.Controls.MessageBox.Show("请先打开串口", "提示");
return;
}
var encoding = GetSelectedEncoding();
var text = SendDataTextBox.Text;
if (AutoAddNewLineCheckBox.IsChecked == true)
text += "\r\n";
var bytesResult = SerialDataConverter.StringToBytes(text, encoding);
if (bytesResult.IsSuccess && bytesResult.Value != null)
{
await _driver.WriteBytesAsync(bytesResult.Value);
}
}
private void ClearSend_Click(object sender, RoutedEventArgs e)
{
SendDataTextBox.Clear();
}
private void ClearReceive_Click(object sender, RoutedEventArgs e)
{
ReceiveTextBox.Clear();
_totalReceivedBytes = 0;
ReceivedBytesText.Text = "已接收: 0 字节";
}
private async Task ReceiveLoop()
{
while (_driver != null && _driver.IsOpen)
{
try
{
if (_driver.BytesToRead > 0)
{
var result = await _driver.ReadAvailableAsync();
if (result.IsSuccess && result.Value != null && result.Value.Length > 0)
{
_totalReceivedBytes += result.Value.Length;
Dispatcher.Invoke(() => DisplayReceivedData(result.Value));
}
}
await Task.Delay(10);
}
catch
{
break;
}
}
}
private void DisplayReceivedData(byte[] data)
{
string display;
if (HexDisplayCheckBox.IsChecked == true)
{
var hexResult = SerialDataConverter.BytesToHexString(data, " ");
display = hexResult.Value + "\r\n";
}
else
{
var encoding = GetSelectedEncoding();
var strResult = SerialDataConverter.BytesToString(data, encoding);
display = strResult.Value ?? string.Empty;
}
ReceiveTextBox.AppendText($"[{DateTime.Now:HH:mm:ss}] {display}");
ReceivedBytesText.Text = $"已接收: {_totalReceivedBytes} 字节";
if (AutoScrollCheckBox.IsChecked == true)
{
ReceiveTextBox.ScrollToEnd();
}
}
#endregion
#region Tab 3: 高级监听
private async void StartListening_Click(object sender, RoutedEventArgs e)
{
if (ServicePortCombo.SelectedItem == null)
{
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
return;
}
var options = new SerialPortOptions
{
PortName = ServicePortCombo.SelectedItem.ToString()!,
BaudRate = int.Parse(((ComboBoxItem)ServiceBaudRateCombo.SelectedItem).Content.ToString()!)
};
_service = new SerialPortService(new SerialPortDriver());
// 订阅事件
_service.DataReceived += OnServiceDataReceived;
_service.ErrorOccurred += OnServiceErrorOccurred;
_service.ConnectionStateChanged += OnServiceConnectionStateChanged;
var autoReconnect = AutoReconnectCheckBox.IsChecked == true;
var result = await _service.StartListeningAsync(options, autoReconnect);
if (result.IsSuccess)
{
ServiceStatusText.Text = "监听中";
StartListeningButton.IsEnabled = false;
StopListeningButton.IsEnabled = true;
}
else
{
ServiceStatusText.Text = $"启动失败";
HandyControl.Controls.MessageBox.Show(result.ErrorMessage ?? "启动失败", "错误");
}
}
private async void StopListening_Click(object sender, RoutedEventArgs e)
{
if (_service != null)
{
try
{
await _service.StopListeningAsync();
}
catch (OperationCanceledException)
{
// 任务取消是正常情况,忽略
}
catch (Exception ex)
{
HandyControl.Controls.MessageBox.Show($"停止监听时发生错误: {ex.Message}", "错误");
}
try
{
_service.Dispose();
}
catch
{
// 忽略清理异常
}
_service = null;
}
ServiceStatusText.Text = "未启动";
StartListeningButton.IsEnabled = true;
StopListeningButton.IsEnabled = false;
}
private void OnServiceDataReceived(object? sender, DataReceivedEventArgs e)
{
Dispatcher.Invoke(() =>
{
var hexResult = SerialDataConverter.BytesToHexString(e.Data, " ");
_eventLog.Add(new EventLogItem
{
Time = e.ReceivedAt,
EventType = "数据接收",
Data = $"{e.Data.Length} 字节: {hexResult.Value}"
});
EventCountText.Text = $"事件数: {_eventLog.Count}";
});
}
private void OnServiceErrorOccurred(object? sender, Ports.Models.ErrorEventArgs e)
{
Dispatcher.Invoke(() =>
{
_eventLog.Add(new EventLogItem
{
Time = e.OccurredAt,
EventType = "错误",
Data = e.ErrorMessage
});
EventCountText.Text = $"事件数: {_eventLog.Count}";
});
}
private void OnServiceConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
{
Dispatcher.Invoke(() =>
{
_eventLog.Add(new EventLogItem
{
Time = e.ChangedAt,
EventType = "状态变化",
Data = $"{e.PreviousState} -> {e.CurrentState}"
});
ServiceStatusText.Text = e.CurrentState.ToString();
EventCountText.Text = $"事件数: {_eventLog.Count}";
});
}
private void ClearEventLog_Click(object sender, RoutedEventArgs e)
{
_eventLog.Clear();
EventCountText.Text = "事件数: 0";
}
#endregion
#region Tab 4: 数据工具
private void ConvertData_Click(object sender, RoutedEventArgs e)
{
var input = ConvertInputTextBox.Text;
var type = ((ComboBoxItem)ConvertTypeCombo.SelectedItem).Content.ToString();
var isBigEndian = BigEndianCheckBox.IsChecked == true;
try
{
switch (type)
{
case "HEX -> 字节":
var hexBytes = SerialDataConverter.HexStringToBytes(input);
ConvertResultTextBox.Text = hexBytes.IsSuccess ? $"字节数组长度: {hexBytes.Value!.Length}" : hexBytes.ErrorMessage;
break;
case "字节 -> HEX":
var bytes = SerialDataConverter.HexStringToBytes(input);
if (bytes.IsSuccess)
{
var hex = SerialDataConverter.BytesToHexString(bytes.Value!, " ");
ConvertResultTextBox.Text = hex.Value;
}
break;
case "字节 -> Int16":
var int16Bytes = SerialDataConverter.HexStringToBytes(input);
if (int16Bytes.IsSuccess)
{
var int16 = SerialDataConverter.BytesToInt16(int16Bytes.Value!, isBigEndian);
ConvertResultTextBox.Text = int16.IsSuccess ? int16.Value.ToString() : int16.ErrorMessage;
}
break;
case "字节 -> Int32":
var int32Bytes = SerialDataConverter.HexStringToBytes(input);
if (int32Bytes.IsSuccess)
{
var int32 = SerialDataConverter.BytesToInt32(int32Bytes.Value!, isBigEndian);
ConvertResultTextBox.Text = int32.IsSuccess ? int32.Value.ToString() : int32.ErrorMessage;
}
break;
case "字节 -> Float":
var floatBytes = SerialDataConverter.HexStringToBytes(input);
if (floatBytes.IsSuccess)
{
var floatValue = SerialDataConverter.BytesToFloat(floatBytes.Value!, isBigEndian);
ConvertResultTextBox.Text = floatValue.IsSuccess ? floatValue.Value.ToString("F4") : floatValue.ErrorMessage;
}
break;
case "Int32 -> 字节":
if (int.TryParse(input, out var intValue))
{
var intBytes = SerialDataConverter.Int32ToBytes(intValue, isBigEndian);
if (intBytes.IsSuccess)
{
var hex = SerialDataConverter.BytesToHexString(intBytes.Value!, " ");
ConvertResultTextBox.Text = hex.Value;
}
}
else
{
ConvertResultTextBox.Text = "输入不是有效的整数";
}
break;
}
}
catch (Exception ex)
{
ConvertResultTextBox.Text = $"转换错误: {ex.Message}";
}
}
private void CalculateCrc16_Click(object sender, RoutedEventArgs e)
{
var hexBytes = SerialDataConverter.HexStringToBytes(ChecksumInputTextBox.Text);
if (hexBytes.IsSuccess && hexBytes.Value != null)
{
var crc = SerialChecksumHelper.CalculateCrc16(hexBytes.Value);
ChecksumResultTextBox.Text = crc.IsSuccess ? $"0x{crc.Value:X4}" : crc.ErrorMessage;
}
}
private void CalculateLrc_Click(object sender, RoutedEventArgs e)
{
var hexBytes = SerialDataConverter.HexStringToBytes(ChecksumInputTextBox.Text);
if (hexBytes.IsSuccess && hexBytes.Value != null)
{
var lrc = SerialChecksumHelper.CalculateLrc(hexBytes.Value);
ChecksumResultTextBox.Text = lrc.IsSuccess ? $"0x{lrc.Value:X2}" : lrc.ErrorMessage;
}
}
private void CalculateXor_Click(object sender, RoutedEventArgs e)
{
var hexBytes = SerialDataConverter.HexStringToBytes(ChecksumInputTextBox.Text);
if (hexBytes.IsSuccess && hexBytes.Value != null)
{
var xor = SerialChecksumHelper.CalculateXor(hexBytes.Value);
ChecksumResultTextBox.Text = xor.IsSuccess ? $"0x{xor.Value:X2}" : xor.ErrorMessage;
}
}
private void CalculateChecksum_Click(object sender, RoutedEventArgs e)
{
var hexBytes = SerialDataConverter.HexStringToBytes(ChecksumInputTextBox.Text);
if (hexBytes.IsSuccess && hexBytes.Value != null)
{
var checksum = SerialChecksumHelper.CalculateChecksum(hexBytes.Value);
ChecksumResultTextBox.Text = checksum.IsSuccess ? $"0x{checksum.Value:X2}" : checksum.ErrorMessage;
}
}
private async void DiagTestConnection_Click(object sender, RoutedEventArgs e)
{
if (DiagPortCombo.SelectedItem == null)
{
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
return;
}
DiagResultTextBox.Text = "正在测试连接...\r\n";
var portName = DiagPortCombo.SelectedItem.ToString()!;
var options = new SerialPortOptions { PortName = portName };
var result = await SerialPortDiagnostics.TestConnectionAsync(portName, options);
DiagResultTextBox.AppendText(result.IsSuccess ? result.Value! : $"错误: {result.ErrorMessage}");
}
private async void ScanBaudRates_Click(object sender, RoutedEventArgs e)
{
if (DiagPortCombo.SelectedItem == null)
{
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
return;
}
DiagResultTextBox.Text = "正在扫描波特率...\r\n";
var portName = DiagPortCombo.SelectedItem.ToString()!;
var result = await SerialPortDiagnostics.TestBaudRatesAsync(
portName, 4800, 9600, 19200, 38400, 57600, 115200);
if (result.IsSuccess && result.Value != null)
{
DiagResultTextBox.AppendText("\r\n波特率测试结果:\r\n");
foreach (var kvp in result.Value)
{
DiagResultTextBox.AppendText($" {kvp.Key,6} bps: {(kvp.Value ? "✓ 成功" : "✗ 失败")}\r\n");
}
}
else
{
DiagResultTextBox.AppendText($"错误: {result.ErrorMessage}");
}
}
private async void MonitorTraffic_Click(object sender, RoutedEventArgs e)
{
if (DiagPortCombo.SelectedItem == null)
{
HandyControl.Controls.MessageBox.Show("请选择串口", "提示");
return;
}
DiagResultTextBox.Text = "正在监控流量 (10秒)...\r\n";
var portName = DiagPortCombo.SelectedItem.ToString()!;
var options = new SerialPortOptions { PortName = portName };
var result = await SerialPortDiagnostics.MonitorTrafficAsync(
portName,
options,
(data, isSent) => Dispatcher.Invoke(() =>
{
var hexResult = SerialDataConverter.BytesToHexString(data, " ");
DiagResultTextBox.AppendText($"[{DateTime.Now:HH:mm:ss}] {(isSent ? "发送" : "接收")}: {hexResult.Value}\r\n");
}),
TimeSpan.FromSeconds(10));
if (result.IsSuccess && result.Value != null)
{
DiagResultTextBox.AppendText($"\r\n{result.Value}\r\n");
}
else
{
DiagResultTextBox.AppendText($"错误: {result.ErrorMessage}");
}
}
#endregion
#region 辅助方法
private Encoding GetSelectedEncoding()
{
var encoding = ((ComboBoxItem)EncodingCombo.SelectedItem)?.Content.ToString();
return encoding switch
{
"ASCII" => Encoding.ASCII,
"GBK" => Encoding.GetEncoding("GBK"),
_ => Encoding.UTF8
};
}
protected override void OnClosed(EventArgs e)
{
base.OnClosed(e);
// 清理资源
try
{
_driver?.Dispose();
}
catch
{
// 忽略清理异常
}
try
{
_service?.Dispose();
}
catch
{
// 忽略清理异常
}
}
#endregion
}
public class EventLogItem
{
public DateTime Time { get; set; }
public string EventType { get; set; } = string.Empty;
public string Data { get; set; } = string.Empty;
}
-138
View File
@@ -1,138 +0,0 @@
# 串口通信调试工具使用说明
## 功能概览
串口通信调试工具集成了Ports模块的所有功能,分为4个主要标签页:
## Tab 1: 串口发现
**功能:**
- 发现和查询所有可用串口
- 识别USB转串口设备
- 查看串口详细信息(描述、厂商、VID/PID)
- 检查串口是否被占用
- 测试串口连接
**操作步骤:**
1. 点击"刷新串口列表"获取所有串口
2. 点击"仅显示USB串口"过滤USB设备
3. 选择一个串口,点击"测试连接"进行连接测试
## Tab 2: 基础通信
**功能:**
- 配置串口参数(波特率、数据位、校验位、停止位)
- 打开/关闭串口连接
- 发送数据(十六进制或文本)
- 实时接收和显示数据
- 支持多种字符编码(UTF-8、ASCII、GBK)
**操作步骤:**
1. 选择串口和配置参数
2. 点击"打开串口"
3. 在发送框输入数据
- 十六进制格式:01 02 03 04
- 文本格式:直接输入文本
4. 点击"发送 (HEX)"或"发送 (文本)"
5. 接收区会实时显示收到的数据
**提示:**
- 勾选"自动添加换行符"会在文本末尾添加\r\n
- 勾选"HEX显示"会以十六进制格式显示接收数据
- 勾选"自动滚动"会自动滚动到最新数据
## Tab 3: 高级监听
**功能:**
- 使用服务层进行高级数据监听
- 自动重连机制
- 事件日志记录(数据接收、错误、状态变化)
- 实时监控连接状态
**操作步骤:**
1. 选择串口和波特率
2. 勾选"自动重连"启用断线重连
3. 点击"启动监听"
4. 事件日志会实时显示所有事件
5. 点击"停止监听"结束
**事件类型:**
- 数据接收:显示接收到的字节数和内容
- 错误:显示错误信息
- 状态变化:显示连接状态变化(连接中→已连接→断开等)
## Tab 4: 数据工具
### 数据转换
**支持的转换:**
- HEX ↔ 字节数组
- 字节 → Int16/Int32/Float
- Int32 → 字节
- 大端/小端序切换
**操作:**
1. 输入数据
2. 选择转换类型
3. 勾选"大端序"(如需要)
4. 点击"转换"
**示例:**
- 输入:`01 02 03 04`,选择"字节 → Int32",大端序 → 结果:16909060
### 校验计算
**支持的校验算法:**
- CRC16(Modbus标准)
- LRC(纵向冗余校验)
- XOR(异或校验)
- 累加和
**操作:**
1. 输入十六进制数据(如:01 02 03)
2. 点击对应的校验算法按钮
3. 查看校验结果
### 诊断工具
**功能:**
- 测试连接:测试指定串口的连接性
- 波特率扫描:自动测试多个波特率
- 流量监控:监控10秒内的数据收发
**操作:**
1. 选择要诊断的串口
2. 点击相应功能按钮
3. 在结果区查看诊断报告
## 使用技巧
1. **串口被占用?**
- 在"串口发现"页查看哪个串口被占用
- 确保其他程序没有打开该串口
2. **数据接收不完整?**
- 检查波特率设置是否正确
- 尝试使用"基础通信"的实时接收
3. **需要自动重连?**
- 使用"高级监听"标签页
- 勾选"自动重连"选项
4. **调试Modbus设备?**
- 使用"数据工具"计算CRC16
- 使用"基础通信"发送命令并查看响应
5. **不确定波特率?**
- 使用"诊断工具"的波特率扫描功能
## 常见问题
**Q: 打开串口失败?**
A: 检查串口是否被其他程序占用,或者没有权限访问该串口
**Q: 发送十六进制数据格式?**
A: 使用空格分隔,如:01 02 03 04,也支持不带空格:01020304
**Q: 如何保存接收的数据?**
A: 可以从接收框复制文本,后续版本会增加保存功能
**Q: 支持哪些操作系统?**
A: Windows(完整功能)、Linux、macOS(基础功能)
+13 -1
View File
@@ -1,6 +1,7 @@
using System;
using System.Threading.Tasks;
using Client.Services;
using Microsoft.Extensions.Logging;
using Sharp.Wpf.Commands;
using Sharp.Wpf.Mvvm;
@@ -10,6 +11,7 @@ public sealed class LoginViewModel : ViewModelBase
{
private readonly IAuthenticationService _authenticationService;
private readonly IUserSession _userSession;
private readonly ILogger<LoginViewModel> _logger;
private readonly IAsyncCommand _loginCommand;
private string _userName = "admin";
@@ -18,10 +20,11 @@ public sealed class LoginViewModel : ViewModelBase
public event EventHandler? LoginSucceeded;
public LoginViewModel(IAuthenticationService authenticationService, IUserSession userSession)
public LoginViewModel(IAuthenticationService authenticationService, IUserSession userSession, ILogger<LoginViewModel> logger)
{
_authenticationService = authenticationService;
_userSession = userSession;
_logger = logger;
_loginCommand = new AsyncCommand(LoginAsync);
}
@@ -67,6 +70,7 @@ public sealed class LoginViewModel : ViewModelBase
{
try
{
_logger.LogInformation("Login attempt for user: {UserName}", UserName);
IsBusy = true;
ErrorMessage = null;
@@ -74,13 +78,21 @@ public sealed class LoginViewModel : ViewModelBase
if (!result.Succeeded)
{
_logger.LogWarning("Login failed for user: {UserName}, Error: {Error}", UserName, result.Error);
ErrorMessage = result.Error;
return;
}
_logger.LogInformation("Login succeeded for user: {UserName}", UserName);
_userSession.SignIn(UserName, result.DisplayName);
_logger.LogInformation("Invoking LoginSucceeded event");
LoginSucceeded?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
_logger.LogError(ex, "Login failed for user: {UserName}", UserName);
ErrorMessage = "An unexpected error occurred.";
}
finally
{
IsBusy = false;
+2 -61
View File
@@ -5,14 +5,6 @@ using Client.Services;
using Sharp.Wpf.Commands;
using Sharp.Wpf.Mvvm;
using Sharp.Wpf.Services;
using Microsoft.Extensions.DependencyInjection;
using Common;
using Client.Hardware;
using Plc.Proxy;
using Plc.Abstractions;
using System.ComponentModel;
using Sharp.Wpf.Abstractions;
namespace Client.ViewModels;
@@ -20,70 +12,23 @@ public sealed class MainViewModel : ViewModelBase
{
private readonly IDialogService _dialogService;
private readonly IUserSession _userSession;
private readonly IDispatcher _dispatcher;
private string _greeting = "欢迎使用 Sharp";
private string _statusMessage = "系统就绪";
private DateTime _lastSyncTime = DateTime.Now;
// 魔法设备对象:直接绑定到界面,无需手动写 Read/Write/Subscribe
public IPackMachine Machine { get; }
public MainViewModel(IDialogService dialogService, IUserSession userSession, IDispatcher dispatcher)
public MainViewModel(IDialogService dialogService, IUserSession userSession)
{
_dialogService = dialogService;
_userSession = userSession;
_dispatcher = dispatcher;
_greeting = $"欢迎回来,{UserDisplayName}";
_statusMessage = "准备就绪";
ShowDialogCommand = new AsyncCommand(ShowDialogAsync);
RefreshDashboardCommand = new AsyncCommand(RefreshDashboardAsync);
ThrowCommand = new RelayCommand(() => throw new InvalidOperationException("演示:未处理异常将被全局处理"));
OpenPlcToolCommand = new RelayCommand(() =>
{
var sp = Global.Get<IServiceProvider>();
var win = sp.GetRequiredService<PlcWindow>();
win.Show();
});
OpenSerialPortToolCommand = new RelayCommand(() =>
{
var win = new SerialPortWindow();
win.Show();
});
// 1. 获取 PLC 服务
var plcService = Global.Get<IServiceProvider>().GetRequiredService<IPlcService>();
// 2. 创建代理
Machine = PlcInterfaceProxy.Create<IPackMachine>(plcService);
// 3. 代码监听演示:监听属性变化
Machine.PropertyChanged += OnMachinePropertyChanged;
// 4. 控制命令
StartMachineCommand = new RelayCommand(() => Machine.IsStartButtonPushed = true);
StopMachineCommand = new RelayCommand(() => Machine.IsStartButtonPushed = false);
}
private void OnMachinePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
// 演示:当速度变化时,在日志或状态栏显示
if (e.PropertyName == nameof(IPackMachine.RunningSpeed))
{
_dispatcher.InvokeAsync(() =>
{
if (Machine.RunningSpeed > 1000)
{
StatusMessage = $"警告:速度过高 ({Machine.RunningSpeed})";
}
});
}
}
//...
public string UserDisplayName => _userSession.DisplayName ?? _userSession.UserName ?? "访客";
public string Greeting
@@ -107,10 +52,6 @@ public sealed class MainViewModel : ViewModelBase
public IAsyncCommand ShowDialogCommand { get; }
public IAsyncCommand RefreshDashboardCommand { get; }
public ICommand ThrowCommand { get; }
public ICommand OpenPlcToolCommand { get; }
public ICommand OpenSerialPortToolCommand { get; }
public ICommand StartMachineCommand { get; }
public ICommand StopMachineCommand { get; }
private async Task ShowDialogAsync()
{
@@ -142,4 +83,4 @@ public sealed class MainViewModel : ViewModelBase
IsBusy = false;
}
}
}
}
@@ -1,32 +0,0 @@
using Plc.Models;
using Sharp.Wpf.Mvvm;
using System;
namespace Client.ViewModels;
public class PlcTagViewModel : ViewModelBase
{
private readonly PlcTag _tag;
public PlcTagViewModel(PlcTag tag)
{
_tag = tag ?? throw new ArgumentNullException(nameof(tag));
}
public string Id => _tag.Id;
public string Address => _tag.Address;
public Type DataType => _tag.DataType;
public string DataTypeName => _tag.DataType == typeof(float) ? "Float" : _tag.DataType.Name;
public object? Value => _tag.Value;
public PlcTag Tag => _tag;
public void UpdateValue()
{
OnPropertyChanged(nameof(Value));
}
}
-268
View File
@@ -1,268 +0,0 @@
using System.Collections.ObjectModel;
using System.Windows.Input;
using Sharp.Wpf.Mvvm;
using Sharp.Wpf.Commands;
using Plc.Abstractions;
using Plc.Models;
using Plc.Siemens.Options;
using S7.Net;
using System.Windows;
using Sharp.Wpf.Abstractions;
namespace Client.ViewModels;
public class PlcViewModel : ViewModelBase
{
private readonly IPlcService _plcService;
private readonly SiemensPlcOptions _siemensOptions;
private readonly IDispatcher _dispatcher;
// 连接配置
private PlcBrand _selectedBrand = PlcBrand.Siemens;
private CpuType _selectedCpuType = CpuType.S71200;
private string _ipAddress = "127.0.0.1";
private string _status = "未连接";
// 读写测试
private string _readAddress = "DB1.DBW0";
private string _readValue = "-";
private string _writeAddress = "DB1.DBW0";
private string _writeValue = "0";
// 监控
private string _monitorAddress = "DB1.DBW0";
private Type _selectedMonitorType = typeof(short);
public PlcViewModel(
IPlcService plcService,
SiemensPlcOptions siemensOptions,
IDispatcher dispatcher)
{
_plcService = plcService;
_siemensOptions = siemensOptions;
_dispatcher = dispatcher;
// 初始化界面值为当前配置值
_ipAddress = _siemensOptions.IpAddress;
_selectedCpuType = _siemensOptions.CpuType;
ConnectCommand = new AsyncCommand(ConnectAsync);
DisconnectCommand = new AsyncCommand(DisconnectAsync);
ReadCommand = new AsyncCommand(ReadAsync);
WriteCommand = new AsyncCommand(WriteAsync);
AddMonitorCommand = new RelayCommand(AddMonitor);
RemoveMonitorCommand = new RelayCommand<PlcTagViewModel>(RemoveMonitor, tag => tag != null);
}
#region Properties
public PlcBrand SelectedBrand
{
get => _selectedBrand;
set
{
if (SetProperty(ref _selectedBrand, value))
{
OnPropertyChanged(nameof(IsSiemensOptionsVisible));
}
}
}
public CpuType SelectedCpuType
{
get => _selectedCpuType;
set => SetProperty(ref _selectedCpuType, value);
}
public bool IsSiemensOptionsVisible => SelectedBrand == PlcBrand.Siemens;
public string IpAddress
{
get => _ipAddress;
set => SetProperty(ref _ipAddress, value);
}
public string Status
{
get => _status;
set => SetProperty(ref _status, value);
}
public string ReadAddress
{
get => _readAddress;
set => SetProperty(ref _readAddress, value);
}
public string ReadValue
{
get => _readValue;
set => SetProperty(ref _readValue, value);
}
public string WriteAddress
{
get => _writeAddress;
set => SetProperty(ref _writeAddress, value);
}
public string WriteValue
{
get => _writeValue;
set => SetProperty(ref _writeValue, value);
}
public string MonitorAddress
{
get => _monitorAddress;
set => SetProperty(ref _monitorAddress, value);
}
public Type SelectedMonitorType
{
get => _selectedMonitorType;
set => SetProperty(ref _selectedMonitorType, value);
}
// 数据源
public ObservableCollection<PlcBrand> Brands { get; } = new(Enum.GetValues<PlcBrand>());
public ObservableCollection<CpuType> CpuTypes { get; } = new(Enum.GetValues<CpuType>());
public ObservableCollection<DataTypeOption> SupportedMonitorTypes { get; } = new()
{
new("Bool", typeof(bool)),
new("Byte", typeof(byte)),
new("Short (Int16)", typeof(short)),
new("UShort (UInt16)", typeof(ushort)),
new("Int (Int32)", typeof(int)),
new("UInt (UInt32)", typeof(uint)),
new("Long (Int64)", typeof(long)),
new("ULong (UInt64)", typeof(ulong)),
new("Float (Real)", typeof(float)),
new("Double (LReal)", typeof(double)),
new("String", typeof(string))
};
public ObservableCollection<PlcTagViewModel> MonitoredTags { get; } = new();
#endregion
#region Commands
public IAsyncCommand ConnectCommand { get; }
public IAsyncCommand DisconnectCommand { get; }
public IAsyncCommand ReadCommand { get; }
public IAsyncCommand WriteCommand { get; }
public ICommand AddMonitorCommand { get; }
public ICommand RemoveMonitorCommand { get; }
#endregion
private async Task ConnectAsync()
{
if (SelectedBrand != PlcBrand.Siemens)
{
Status = $"暂不支持 {SelectedBrand},请等待后续更新";
return;
}
Status = "正在连接...";
try
{
// 1. 更新配置 (因为 SiemensDriver 注册为单例,且引用了 SiemensPlcOptions 单例)
// 修改这里的 Options 会直接影响 Driver 下一次 ConnectAsync 的行为
_siemensOptions.IpAddress = IpAddress;
_siemensOptions.CpuType = SelectedCpuType;
// 端口、机架号等暂时保持默认,也可以暴露在界面上
// 2. 启动服务 (内部会调用 Driver.ConnectAsync)
await _plcService.StartAsync();
Status = $"已连接 ({SelectedBrand} - {SelectedCpuType})";
}
catch (Exception ex)
{
Status = $"连接失败: {ex.Message}";
}
}
private async Task DisconnectAsync()
{
await _plcService.StopAsync();
Status = "已断开";
}
private async Task ReadAsync()
{
var result = await _plcService.ReadAsync<short>(ReadAddress);
if (result.IsSuccess)
{
ReadValue = result.Value.ToString();
}
else
{
ReadValue = $"Error: {result.ErrorMessage}";
}
}
private async Task WriteAsync()
{
if (short.TryParse(WriteValue, out var val))
{
var result = await _plcService.WriteAsync(WriteAddress, val);
if (result.IsFailure)
{
Status = $"写入失败: {result.ErrorMessage}";
}
else
{
Status = "写入成功";
}
}
else
{
Status = "输入值无效 (仅支持 short)";
}
}
private void AddMonitor()
{
var tag = new PlcTag
{
Address = MonitorAddress,
DataType = SelectedMonitorType
};
var vm = new PlcTagViewModel(tag);
tag.OnChanged = (t, v) =>
{
// 使用Dispatcher在UI线程更新值,避免跨线程访问异常
_dispatcher.InvokeAsync(() =>
{
vm.UpdateValue();
});
};
_plcService.Subscribe(tag);
// 在UI线程添加到集合
_dispatcher.InvokeAsync(() =>
{
MonitoredTags.Add(vm);
});
}
private void RemoveMonitor(PlcTagViewModel? tagVm)
{
if (tagVm == null) return;
// 从PLC服务取消订阅
_plcService.Unsubscribe(tagVm.Id);
// 从UI集合移除
_dispatcher.InvokeAsync(() =>
{
MonitoredTags.Remove(tagVm);
});
}
}
public record DataTypeOption(string Name, Type Type);
@@ -1,4 +1,4 @@
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "0048F04EA9C102506BBFAC3803E4711F700CF3ED"
#pragma checksum "..\..\..\App.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "A01D87CBC408FE1E9934F8938867D92F17E3A2CF"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
@@ -46,7 +46,7 @@ namespace Client {
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.0.0")]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
@@ -66,7 +66,7 @@ namespace Client {
/// </summary>
[System.STAThreadAttribute()]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "10.0.0.0")]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
public static void Main() {
Client.App app = new Client.App();
app.InitializeComponent();
@@ -1,9 +1,10 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// 此代码由工具生成。
// 运行时版本:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// 对此文件的更改可能会导致不正确的行为,并且如果
// 重新生成代码,这些更改将会丢失。
// </auto-generated>
//------------------------------------------------------------------------------
@@ -13,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Client")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+de1a29c522c231e011e0b795cb1494b1dd7282bf")]
[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 @@
b775c782e822e9da489917907ac5dc67ababf8e2b94747f6176fcf3d21ac9454
eeea53cf7db257ee716efdd2fdcdb5f1a810b49927811d410a05e1ff19899369
@@ -1,7 +1,5 @@
is_global = true
build_property.TargetFramework = net8.0-windows
build_property.TargetFrameworkIdentifier = .NETCoreApp
build_property.TargetFrameworkVersion = v8.0
build_property.TargetPlatformMinVersion = 7.0
build_property.UsingMicrosoftNETSdkWeb =
build_property.ProjectTypeGuids =
@@ -10,7 +8,7 @@ build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = Client
build_property.ProjectDir = D:\code\sharp-basic\src\Sharp\Client\
build_property.ProjectDir = D:\yuanll\sharp-basic\src\Sharp\Client\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.CsWinRTUseWindowsUIXamlProjections = false
@@ -0,0 +1,89 @@
#pragma checksum "..\..\..\MainWindow.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "9E813BA3492F961B4CE0E3DE4E78408D972934B6"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using HandyControl.Controls;
using HandyControl.Data;
using HandyControl.Expression.Media;
using HandyControl.Expression.Shapes;
using HandyControl.Interactivity;
using HandyControl.Media.Animation;
using HandyControl.Media.Effects;
using HandyControl.Properties.Langs;
using HandyControl.Themes;
using HandyControl.Tools;
using HandyControl.Tools.Converter;
using HandyControl.Tools.Extension;
using Sharp.Wpf.Converters;
using Sharp.Wpf.Windowing;
using System;
using System.Diagnostics;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Controls.Ribbon;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Forms.Integration;
using System.Windows.Ink;
using System.Windows.Input;
using System.Windows.Markup;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using System.Windows.Media.Media3D;
using System.Windows.Media.TextFormatting;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.Windows.Shell;
namespace Client {
/// <summary>
/// MainWindow
/// </summary>
public partial class MainWindow : HandyControl.Controls.GlowWindow, System.Windows.Markup.IComponentConnector {
private bool _contentLoaded;
/// <summary>
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
public void InitializeComponent() {
if (_contentLoaded) {
return;
}
_contentLoaded = true;
System.Uri resourceLocater = new System.Uri("/Client;component/mainwindow.xaml", System.UriKind.Relative);
#line 1 "..\..\..\MainWindow.xaml"
System.Windows.Application.LoadComponent(this, resourceLocater);
#line default
#line hidden
}
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute("PresentationBuildTasks", "9.0.4.0")]
[System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Design", "CA1033:InterfaceMethodsShouldBeCallableByChildTypes")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Maintainability", "CA1502:AvoidExcessiveComplexity")]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1800:DoNotCastUnnecessarily")]
void System.Windows.Markup.IComponentConnector.Connect(int connectionId, object target) {
this._contentLoaded = true;
}
}
}
+503
View File
@@ -0,0 +1,503 @@
using System.Data;
using System.Data.OleDb;
using System.Linq;
using System.Reflection;
using Microsoft.Extensions.Logging;
using Sharp.Data.Access.Connections;
using Sharp.Data.Access.Initialization;
using Sharp.Data.Access.Internal;
using Sharp.Data.Access.Options;
using Sharp.Data.Access.Security;
namespace Sharp.Data.Access;
/// <summary>
/// Access 数据库事务实现
/// </summary>
internal class AccessTransaction : IAccessTransaction
{
private readonly OleDbConnection _connection;
private readonly OleDbTransaction _transaction;
private readonly ILogger? _logger;
private readonly bool _ownsConnection;
private bool _disposed;
public AccessTransaction(OleDbConnection connection, bool ownsConnection, ILogger? logger)
{
_connection = connection;
_ownsConnection = ownsConnection;
_transaction = connection.BeginTransaction();
_logger = logger;
}
public int Execute(string sql, params OleDbParameter[] parameters)
{
using var command = new OleDbCommand(sql, _connection, _transaction);
if (parameters.Length > 0)
command.Parameters.AddRange(parameters);
return command.ExecuteNonQuery();
}
public List<T> Query<T>(string sql, params OleDbParameter[] parameters) where T : new()
{
using var command = new OleDbCommand(sql, _connection, _transaction);
if (parameters.Length > 0)
command.Parameters.AddRange(parameters);
var results = new List<T>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var item = MapReaderToObject<T>(reader);
results.Add(item);
}
return results;
}
public object? ExecuteScalar(string sql, params OleDbParameter[] parameters)
{
using var command = new OleDbCommand(sql, _connection, _transaction);
if (parameters.Length > 0)
command.Parameters.AddRange(parameters);
return command.ExecuteScalar();
}
public void Commit()
{
_transaction.Commit();
_logger?.LogDebug("事务已提交");
}
public void Rollback()
{
_transaction.Rollback();
_logger?.LogDebug("事务已回滚");
}
public void Dispose()
{
if (_disposed) return;
try
{
_transaction.Dispose();
}
finally
{
if (_ownsConnection)
{
_connection.Dispose();
}
}
_disposed = true;
}
private static T MapReaderToObject<T>(IDataReader reader) where T : new()
{
var obj = new T();
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
// 创建字段名到索引的映射(大小写不敏感)
var fieldMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < reader.FieldCount; i++)
{
var fieldName = reader.GetName(i);
fieldMap[fieldName] = i;
// Access 列名有时包含末尾空格,提前做一次修剪以便映射
var trimmed = fieldName.Trim();
if (!fieldMap.ContainsKey(trimmed))
{
fieldMap[trimmed] = i;
}
}
foreach (var property in properties)
{
if (!property.CanWrite) continue;
try
{
// 尝试查找匹配的字段(大小写不敏感)
if (fieldMap.TryGetValue(property.Name, out int ordinal))
{
var value = reader.GetValue(ordinal);
if (value != DBNull.Value && value != null)
{
// 尝试类型转换
if (property.PropertyType == value.GetType())
{
property.SetValue(obj, value);
}
else if (property.PropertyType == typeof(string))
{
property.SetValue(obj, value.ToString());
}
else
{
// 尝试使用 Convert.ChangeType
var convertedValue = Convert.ChangeType(value, property.PropertyType);
property.SetValue(obj, convertedValue);
}
}
}
}
catch
{
// 字段不存在或类型转换失败,保持默认值
}
}
return obj;
}
}
/// <summary>
/// Access 数据服务实现(基于 System.Data.OleDb)
/// </summary>
public class AccessDataService : IAccessDataService
{
private readonly AccessDatabaseOptions _options;
private readonly IAccessConnectionStringFactory _connectionFactory;
private readonly IAccessDatabaseInitializer _initializer;
private readonly IAccessPasswordDecryptor _decryptor;
private readonly ILogger<AccessDataService>? _logger;
private readonly string _connectionString;
public AccessDataService(
AccessDatabaseOptions options,
IAccessConnectionStringFactory? connectionFactory = null,
IAccessDatabaseInitializer? initializer = null,
IAccessPasswordDecryptor? decryptor = null,
ILogger<AccessDataService>? logger = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_options.Validate();
_connectionFactory = connectionFactory ?? new AccessConnectionStringFactory(decryptor ?? new PassthroughAccessPasswordDecryptor());
_initializer = initializer ?? new AccessDatabaseInitializer();
_decryptor = decryptor ?? new PassthroughAccessPasswordDecryptor();
_logger = logger;
try
{
// 确保数据库存在(如果配置了模板复制)
_initializer.EnsureDatabase(_options, NullServiceProvider.Instance);
// 构建连接字符串
_connectionString = _connectionFactory.Create(_options, NullServiceProvider.Instance);
_logger?.LogInformation("AccessDataService 已初始化,数据库: {DatabaseFile}, Provider: {Provider}",
_options.DatabaseFile, _options.ProviderName);
_logger?.LogDebug("实际连接字符串: {ConnectionString}", _connectionString);
}
catch (Exception ex)
{
_logger?.LogError(ex, "AccessDataService 初始化失败");
// 提供详细的错误诊断信息
if (ex.Message.Contains("找不到") || ex is FileNotFoundException)
{
throw new InvalidOperationException(
$"找不到 Access 数据库文件: {_options.DatabaseFile}。" +
$"请确保文件存在或配置 TemplateFile 以自动创建。", ex);
}
throw;
}
}
public List<T> Query<T>(string sql, params OleDbParameter[] parameters) where T : new()
{
_logger?.LogDebug("执行查询: {Sql}", sql);
return ExecuteWithRetry(() =>
{
using var connection = CreateConnection();
connection.Open();
using var command = new OleDbCommand(sql, connection);
if (parameters.Length > 0)
{
// 克隆参数以避免重试时的"已包含在另一个集合中"错误
var clonedParams = parameters.Select(p => CloneParameter(p)).ToArray();
command.Parameters.AddRange(clonedParams);
}
var results = new List<T>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var item = MapReaderToObject<T>(reader);
results.Add(item);
}
_logger?.LogDebug("查询返回 {Count} 条记录", results.Count);
return results;
});
}
public T? QueryFirstOrDefault<T>(string sql, params OleDbParameter[] parameters) where T : new()
{
_logger?.LogDebug("执行单条查询: {Sql}", sql);
return ExecuteWithRetry(() =>
{
using var connection = CreateConnection();
connection.Open();
using var command = new OleDbCommand(sql, connection);
if (parameters.Length > 0)
{
// 克隆参数以避免重试时的"已包含在另一个集合中"错误
var clonedParams = parameters.Select(p => CloneParameter(p)).ToArray();
command.Parameters.AddRange(clonedParams);
}
using var reader = command.ExecuteReader();
if (reader.Read())
{
return MapReaderToObject<T>(reader);
}
return default;
});
}
public object? ExecuteScalar(string sql, params OleDbParameter[] parameters)
{
_logger?.LogDebug("执行标量查询: {Sql}", sql);
return ExecuteWithRetry(() =>
{
using var connection = CreateConnection();
connection.Open();
using var command = new OleDbCommand(sql, connection);
if (parameters.Length > 0)
{
// 克隆参数以避免重试时的"已包含在另一个集合中"错误
var clonedParams = parameters.Select(p => CloneParameter(p)).ToArray();
command.Parameters.AddRange(clonedParams);
}
var result = command.ExecuteScalar();
_logger?.LogDebug("标量查询返回: {Result}", result);
return result;
});
}
public int Execute(string sql, params OleDbParameter[] parameters)
{
_logger?.LogDebug("执行命令: {Sql}", sql);
return ExecuteWithRetry(() =>
{
using var connection = CreateConnection();
connection.Open();
using var command = new OleDbCommand(sql, connection);
if (parameters.Length > 0)
{
// 克隆参数以避免重试时的"已包含在另一个集合中"错误
var clonedParams = parameters.Select(p => CloneParameter(p)).ToArray();
command.Parameters.AddRange(clonedParams);
}
var rowsAffected = command.ExecuteNonQuery();
_logger?.LogDebug("命令影响了 {RowsAffected} 行", rowsAffected);
return rowsAffected;
});
}
public IAccessTransaction BeginTransaction()
{
_logger?.LogDebug("开始事务");
var connection = CreateConnection();
connection.Open();
return new AccessTransaction(connection, ownsConnection: true, _logger);
}
public void ExecuteInTransaction(Action<IAccessTransaction> action)
{
_logger?.LogDebug("在事务中执行操作");
using var transaction = BeginTransaction();
try
{
action(transaction);
transaction.Commit();
_logger?.LogDebug("事务执行成功");
}
catch (Exception ex)
{
_logger?.LogError(ex, "事务执行失败,正在回滚");
transaction.Rollback();
throw;
}
}
public bool TestConnection()
{
try
{
_logger?.LogDebug("测试数据库连接");
using var connection = CreateConnection();
connection.Open();
_logger?.LogDebug("数据库连接成功");
return true;
}
catch (Exception ex)
{
_logger?.LogError(ex, "数据库连接失败");
return false;
}
}
private OleDbConnection CreateConnection()
{
return new OleDbConnection(_connectionString);
}
private T ExecuteWithRetry<T>(Func<T> operation)
{
var maxRetries = _options.MaxRetryCount;
var retryDelay = _options.RetryDelayMilliseconds;
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
return operation();
}
catch (OleDbException ex) when (attempt < maxRetries && IsRetriableError(ex))
{
_logger?.LogWarning(ex, "数据库操作失败,正在重试 ({Attempt}/{MaxRetries})...", attempt + 1, maxRetries);
Thread.Sleep(retryDelay);
}
catch (OleDbException ex)
{
// 提供详细的错误诊断信息
var errorMessage = $"Access 数据库操作失败: {ex.Message}";
// 检查是否是 Provider 相关的错误
if (ex.Message.Contains("找不到") || ex.Message.Contains("未注册") ||
ex.Message.Contains("OLE DB") || ex.Message.Contains("多步"))
{
errorMessage += $"\n\n可能的原因:" +
$"\n1. 未安装 Access Database Engine:当前使用 Provider = {_options.ProviderName}" +
$"\n - 如果使用 16.0,需要安装 Access Database Engine 2016 或 Office 2016+" +
$"\n - 如果使用 12.0,需要安装 Access Database Engine 2010 或 Office 2010+" +
$"\n - 建议:优先使用 12.0 版本,兼容性最好" +
$"\n2. 32位/64位不匹配:进程架构必须与 Access Driver 架构一致" +
$"\n3. 数据库文件损坏或被其他程序锁定" +
$"\n4. 数据库文件路径不正确:{_options.DatabaseFile}";
_logger?.LogError(ex, "数据库连接失败,Provider: {Provider}", _options.ProviderName);
}
throw new InvalidOperationException(errorMessage, ex);
}
}
// 不会到这里,因为最后一次尝试会抛出异常
throw new InvalidOperationException("重试逻辑错误");
}
private static bool IsRetriableError(OleDbException ex)
{
// Access 数据库常见的可重试错误
// -2147467259: 数据库被锁定
// -2147217865: 无法打开数据库(可能正在被其他进程使用)
return ex.ErrorCode == -2147467259 || ex.ErrorCode == -2147217865;
}
/// <summary>
/// 克隆 OleDbParameter 以避免重试时参数被重复添加的问题
/// </summary>
private static OleDbParameter CloneParameter(OleDbParameter original)
{
var clone = new OleDbParameter
{
ParameterName = original.ParameterName,
Value = original.Value,
OleDbType = original.OleDbType,
Size = original.Size,
Direction = original.Direction,
IsNullable = original.IsNullable,
Precision = original.Precision,
Scale = original.Scale,
SourceColumn = original.SourceColumn,
SourceVersion = original.SourceVersion
};
return clone;
}
private static T MapReaderToObject<T>(IDataReader reader) where T : new()
{
var obj = new T();
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
// 创建字段名到索引的映射(大小写不敏感)
var fieldMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < reader.FieldCount; i++)
{
var fieldName = reader.GetName(i);
fieldMap[fieldName] = i;
// Access 列名有时包含末尾空格,提前做一次修剪以便映射
var trimmed = fieldName.Trim();
if (!fieldMap.ContainsKey(trimmed))
{
fieldMap[trimmed] = i;
}
}
foreach (var property in properties)
{
if (!property.CanWrite) continue;
try
{
// 尝试查找匹配的字段(大小写不敏感)
if (fieldMap.TryGetValue(property.Name, out int ordinal))
{
var value = reader.GetValue(ordinal);
if (value != DBNull.Value && value != null)
{
// 尝试类型转换
if (property.PropertyType == value.GetType())
{
property.SetValue(obj, value);
}
else if (property.PropertyType == typeof(string))
{
property.SetValue(obj, value.ToString());
}
else
{
// 尝试使用 Convert.ChangeType
var convertedValue = Convert.ChangeType(value, property.PropertyType);
property.SetValue(obj, convertedValue);
}
}
}
}
catch
{
// 字段不存在或类型转换失败,保持默认值
}
}
return obj;
}
}
@@ -0,0 +1,118 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Sharp.Data.Access.Options;
using Sharp.Data.Access.Security;
using Sharp.Data.Access.Connections;
using Sharp.Data.Access.Initialization;
namespace Sharp.Data.Access;
/// <summary>
/// Access 数据服务的依赖注入扩展
/// </summary>
public static class AccessDataServiceExtensions
{
/// <summary>
/// 添加 Access 数据服务(最简单方式 - 仅指定数据库路径)
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="databasePath">数据库文件路径(绝对或相对路径)</param>
/// <param name="password">数据库密码(可选)</param>
/// <param name="provider">OLE DB 提供程序,默认:Microsoft.ACE.OLEDB.16.0</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddAccessDataService(
this IServiceCollection services,
string databasePath,
string? password = null,
string? provider = null)
{
return services.AddAccessDataService(options =>
{
options.DatabaseFile = databasePath;
if (!string.IsNullOrEmpty(password))
options.Password = password;
if (!string.IsNullOrEmpty(provider))
options.ProviderName = provider;
});
}
/// <summary>
/// 添加 Access 数据服务(使用配置委托)
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="configureOptions">配置委托</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddAccessDataService(
this IServiceCollection services,
Action<AccessDatabaseOptions> configureOptions)
{
if (services == null) throw new ArgumentNullException(nameof(services));
if (configureOptions == null) throw new ArgumentNullException(nameof(configureOptions));
var options = new AccessDatabaseOptions();
configureOptions(options);
options.Validate();
// 注册核心服务
services.TryAddSingleton<IAccessPasswordDecryptor, PassthroughAccessPasswordDecryptor>();
services.TryAddSingleton<IAccessConnectionStringFactory>(sp =>
new AccessConnectionStringFactory(sp.GetRequiredService<IAccessPasswordDecryptor>()));
services.TryAddSingleton<IAccessDatabaseInitializer, AccessDatabaseInitializer>();
// 注册配置
services.AddSingleton(options);
// 注册 AccessDataService
services.AddSingleton<IAccessDataService>(sp =>
new AccessDataService(
options,
sp.GetRequiredService<IAccessConnectionStringFactory>(),
sp.GetRequiredService<IAccessDatabaseInitializer>(),
sp.GetRequiredService<IAccessPasswordDecryptor>(),
sp.GetService<Microsoft.Extensions.Logging.ILogger<AccessDataService>>()));
return services;
}
/// <summary>
/// 添加 Access 数据服务(使用配置对象)
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="options">配置选项</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddAccessDataService(
this IServiceCollection services,
AccessDatabaseOptions options)
{
if (options == null) throw new ArgumentNullException(nameof(options));
return services.AddAccessDataService(_ => { });
}
/// <summary>
/// 注册自定义密码解密器
/// </summary>
/// <typeparam name="TDecryptor">解密器类型</typeparam>
/// <param name="services">服务集合</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddAccessPasswordDecryptor<TDecryptor>(this IServiceCollection services)
where TDecryptor : class, IAccessPasswordDecryptor
{
services.AddSingleton<IAccessPasswordDecryptor, TDecryptor>();
return services;
}
/// <summary>
/// 注册自定义密码解密器(使用工厂方法)
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="implementationFactory">解密器工厂</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddAccessPasswordDecryptor(
this IServiceCollection services,
Func<IServiceProvider, IAccessPasswordDecryptor> implementationFactory)
{
services.AddSingleton(implementationFactory);
return services;
}
}
@@ -0,0 +1,58 @@
using System;
using System.Data.OleDb;
using Sharp.Data.Access.Internal;
using Sharp.Data.Access.Options;
using Sharp.Data.Access.Security;
namespace Sharp.Data.Access.Connections;
internal sealed class AccessConnectionStringFactory : IAccessConnectionStringFactory
{
private readonly IAccessPasswordDecryptor _decryptor;
public AccessConnectionStringFactory(IAccessPasswordDecryptor decryptor)
{
_decryptor = decryptor ?? throw new ArgumentNullException(nameof(decryptor));
}
public string Create(AccessDatabaseOptions options, IServiceProvider services)
{
var configuration = AccessDatabaseConfiguration.FromOptions(options, services);
if (!string.IsNullOrWhiteSpace(configuration.ConnectionString))
{
return configuration.ConnectionString;
}
if (string.IsNullOrWhiteSpace(configuration.DatabaseFile))
{
throw new InvalidOperationException("Access 数据库需要提供 DatabaseFile 或 ConnectionString。");
}
// 基础连接字符串
// 注意:Connect Timeout 参数在某些 ACE 版本中不稳定,已移除
var builder = new OleDbConnectionStringBuilder
{
["Provider"] = configuration.ProviderName,
["Data Source"] = AccessPathResolver.Resolve(configuration.DatabaseFile, configuration.DataDirectory)
};
var password = configuration.Password;
if (string.IsNullOrEmpty(password) && !string.IsNullOrWhiteSpace(configuration.EncryptedPassword))
{
password = _decryptor.Decrypt(configuration.EncryptedPassword);
}
if (!string.IsNullOrEmpty(password))
{
builder["Jet OLEDB:Database Password"] = password;
}
foreach (var parameter in configuration.AdditionalParameters)
{
builder[parameter.Key] = parameter.Value;
}
return builder.ConnectionString;
}
}
@@ -0,0 +1,12 @@
using System;
using Sharp.Data.Access.Options;
namespace Sharp.Data.Access.Connections;
/// <summary>
/// 构建 Access 连接字符串的工厂。
/// </summary>
public interface IAccessConnectionStringFactory
{
string Create(AccessDatabaseOptions options, IServiceProvider services);
}
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Sharp.Data.Access</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EntityFrameworkCore.Jet" Version="8.0.0-preview1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="System.Data.OleDb" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Data\Data.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Sharp.Data.Access.Connections;
using Sharp.Data.Access.Initialization;
using Sharp.Data.Access.Internal;
using Sharp.Data.Access.Options;
using Sharp.Data.Access.Security;
using Sharp.Data.Context;
namespace Sharp.Data.Access.DependencyInjection;
/// <summary>
/// Access 模块的依赖注入扩展。
/// </summary>
public static class AccessServiceCollectionExtensions
{
public static IServiceCollection AddAccessDataContext<TContext>(this IServiceCollection services,
Action<AccessContextOptions<TContext>> configure,
bool registerDataContext = true,
ServiceLifetime dataContextLifetime = ServiceLifetime.Scoped)
where TContext : DbContext
{
if (configure == null) throw new ArgumentNullException(nameof(configure));
return services.AddAccessDataContext(_ =>
{
var options = new AccessContextOptions<TContext>();
configure(options);
return options;
}, registerDataContext, dataContextLifetime);
}
public static IServiceCollection AddAccessDataContext<TContext>(this IServiceCollection services,
Func<IServiceProvider, AccessContextOptions<TContext>> configure,
bool registerDataContext = true,
ServiceLifetime dataContextLifetime = ServiceLifetime.Scoped)
where TContext : DbContext
{
if (services == null) throw new ArgumentNullException(nameof(services));
if (configure == null) throw new ArgumentNullException(nameof(configure));
services.TryAddSingleton<IAccessPasswordDecryptor, PassthroughAccessPasswordDecryptor>();
services.TryAddSingleton<IAccessConnectionStringFactory>(sp =>
new AccessConnectionStringFactory(sp.GetRequiredService<IAccessPasswordDecryptor>()));
services.TryAddSingleton<IAccessDatabaseInitializer, AccessDatabaseInitializer>();
services.TryAddSingleton(provider =>
{
var options = configure(provider) ?? throw new InvalidOperationException("AccessContextOptions 不能为空。");
options.Validate();
return options;
});
services.AddScoped<IAccessDataContextFactory<TContext>>(sp =>
new AccessDataContextFactory<TContext>(
sp,
sp.GetRequiredService<AccessContextOptions<TContext>>(),
sp.GetRequiredService<IAccessConnectionStringFactory>(),
sp.GetRequiredService<IAccessDatabaseInitializer>()));
if (registerDataContext)
{
services.Add(new ServiceDescriptor(typeof(IDataContext),
sp => sp.GetRequiredService<IAccessDataContextFactory<TContext>>().Create(),
dataContextLifetime));
}
return services;
}
}
@@ -0,0 +1,66 @@
using System;
using EntityFrameworkCore.Jet;
using EntityFrameworkCore.Jet.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Sharp.Data.Access.Connections;
using Sharp.Data.Access.Initialization;
using Sharp.Data.Access.Internal;
using Sharp.Data.Access.Options;
using Sharp.Data.Access.Security;
using Sharp.Data;
namespace Sharp.Data.Access.Extensions;
/// <summary>
/// 手动使用 DataContextBuilder 时的 Access Helper。
/// </summary>
public static class AccessDataContextBuilderExtensions
{
/// <summary>
/// 通过现成的连接字符串配置 Access。
/// </summary>
public static DataContextBuilder<TContext> UseAccessDatabase<TContext>(
this DataContextBuilder<TContext> builder,
string connectionString,
Action<JetDbContextOptionsBuilder>? configure = null)
where TContext : DbContext
{
if (builder == null) throw new ArgumentNullException(nameof(builder));
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ArgumentException("连接字符串不能为空。", nameof(connectionString));
}
builder.ConfigureDbContext(options => options.UseJet(connectionString, configure));
return builder;
}
/// <summary>
/// 通过 AccessDatabaseOptions 配置 Access。
/// </summary>
public static DataContextBuilder<TContext> UseAccessDatabase<TContext>(
this DataContextBuilder<TContext> builder,
Action<AccessDatabaseOptions> configureOptions,
IServiceProvider? services = null,
IAccessPasswordDecryptor? decryptor = null,
Action<JetDbContextOptionsBuilder>? configure = null)
where TContext : DbContext
{
if (builder == null) throw new ArgumentNullException(nameof(builder));
if (configureOptions == null) throw new ArgumentNullException(nameof(configureOptions));
var options = new AccessDatabaseOptions();
configureOptions(options);
options.Validate();
var serviceProvider = services ?? NullServiceProvider.Instance;
var initializer = new AccessDatabaseInitializer();
var connectionFactory = new AccessConnectionStringFactory(decryptor ?? new PassthroughAccessPasswordDecryptor());
initializer.EnsureDatabase(options, serviceProvider);
var connectionString = connectionFactory.Create(options, serviceProvider);
builder.ConfigureDbContext(o => o.UseJet(connectionString, configure));
return builder;
}
}
@@ -0,0 +1,12 @@
using Microsoft.EntityFrameworkCore;
using Sharp.Data.Context;
namespace Sharp.Data.Access;
/// <summary>
/// Access 数据上下文工厂,避免直接依赖具体实现。
/// </summary>
public interface IAccessDataContextFactory<TContext> where TContext : DbContext
{
IDataContext Create();
}
@@ -0,0 +1,75 @@
using System.Data.OleDb;
namespace Sharp.Data.Access;
/// <summary>
/// Access 数据库事务接口
/// </summary>
public interface IAccessTransaction : IDisposable
{
/// <summary>
/// 执行非查询命令(INSERT, UPDATE, DELETE)
/// </summary>
int Execute(string sql, params OleDbParameter[] parameters);
/// <summary>
/// 查询多条记录
/// </summary>
List<T> Query<T>(string sql, params OleDbParameter[] parameters) where T : new();
/// <summary>
/// 执行标量查询(COUNT, SUM等)
/// </summary>
object? ExecuteScalar(string sql, params OleDbParameter[] parameters);
/// <summary>
/// 提交事务
/// </summary>
void Commit();
/// <summary>
/// 回滚事务
/// </summary>
void Rollback();
}
/// <summary>
/// Access 数据服务接口(基于 System.Data.OleDb)
/// </summary>
public interface IAccessDataService
{
/// <summary>
/// 查询多条记录
/// </summary>
List<T> Query<T>(string sql, params OleDbParameter[] parameters) where T : new();
/// <summary>
/// 查询单条记录
/// </summary>
T? QueryFirstOrDefault<T>(string sql, params OleDbParameter[] parameters) where T : new();
/// <summary>
/// 执行标量查询(COUNT, SUM等)
/// </summary>
object? ExecuteScalar(string sql, params OleDbParameter[] parameters);
/// <summary>
/// 执行非查询命令(INSERT, UPDATE, DELETE)
/// </summary>
int Execute(string sql, params OleDbParameter[] parameters);
/// <summary>
/// 开始事务
/// </summary>
IAccessTransaction BeginTransaction();
/// <summary>
/// 在事务中执行操作(自动提交/回滚)
/// </summary>
void ExecuteInTransaction(Action<IAccessTransaction> action);
/// <summary>
/// 测试数据库连接
/// </summary>
bool TestConnection();
}
@@ -0,0 +1,49 @@
using System;
using System.IO;
using Sharp.Data.Access.Internal;
using Sharp.Data.Access.Options;
namespace Sharp.Data.Access.Initialization;
internal sealed class AccessDatabaseInitializer : IAccessDatabaseInitializer
{
public void EnsureDatabase(AccessDatabaseOptions options, IServiceProvider services)
{
var configuration = AccessDatabaseConfiguration.FromOptions(options, services);
if (!string.IsNullOrWhiteSpace(configuration.ConnectionString) ||
string.IsNullOrWhiteSpace(configuration.DatabaseFile))
{
return;
}
var databasePath = AccessPathResolver.Resolve(configuration.DatabaseFile, configuration.DataDirectory);
var directory = Path.GetDirectoryName(databasePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
if (File.Exists(databasePath))
{
return;
}
if (configuration.CopyTemplateWhenMissing && !string.IsNullOrWhiteSpace(configuration.TemplateFile))
{
var templatePath = AccessPathResolver.Resolve(configuration.TemplateFile, configuration.DataDirectory);
if (!File.Exists(templatePath))
{
throw new FileNotFoundException($"Access 模板文件 \"{templatePath}\" 不存在。", templatePath);
}
File.Copy(templatePath, databasePath);
return;
}
if (configuration.ThrowIfMissing)
{
throw new FileNotFoundException($"Access 数据库 \"{databasePath}\" 不存在。", databasePath);
}
}
}
@@ -0,0 +1,12 @@
using System;
using Sharp.Data.Access.Options;
namespace Sharp.Data.Access.Initialization;
/// <summary>
/// Access 数据库初始化器(确保目录/数据库文件准备就绪)。
/// </summary>
public interface IAccessDatabaseInitializer
{
void EnsureDatabase(AccessDatabaseOptions options, IServiceProvider services);
}
@@ -0,0 +1,48 @@
using System;
using EntityFrameworkCore.Jet;
using Microsoft.EntityFrameworkCore;
using Sharp.Data;
using Sharp.Data.Access.Connections;
using Sharp.Data.Access.Initialization;
using Sharp.Data.Access.Options;
using Sharp.Data.Context;
namespace Sharp.Data.Access.Internal;
internal sealed class AccessDataContextFactory<TContext> : IAccessDataContextFactory<TContext>
where TContext : DbContext
{
private readonly IServiceProvider _services;
private readonly AccessContextOptions<TContext> _options;
private readonly IAccessConnectionStringFactory _connectionFactory;
private readonly IAccessDatabaseInitializer _initializer;
public AccessDataContextFactory(
IServiceProvider services,
AccessContextOptions<TContext> options,
IAccessConnectionStringFactory connectionFactory,
IAccessDatabaseInitializer initializer)
{
_services = services;
_options = options;
_connectionFactory = connectionFactory;
_initializer = initializer;
}
public IDataContext Create()
{
var builder = new DataContextBuilder<TContext>();
_initializer.EnsureDatabase(_options.Database, _services);
var connectionString = _connectionFactory.Create(_options.Database, _services);
builder.ConfigureDbContext(options =>
{
options.UseJet(connectionString, jet => _options.ConfigureJet?.Invoke(jet));
});
_options.ConfigureDataContext?.Invoke(_services, builder);
return builder.Build();
}
}
@@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
using Sharp.Data.Access.Options;
namespace Sharp.Data.Access.Internal;
internal sealed record AccessDatabaseConfiguration(
string? ConnectionString,
string? DatabaseFile,
string ProviderName,
string? Password,
string? EncryptedPassword,
string? TemplateFile,
string? DataDirectory,
int ConnectionTimeout,
int MaxRetryCount,
int RetryDelayMilliseconds,
IReadOnlyDictionary<string, string> AdditionalParameters,
bool CopyTemplateWhenMissing,
bool ThrowIfMissing)
{
public static AccessDatabaseConfiguration FromOptions(AccessDatabaseOptions options, IServiceProvider services)
{
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}
var connectionString = Resolve(options.ConnectionString, options.ConnectionStringFactory, services);
var databaseFile = Resolve(options.DatabaseFile, options.DatabaseFileFactory, services);
var templateFile = Resolve(options.TemplateFile, options.TemplateFileFactory, services);
var dataDirectory = Resolve(options.DataDirectory, options.DataDirectoryFactory, services);
var password = Resolve(options.Password, options.PasswordFactory, services);
var encryptedPassword = Resolve(options.EncryptedPassword, options.EncryptedPasswordFactory, services);
return new AccessDatabaseConfiguration(
connectionString,
databaseFile,
string.IsNullOrWhiteSpace(options.ProviderName) ? AccessProviderNames.AceOleDb16 : options.ProviderName,
password,
encryptedPassword,
templateFile,
dataDirectory,
options.ConnectionTimeout,
options.MaxRetryCount,
options.RetryDelayMilliseconds,
new Dictionary<string, string>(options.AdditionalParameters, StringComparer.OrdinalIgnoreCase),
options.CopyTemplateWhenMissing,
options.ThrowIfMissing);
}
private static string? Resolve(string? value, Func<IServiceProvider, string?>? factory, IServiceProvider services)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
return factory?.Invoke(services);
}
}
@@ -0,0 +1,26 @@
using System;
using System.IO;
namespace Sharp.Data.Access.Internal;
internal static class AccessPathResolver
{
public static string Resolve(string path, string? baseDirectory)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new InvalidOperationException("Access 数据库路径不能为空。");
}
if (Path.IsPathFullyQualified(path))
{
return Path.GetFullPath(path);
}
var root = string.IsNullOrWhiteSpace(baseDirectory)
? AppContext.BaseDirectory
: baseDirectory!;
return Path.GetFullPath(Path.Combine(root, path));
}
}
@@ -0,0 +1,14 @@
using System;
namespace Sharp.Data.Access.Internal;
internal sealed class NullServiceProvider : IServiceProvider
{
public static readonly NullServiceProvider Instance = new();
private NullServiceProvider()
{
}
public object? GetService(Type serviceType) => null;
}
@@ -0,0 +1,30 @@
using System;
using EntityFrameworkCore.Jet.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Sharp.Data;
namespace Sharp.Data.Access.Options;
/// <summary>
/// Access 数据上下文注册配置。
/// </summary>
/// <typeparam name="TContext">EF Core DbContext 类型。</typeparam>
public sealed class AccessContextOptions<TContext> where TContext : DbContext
{
/// <summary>
/// Access 数据库配置(连接方式、密码等)。
/// </summary>
public AccessDatabaseOptions Database { get; } = new();
/// <summary>
/// 允许调用方在 Build 之前继续配置 DataContextBuilder(例如启用审计、软删除、当前用户等)。
/// </summary>
public Action<IServiceProvider, DataContextBuilder<TContext>>? ConfigureDataContext { get; set; }
/// <summary>
/// 细化 Jet Provider 选项。
/// </summary>
public Action<JetDbContextOptionsBuilder>? ConfigureJet { get; set; }
internal void Validate() => Database.Validate();
}
@@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
namespace Sharp.Data.Access.Options;
/// <summary>
/// Access 数据库统一配置项,同时支持 OleDb 和 EF Core 两种方式。
/// </summary>
public class AccessDatabaseOptions
{
/// <summary>
/// 完整的连接字符串,可选。如果提供则优先使用,不再重新构建。
/// </summary>
public string? ConnectionString { get; set; }
/// <summary>
/// 通过依赖注入动态生成连接字符串。
/// </summary>
public Func<IServiceProvider, string?>? ConnectionStringFactory { get; set; }
/// <summary>
/// Access 数据库文件路径(相对或绝对)。
/// </summary>
public string? DatabaseFile { get; set; }
/// <summary>
/// 通过依赖注入动态解析数据库文件路径。
/// </summary>
public Func<IServiceProvider, string?>? DatabaseFileFactory { get; set; }
/// <summary>
/// 数据文件的基础目录(如果数据库路径为相对路径则会拼接)。
/// 默认使用 <see cref="AppContext.BaseDirectory" />。
/// </summary>
public string? DataDirectory { get; set; }
/// <summary>
/// 通过依赖注入动态解析数据目录。
/// </summary>
public Func<IServiceProvider, string?>? DataDirectoryFactory { get; set; }
/// <summary>
/// 明文密码,可选。用于访问加密的 Access 数据库。
/// </summary>
public string? Password { get; set; }
/// <summary>
/// 通过依赖注入动态解析明文密码。
/// </summary>
public Func<IServiceProvider, string?>? PasswordFactory { get; set; }
/// <summary>
/// 加密后的密码,可选。将通过 <see cref="Security.IAccessPasswordDecryptor" /> 解密。
/// </summary>
public string? EncryptedPassword { get; set; }
/// <summary>
/// 通过依赖注入动态解析加密密码。
/// </summary>
public Func<IServiceProvider, string?>? EncryptedPasswordFactory { get; set; }
/// <summary>
/// 数据库模板(*.mdb / *.accdb),在文件不存在时复制一份。
/// </summary>
public string? TemplateFile { get; set; }
/// <summary>
/// 通过依赖注入动态解析模板文件路径。
/// </summary>
public Func<IServiceProvider, string?>? TemplateFileFactory { get; set; }
/// <summary>
/// 当数据库文件缺失时是否尝试从模板复制。
/// </summary>
public bool CopyTemplateWhenMissing { get; set; } = true;
/// <summary>
/// 当数据库文件缺失(且无法复制)时是否抛出异常。
/// </summary>
public bool ThrowIfMissing { get; set; } = true;
/// <summary>
/// Provider 名称,默认为 ACE OLE DB 16.0(功能最完整)。
/// 可选值:
/// - AccessProviderNames.AceOleDb16 (Microsoft.ACE.OLEDB.16.0) - 推荐,需要 Office 2016+
/// - AccessProviderNames.AceOleDb12 (Microsoft.ACE.OLEDB.12.0) - 需要 Office 2010+,不支持 Connect Timeout
/// - AccessProviderNames.JetOleDb4 (Microsoft.Jet.OLEDB.4.0) - 仅支持 32 位和旧版 Access
/// </summary>
public string ProviderName { get; set; } = AccessProviderNames.AceOleDb16;
/// <summary>
/// 连接超时时间(秒),默认 15 秒。
/// </summary>
public int ConnectionTimeout { get; set; } = 15;
/// <summary>
/// 最大重试次数,默认 3 次。用于增强稳健性。
/// </summary>
public int MaxRetryCount { get; set; } = 3;
/// <summary>
/// 重试延迟(毫秒),默认 100 毫秒。
/// </summary>
public int RetryDelayMilliseconds { get; set; } = 100;
/// <summary>
/// 额外的 OLE DB 参数,按需添加。
/// </summary>
public IDictionary<string, string> AdditionalParameters { get; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
internal void Validate()
{
if (string.IsNullOrWhiteSpace(ConnectionString) &&
ConnectionStringFactory is null &&
string.IsNullOrWhiteSpace(DatabaseFile) &&
DatabaseFileFactory is null)
{
throw new InvalidOperationException(
"AccessDatabaseOptions 需要连接字符串或数据库文件路径。");
}
if (string.IsNullOrWhiteSpace(ProviderName))
{
throw new InvalidOperationException("必须指定 Access Provider。");
}
if (ConnectionTimeout <= 0)
{
throw new InvalidOperationException("ConnectionTimeout 必须大于 0。");
}
if (MaxRetryCount < 0)
{
throw new InvalidOperationException("MaxRetryCount 不能小于 0。");
}
if (RetryDelayMilliseconds < 0)
{
throw new InvalidOperationException("RetryDelayMilliseconds 不能小于 0。");
}
}
}
@@ -0,0 +1,22 @@
namespace Sharp.Data.Access.Options;
/// <summary>
/// 常用 Access 数据库 Provider 名称。
/// </summary>
public static class AccessProviderNames
{
/// <summary>
/// Microsoft Access Database Engine 2016 (推荐)。
/// </summary>
public const string AceOleDb16 = "Microsoft.ACE.OLEDB.16.0";
/// <summary>
/// Microsoft Access Database Engine 2010。
/// </summary>
public const string AceOleDb12 = "Microsoft.ACE.OLEDB.12.0";
/// <summary>
/// 老旧的 Jet 4.0 Provider(仅限 32 位进程)。
/// </summary>
public const string JetOleDb4 = "Microsoft.Jet.OLEDB.4.0";
}
+374
View File
@@ -0,0 +1,374 @@
# AccessDataService 简化 API 使用指南
## 🎯 设计目标
Data.Access 模块的核心目标是**让调用者使用起来更简单**,将连接管理、SQL 执行、参数处理、事务管理等复杂度封装起来。
## 📊 代码对比
### ❌ 之前:直接使用 OleDb(复杂)
```csharp
// 每次都要手动创建连接
private OleDbConnection CreateConnection()
{
var dbPath = Path.GetFullPath("dbDwDmsSetup.accdb");
var connectionString = $"Provider=Microsoft.ACE.OLEDB.12.0;Data Source={dbPath}";
return new OleDbConnection(connectionString);
}
// 查询用户 - 需要写大量样板代码
private async Task<List<UserInfoDto>> LoadUsersAsync()
{
using var connection = CreateConnection();
await connection.OpenAsync();
using var command = new OleDbCommand("SELECT UserName, Password FROM UserInfo", connection);
using var reader = await Task.Run(() => command.ExecuteReader());
var users = new List<UserInfoDto>();
while (await Task.Run(() => reader.Read()))
{
var user = new UserInfoDto
{
UserName = reader["UserName"]?.ToString() ?? "",
Password = reader["Password"]?.ToString() ?? ""
};
users.Add(user);
}
return users;
}
// 删除用户 - 需要手动管理事务
private async Task DeleteUserAsync(string userName)
{
using var connection = CreateConnection();
await connection.OpenAsync();
using var transaction = connection.BeginTransaction();
try
{
using (var deletePermCmd = new OleDbCommand(
"DELETE FROM UserPermission WHERE UserInfo_UserName = ?", connection, transaction))
{
deletePermCmd.Parameters.AddWithValue("@UserName", userName);
await Task.Run(() => deletePermCmd.ExecuteNonQuery());
}
using (var deleteUserCmd = new OleDbCommand(
"DELETE FROM UserInfo WHERE UserName = ?", connection, transaction))
{
deleteUserCmd.Parameters.AddWithValue("@UserName", userName);
await Task.Run(() => deleteUserCmd.ExecuteNonQuery());
}
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
```
**问题**:
- 大量重复的连接管理代码
- 手动映射查询结果到对象
- 手动管理事务的提交和回滚
- 代码冗长,不易维护
---
### ✅ 现在:使用 AccessDataService(简洁)
```csharp
// 注入服务,无需手动创建连接
private readonly IAccessDataService _accessDataService;
// 查询用户 - 一行代码!自动映射!
private async Task<List<UserInfoDto>> LoadUsersAsync()
{
return await _accessDataService.QueryAsync<UserInfoDto>(
"SELECT UserName, [Password] FROM UserInfo");
}
// 删除用户 - 自动管理事务!
private async Task DeleteUserAsync(string userName)
{
await _accessDataService.ExecuteInTransactionAsync(async transaction =>
{
await transaction.ExecuteAsync(
"DELETE FROM UserPermission WHERE UserInfo_UserName = ?",
new OleDbParameter("@UserName", userName));
await transaction.ExecuteAsync(
"DELETE FROM UserInfo WHERE UserName = ?",
new OleDbParameter("@UserName", userName));
});
}
```
**优势**:
- ✅ 无需手动管理连接
- ✅ 自动泛型映射查询结果
- ✅ 自动管理事务(提交/回滚)
- ✅ 代码简洁,易于维护
- ✅ 从 60+ 行减少到 10 行左右
---
## 🚀 快速开始
### 1. 初始化服务
在 `App.xaml.cs` 中初始化:
```csharp
private void InitializeAccessDataService()
{
var databasePath = Path.GetFullPath("dbDwDmsSetup.accdb");
var options = new AccessDataServiceOptions
{
DatabasePath = databasePath
};
AccessDataService = new AccessDataService(options, LoggerFactory.CreateLogger<AccessDataService>());
}
```
### 2. 在窗口中使用
```csharp
public partial class AccessDatabaseWindowOleDb : Window
{
private readonly IAccessDataService _accessDataService;
public AccessDatabaseWindowOleDb()
{
InitializeComponent();
var app = (App)Application.Current;
_accessDataService = app.AccessDataService;
}
}
```
---
## 📚 API 使用示例
### 1. 查询多条记录
```csharp
// 自动映射到对象
var users = await _accessDataService.QueryAsync<UserInfoDto>(
"SELECT UserName, [Password] FROM UserInfo");
// 带参数的查询
var users = await _accessDataService.QueryAsync<UserInfoDto>(
"SELECT UserName, [Password] FROM UserInfo WHERE UserName LIKE ?",
new OleDbParameter("@Pattern", "%admin%"));
```
### 2. 查询单条记录
```csharp
var user = await _accessDataService.QueryFirstOrDefaultAsync<UserInfoDto>(
"SELECT UserName, [Password] FROM UserInfo WHERE UserName = ?",
new OleDbParameter("@UserName", "admin"));
if (user != null)
{
Console.WriteLine($"找到用户: {user.UserName}");
}
```
### 3. 执行标量查询
```csharp
// 查询数量
var count = await _accessDataService.ExecuteScalarAsync(
"SELECT COUNT(*) FROM UserInfo WHERE UserName = ?",
new OleDbParameter("@UserName", "admin"));
int userCount = Convert.ToInt32(count);
```
### 4. 插入数据
```csharp
var rowsAffected = await _accessDataService.ExecuteAsync(
"INSERT INTO UserInfo (UserName, [Password]) VALUES (?, ?)",
new OleDbParameter("@UserName", "newuser"),
new OleDbParameter("@Password", "password123"));
Console.WriteLine($"插入了 {rowsAffected} 行");
```
### 5. 更新数据
```csharp
var rowsAffected = await _accessDataService.ExecuteAsync(
"UPDATE UserInfo SET [Password] = ? WHERE UserName = ?",
new OleDbParameter("@Password", "newpassword"),
new OleDbParameter("@UserName", "admin"));
Console.WriteLine($"更新了 {rowsAffected} 行");
```
### 6. 删除数据
```csharp
var rowsAffected = await _accessDataService.ExecuteAsync(
"DELETE FROM UserInfo WHERE UserName = ?",
new OleDbParameter("@UserName", "testuser"));
Console.WriteLine($"删除了 {rowsAffected} 行");
```
### 7. 使用事务(自动提交/回滚)
```csharp
// 推荐:使用 ExecuteInTransactionAsync(自动管理)
await _accessDataService.ExecuteInTransactionAsync(async transaction =>
{
// 删除权限
await transaction.ExecuteAsync(
"DELETE FROM UserPermission WHERE UserInfo_UserName = ?",
new OleDbParameter("@UserName", userName));
// 删除用户
await transaction.ExecuteAsync(
"DELETE FROM UserInfo WHERE UserName = ?",
new OleDbParameter("@UserName", userName));
// 自动提交!如果有异常,自动回滚!
});
```
### 8. 手动管理事务
```csharp
// 如果需要手动控制
using var transaction = _accessDataService.BeginTransaction();
try
{
await transaction.ExecuteAsync("INSERT INTO ...", ...);
await transaction.ExecuteAsync("UPDATE ...", ...);
transaction.Commit(); // 手动提交
}
catch
{
transaction.Rollback(); // 手动回滚
throw;
}
```
### 9. 测试连接
```csharp
var success = await _accessDataService.TestConnectionAsync();
if (success)
{
Console.WriteLine("数据库连接成功!");
}
```
---
## 🎨 完整示例
```csharp
public class UserService
{
private readonly IAccessDataService _accessDataService;
public UserService(IAccessDataService accessDataService)
{
_accessDataService = accessDataService;
}
// 获取所有用户
public async Task<List<UserInfoDto>> GetAllUsersAsync()
{
return await _accessDataService.QueryAsync<UserInfoDto>(
"SELECT UserName, [Password] FROM UserInfo");
}
// 获取用户权限数量
public async Task<int> GetUserPermissionCountAsync(string userName)
{
var count = await _accessDataService.ExecuteScalarAsync(
"SELECT COUNT(*) FROM UserPermission WHERE UserInfo_UserName = ?",
new OleDbParameter("@UserName", userName));
return Convert.ToInt32(count);
}
// 添加用户
public async Task<bool> AddUserAsync(string userName, string password)
{
// 检查是否已存在
var exists = await _accessDataService.ExecuteScalarAsync(
"SELECT COUNT(*) FROM UserInfo WHERE UserName = ?",
new OleDbParameter("@UserName", userName));
if (Convert.ToInt32(exists) > 0)
return false;
// 插入用户
await _accessDataService.ExecuteAsync(
"INSERT INTO UserInfo (UserName, [Password]) VALUES (?, ?)",
new OleDbParameter("@UserName", userName),
new OleDbParameter("@Password", password));
return true;
}
// 删除用户(包含权限)
public async Task DeleteUserAsync(string userName)
{
await _accessDataService.ExecuteInTransactionAsync(async transaction =>
{
// 先删除权限
await transaction.ExecuteAsync(
"DELETE FROM UserPermission WHERE UserInfo_UserName = ?",
new OleDbParameter("@UserName", userName));
// 再删除用户
await transaction.ExecuteAsync(
"DELETE FROM UserInfo WHERE UserName = ?",
new OleDbParameter("@UserName", userName));
});
}
}
```
---
## 💡 核心优势总结
| 特性 | 直接使用 OleDb | 使用 AccessDataService |
|------|---------------|----------------------|
| 连接管理 | 手动创建、打开、释放 | ✅ 自动管理 |
| 结果映射 | 手动读取 DataReader | ✅ 自动泛型映射 |
| 事务管理 | 手动 BeginTransaction/Commit/Rollback | ✅ 自动管理 |
| 代码量 | 60+ 行 | ✅ 10 行左右 |
| 可维护性 | 低(大量重复代码) | ✅ 高 |
| 易用性 | 低(需要理解 ADO.NET) | ✅ 高 |
---
## 📖 参考文件
- **核心实现**: `Data.Access/AccessDataService.cs`
- **接口定义**: `Data.Access/IAccessDataService.cs`
- **配置选项**: `Data.Access/AccessDataServiceOptions.cs`
- **使用示例**: `Server/Views/AccessDatabaseWindowOleDb.xaml.cs`
---
**总结**:Data.Access 模块通过封装 OleDb 的复杂度,提供了简洁优雅的 API,让调用者可以用更少的代码完成相同的功能!
+115
View File
@@ -0,0 +1,115 @@
# Sharp.Data.Access
基于 EF Core + Access (Jet) 的数据库基础设施。遵循 “依赖大于配置” 的理念,提供易于集成的连接串构建、密码解密、数据库初始化以及依赖注入支持,让其他项目只专注于提供配置即可。
> **定位**:仅提供 Access 数据层能力(连接、仓储、上下文构建),不含任何业务逻辑。
## 特性
- ✅ **DI 集成** – 一行 `AddAccessDataContext<TContext>()` 注册,其他模块通过构造函数获取 `IDataContext`。
- ✅ **最小配置** – 只需要提供数据库文件或连接字符串即可完成集成。
- ✅ **密码解密** – 内置 `IAccessPasswordDecryptor`,可注入自定义实现来解密加密密码。
- ✅ **数据库初始化** – 自动创建目录、可选基于模板复制空库文件,避免运行期错误。
- ✅ **灵活扩展** – 支持通过委托访问 `IServiceProvider`,从任意配置中心或密钥管理系统读取参数。
## 快速开始
```csharp
using Microsoft.Extensions.DependencyInjection;
using Sharp.Data.Access.DependencyInjection;
using Sharp.Data.Context;
builder.Services.AddAccessDataContext<AppDbContext>(options =>
{
options.Database.DatabaseFile = "data/app.accdb";
options.Database.EncryptedPassword = configuration["Access:EncryptedPassword"];
// 配置 Data 模块能力
options.ConfigureDataContext = (sp, ctx) =>
{
ctx.UseAudit()
.UseSoftDelete()
.UseCurrentUserProvider(() => sp.GetService<ICurrentUser>()?.Id);
};
});
```
随后只需要注入 `IDataContext` 或 `IAccessDataContextFactory<AppDbContext>` 即可使用:
```csharp
public class UserService
{
private readonly IDataContext _db;
public UserService(IDataContext db) => _db = db;
public Task<List<User>> GetUsersAsync() => _db.Repository<User>().GetAllAsync();
}
```
## 依赖注入示例(带模板/密码)
```csharp
var services = new ServiceCollection();
services.AddSingleton<ISecretProvider, SecretProvider>(); // 自定义的密钥服务
services.AddAccessDataContext<AppDbContext>(options =>
{
options.Database.DatabaseFile = "data/app.accdb";
options.Database.TemplateFile = "data/template.accdb";
options.Database.CopyTemplateWhenMissing = true;
options.Database.EncryptedPasswordFactory = sp =>
sp.GetRequiredService<ISecretProvider>().Get("AccessDbPassword");
options.ConfigureDataContext = (sp, ctx) =>
{
ctx.UseAudit()
.UseSoftDelete()
.UseCurrentUserProvider(() => sp.GetRequiredService<ICurrentUserAccessor>().Id);
};
});
await using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IAccessDataContextFactory<AppDbContext>>();
await using var dataContext = factory.Create();
```
> 在实际业务项目中可以把 `IAccessDataContextFactory<TContext>` 注入到需要“短生命周期”数据库连接的服务中,或直接注入 `IDataContext`(作用域作用域内共享同一实例)。
## 处理加密密码
实现 `IAccessPasswordDecryptor` 并注册到 DI:
```csharp
builder.Services.AddSingleton<IAccessPasswordDecryptor, AesPasswordDecryptor>();
```
`AccessDatabaseOptions.EncryptedPassword` 会在构建连接字符串前自动调用该服务完成解密,也可以直接提供明文 `Password`。
## 模板与初始化
- `CopyTemplateWhenMissing = true`(默认)时,如果指定了 `TemplateFile`,将在首次运行时复制模板数据库。
- `ThrowIfMissing = true` 可在缺少数据库文件时立即抛出异常,便于早期发现部署问题。
- 所有路径都支持相对路径(基于 `AppContext.BaseDirectory`),也可以通过 `DataDirectoryFactory` 自定义。
## 手动使用(无依赖注入)
```csharp
var builder = new DataContextBuilder<AppDbContext>();
builder.UseAccessDatabase(options =>
{
options.DatabaseFile = "data/app.accdb";
options.Password = "secret";
});
var db = builder.Build();
```
## 可扩展点
- `IAccessPasswordDecryptor`:自定义密码加解密。
- `IAccessConnectionStringFactory`:自定义连接串构建逻辑。
- `IAccessDatabaseInitializer`:自定义数据库初始化策略(检查锁、备份等)。
- `AccessContextOptions.ConfigureDataContext`:复用 Data 模块的审计、软删除、自动迁移等能力。
通过这些基础设施,Access 支持可在任何项目中复用,同时保持代码简洁、低耦合。
@@ -0,0 +1,14 @@
namespace Sharp.Data.Access.Security;
/// <summary>
/// Access 密码解密接口,调用方可替换以实现自定义加解密策略。
/// </summary>
public interface IAccessPasswordDecryptor
{
/// <summary>
/// 解密 Access 数据库密码。
/// </summary>
/// <param name="encryptedPassword">加密后的密码。</param>
/// <returns>返回明文密码,如果无法解密则返回 null。</returns>
string? Decrypt(string? encryptedPassword);
}
@@ -0,0 +1,9 @@
namespace Sharp.Data.Access.Security;
/// <summary>
/// 默认的密码解密器,直接返回原始值。
/// </summary>
public sealed class PassthroughAccessPasswordDecryptor : IAccessPasswordDecryptor
{
public string? Decrypt(string? encryptedPassword) => encryptedPassword;
}
-12
View File
@@ -1,12 +0,0 @@
# Sharp.EventBus
> **🚧 开发中 / Under Construction**
这是一个预留的事件总线模块,计划用于实现进程内(In-Process)或分布式(Distributed)的消息发布/订阅模式。
## 计划功能
- 定义统一的 `IEventBus` 接口。
- 提供基于内存(Channel/MemoryCache)的默认实现。
- 提供集成 RabbitMQ / Redis 的扩展实现。
目前该目录仅包含占位符。
+12 -1
View File
@@ -294,9 +294,20 @@ public class HttpServer
var controller = _serviceProvider.GetService(controllerType);
if (controller != null)
return controller;
// 使用 ActivatorUtilities 创建实例(可以解析构造函数参数)
try
{
return ActivatorUtilities.CreateInstance(_serviceProvider, controllerType);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to create controller {ControllerType} with DI", controllerType.Name);
throw;
}
}
// 使用激活器创建
// 没有 DI 容器时,使用默认激活器创建(需要无参构造)
return Activator.CreateInstance(controllerType)!;
}
@@ -1,83 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Plc.Abstractions;
using Plc.Services;
using Plc.Siemens;
using Plc.Siemens.Options;
using Plc.Options;
namespace Plc.Siemens.DependencyInjection;
public static class ServiceCollectionExtensions
{
/// <summary>
/// 添加西门子PLC服务到DI容器
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="configureOptions">配置选项的委托</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddSiemensPlc(
this IServiceCollection services,
Action<SiemensPlcOptions> configureOptions)
{
// 使用标准的 IOptions 模式
services.Configure(configureOptions);
// 同时注册 SiemensPlcOptions 单例,供需要直接访问的地方使用(如 PlcViewModel)
// 注意:这是一个共享实例,从 IOptions 获取值
services.AddSingleton(sp => sp.GetRequiredService<IOptions<SiemensPlcOptions>>().Value);
// 将 SiemensPlcOptions.MonitorInterval 映射到 PlcMonitorOptions
services.Configure<PlcMonitorOptions>(options =>
{
// 这里我们无法直接获取 IOptions<SiemensPlcOptions>,因为 Configure 是在构建容器前执行的
// 所以我们创建一个临时的 SiemensPlcOptions 来获取配置
// 但更好的方式是让 PlcMonitorOptions 的配置源自 SiemensPlcOptions 的配置源
// 简单起见,我们在这里通过回调再次执行 configureOptions
var tempOptions = new SiemensPlcOptions();
configureOptions(tempOptions);
options.RefreshRate = TimeSpan.FromMilliseconds(tempOptions.MonitorInterval);
});
// 注意:上面的映射方式有一个小缺陷,如果 SiemensPlcOptions 是通过 appsettings.json 绑定的,
// 那么这里的 configureOptions 可能只是部分配置或为空。
// 更稳健的方式是使用 PostConfigure
services.PostConfigure<PlcMonitorOptions>(options =>
{
// 这种方式需要通过 ServiceProvider 获取 SiemensPlcOptions,但在 PostConfigure 中拿不到 ServiceProvider
// 所以通常我们在注册服务工厂时手动转换,或者使用 OptionsMonitor
});
// 修正:在注册 IPlcService 时,其实不需要显式配置 PlcMonitorOptions,
// 只要我们确保 PlcMonitorOptions 被正确配置即可。
// 由于 configureOptions 委托是用户传进来的,我们再次利用它配置 PlcMonitorOptions 是最直接的。
// 但如果用户用了 Configuration Bind,那么 configureOptions 可能是空的。
// 更好的方案:使用 IPostConfigureOptions<PlcMonitorOptions>
services.AddSingleton<IPostConfigureOptions<PlcMonitorOptions>, SiemensPlcMonitorOptionsConfigurator>();
// 注册驱动程序(使用 IOptions)
services.AddSingleton<IPlcDriver>(sp =>
{
var options = sp.GetRequiredService<IOptions<SiemensPlcOptions>>().Value;
return new SiemensDriver(options);
});
// 注册优化器(使用 IOptions 中的 MaxGapBytes)
services.AddSingleton<IPlcOptimizer>(sp =>
{
var options = sp.GetRequiredService<IOptions<SiemensPlcOptions>>().Value;
var logger = sp.GetService<Microsoft.Extensions.Logging.ILogger<SiemensOptimizer>>();
return new SiemensOptimizer(options.MaxGapBytes, logger);
});
// 注册数据转换器
services.AddSingleton<IDataConverter, BigEndianConverter>();
// 注册PLC服务
services.AddSingleton<IPlcService, PlcMonitorService>();
return services;
}
}
@@ -1,27 +0,0 @@
using Plc.Abstractions;
namespace Plc.Siemens.Models;
public enum SiemensMemoryArea
{
Inputs = 0x81,
Outputs = 0x82,
Memory = 0x83,
DataBlock = 0x84,
Timer = 0x1D,
Counter = 0x1C
}
public class SiemensAddress : IPlcAddress
{
public SiemensMemoryArea Area { get; set; }
public int DbNumber { get; set; } // Only for DataBlock
public int ByteOffset { get; set; }
public int BitOffset { get; set; }
// Helper to sort/group
public override string ToString()
{
return $"{Area}:{DbNumber}:{ByteOffset}.{BitOffset}";
}
}
@@ -1,20 +0,0 @@
using Microsoft.Extensions.Options;
using Plc.Options;
namespace Plc.Siemens.Options;
public class SiemensPlcMonitorOptionsConfigurator : IPostConfigureOptions<PlcMonitorOptions>
{
private readonly IOptionsMonitor<SiemensPlcOptions> _siemensOptions;
public SiemensPlcMonitorOptionsConfigurator(IOptionsMonitor<SiemensPlcOptions> siemensOptions)
{
_siemensOptions = siemensOptions;
}
public void PostConfigure(string? name, PlcMonitorOptions options)
{
// 从 SiemensPlcOptions 同步 MonitorInterval 到 PlcMonitorOptions
options.RefreshRate = TimeSpan.FromMilliseconds(_siemensOptions.CurrentValue.MonitorInterval);
}
}
@@ -1,75 +0,0 @@
using S7.Net;
using System.ComponentModel.DataAnnotations;
using System.Net;
namespace Plc.Siemens.Options;
public class SiemensPlcOptions : IValidatableObject
{
[Required]
public string IpAddress { get; set; } = "127.0.0.1";
[Range(1, 65535)]
public int Port { get; set; } = 102;
[Range(0, 255)]
public short Rack { get; set; } = 0;
[Range(0, 255)]
public short Slot { get; set; } = 1;
/// <summary>
/// S7-1200/1500 通常支持 960,S7-300 通常支持 240。
/// 默认保守值设为 240。
/// </summary>
[Range(128, 960)]
public int PduSize { get; set; } = 240;
/// <summary>
/// CPU 类型 (例如: S71200, S71500, S7300, S7400, S7200, S7200Smart).
/// 默认为 S71200。
/// </summary>
public CpuType CpuType { get; set; } = CpuType.S71200;
/// <summary>
/// 优化器允许的最大间隔字节数。
/// 两个标签之间如果间隔小于此值,会合并为一次读取。
/// 默认为 20。
/// </summary>
[Range(0, 1000)]
public int MaxGapBytes { get; set; } = 20;
/// <summary>
/// 监控循环刷新率 (毫秒)。
/// 默认为 200ms。
/// </summary>
[Range(10, 60000)]
public int MonitorInterval { get; set; } = 200;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
// 验证IP地址格式
if (!IPAddress.TryParse(IpAddress, out _))
{
yield return new ValidationResult(
$"Invalid IP address format: {IpAddress}",
new[] { nameof(IpAddress) });
}
// 验证PDU大小合理性
if (PduSize < 128 || PduSize > 960)
{
yield return new ValidationResult(
$"PDU size must be between 128 and 960, got: {PduSize}",
new[] { nameof(PduSize) });
}
// 验证MaxGapBytes的合理性
if (MaxGapBytes < 0 || MaxGapBytes > PduSize)
{
yield return new ValidationResult(
$"MaxGapBytes ({MaxGapBytes}) must be between 0 and PduSize ({PduSize})",
new[] { nameof(MaxGapBytes) });
}
}
}
-20
View File
@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Plc\Plc.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
<PackageReference Include="S7netplus" Version="0.20.0" />
</ItemGroup>
</Project>
-710
View File
@@ -1,710 +0,0 @@
# Plc.Siemens - 西门子S7 PLC通信驱动
[![.NET](https://img.shields.io/badge/.NET-8.0-blue)](https://dotnet.microsoft.com/)
[![S7netplus](https://img.shields.io/badge/S7netplus-0.20.0-orange)](https://github.com/S7NetPlus/s7netplus)
## 📖 概述
Plc.Siemens 是基于 [S7.Net Plus](https://github.com/S7NetPlus/s7netplus) 库的西门子S7系列PLC通信驱动模块,实现了 `Plc` 核心库的抽象接口,提供了对西门子 S7-200、S7-300、S7-400、S7-1200、S7-1500 系列PLC的完整支持。
### 核心特性
- ✅ **全系列支持** - S7-200/300/400/1200/1500
- ✅ **S7协议** - 基于标准 S7 通信协议
- ✅ **地址智能解析** - 支持多种西门子地址格式
- ✅ **批量优化** - 针对S7协议的智能批量读取
- ✅ **大端序转换** - 自动处理字节序
- ✅ **完整配置** - 支持所有连接参数
- ✅ **类型安全** - 严格的配置验证
---
## 🎯 支持的PLC型号
| 系列 | CPU类型 | 默认端口 | PDU大小 | 测试状态 |
|------|---------|---------|---------|---------|
| S7-200 | S7200 | 102 | 240 | ✅ 已测试 |
| S7-300 | S7300 | 102 | 240 | ✅ 已测试 |
| S7-400 | S7400 | 102 | 480 | ✅ 已测试 |
| S7-1200 | S71200 | 102 | 960 | ✅ 已测试 |
| S7-1500 | S71500 | 102 | 960 | ✅ 已测试 |
| S7-200 Smart | S7200Smart | 102 | 240 | ⚠️ 未测试 |
---
## 🚀 快速开始
### 安装
```bash
dotnet add package YourCompany.Plc.Siemens
```
### 基本配置
```csharp
using Plc.Siemens.DependencyInjection;
services.AddSiemensPlc(options =>
{
options.IpAddress = "192.168.1.100";
options.CpuType = S7.Net.CpuType.S71200;
options.Port = 102;
options.Rack = 0;
options.Slot = 1;
options.PduSize = 960; // S7-1200/1500: 960, S7-300: 240
options.MaxGapBytes = 20; // 批量优化间隔阈值
options.MonitorInterval = 200; // 监控循环刷新率 (毫秒)
});
```
> **注意**:`MonitorInterval` 参数会被自动映射到核心服务 `PlcMonitorOptions.RefreshRate`。这是通过内部的 `Configure<PlcMonitorOptions>` 和 `IPostConfigureOptions` 机制实现的,用户无需手动配置核心选项。
### 完整示例
```csharp
using Plc.Abstractions;
using Plc.Models;
public class PlcController
{
private readonly IPlcService _plcService;
public PlcController(IPlcService plcService)
{
_plcService = plcService;
}
public async Task RunExample()
{
// 1. 启动连接
await _plcService.StartAsync();
// 2. 读取数据块
var temp = await _plcService.ReadAsync<float>("DB1.DBD0");
Console.WriteLine($"温度: {temp.Value}°C");
// 3. 写入数据
await _plcService.WriteAsync("DB1.DBW10", (short)100);
// 4. 读取位
var alarm = await _plcService.ReadAsync<bool>("M0.5");
Console.WriteLine($"报警状态: {alarm.Value}");
// 5. 订阅监控
var tag = new PlcTag
{
Address = "DB1.DBD0",
DataType = typeof(float),
OnChanged = (t, v) => Console.WriteLine($"温度变化: {v}°C")
};
_plcService.Subscribe(tag);
// 6. 等待一段时间观察变化
await Task.Delay(10000);
// 7. 停止服务
await _plcService.StopAsync();
}
}
```
---
## 📍 西门子地址格式
### 支持的存储区域
| 区域 | 标识符 | 说明 | 示例 |
|------|--------|------|------|
| 数据块 (DB) | DB | Data Block | `DB1.DBW0` |
| 标志位 (M) | M | Memory/Flags | `M100.0` |
| 输入 (I) | I | Input | `I0.0` |
| 输出 (Q) | Q | Output | `Q1.5` |
| 定时器 (T) | T | Timer | `T0` |
| 计数器 (C) | C | Counter | `C5` |
### 数据类型标识
| 标识符 | 类型 | 字节数 | C# 类型 | 示例 |
|--------|------|--------|---------|------|
| DBX | Bool (位) | 1 bit | `bool` | `DB1.DBX0.0` |
| DBB | Byte | 1 | `byte` | `DB1.DBB0` |
| DBW | Word | 2 | `short/ushort` | `DB1.DBW0` |
| DBD | DWord | 4 | `int/uint/float` | `DB1.DBD0` |
### 地址格式详解
#### 1. 数据块 (DB)
```csharp
// 格式: DB{块号}.DB{类型}{偏移}[.{位}]
"DB1.DBX0.5" // DB1, 字节0, 位5 (bool)
"DB1.DBB0" // DB1, 字节0 (byte)
"DB1.DBW0" // DB1, 字0 (起始字节0, 长度2) (short/ushort)
"DB1.DBD0" // DB1, 双字0 (起始字节0, 长度4) (int/uint/float)
"DB100.DBD500" // DB100, 双字500 (起始字节500)
```
#### 2. 标志位/内部存储器 (M)
```csharp
// 格式: M{字节}[.{位}] 或 M{类型}{字节}
"M0.0" // 标志位 M0.0 (bool)
"M0.7" // 标志位 M0.7 (bool)
"MB10" // 标志字节 MB10 (byte)
"MW100" // 标志字 MW100 (short/ushort)
"MD200" // 标志双字 MD200 (int/uint/float)
```
#### 3. 输入 (I) 和输出 (Q)
```csharp
// 输入区域
"I0.0" // 输入位 I0.0
"IB0" // 输入字节 IB0
"IW0" // 输入字 IW0
// 输出区域
"Q1.5" // 输出位 Q1.5
"QB10" // 输出字节 QB10
"QW20" // 输出字 QW20
```
### 地址解析示例
```csharp
// 西门子地址解析器会自动识别格式
var examples = new[]
{
("DB1.DBX0.0", "数据块1, 字节0, 位0"),
("DB1.DBB0", "数据块1, 字节0"),
("DB1.DBW0", "数据块1, 字0 (字节0-1)"),
("DB1.DBD0", "数据块1, 双字0 (字节0-3)"),
("M100.5", "标志位 M100.5"),
("MW100", "标志字 MW100 (字节100-101)"),
("I0.0", "输入位 I0.0"),
("Q1.5", "输出位 Q1.5")
};
```
---
## ⚙️ 配置选项
### SiemensPlcOptions 详解
```csharp
public class SiemensPlcOptions
{
/// <summary>
/// PLC IP地址
/// 验证: 必须是有效的IPv4地址
/// 默认: "127.0.0.1"
/// </summary>
public string IpAddress { get; set; }
/// <summary>
/// 通信端口
/// 验证: 1-65535
/// 默认: 102 (S7标准端口)
/// </summary>
public int Port { get; set; }
/// <summary>
/// 机架号
/// 验证: 0-255
/// 默认: 0
/// </summary>
public short Rack { get; set; }
/// <summary>
/// 插槽号
/// S7-300/400: 通常为 2
/// S7-1200/1500: 通常为 1
/// 验证: 0-255
/// 默认: 1
/// </summary>
public short Slot { get; set; }
/// <summary>
/// PDU (协议数据单元) 大小
/// S7-1200/1500: 最大 960
/// S7-300/400: 最大 480
/// S7-200: 最大 240
/// 验证: 128-960
/// 默认: 240 (保守值)
/// </summary>
public int PduSize { get; set; }
/// <summary>
/// CPU 类型
/// 默认: S71200
/// </summary>
public CpuType CpuType { get; set; }
/// <summary>
/// 批量优化最大间隔字节数
/// 两个标签之间如果间隔小于此值,会合并为一次读取
/// 验证: 0-1000
/// 默认: 20
/// </summary>
public int MaxGapBytes { get; set; }
/// <summary>
/// 监控循环刷新率 (毫秒)
/// 验证: 10-60000
/// 默认: 200
/// </summary>
public int MonitorInterval { get; set; }
}
```
### 配置验证
配置类实现了 `IValidatableObject`,会在启动时自动验证:
```csharp
services.AddSiemensPlc(options =>
{
options.IpAddress = "invalid-ip"; // ❌ 验证失败
options.Port = 70000; // ❌ 端口超出范围
options.PduSize = 2000; // ❌ PDU过大
});
// 启动时会抛出 OptionsValidationException
```
### 针对不同型号的推荐配置
#### S7-1200 配置
```csharp
services.AddSiemensPlc(options =>
{
options.IpAddress = "192.168.1.100";
options.CpuType = S7.Net.CpuType.S71200;
options.Port = 102;
options.Rack = 0;
options.Slot = 1; // S7-1200 通常是槽位1
options.PduSize = 960; // S7-1200 支持最大960
options.MaxGapBytes = 30;
});
```
#### S7-1500 配置
```csharp
services.AddSiemensPlc(options =>
{
options.IpAddress = "192.168.1.200";
options.CpuType = S7.Net.CpuType.S71500;
options.Port = 102;
options.Rack = 0;
options.Slot = 1; // S7-1500 通常是槽位1
options.PduSize = 960; // S7-1500 支持最大960
options.MaxGapBytes = 50;
});
```
#### S7-300 配置
```csharp
services.AddSiemensPlc(options =>
{
options.IpAddress = "192.168.1.50";
options.CpuType = S7.Net.CpuType.S7300;
options.Port = 102;
options.Rack = 0;
options.Slot = 2; // S7-300 通常是槽位2
options.PduSize = 240; // S7-300 支持最大240
options.MaxGapBytes = 20;
});
```
---
## 🎨 高级功能
### 1. 批量优化原理
`SiemensOptimizer` 针对S7协议优化批量读取:
```csharp
// 假设订阅了以下标签
var tags = new[]
{
new PlcTag { Address = "DB1.DBW0", DataType = typeof(short) }, // 字节 0-1
new PlcTag { Address = "DB1.DBW4", DataType = typeof(short) }, // 字节 4-5
new PlcTag { Address = "DB1.DBW8", DataType = typeof(short) }, // 字节 8-9
new PlcTag { Address = "DB1.DBW100", DataType = typeof(short) }, // 字节 100-101
};
// 优化器会生成2个批次:
// 批次1: DB1.DBW0, 10字节 (包含字节0-9, 覆盖前3个标签)
// 批次2: DB1.DBW100, 2字节 (第4个标签距离太远)
```
**优化规则:**
1. 按 **存储区域** 和 **DB号** 分组
2. 按 **字节偏移** 排序
3. 间隔 ≤ `MaxGapBytes` 的标签合并
4. 单次读取 ≤ `PduSize` 限制
### 2. 字节序处理
西门子PLC使用 **大端序 (Big-Endian)**:
```csharp
// PLC中的字节序: [00] [64]
// 读取为 short: 0x0064 = 100
var value = await _plcService.ReadAsync<short>("DB1.DBW0");
// BigEndianConverter 自动转换
// 结果: value = 100
```
**支持的转换:**
- ✅ Bool (按位)
- ✅ Byte
- ✅ Short / UShort (2字节大端)
- ✅ Int / UInt / Float (4字节大端)
- ✅ Double (8字节大端)
- ✅ String (S7格式: [MaxLen][ActualLen][Data...])
### 3. S7 String 格式
```csharp
// S7 String 结构
// 字节0: MaxLength (最大长度)
// 字节1: ActualLength (实际长度)
// 字节2+: ASCII字符
// 写入字符串
await _plcService.WriteAsync("DB1.DBB0", "Hello", length: 254);
// PLC中的数据: [FE] [05] [48] [65] [6C] [6C] [6F] [00] [00] ...
// ↑ ↑ H e l l o
// Max Actual
// 读取字符串
var str = await _plcService.ReadAsync<string>("DB1.DBB0");
// 结果: "Hello"
```
### 4. Bool类型的位操作
```csharp
// 西门子PLC的Bool使用位寻址
var tag = new PlcTag
{
Address = "M10.5", // 标志字节10的第5位
DataType = typeof(bool)
};
// BigEndianConverter 会正确处理位偏移
// 读取: 提取字节10的第5位
// 写入: 只修改字节10的第5位,保持其他位不变
```
---
## 📊 性能调优
### 1. PDU大小优化
```csharp
// S7-1200/1500 支持更大的PDU
services.AddSiemensPlc(options =>
{
options.PduSize = 960; // 最大化单次传输
});
// 单次可读取更多数据,减少网络往返
```
### 2. MaxGapBytes 调整
```csharp
// 场景1: 地址密集分布
services.AddSiemensPlc(options =>
{
options.MaxGapBytes = 50; // 增大合并范围
});
// 场景2: 地址稀疏分布
services.AddSiemensPlc(options =>
{
options.MaxGapBytes = 10; // 减少无效数据传输
});
```
### 3. 标签地址规划
```csharp
// ✅ 好的实践: 连续地址
var tags = new[]
{
new PlcTag { Address = "DB1.DBW0", ... },
new PlcTag { Address = "DB1.DBW2", ... },
new PlcTag { Address = "DB1.DBW4", ... }
};
// 优化为1次读取
// ❌ 避免: 分散地址
var tags = new[]
{
new PlcTag { Address = "DB1.DBW0", ... },
new PlcTag { Address = "DB5.DBW0", ... },
new PlcTag { Address = "DB10.DBW0", ... }
};
// 需要3次读取
```
---
## 🔍 故障排查
### 常见问题
#### 1. 连接失败
```
错误: Connection failed: A connection attempt failed because the connected party did not properly respond...
```
**解决方案:**
- ✅ 检查PLC IP地址是否正确
- ✅ 检查网络连通性 (`ping 192.168.1.100`)
- ✅ 确认PLC的 **PUT/GET** 权限已启用
- ✅ 检查防火墙规则(端口102)
- ✅ 验证 Rack/Slot 配置
#### 2. PDU大小错误
```
错误: Read failed: The PDU size is too large
```
**解决方案:**
```csharp
// 根据PLC型号调整PDU
options.PduSize = 240; // S7-300使用较小值
```
#### 3. 地址格式错误
```
错误: 不支持或无效的地址格式: DB1.0
```
**解决方案:**
```csharp
// ❌ 错误格式
"DB1.0" // 缺少数据类型
"DB1DBW0" // 缺少点分隔符
// ✅ 正确格式
"DB1.DBW0" // 数据块1, 字0
"DB1.DBX0.0" // 数据块1, 字节0, 位0
```
#### 4. 权限问题
```
错误: Access denied or protection active
```
**解决方案:**
- 在TIA Portal中检查 **保护级别**
- 启用 **PUT/GET 通信**
- 确认 DB块的 **访问权限** 未设置为 **优化访问**
### 调试技巧
#### 启用详细日志
```csharp
builder.Services.AddLogging(logging =>
{
logging.AddConsole();
logging.SetMinimumLevel(LogLevel.Trace); // 查看所有日志
});
// 日志输出示例
[TRACE] Parsed address DB1.DBW0 for tag temp_001
[DEBUG] Optimizing 10 tags into 3 batches
[TRACE] Created new batch starting at DB1.DBW0
[TRACE] Merged tag temp_002 into batch (gap=2, newLength=10)
[INFO] Optimization complete: 10 tags -> 3 batches
```
#### 使用Wireshark抓包
```bash
# 过滤S7通信
tcp.port == 102
# 查看S7协议细节
s7comm
```
---
## 🧪 测试
### 模拟PLC测试
```csharp
using Moq;
using Plc.Abstractions;
using Plc.Siemens;
[Fact]
public async Task SiemensDriver_ReadBytes_Success()
{
// Arrange
var options = new SiemensPlcOptions
{
IpAddress = "192.168.1.100",
CpuType = CpuType.S71200
};
var driver = new SiemensDriver(options);
// 注意: 需要真实的PLC或模拟器
await driver.ConnectAsync();
// Act
var result = await driver.ReadBytesAsync("DB1.DBW0", 2);
// Assert
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Length);
await driver.DisconnectAsync();
}
```
### 地址解析测试
```csharp
[Theory]
[InlineData("DB1.DBW0", 0x84, 1, 0, 0)]
[InlineData("DB1.DBX0.5", 0x84, 1, 0, 5)]
[InlineData("M100.3", 0x83, 0, 100, 3)]
public void SiemensAddressParser_Parse_ValidAddress(
string address,
byte expectedArea,
int expectedDbNumber,
int expectedByteOffset,
int expectedBitOffset)
{
// Act
var result = SiemensAddressParser.Parse(address);
// Assert
Assert.Equal((SiemensMemoryArea)expectedArea, result.Area);
Assert.Equal(expectedDbNumber, result.DbNumber);
Assert.Equal(expectedByteOffset, result.ByteOffset);
Assert.Equal(expectedBitOffset, result.BitOffset);
}
```
---
## 📚 参考资料
### 官方文档
- [TIA Portal 文档](https://support.industry.siemens.com/cs/document/109742691)
- [S7 通信协议](https://support.industry.siemens.com/cs/document/26483647)
- [S7.Net Plus GitHub](https://github.com/S7NetPlus/s7netplus)
### 相关资源
- **S7 地址格式**: [Siemens 地址规范](https://support.industry.siemens.com/cs/document/67785739)
- **CPU 规格**: [S7-1200 系统手册](https://support.industry.siemens.com/cs/document/36932465)
- **通信设置**: [S7 Open User Communication](https://support.industry.siemens.com/cs/document/26483647)
---
## 🔐 安全注意事项
1. **网络隔离** - PLC应在独立的工业网络中
2. **访问控制** - 限制对PLC IP的访问
3. **保护级别** - 在TIA Portal中设置适当的保护级别
4. **审计日志** - 记录所有PLC写入操作
5. **变更管理** - 写入操作应经过审批流程
```csharp
// 建议:添加写入权限检查
public class SecurePlcService : IPlcService
{
private readonly IPlcService _inner;
private readonly IAuthorizationService _auth;
public async Task<Result> WriteAsync<T>(string address, T value)
{
if (!await _auth.AuthorizeAsync("PlcWrite"))
{
return Result.Failure("Unauthorized");
}
_logger.LogWarning("User {User} writing to {Address}",
_currentUser.Name, address);
return await _inner.WriteAsync(address, value);
}
}
```
---
## 📈 版本历史
### v1.0.0 (2025-01-09)
- ✅ 初始发布
- ✅ 支持 S7-200/300/400/1200/1500
- ✅ 完整的地址解析
- ✅ 批量优化
- ✅ 配置验证
- ✅ 日志集成
- ✅ 自动重连
---
## 🤝 贡献
欢迎为西门子驱动贡献代码!
### 开发环境要求
- .NET 8.0 SDK
- TIA Portal (用于测试)
- 西门子PLC或PLCSim Advanced
### 测试清单
- [ ] 连接测试(真实PLC)
- [ ] 地址解析测试(所有格式)
- [ ] 数据类型测试(所有类型)
- [ ] 批量优化测试
- [ ] 性能测试
- [ ] 错误处理测试
---
## 📄 许可证
MIT License - 详见 [LICENSE](../LICENSE) 文件
---
## 📞 技术支持
- 问题反馈: [GitHub Issues](https://github.com/yourcompany/plc/issues)
- 西门子论坛: [Siemens Industry Online Support](https://support.industry.siemens.com/)
- S7.Net Plus: [S7NetPlus Issues](https://github.com/S7NetPlus/s7netplus/issues)
-138
View File
@@ -1,138 +0,0 @@
using Common.Results;
using Plc.Abstractions;
using Plc.Siemens.Models;
using Plc.Siemens.Options;
using Plc.Siemens.Utils;
using S7.Net;
namespace Plc.Siemens;
public class SiemensDriver : IPlcDriver
{
private readonly SiemensPlcOptions _options;
private S7.Net.Plc? _plc;
public SiemensDriver(SiemensPlcOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
public bool IsConnected => _plc?.IsConnected ?? false;
public int MaxPduSize => _options.PduSize;
public async Task<Result> ConnectAsync(CancellationToken cancellationToken = default)
{
try
{
// Close existing connection if any
if (_plc != null)
{
_plc.Close();
_plc = null;
}
// Create new instance with latest options
_plc = new S7.Net.Plc(
_options.CpuType,
_options.IpAddress,
_options.Port,
_options.Rack,
_options.Slot);
await _plc.OpenAsync(cancellationToken);
if (_plc.IsConnected)
return Result.Success();
return Result.Failure("Failed to connect to PLC (IsConnected is false).");
}
catch (Exception ex)
{
return Result.Failure($"Connection failed: {ex.Message}");
}
}
public Task<Result> DisconnectAsync()
{
try
{
_plc?.Close();
return Task.FromResult(Result.Success());
}
catch (Exception ex)
{
return Task.FromResult(Result.Failure($"Disconnect failed: {ex.Message}"));
}
}
public async Task<Result<byte[]>> ReadBytesAsync(string address, ushort length, CancellationToken cancellationToken = default)
{
if (!IsConnected || _plc == null)
return Result.Failure<byte[]>("PLC is not connected");
try
{
var siemensAddr = SiemensAddressParser.Parse(address);
var dataType = MapToS7DataType(siemensAddr.Area);
var bytes = await _plc.ReadBytesAsync(
dataType,
siemensAddr.DbNumber,
siemensAddr.ByteOffset,
length,
cancellationToken);
return Result.Success(bytes);
}
catch (Exception ex)
{
return Result.Failure<byte[]>($"Read failed: {ex.Message}");
}
}
public async Task<Result> WriteBytesAsync(string address, byte[] data, CancellationToken cancellationToken = default)
{
if (!IsConnected || _plc == null)
return Result.Failure("PLC is not connected");
try
{
var siemensAddr = SiemensAddressParser.Parse(address);
var dataType = MapToS7DataType(siemensAddr.Area);
await _plc.WriteBytesAsync(
dataType,
siemensAddr.DbNumber,
siemensAddr.ByteOffset,
data,
cancellationToken);
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Write failed: {ex.Message}");
}
}
private DataType MapToS7DataType(SiemensMemoryArea area)
{
return area switch
{
SiemensMemoryArea.DataBlock => DataType.DataBlock,
SiemensMemoryArea.Memory => DataType.Memory,
SiemensMemoryArea.Inputs => DataType.Input,
SiemensMemoryArea.Outputs => DataType.Output,
SiemensMemoryArea.Timer => DataType.Timer,
SiemensMemoryArea.Counter => DataType.Counter,
_ => throw new ArgumentOutOfRangeException(nameof(area), area, $"Unsupported memory area: {area}")
};
}
public void Dispose()
{
_plc?.Close();
_plc = null;
}
}
-135
View File
@@ -1,135 +0,0 @@
using Plc.Abstractions;
using Plc.Models;
using Plc.Siemens.Models;
using Plc.Siemens.Utils;
using Plc.Utils;
using Microsoft.Extensions.Logging;
namespace Plc.Siemens;
public class SiemensOptimizer : IPlcOptimizer
{
// 允许变量之间合并为一次读取的最大间隔字节数
private const int DefaultMaxGapBytes = 20;
private readonly int _maxGapBytes;
private readonly ILogger<SiemensOptimizer>? _logger;
public SiemensOptimizer(int maxGapBytes = DefaultMaxGapBytes, ILogger<SiemensOptimizer>? logger = null)
{
_maxGapBytes = maxGapBytes;
_logger = logger;
}
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
{
var batches = new List<PlcReadBatch>();
// 1. 准备标签(解析地址)
var validTags = new List<PlcTag>();
foreach (var tag in tags)
{
if (tag.ParsedAddress == null)
{
try
{
tag.ParsedAddress = SiemensAddressParser.Parse(tag.Address);
_logger?.LogTrace("Parsed address {Address} for tag {TagId}", tag.Address, tag.Id);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to parse address {Address} for tag {TagId}, skipping",
tag.Address, tag.Id);
continue;
}
}
validTags.Add(tag);
}
_logger?.LogDebug("Optimizing {ValidCount} valid tags out of {TotalCount} tags",
validTags.Count, tags.Count());
// 2. 按区域和 DB 号分组(物理位置)
var groups = validTags.GroupBy(t =>
{
var addr = (SiemensAddress)t.ParsedAddress!;
return new { addr.Area, addr.DbNumber };
});
foreach (var group in groups)
{
// 3. 按字节偏移量排序
var sortedTags = group.OrderBy(t => ((SiemensAddress)t.ParsedAddress!).ByteOffset).ToList();
_logger?.LogTrace("Processing group: Area={Area}, DB={DbNumber}, Tags={Count}",
group.Key.Area, group.Key.DbNumber, sortedTags.Count);
PlcReadBatch? currentBatch = null;
foreach (var tag in sortedTags)
{
var addr = (SiemensAddress)tag.ParsedAddress!;
int typeSize = tag.DataType == typeof(string)
? TypeHelper.GetSize(tag.DataType, tag.Length)
: TypeHelper.GetSize(tag.DataType);
int byteLength = typeSize * tag.Length; // 此标签的总字节数
// 如果是 bool,它可能在一个字节内,但我们在检查边界时假设它占用该字节
if (currentBatch == null)
{
currentBatch = CreateBatch(tag, addr, byteLength);
batches.Add(currentBatch);
_logger?.LogTrace("Created new batch starting at {Address}", tag.Address);
continue;
}
var batchStartAddr = (SiemensAddress)currentBatch.ParsedStartAddress!;
int currentBatchEnd = batchStartAddr.ByteOffset + currentBatch.TotalLength;
// 计算间隔 (Gap)
int gap = addr.ByteOffset - currentBatchEnd;
// 计算潜在的新长度
// 新标签地址 + 其长度 - 批次起始位置
int newTotalLength = (addr.ByteOffset + byteLength) - batchStartAddr.ByteOffset;
// 合并条件:
// 1. 标签在当前结束之后(或位置相同)开始(因为已排序,所以是肯定的)
// 2. 间隔足够小
// 3. 总大小适合 PDU
if (gap >= 0 && gap <= _maxGapBytes && newTotalLength <= maxPduSize)
{
currentBatch.Tags.Add(tag);
currentBatch.TotalLength = newTotalLength;
_logger?.LogTrace("Merged tag {TagId} into batch (gap={Gap}, newLength={Length})",
tag.Id, gap, newTotalLength);
}
else
{
// 开启新批次
_logger?.LogTrace("Cannot merge tag {TagId} (gap={Gap}, newLength={Length}, maxPdu={MaxPdu}), creating new batch",
tag.Id, gap, newTotalLength, maxPduSize);
currentBatch = CreateBatch(tag, addr, byteLength);
batches.Add(currentBatch);
}
}
}
_logger?.LogDebug("Optimization complete: {TagCount} tags -> {BatchCount} batches",
validTags.Count, batches.Count);
return batches;
}
private PlcReadBatch CreateBatch(PlcTag tag, SiemensAddress addr, int byteLength)
{
return new PlcReadBatch
{
StartAddress = tag.Address,
ParsedStartAddress = addr,
TotalLength = byteLength,
Tags = new List<PlcTag> { tag }
};
}
}
@@ -1,84 +0,0 @@
using Plc.Siemens.Models;
using System.Text.RegularExpressions;
namespace Plc.Siemens.Utils;
public static class SiemensAddressParser
{
// DB匹配: DB1.DBB0, DB1.DBW2, DB1.DBD4, DB1.DBX0.0
// Group 1: DbNumber
// Group 2: Type (B,W,D,X) - Ignored
// Group 3: ByteOffset
// Group 5: BitOffset (Optional)
private static readonly Regex DbRegex = new(@"^DB(\d+)\.DB[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
// M匹配: M100, MB100, MW100, MD100, MX100.0, M100.1
// Group 1: ByteOffset
// Group 3: BitOffset (Optional)
private static readonly Regex MRegex = new(@"^M[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
// I匹配: I0.0, IB0, IW0, ID0
private static readonly Regex IRegex = new(@"^I[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
// Q匹配: Q0.0, QB0, QW0, QD0
private static readonly Regex QRegex = new(@"^Q[B|W|D|X]?(\d+)(\.(\d+))?$", RegexOptions.IgnoreCase);
public static SiemensAddress Parse(string address)
{
address = address.Trim().ToUpper();
// 1. Try DB
var dbMatch = DbRegex.Match(address);
if (dbMatch.Success)
{
return new SiemensAddress
{
Area = SiemensMemoryArea.DataBlock,
DbNumber = int.Parse(dbMatch.Groups[1].Value),
ByteOffset = int.Parse(dbMatch.Groups[2].Value),
BitOffset = dbMatch.Groups[4].Success ? int.Parse(dbMatch.Groups[4].Value) : 0
};
}
// 2. Try M (Memory/Flags)
var mMatch = MRegex.Match(address);
if (mMatch.Success)
{
return new SiemensAddress
{
Area = SiemensMemoryArea.Memory,
DbNumber = 0,
ByteOffset = int.Parse(mMatch.Groups[1].Value),
BitOffset = mMatch.Groups[3].Success ? int.Parse(mMatch.Groups[3].Value) : 0
};
}
// 3. Try I (Inputs)
var iMatch = IRegex.Match(address);
if (iMatch.Success)
{
return new SiemensAddress
{
Area = SiemensMemoryArea.Inputs,
DbNumber = 0,
ByteOffset = int.Parse(iMatch.Groups[1].Value),
BitOffset = iMatch.Groups[3].Success ? int.Parse(iMatch.Groups[3].Value) : 0
};
}
// 4. Try Q (Outputs)
var qMatch = QRegex.Match(address);
if (qMatch.Success)
{
return new SiemensAddress
{
Area = SiemensMemoryArea.Outputs,
DbNumber = 0,
ByteOffset = int.Parse(qMatch.Groups[1].Value),
BitOffset = qMatch.Groups[3].Success ? int.Parse(qMatch.Groups[3].Value) : 0
};
}
throw new ArgumentException($"不支持或无效的地址格式: {address}");
}
}
@@ -1,23 +0,0 @@
namespace Plc.Abstractions;
public interface IDataConverter
{
/// <summary>
/// 将原始字节(从指定偏移量开始)转换为目标类型
/// </summary>
/// <param name="buffer">字节缓冲区</param>
/// <param name="offset">起始偏移量</param>
/// <param name="type">目标类型</param>
/// <param name="length">数据长度(对字符串和数组有效)</param>
/// <param name="bitOffset">位偏移量(仅对bool类型有效)</param>
object? Parse(byte[] buffer, int offset, Type type, int length, int bitOffset = 0);
/// <summary>
/// 将值转换为字节
/// </summary>
/// <param name="value">要转换的值</param>
/// <param name="type">值的类型</param>
/// <param name="length">数据长度(对字符串和数组有效)</param>
/// <param name="bitOffset">位偏移量(仅对bool类型有效)</param>
byte[] ToBytes(object value, Type type, int length, int bitOffset = 0);
}
-22
View File
@@ -1,22 +0,0 @@
namespace Plc.Abstractions;
/// <summary>
/// 定义PLC地址的通用接口,用于不同品牌的地址解析结果
/// </summary>
public interface IPlcAddress
{
/// <summary>
/// 获取字节偏移量
/// </summary>
int ByteOffset { get; }
/// <summary>
/// 获取位偏移量(仅对bool类型有效)
/// </summary>
int BitOffset { get; }
/// <summary>
/// 获取地址的字符串表示
/// </summary>
string ToString();
}
-45
View File
@@ -1,45 +0,0 @@
using Common.Results;
namespace Plc.Abstractions;
/// <summary>
/// 定义 PLC 驱动的底层契约。
/// 此接口的实现处理具体的协议细节(例如:S7, Modbus)。
/// </summary>
public interface IPlcDriver : IDisposable
{
/// <summary>
/// 获取一个值,指示驱动程序当前是否已连接到 PLC。
/// </summary>
bool IsConnected { get; }
/// <summary>
/// 获取连接支持的最大 PDU(协议数据单元)大小。
/// 优化器使用此值来确定最大批量大小。
/// </summary>
int MaxPduSize { get; }
/// <summary>
/// 建立与 PLC 的连接。
/// </summary>
Task<Result> ConnectAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 关闭与 PLC 的连接。
/// </summary>
Task<Result> DisconnectAsync();
/// <summary>
/// 从指定地址读取原始字节序列。
/// </summary>
/// <param name="address">起始地址字符串(例如 "DB1.0")。</param>
/// <param name="length">要读取的字节数。</param>
Task<Result<byte[]>> ReadBytesAsync(string address, ushort length, CancellationToken cancellationToken = default);
/// <summary>
/// 将原始字节序列写入指定地址。
/// </summary>
/// <param name="address">起始地址字符串。</param>
/// <param name="data">要写入的字节。</param>
Task<Result> WriteBytesAsync(string address, byte[] data, CancellationToken cancellationToken = default);
}
@@ -1,44 +0,0 @@
using Plc.Models;
namespace Plc.Abstractions;
/// <summary>
/// 负责分析请求的标签列表,并将它们分组为优化的批量请求。
/// </summary>
public interface IPlcOptimizer
{
/// <summary>
/// 基于地址位置和 PDU 大小,将单个标签分组为高效的读取批次。
/// </summary>
/// <param name="tags">活动订阅列表。</param>
/// <param name="maxPduSize">单次请求中可读取的最大字节数。</param>
/// <returns>批量请求列表。</returns>
List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize);
}
/// <summary>
/// 表示覆盖多个标签的单个优化读取操作。
/// </summary>
public class PlcReadBatch
{
/// <summary>
/// 此批量读取的起始地址。
/// </summary>
public string StartAddress { get; set; } = string.Empty;
/// <summary>
/// 要读取的总字节数。
/// </summary>
public int TotalLength { get; set; }
/// <summary>
/// 包含在此批量范围内的标签列表。
/// </summary>
public List<PlcTag> Tags { get; set; } = new();
/// <summary>
/// 驱动程序特定的起始地址上下文(避免重新解析)。
/// 使用 IPlcAddress 接口以支持类型安全访问。
/// </summary>
public IPlcAddress? ParsedStartAddress { get; set; }
}
-41
View File
@@ -1,41 +0,0 @@
using Common.Results;
using Plc.Models;
namespace Plc.Abstractions;
/// <summary>
/// 用于与 PLC 交互的高层服务。
/// 处理连接管理、批量读取和数据转换。
/// </summary>
public interface IPlcService
{
/// <summary>
/// 启动后台监控循环。
/// </summary>
Task StartAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 停止后台监控循环。
/// </summary>
Task StopAsync();
/// <summary>
/// 对单个地址执行立即、临时的读取。
/// </summary>
Task<Result<T>> ReadAsync<T>(string address);
/// <summary>
/// 对单个地址执行立即、临时的写入。
/// </summary>
Task<Result> WriteAsync<T>(string address, T value);
/// <summary>
/// 订阅一个标签到监控循环。
/// </summary>
void Subscribe(PlcTag tag);
/// <summary>
/// 从监控循环取消订阅标签。
/// </summary>
void Unsubscribe(string tagId);
}
@@ -1,14 +0,0 @@
namespace Plc.Attributes;
[AttributeUsage(AttributeTargets.Property)]
public class PlcAddressAttribute : Attribute
{
public string Address { get; }
public int Length { get; }
public PlcAddressAttribute(string address, int length = 1)
{
Address = address;
Length = length;
}
}
-9
View File
@@ -1,9 +0,0 @@
namespace Plc.Models;
public enum PlcBrand
{
Siemens,
Omron, // 预留
Mitsubishi, // 预留
ModbusTcp // 预留
}
-47
View File
@@ -1,47 +0,0 @@
using Plc.Abstractions;
namespace Plc.Models;
/// <summary>
/// 表示用户想要监控或访问的 PLC 中的特定数据点。
/// </summary>
public class PlcTag
{
/// <summary>
/// 此标签订阅的唯一标识符。
/// </summary>
public string Id { get; set; } = Guid.NewGuid().ToString();
/// <summary>
/// PLC 地址字符串(例如 "DB1.DBW0", "M100.1")。
/// </summary>
public required string Address { get; set; }
/// <summary>
/// 此数据表示的 .NET 类型(例如 typeof(int), typeof(float))。
/// </summary>
public required Type DataType { get; set; }
/// <summary>
/// 数据长度(主要用于字符串或数组)。默认为 1。
/// </summary>
public int Length { get; set; } = 1;
/// <summary>
/// 此标签的最后已知值。
/// </summary>
public object? Value { get; set; }
/// <summary>
/// 当值更改(或刷新)时调用的操作。
/// 回调接收 Tag 实例和新值。
/// </summary>
public Action<PlcTag, object?>? OnChanged { get; set; }
/// <summary>
/// 内部使用:由驱动程序/优化器用于存储解析后的地址对象。
/// 这避免了在每个周期重新解析地址字符串。
/// 使用 IPlcAddress 接口以支持类型安全访问。
/// </summary>
public IPlcAddress? ParsedAddress { get; set; }
}
@@ -1,9 +0,0 @@
namespace Plc.Options;
public class PlcMonitorOptions
{
/// <summary>
/// 监控循环刷新率 (毫秒)
/// </summary>
public TimeSpan RefreshRate { get; set; } = TimeSpan.FromMilliseconds(200);
}
-18
View File
@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
</ItemGroup>
</Project>
-106
View File
@@ -1,106 +0,0 @@
using System.ComponentModel;
using System.Reflection;
using Plc.Abstractions;
using Plc.Attributes;
using Plc.Models;
namespace Plc.Proxy;
public class PlcInterfaceProxy : DispatchProxy, INotifyPropertyChanged
{
private IPlcService _plcService = null!;
private readonly Dictionary<string, PlcTag> _tags = new();
// 关键修改:使用私有字段手动管理委托链,避免递归调用
private PropertyChangedEventHandler? _propertyChangedInternal;
// 声明为 virtual event 只是为了骗过编译器的 TypeLoadException,
// 但我们在 Invoke 里不操作它,而是操作 _propertyChangedInternal。
// 实际上,对于 DispatchProxy 来说,我们并不需要这个 event 真的工作,
// 只需要它存在以满足接口签名,并允许被重写。
public virtual event PropertyChangedEventHandler? PropertyChanged
{
add => _propertyChangedInternal += value;
remove => _propertyChangedInternal -= value;
}
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
{
if (targetMethod == null) return null;
// 1. Handle Property Getters (get_PropName)
if (targetMethod.Name.StartsWith("get_"))
{
var propName = targetMethod.Name.Substring(4);
if (_tags.TryGetValue(propName, out var tag))
{
return tag.Value; // Return cached value
}
}
// 2. Handle Property Setters (set_PropName)
if (targetMethod.Name.StartsWith("set_"))
{
var propName = targetMethod.Name.Substring(4);
if (_tags.TryGetValue(propName, out var tag) && args != null && args.Length > 0)
{
var newValue = args[0];
_plcService.WriteAsync(tag.Address, newValue).GetAwaiter().GetResult();
return null;
}
}
// 3. Handle INotifyPropertyChanged methods (add_PropertyChanged, remove_PropertyChanged)
// 这里是关键:我们拦截对 add_PropertyChanged 的调用,
// 将其重定向到我们的私有字段 _propertyChangedInternal,
// 从而避免调用 this.PropertyChanged += ... 导致的递归。
if (targetMethod.Name == "add_PropertyChanged" && args != null && args.Length == 1)
{
var handler = (PropertyChangedEventHandler)args[0]!;
_propertyChangedInternal += handler;
return null;
}
if (targetMethod.Name == "remove_PropertyChanged" && args != null && args.Length == 1)
{
var handler = (PropertyChangedEventHandler)args[0]!;
_propertyChangedInternal -= handler;
return null;
}
return null;
}
public static T Create<T>(IPlcService plcService) where T : class, INotifyPropertyChanged
{
object proxy = Create<T, PlcInterfaceProxy>();
((PlcInterfaceProxy)proxy).Initialize(typeof(T), plcService);
return (T)proxy;
}
private void Initialize(Type interfaceType, IPlcService plcService)
{
_plcService = plcService;
foreach (var prop in interfaceType.GetProperties())
{
var attr = prop.GetCustomAttribute<PlcAddressAttribute>();
if (attr != null)
{
var tag = new PlcTag
{
Address = attr.Address,
DataType = prop.PropertyType,
Length = attr.Length,
OnChanged = (t, v) =>
{
// 触发时,调用私有字段
_propertyChangedInternal?.Invoke(this, new PropertyChangedEventArgs(prop.Name));
}
};
_tags[prop.Name] = tag;
_plcService.Subscribe(tag);
}
}
}
}
-730
View File
@@ -1,730 +0,0 @@
# Plc - 工业PLC通信核心库
[![.NET](https://img.shields.io/badge/.NET-8.0-blue)](https://dotnet.microsoft.com/)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
## 📖 概述
Plc 是一个通用的工业PLC通信抽象层库,提供了统一的接口来与不同品牌的PLC设备进行通信。该库采用分层架构设计,将协议细节抽象化,使得上层应用无需关心具体的PLC品牌和协议实现。
### 核心特性
- ✅ **品牌无关** - 抽象接口设计,支持多种PLC品牌(西门子、欧姆龙、三菱等)
- ✅ **高性能优化** - 智能批量读取优化,减少网络往返次数
- ✅ **实时监控** - 支持标签订阅模式,自动轮询和变更通知
- ✅ **类型安全** - 强类型数据转换,支持多种基础数据类型
- ✅ **连接管理** - 自动重连、指数退避策略
- ✅ **完整日志** - 集成 Microsoft.Extensions.Logging
- ✅ **依赖注入** - 原生支持 .NET DI 容器
---
## 🏗️ 架构设计
### 分层架构
```
┌─────────────────────────────────────┐
│ 应用层 (Application Layer) │
│ - ViewModel/Controller │
│ - Business Logic │
└────────────┬────────────────────────┘
│
┌────────────▼────────────────────────┐
│ 服务层 (Service Layer) │
│ - PlcMonitorService │ ← 核心服务
│ - 订阅管理、批量优化 │
└────────────┬────────────────────────┘
│
┌────────────▼────────────────────────┐
│ 抽象层 (Abstraction Layer) │
│ - IPlcDriver │ ← 驱动接口
│ - IPlcOptimizer │ ← 优化器接口
│ - IDataConverter │ ← 转换器接口
└────────────┬────────────────────────┘
│
┌────────────▼────────────────────────┐
│ 实现层 (Implementation Layer) │
│ - SiemensDriver │ ← 品牌驱动
│ - OmronDriver │
│ - ModbusDriver │
└─────────────────────────────────────┘
```
### 核心组件
#### 1. **IPlcService** - PLC服务接口
提供统一的PLC通信入口,包括:
- 连接管理(`StartAsync`, `StopAsync`)
- 即时读写(`ReadAsync<T>`, `WriteAsync<T>`)
- 订阅监控(`Subscribe`, `Unsubscribe`)
#### 2. **IPlcDriver** - PLC驱动接口
定义底层通信协议的契约:
- 连接/断开(`ConnectAsync`, `DisconnectAsync`)
- 字节级读写(`ReadBytesAsync`, `WriteBytesAsync`)
- 状态查询(`IsConnected`, `MaxPduSize`)
#### 3. **IPlcOptimizer** - 批量优化器接口
负责优化多标签读取策略:
- 地址排序和分组
- 间隙合并(减少通信次数)
- PDU大小限制
#### 4. **IDataConverter** - 数据转换器接口
处理PLC字节序和.NET类型转换:
- 大端/小端转换
- 基础类型(bool, byte, short, int, float, double, string)
- 位偏移支持(bool类型)
---
## 🚀 快速开始
### 安装
```bash
# 安装核心库
dotnet add package YourCompany.Plc
# 安装西门子驱动
dotnet add package YourCompany.Plc.Siemens
```
### 基本使用
#### 1. 注册服务
```csharp
using Plc.Siemens.DependencyInjection;
// 在 Program.cs 或 Startup.cs 中
services.AddSiemensPlc(options =>
{
options.IpAddress = "192.168.1.100";
options.CpuType = S7.Net.CpuType.S71200;
options.Port = 102;
options.PduSize = 960;
});
```
#### 2. 使用服务
```csharp
public class MyController
{
private readonly IPlcService _plcService;
public MyController(IPlcService plcService)
{
_plcService = plcService;
}
public async Task Example()
{
// 启动PLC服务
await _plcService.StartAsync();
// 读取数据
var result = await _plcService.ReadAsync<short>("DB1.DBW0");
if (result.IsSuccess)
{
Console.WriteLine($"Value: {result.Value}");
}
// 写入数据
await _plcService.WriteAsync("DB1.DBW0", (short)1234);
// 停止服务
await _plcService.StopAsync();
}
}
```
---
## 📚 核心概念
### PlcTag - 标签模型
`PlcTag` 表示PLC中的一个数据点:
```csharp
var tag = new PlcTag
{
Id = "temperature_sensor",
Address = "DB1.DBD0",
DataType = typeof(float),
Length = 1,
OnChanged = (tag, newValue) =>
{
Console.WriteLine($"温度变化: {newValue}°C");
}
};
_plcService.Subscribe(tag);
```
**属性说明:**
- `Id` - 唯一标识符
- `Address` - PLC地址(格式取决于驱动)
- `DataType` - .NET 类型
- `Length` - 数组长度(字符串的最大长度)
- `OnChanged` - 值变化回调
- `Value` - 当前值(只读)
### 支持的数据类型
| C# 类型 | PLC 类型 | 字节数 | 说明 |
|---------|---------|--------|------|
| `bool` | BOOL | 1 | 支持位偏移 |
| `byte` | BYTE | 1 | 无符号8位 |
| `short` | INT | 2 | 有符号16位 |
| `ushort` | UINT | 2 | 无符号16位 |
| `int` | DINT | 4 | 有符号32位 |
| `uint` | UDINT | 4 | 无符号32位 |
| `float` | REAL | 4 | 单精度浮点 |
| `double` | LREAL | 8 | 双精度浮点 |
| `string` | STRING | 2+N | S7格式字符串 |
---
## ⚙️ 核心通信优化机制
本项目采用了**分桶(Batching/Bucketing)**优化策略,这是高性能 PLC 通信的关键。
### 1. 为什么需要优化?
PLC 通信通常基于 TCP/IP 协议,每次读写请求都包含网络握手、协议头封装和 PLC 内部处理时间。如果用户订阅了 100 个离散点位,如果发送 100 次网络请求,延迟将极高(例如 100 * 20ms = 2000ms),无法满足实时监控需求。
### 2. 优化原理
系统通过 `IPlcOptimizer`(如 `SiemensOptimizer`)在内存中动态重组读写请求,将多个小的、地址相邻的请求合并为一个大的网络请求。
#### 处理流程(伪代码)
```mermaid
graph LR
A[原始订阅Tags] --> B[1. 分组 Grouping]
B --> C[2. 排序 Sorting]
C --> D[3. 合并 Merging]
D --> E[执行网络请求]
```
1. **分组 (Grouping)**: 根据内存区域(如 DB1, DB2, M区, I区)将 Tag 分组。不同区域无法合并。
2. **排序 (Sorting)**: 在同一组内,按字节偏移量(Byte Offset)从小到大排序。
3. **合并 (Merging)**:
* 遍历排序后的 Tags。
* 计算两个 Tag 之间的**间隙 (Gap)**。
* 如果 `Gap <= MaxGapBytes` (默认20字节) 且 `TotalLength <= MaxPduSize` (最大包长),则合并。
* 否则,创建一个新的**批次 (Batch)**。
### 3. 实际案例分析
假设用户订阅了以下点位(西门子 S7):
- Tag A: `DB1.DBW0` (2 bytes)
- Tag B: `DB1.DBW4` (2 bytes)
- Tag C: `DB1.DBW1000` (2 bytes)
**处理逻辑:**
1. **Tag A 和 Tag B**:
* Tag A 结束于 byte 2,Tag B 开始于 byte 4。
* 间隙 Gap = 4 - 2 = 2 bytes。
* 2 bytes < 20 bytes (阈值),且总长未超 PDU。
* **结果**: 合并为一个请求:`Start: 0, Length: 6`。虽然读回了无用的 byte 2-3,但节省了一次网络请求。
2. **Tag B 和 Tag C**:
* Tag B 结束于 byte 6,Tag C 开始于 byte 1000。
* 间隙 Gap = 994 bytes。
* 994 bytes > 20 bytes。
* **结果**: 断开,Tag C 放入新的批次。
**最终网络请求**: 2 次(而不是 3 次)。
### 4. 极限情况处理
* **只订阅 1 个点位**: 优化器生成 1 个批次,包含 1 个 Tag。无额外开销。
* **连续订阅 1000 个点位 (例如 DB1.DBW0 - DB1.DBW2000)**:
* 总长度 2000 字节。
* 假设 PLC PDU 限制为 960 字节。
* 优化器会自动切分为 3 个批次:
* Batch 1: 0 - 960
* Batch 2: 960 - 1920
* Batch 3: 1920 - 2000
* 系统自动处理分包,上层业务无感。
### 5. 未来扩展性 (适配欧姆龙/三菱)
本架构设计之初就考虑了多品牌支持。适配新品牌只需实现以下接口,**无需修改 Client/ViewModel 代码**:
1. **实现 `IPlcDriver`**: 处理底层的 Socket/串口 通信(如 FINS, MC 协议)。
2. **实现 `IPlcAddress`**: 定义新品牌的地址格式(如欧姆龙的 `D100`, `W0.00`)。
3. **实现 `IPlcOptimizer`**: 针对新协议调整优化参数。
#### 扩展示例(伪代码)
```csharp
// 欧姆龙 FINS 协议优化器示例
public class OmronOptimizer : IPlcOptimizer
{
// 欧姆龙按 Word (2byte) 寻址,且单次读取限制不同
private const int MaxWordCount = 2000;
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
{
// 1. 按内存区 (DM, CIO, WR) 分组
// 2. 按 Word 地址排序
// 3. 合并逻辑:如果两个地址相差 < 10 个 Words,则合并读取
// 4. 返回适用于 FINS 协议的 ReadRequest 列表
}
}
```
---
## 📅 分级订阅方案 (Proposed Tiered Subscription)
> 本节描述了未来支持不同频率订阅(快/慢循环)的实现思路,目前尚未实装。
### 1. 需求背景
在复杂的工业场景中,不同数据的实时性要求不同:
- **关键控制信号**(如 启动/停止/报警):需要高频刷新(100ms)。
- **普通模拟量**(如 温度/液位):变化较慢,只需低频刷新(1000ms)。
- **配方/参数**(如 设定值):只需在变动时读取,或极低频刷新(5000ms)。
当前单一循环机制(统一刷新率)会导致资源浪费或关键数据延迟。
### 2. 接口设计变更
在订阅时增加 `Priority` 或 `Interval` 参数:
```csharp
public enum MonitorPriority
{
High, // 100ms
Normal, // 200ms (默认)
Low // 1000ms
}
public class PlcTag
{
// ... 原有属性
public MonitorPriority Priority { get; set; } = MonitorPriority.Normal;
}
// 或者在 Subscribe 时指定
_plcService.Subscribe(tag, MonitorPriority.High);
```
### 3. 实现策略
#### 方案 A:多循环线程 (Multi-Loop)
`PlcMonitorService` 内部维护多个 `MonitorLoop` 任务,每个任务对应一个优先级队列。
* **HighPriorityTask**: `While(true) { Read(HighTags); Delay(100ms); }`
* **LowPriorityTask**: `While(true) { Read(LowTags); Delay(1000ms); }`
**优点**:逻辑清晰,互不干扰。
**缺点**:如果底层驱动不支持并发(非线程安全),需要加锁,导致高优任务被低优任务阻塞。S7.Net 库通常不支持并发读写,需要加全局锁。
#### 方案 B:加权轮询 (Weighted Round-Robin)
在单个 `MonitorLoop` 中通过计数器控制读取频率。假设基准频率为 100ms。
* **Tick 1**: 读取 HighTags
* **Tick 2**: 读取 HighTags
* **Tick 3**: 读取 HighTags
* **...**
* **Tick 10**: 读取 HighTags + LowTags (每10次读取一次低优)
**伪代码:**
```csharp
long tick = 0;
while (!token.IsCancellationRequested)
{
var highTags = _subscriptions.Where(t => t.Priority == High);
var lowTags = _subscriptions.Where(t => t.Priority == Low);
var tagsToRead = new List<PlcTag>(highTags);
// 每 10 个周期 (10 * 100ms = 1s) 读取一次低频数据
if (tick % 10 == 0)
{
tagsToRead.AddRange(lowTags);
}
// 统一优化和读取
var batches = _optimizer.Optimize(tagsToRead, ...);
await ExecuteBatches(batches);
tick++;
await Task.Delay(100);
}
```
**优点**:单线程模型,无并发冲突,驱动兼容性好。
**缺点**:如果低频数据量极大,在 Tick 10 这一轮会耗时较长,可能导致该轮的高频数据延迟。可以通过将低频数据分片(Time-Slicing)分散到不同 Tick 来解决。
### 4. 配置扩展
在 `SiemensPlcOptions` 中增加各级频率配置:
```csharp
public class SiemensPlcOptions
{
public int HighInterval { get; set; } = 100;
public int NormalInterval { get; set; } = 200;
public int LowInterval { get; set; } = 1000;
}
```
---
## 🎯 高级功能
### 1. 标签订阅与监控
```csharp
// 订阅多个标签
var tags = new[]
{
new PlcTag { Address = "DB1.DBW0", DataType = typeof(short) },
new PlcTag { Address = "DB1.DBW2", DataType = typeof(short) },
new PlcTag { Address = "DB1.DBD4", DataType = typeof(float) }
};
foreach (var tag in tags)
{
_plcService.Subscribe(tag);
}
// 服务会自动批量优化读取
await _plcService.StartAsync();
```
### 3. 使用特性绑定
```csharp
using Plc.Attributes;
using Plc.Proxy;
public interface IPlcData : INotifyPropertyChanged
{
[PlcAddress("DB1.DBW0")]
short Temperature { get; set; }
[PlcAddress("DB1.DBW2")]
short Pressure { get; set; }
[PlcAddress("DB1.DBX4.0")]
bool AlarmFlag { get; set; }
}
// 创建代理
var plcData = PlcInterfaceProxy.Create<IPlcData>(_plcService);
// 自动订阅和双向绑定
plcData.PropertyChanged += (s, e) =>
{
Console.WriteLine($"{e.PropertyName} changed");
};
// 写入会自动发送到PLC
plcData.Temperature = 25;
```
### 4. 自定义优化器
```csharp
public class MyCustomOptimizer : IPlcOptimizer
{
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
{
// 自定义优化逻辑
// 例如:按优先级分组、按更新频率分组等
}
}
// 注册自定义优化器
services.AddSingleton<IPlcOptimizer, MyCustomOptimizer>();
```
---
## ⚙️ 配置选项
### PlcMonitorService 配置
```csharp
public class PlcMonitorService
{
// 刷新率(默认 200ms)
private readonly TimeSpan _refreshRate = TimeSpan.FromMilliseconds(200);
// 最大重连次数(默认 10)
private const int MaxReconnectAttempts = 10;
}
```
可通过继承 `PlcMonitorService` 自定义这些参数。
---
## 🔍 错误处理
所有操作都返回 `Result` 或 `Result<T>` 类型:
```csharp
var result = await _plcService.ReadAsync<int>("DB1.DBD0");
if (result.IsSuccess)
{
Console.WriteLine($"读取成功: {result.Value}");
}
else
{
Console.WriteLine($"读取失败: {result.ErrorMessage}");
// 也可以获取异常详情
if (result.Exception != null)
{
_logger.LogError(result.Exception, "PLC读取异常");
}
}
```
---
## 📊 日志集成
库已集成 `Microsoft.Extensions.Logging`:
```csharp
// 配置日志级别
builder.Services.AddLogging(logging =>
{
logging.AddConsole();
logging.SetMinimumLevel(LogLevel.Debug);
});
// 日志输出示例
[12:00:00 INF] Starting PLC monitor service
[12:00:01 DBG] Subscribed to tag temp_001 at address DB1.DBW0
[12:00:01 TRC] Optimized 10 tags into 3 batches
[12:00:02 WRN] PLC disconnected, attempting to reconnect (attempt 1/10)
```
---
## 🔧 扩展指南
### 实现新的PLC品牌驱动
#### 1. 实现 `IPlcDriver` 接口
```csharp
public class ModbusDriver : IPlcDriver
{
public bool IsConnected { get; private set; }
public int MaxPduSize => 253; // Modbus TCP 限制
public async Task<Result> ConnectAsync(CancellationToken cancellationToken)
{
// 实现 Modbus TCP 连接逻辑
}
public async Task<Result<byte[]>> ReadBytesAsync(
string address,
ushort length,
CancellationToken cancellationToken)
{
// 解析 Modbus 地址格式
// 例如: "40001" -> 寄存器地址
// 调用 Modbus 协议读取
}
// ... 其他方法
}
```
#### 2. 实现地址解析器
```csharp
public class ModbusAddress : IPlcAddress
{
public int RegisterAddress { get; set; }
public int ByteOffset { get; set; }
public int BitOffset { get; set; }
}
public static class ModbusAddressParser
{
public static ModbusAddress Parse(string address)
{
// 解析 Modbus 地址格式
// 例如: "40001" -> Holding Register 1
}
}
```
#### 3. 实现优化器
```csharp
public class ModbusOptimizer : IPlcOptimizer
{
public List<PlcReadBatch> Optimize(IEnumerable<PlcTag> tags, int maxPduSize)
{
// Modbus 特定优化
// 连续寄存器合并读取
}
}
```
#### 4. 创建扩展方法
```csharp
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddModbusPlc(
this IServiceCollection services,
Action<ModbusPlcOptions> configureOptions)
{
services.Configure(configureOptions);
services.AddSingleton<IPlcDriver, ModbusDriver>();
services.AddSingleton<IPlcOptimizer, ModbusOptimizer>();
services.AddSingleton<IDataConverter, LittleEndianConverter>();
services.AddSingleton<IPlcService, PlcMonitorService>();
return services;
}
}
```
---
## 🎨 设计原则
### 1. **依赖倒置原则 (DIP)**
- 高层模块(PlcMonitorService)不依赖低层模块(SiemensDriver)
- 两者都依赖抽象(IPlcDriver, IPlcOptimizer)
### 2. **开闭原则 (OCP)**
- 对扩展开放:可添加新的PLC品牌驱动
- 对修改关闭:添加新品牌无需修改核心代码
### 3. **单一职责原则 (SRP)**
- **PlcMonitorService** - 只负责监控循环和订阅管理
- **IPlcDriver** - 只负责底层通信
- **IPlcOptimizer** - 只负责批量优化
- **IDataConverter** - 只负责类型转换
### 4. **接口隔离原则 (ISP)**
- 接口粒度合适,不强迫实现不需要的方法
- 驱动只需实现 `IPlcDriver`,无需关心优化逻辑
---
## 📈 性能优化建议
### 1. 合理设置刷新率
```csharp
// 高频数据(100ms)
var criticalTag = new PlcTag { Address = "...", ... };
// 低频数据可以单独轮询
Task.Run(async () =>
{
while (true)
{
await _plcService.ReadAsync<int>("DB100.DBD0");
await Task.Delay(5000); // 5秒轮询一次
}
});
```
### 2. 减少订阅数量
- 只订阅真正需要监控的标签
- 一次性读取的数据使用 `ReadAsync`
### 3. 调整 MaxGapBytes
```csharp
services.AddSiemensPlc(options =>
{
options.MaxGapBytes = 50; // 增大间隙合并阈值
});
```
---
## 🧪 测试
### 单元测试示例
```csharp
[Fact]
public async Task ReadAsync_ValidAddress_ReturnsSuccess()
{
// Arrange
var mockDriver = new Mock<IPlcDriver>();
mockDriver.Setup(d => d.ReadBytesAsync(It.IsAny<string>(), It.IsAny<ushort>(), default))
.ReturnsAsync(Result.Success(new byte[] { 0x00, 0x64 })); // 100
var service = new PlcMonitorService(
mockDriver.Object,
Mock.Of<IPlcOptimizer>(),
new BigEndianConverter());
// Act
var result = await service.ReadAsync<short>("DB1.DBW0");
// Assert
Assert.True(result.IsSuccess);
Assert.Equal(100, result.Value);
}
```
---
## 📝 常见问题
### Q: 如何处理连接断开?
A: `PlcMonitorService` 内置自动重连机制,使用指数退避策略(1s, 2s, 4s, 8s, 16s, 30s)。
### Q: 是否支持异步属性?
A: `PlcInterfaceProxy` 目前使用同步写入。对于异步场景,建议使用 `WriteAsync` 方法。
### Q: 如何优化大量标签的性能?
A:
1. 确保标签地址连续或接近
2. 调整 `MaxGapBytes` 参数
3. 增大 `PduSize`(如果PLC支持)
4. 考虑分组订阅(按更新频率)
### Q: 支持哪些PLC品牌?
A: 当前支持西门子S7系列。其他品牌需要实现对应的驱动模块。
---
## 📄 许可证
MIT License - 详见 [LICENSE](LICENSE) 文件
---
## 🤝 贡献
欢迎提交 Issue 和 Pull Request!
### 贡献指南
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启 Pull Request
---
## 📞 联系方式
- 项目主页: [GitHub](https://github.com/yourcompany/plc)
- 问题反馈: [Issues](https://github.com/yourcompany/plc/issues)
- 文档: [Wiki](https://github.com/yourcompany/plc/wiki)
@@ -1,198 +0,0 @@
using System.Buffers.Binary;
using Plc.Abstractions;
namespace Plc.Services;
public class BigEndianConverter : IDataConverter
{
public object? Parse(byte[] buffer, int offset, Type type, int length, int bitOffset = 0)
{
// 参数验证
if (buffer == null)
throw new ArgumentNullException(nameof(buffer));
if (offset < 0 || offset >= buffer.Length)
throw new ArgumentOutOfRangeException(nameof(offset), "Offset is out of buffer range");
// Bool类型处理(按位)
if (type == typeof(bool))
{
if (offset >= buffer.Length)
throw new ArgumentException("Buffer too small for bool");
if (bitOffset < 0 || bitOffset > 7)
throw new ArgumentOutOfRangeException(nameof(bitOffset), "Bit offset must be 0-7");
byte b = buffer[offset];
return (b & (1 << bitOffset)) != 0;
}
// Byte类型
if (type == typeof(byte))
{
if (offset >= buffer.Length)
throw new ArgumentException("Buffer too small for byte");
return buffer[offset];
}
// Short类型
if (type == typeof(short))
{
if (offset + 2 > buffer.Length)
throw new ArgumentException("Buffer too small for short");
return BinaryPrimitives.ReadInt16BigEndian(buffer.AsSpan(offset));
}
// UShort类型
if (type == typeof(ushort))
{
if (offset + 2 > buffer.Length)
throw new ArgumentException("Buffer too small for ushort");
return BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(offset));
}
// Int类型
if (type == typeof(int))
{
if (offset + 4 > buffer.Length)
throw new ArgumentException("Buffer too small for int");
return BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset));
}
// UInt类型
if (type == typeof(uint))
{
if (offset + 4 > buffer.Length)
throw new ArgumentException("Buffer too small for uint");
return BinaryPrimitives.ReadUInt32BigEndian(buffer.AsSpan(offset));
}
// Float类型
if (type == typeof(float))
{
if (offset + 4 > buffer.Length)
throw new ArgumentException("Buffer too small for float");
return BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32BigEndian(buffer.AsSpan(offset)));
}
// Double类型
if (type == typeof(double))
{
if (offset + 8 > buffer.Length)
throw new ArgumentException("Buffer too small for double");
return BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(buffer.AsSpan(offset)));
}
// String类型(S7 String格式)
if (type == typeof(string))
{
// S7 String结构:
// 字节0: MaxLength
// 字节1: ActualLength
// 字节2+: 字符数据
if (offset + 2 > buffer.Length)
throw new ArgumentException("Buffer too small for S7 string header");
int maxLen = buffer[offset];
int actualLen = buffer[offset + 1];
if (actualLen > maxLen)
actualLen = maxLen; // 安全处理
if (offset + 2 + actualLen > buffer.Length)
throw new ArgumentException($"Buffer too small for S7 string data (needs {actualLen} bytes)");
return System.Text.Encoding.ASCII.GetString(buffer, offset + 2, actualLen);
}
throw new NotSupportedException($"Type {type.Name} is not supported by this converter");
}
public byte[] ToBytes(object value, Type type, int length, int bitOffset = 0)
{
if (value == null)
throw new ArgumentNullException(nameof(value));
// Bool类型(按位)
if (type == typeof(bool))
{
if (bitOffset < 0 || bitOffset > 7)
throw new ArgumentOutOfRangeException(nameof(bitOffset), "Bit offset must be 0-7");
byte b = 0;
if ((bool)value)
b = (byte)(1 << bitOffset);
return new[] { b };
}
// Byte类型
if (type == typeof(byte))
return new[] { (byte)value };
// Short类型
if (type == typeof(short))
{
var bytes = new byte[2];
BinaryPrimitives.WriteInt16BigEndian(bytes, (short)value);
return bytes;
}
// UShort类型
if (type == typeof(ushort))
{
var bytes = new byte[2];
BinaryPrimitives.WriteUInt16BigEndian(bytes, (ushort)value);
return bytes;
}
// Int类型
if (type == typeof(int))
{
var bytes = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(bytes, (int)value);
return bytes;
}
// UInt类型
if (type == typeof(uint))
{
var bytes = new byte[4];
BinaryPrimitives.WriteUInt32BigEndian(bytes, (uint)value);
return bytes;
}
// Float类型
if (type == typeof(float))
{
var bytes = new byte[4];
var intVal = BitConverter.SingleToInt32Bits((float)value);
BinaryPrimitives.WriteInt32BigEndian(bytes, intVal);
return bytes;
}
// Double类型
if (type == typeof(double))
{
var bytes = new byte[8];
var longVal = BitConverter.DoubleToInt64Bits((double)value);
BinaryPrimitives.WriteInt64BigEndian(bytes, longVal);
return bytes;
}
// String类型(S7 String格式)
if (type == typeof(string))
{
var str = (string)value;
var strBytes = System.Text.Encoding.ASCII.GetBytes(str);
var actualLen = Math.Min(strBytes.Length, length);
// S7 String结构
var result = new byte[2 + length];
result[0] = (byte)length; // MaxLength
result[1] = (byte)actualLen; // ActualLength
Array.Copy(strBytes, 0, result, 2, actualLen);
return result;
}
throw new NotSupportedException($"Type {type.Name} is not supported by this converter");
}
}
-341
View File
@@ -1,341 +0,0 @@
using Common.Results;
using Plc.Abstractions;
using Plc.Models;
using Plc.Utils;
using Plc.Options;
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Plc.Services;
public class PlcMonitorService : IPlcService, IDisposable
{
private readonly IPlcDriver _driver;
private readonly IPlcOptimizer _optimizer;
private readonly IDataConverter _converter;
private readonly ILogger<PlcMonitorService>? _logger;
private readonly PlcMonitorOptions _options;
private readonly ConcurrentDictionary<string, PlcTag> _subscriptions = new();
private Task? _monitorTask;
private CancellationTokenSource? _cts;
private readonly TimeSpan _refreshRate;
// 重连策略
private int _reconnectAttempts = 0;
private const int MaxReconnectAttempts = 10;
public PlcMonitorService(
IPlcDriver driver,
IPlcOptimizer optimizer,
IDataConverter converter,
IOptions<PlcMonitorOptions> options,
ILogger<PlcMonitorService>? logger = null)
{
_driver = driver;
_optimizer = optimizer;
_converter = converter;
_logger = logger;
_options = options.Value;
_refreshRate = _options.RefreshRate;
}
public async Task StartAsync(CancellationToken cancellationToken = default)
{
if (_monitorTask != null)
{
_logger?.LogWarning("Monitor service is already running");
return;
}
_logger?.LogInformation("Starting PLC monitor service");
var connectResult = await _driver.ConnectAsync(cancellationToken);
if (connectResult.IsFailure)
{
_logger?.LogError("Failed to connect to PLC: {Error}", connectResult.ErrorMessage);
throw new InvalidOperationException($"Cannot start monitor service: {connectResult.ErrorMessage}");
}
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_monitorTask = Task.Run(() => MonitorLoop(_cts.Token), _cts.Token);
_logger?.LogInformation("PLC monitor service started successfully");
}
public async Task StopAsync()
{
_logger?.LogInformation("Stopping PLC monitor service");
_cts?.Cancel();
if (_monitorTask != null)
{
try
{
await _monitorTask;
}
catch (OperationCanceledException)
{
_logger?.LogDebug("Monitor task cancelled successfully");
}
finally
{
_monitorTask = null;
}
}
if (_cts != null)
{
_cts.Dispose();
_cts = null;
}
await _driver.DisconnectAsync();
_logger?.LogInformation("PLC monitor service stopped");
}
public void Subscribe(PlcTag tag)
{
if (_subscriptions.TryAdd(tag.Id, tag))
{
_logger?.LogDebug("Subscribed to tag {TagId} at address {Address}", tag.Id, tag.Address);
}
else
{
_logger?.LogWarning("Tag {TagId} is already subscribed", tag.Id);
}
}
public void Unsubscribe(string tagId)
{
if (_subscriptions.TryRemove(tagId, out _))
{
_logger?.LogDebug("Unsubscribed tag {TagId}", tagId);
}
else
{
_logger?.LogWarning("Failed to unsubscribe tag {TagId} - not found", tagId);
}
}
public async Task<Result<T>> ReadAsync<T>(string address)
{
_logger?.LogDebug("Reading from address {Address}", address);
// 临时读取(未优化)
int size = TypeHelper.GetSize(typeof(T));
var result = await _driver.ReadBytesAsync(address, (ushort)size);
if (result.IsFailure)
{
_logger?.LogError("Failed to read from {Address}: {Error}", address, result.ErrorMessage);
return Result.Failure<T>(result.ErrorMessage ?? result.Exception?.Message ?? "Read failed");
}
if (result.Value is null)
{
_logger?.LogError("Read from {Address} returned null data", address);
return Result.Failure<T>("Read returned null data");
}
var val = _converter.Parse(result.Value, 0, typeof(T), 1);
if (val is null)
{
_logger?.LogError("Failed to convert data from {Address}", address);
return Result.Failure<T>("Conversion failed");
}
_logger?.LogDebug("Successfully read value from {Address}", address);
return Result.Success((T)val);
}
public async Task<Result> WriteAsync<T>(string address, T value)
{
if (value == null)
{
_logger?.LogWarning("Attempted to write null value to {Address}", address);
return Result.Failure("Cannot write null value");
}
_logger?.LogDebug("Writing to address {Address}", address);
int size = TypeHelper.GetSize(typeof(T));
var bytes = _converter.ToBytes(value, typeof(T), size);
if (bytes.Length == 0)
{
_logger?.LogError("Conversion to bytes failed for type {Type}", typeof(T).Name);
return Result.Failure("Conversion to bytes returned empty array. Type might not be supported.");
}
var result = await _driver.WriteBytesAsync(address, bytes);
if (result.IsFailure)
{
_logger?.LogError("Failed to write to {Address}: {Error}", address, result.ErrorMessage);
}
else
{
_logger?.LogDebug("Successfully wrote to {Address}", address);
}
return result;
}
private async Task MonitorLoop(CancellationToken token)
{
_logger?.LogInformation("Monitor loop started");
while (!token.IsCancellationRequested)
{
try
{
// 连接检查和重试逻辑
if (!_driver.IsConnected)
{
_logger?.LogWarning("PLC disconnected, attempting to reconnect (attempt {Attempt}/{Max})",
_reconnectAttempts + 1, MaxReconnectAttempts);
// 指数退避策略
var delay = Math.Min(1000 * Math.Pow(2, _reconnectAttempts), 30000);
await Task.Delay((int)delay, token);
var connectResult = await _driver.ConnectAsync(token);
if (connectResult.IsSuccess)
{
_logger?.LogInformation("Successfully reconnected to PLC");
_reconnectAttempts = 0;
}
else
{
_reconnectAttempts++;
_logger?.LogError("Reconnection failed: {Error}", connectResult.ErrorMessage);
if (_reconnectAttempts >= MaxReconnectAttempts)
{
_logger?.LogCritical("Max reconnection attempts reached, monitor loop will continue trying");
_reconnectAttempts = 0; // 重置以继续尝试
}
continue;
}
}
var currentTags = _subscriptions.Values.ToList();
if (currentTags.Count == 0)
{
await Task.Delay(1000, token);
continue;
}
// 优化:计算分桶
var batches = _optimizer.Optimize(currentTags, _driver.MaxPduSize);
_logger?.LogTrace("Optimized {TagCount} tags into {BatchCount} batches",
currentTags.Count, batches.Count);
// 执行读取
var sw = System.Diagnostics.Stopwatch.StartNew();
foreach (var batch in batches)
{
if (token.IsCancellationRequested) break;
var readResult = await _driver.ReadBytesAsync(batch.StartAddress, (ushort)batch.TotalLength, token);
if (readResult.IsSuccess && readResult.Value != null)
{
DistributeValues(batch, readResult.Value);
}
else
{
_logger?.LogWarning("Batch read failed for address {Address}: {Error}",
batch.StartAddress, readResult.ErrorMessage);
}
}
sw.Stop();
if (sw.Elapsed > _refreshRate)
{
_logger?.LogWarning("Monitor loop took {Elapsed} ms, which is longer than refresh rate {Rate} ms",
sw.Elapsed.TotalMilliseconds, _refreshRate.TotalMilliseconds);
}
}
catch (OperationCanceledException)
{
_logger?.LogDebug("Monitor loop cancelled");
break;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error in monitor loop");
}
await Task.Delay(_refreshRate, token);
}
_logger?.LogInformation("Monitor loop stopped");
}
private void DistributeValues(PlcReadBatch batch, byte[] data)
{
if (batch.ParsedStartAddress == null)
{
_logger?.LogError("Batch missing parsed start address");
return;
}
var batchStart = batch.ParsedStartAddress;
int batchStartByte = batchStart.ByteOffset;
foreach (var tag in batch.Tags)
{
if (tag.ParsedAddress == null)
{
_logger?.LogWarning("Tag {TagId} missing parsed address", tag.Id);
continue;
}
var tagAddr = tag.ParsedAddress;
int tagStartByte = tagAddr.ByteOffset;
int relativeOffset = tagStartByte - batchStartByte;
// 检查边界
int typeSize = tag.DataType == typeof(string)
? TypeHelper.GetSize(tag.DataType, tag.Length)
: TypeHelper.GetSize(tag.DataType);
if (relativeOffset + typeSize <= data.Length)
{
try
{
// 传递位偏移量以支持bool类型
var newVal = _converter.Parse(data, relativeOffset, tag.DataType, tag.Length, tagAddr.BitOffset);
// 变更检测
if (!Equals(tag.Value, newVal))
{
tag.Value = newVal;
tag.OnChanged?.Invoke(tag, newVal);
_logger?.LogTrace("Tag {TagId} value changed to {Value}", tag.Id, newVal);
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Failed to parse value for tag {TagId} at address {Address}",
tag.Id, tag.Address);
}
}
else
{
_logger?.LogWarning("Tag {TagId} offset {Offset} + size {Size} exceeds batch data length {Length}",
tag.Id, relativeOffset, typeSize, data.Length);
}
}
}
public void Dispose()
{
_logger?.LogDebug("Disposing PLC monitor service");
_cts?.Cancel();
_driver.Dispose();
}
}
-47
View File
@@ -1,47 +0,0 @@
namespace Plc.Utils;
public static class TypeHelper
{
/// <summary>
/// 获取PLC数据类型的字节大小
/// </summary>
/// <param name="type">数据类型</param>
/// <returns>字节大小</returns>
/// <exception cref="ArgumentException">当类型不支持或需要明确长度时抛出</exception>
public static int GetSize(Type type)
{
if (type == typeof(bool)) return 1; // S7中bool按字节对齐
if (type == typeof(byte)) return 1;
if (type == typeof(short) || type == typeof(ushort)) return 2;
if (type == typeof(int) || type == typeof(uint) || type == typeof(float)) return 4;
if (type == typeof(long) || type == typeof(ulong) || type == typeof(double)) return 8;
// 字符串类型需要明确指定长度
if (type == typeof(string))
{
throw new ArgumentException(
"String type requires explicit length specification. Use the overload with length parameter or specify length in PlcTag.",
nameof(type));
}
throw new NotSupportedException($"Type {type.Name} is not supported. Supported types: bool, byte, short, ushort, int, uint, float, long, ulong, double, string.");
}
/// <summary>
/// 获取PLC数据类型的字节大小(支持字符串长度)
/// </summary>
/// <param name="type">数据类型</param>
/// <param name="length">长度(对字符串类型有效)</param>
/// <returns>字节大小</returns>
public static int GetSize(Type type, int length)
{
if (type == typeof(string))
{
// S7 String = 2字节头部 + 实际字符数据
return 2 + length;
}
// 对于非字符串类型,忽略length参数
return GetSize(type);
}
}
@@ -1,91 +0,0 @@
using Common.Results;
using Ports.Models;
namespace Ports.Abstractions;
/// <summary>
/// 串口驱动接口
/// </summary>
public interface ISerialPortDriver : IDisposable
{
/// <summary>
/// 获取串口是否已打开
/// </summary>
bool IsOpen { get; }
/// <summary>
/// 获取端口名称
/// </summary>
string? PortName { get; }
/// <summary>
/// 打开串口连接
/// </summary>
/// <param name="options">配置选项</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>操作结果</returns>
Task<Result> OpenAsync(SerialPortOptions options, CancellationToken cancellationToken = default);
/// <summary>
/// 关闭串口连接
/// </summary>
/// <returns>操作结果</returns>
Task<Result> CloseAsync();
/// <summary>
/// 读取指定数量的字节
/// </summary>
/// <param name="count">读取数量</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>读取的数据</returns>
Task<Result<byte[]>> ReadBytesAsync(int count, CancellationToken cancellationToken = default);
/// <summary>
/// 写入字节数据
/// </summary>
/// <param name="data">要写入的数据</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>操作结果</returns>
Task<Result> WriteBytesAsync(byte[] data, CancellationToken cancellationToken = default);
/// <summary>
/// 读取一行数据(使用配置的换行符)
/// </summary>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>读取的字符串</returns>
Task<Result<string>> ReadLineAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 写入一行数据
/// </summary>
/// <param name="data">要写入的数据</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>操作结果</returns>
Task<Result> WriteLineAsync(string data, CancellationToken cancellationToken = default);
/// <summary>
/// 读取所有可用数据
/// </summary>
/// <returns>读取的数据</returns>
Task<Result<byte[]>> ReadAvailableAsync();
/// <summary>
/// 清空输入缓冲区
/// </summary>
void ClearInputBuffer();
/// <summary>
/// 清空输出缓冲区
/// </summary>
void ClearOutputBuffer();
/// <summary>
/// 获取输入缓冲区中待读取的字节数
/// </summary>
int BytesToRead { get; }
/// <summary>
/// 获取输出缓冲区中待发送的字节数
/// </summary>
int BytesToWrite { get; }
}
@@ -1,64 +0,0 @@
using Common.Results;
using Ports.Models;
namespace Ports.Abstractions;
/// <summary>
/// 串口服务接口(高级功能)
/// </summary>
public interface ISerialPortService : IDisposable
{
/// <summary>
/// 获取连接状态
/// </summary>
ConnectionState State { get; }
/// <summary>
/// 获取端口名称
/// </summary>
string? PortName { get; }
/// <summary>
/// 启动监听
/// </summary>
/// <param name="options">配置选项</param>
/// <param name="autoReconnect">是否自动重连</param>
/// <returns>操作结果</returns>
Task<Result> StartListeningAsync(SerialPortOptions options, bool autoReconnect = false);
/// <summary>
/// 停止监听
/// </summary>
/// <returns>操作结果</returns>
Task<Result> StopListeningAsync();
/// <summary>
/// 发送数据(队列化)
/// </summary>
/// <param name="data">要发送的数据</param>
/// <returns>操作结果</returns>
Task<Result> SendAsync(byte[] data);
/// <summary>
/// 发送并等待响应(请求-响应模式)
/// </summary>
/// <param name="data">要发送的数据</param>
/// <param name="timeout">超时时间</param>
/// <returns>响应数据</returns>
Task<Result<byte[]>> SendAndReceiveAsync(byte[] data, TimeSpan timeout);
/// <summary>
/// 数据接收事件
/// </summary>
event EventHandler<DataReceivedEventArgs>? DataReceived;
/// <summary>
/// 错误事件
/// </summary>
event EventHandler<Models.ErrorEventArgs>? ErrorOccurred;
/// <summary>
/// 连接状态变化事件
/// </summary>
event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
}
@@ -1,83 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Ports.Abstractions;
using Ports.Drivers;
using Ports.Services;
namespace Ports.DependencyInjection;
/// <summary>
/// 串口模块依赖注入扩展
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 添加串口驱动(单例)
/// </summary>
/// <param name="services">服务集合</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddSerialPortDriver(this IServiceCollection services)
{
services.AddSingleton<ISerialPortDriver, SerialPortDriver>();
return services;
}
/// <summary>
/// 添加串口驱动(瞬时)
/// </summary>
/// <param name="services">服务集合</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddSerialPortDriverTransient(this IServiceCollection services)
{
services.AddTransient<ISerialPortDriver, SerialPortDriver>();
return services;
}
/// <summary>
/// 添加串口服务(单例)
/// </summary>
/// <param name="services">服务集合</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddSerialPortService(this IServiceCollection services)
{
services.AddSingleton<ISerialPortDriver, SerialPortDriver>();
services.AddSingleton<ISerialPortService>(sp =>
{
var driver = sp.GetRequiredService<ISerialPortDriver>();
return new SerialPortService(driver);
});
return services;
}
/// <summary>
/// 添加串口服务(瞬时)
/// </summary>
/// <param name="services">服务集合</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddSerialPortServiceTransient(this IServiceCollection services)
{
services.AddTransient<ISerialPortDriver, SerialPortDriver>();
services.AddTransient<ISerialPortService>(sp =>
{
var driver = sp.GetRequiredService<ISerialPortDriver>();
return new SerialPortService(driver);
});
return services;
}
/// <summary>
/// 添加命名串口服务(支持多实例)
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="name">服务名称</param>
/// <returns>服务集合</returns>
public static IServiceCollection AddNamedSerialPortService(this IServiceCollection services, string name)
{
services.AddKeyedTransient<ISerialPortDriver>(name, (sp, key) => new SerialPortDriver());
services.AddKeyedTransient<ISerialPortService>(name, (sp, key) =>
{
var driver = sp.GetRequiredKeyedService<ISerialPortDriver>(key);
return new SerialPortService(driver);
});
return services;
}
}
@@ -1,229 +0,0 @@
using System.IO.Ports;
using System.Management;
using Common.Results;
using Ports.Models;
namespace Ports.Discovery;
/// <summary>
/// 串口发现与查询工具
/// </summary>
public static class SerialPortDiscovery
{
/// <summary>
/// 获取所有可用串口
/// </summary>
/// <returns>串口信息列表</returns>
public static Result<List<SerialPortInfo>> GetAvailablePorts()
{
return Result.Try(() =>
{
var portNames = SerialPort.GetPortNames();
var portInfoList = new List<SerialPortInfo>();
foreach (var portName in portNames)
{
var portInfo = GetPortInfo(portName);
if (portInfo.IsSuccess && portInfo.Value != null)
{
portInfoList.Add(portInfo.Value);
}
else
{
// 如果无法获取详细信息,至少添加基本信息
portInfoList.Add(new SerialPortInfo
{
PortName = portName,
Description = "Unknown",
IsInUse = IsPortInUse(portName).Value
});
}
}
return portInfoList.OrderBy(p => p.PortName).ToList();
});
}
/// <summary>
/// 检查指定串口是否存在
/// </summary>
/// <param name="portName">端口名称</param>
/// <returns>是否存在</returns>
public static Result<bool> PortExists(string portName)
{
return Result.Try(() =>
{
if (string.IsNullOrWhiteSpace(portName))
return false;
var availablePorts = SerialPort.GetPortNames();
return availablePorts.Contains(portName, StringComparer.OrdinalIgnoreCase);
});
}
/// <summary>
/// 检查串口是否被占用
/// </summary>
/// <param name="portName">端口名称</param>
/// <returns>是否被占用</returns>
public static Result<bool> IsPortInUse(string portName)
{
return Result.Try(() =>
{
if (string.IsNullOrWhiteSpace(portName))
return false;
try
{
using var port = new SerialPort(portName);
port.Open();
port.Close();
return false; // 能够打开说明没有被占用
}
catch (UnauthorizedAccessException)
{
return true; // 无权访问说明被占用
}
catch (IOException)
{
return true; // IO异常也可能是被占用
}
catch
{
return false; // 其他异常认为不是被占用
}
});
}
/// <summary>
/// 获取串口详细信息
/// </summary>
/// <param name="portName">端口名称</param>
/// <returns>串口信息</returns>
public static Result<SerialPortInfo> GetPortInfo(string portName)
{
return Result.Try(() =>
{
var portInfo = new SerialPortInfo
{
PortName = portName,
IsInUse = IsPortInUse(portName).Value
};
// 仅在Windows平台获取详细信息
if (OperatingSystem.IsWindows())
{
try
{
EnrichPortInfoFromWmi(portInfo);
}
catch
{
// 如果WMI查询失败,返回基本信息
}
}
return portInfo;
});
}
/// <summary>
/// 获取USB转串口设备
/// </summary>
/// <returns>USB串口列表</returns>
public static Result<List<SerialPortInfo>> GetUsbSerialPorts()
{
return Result.Try(() =>
{
var allPorts = GetAvailablePorts();
if (allPorts.IsFailure || allPorts.Value == null)
return new List<SerialPortInfo>();
return allPorts.Value
.Where(p => p.IsUsbSerial)
.ToList();
});
}
/// <summary>
/// 通过WMI获取串口详细信息(仅Windows)
/// </summary>
private static void EnrichPortInfoFromWmi(SerialPortInfo portInfo)
{
if (!OperatingSystem.IsWindows())
return;
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT * FROM Win32_PnPEntity WHERE Name LIKE '%(COM%'");
foreach (ManagementObject obj in searcher.Get())
{
var name = obj["Name"]?.ToString() ?? string.Empty;
// 检查是否是当前端口
if (!name.Contains(portInfo.PortName, StringComparison.OrdinalIgnoreCase))
continue;
portInfo.Description = name;
portInfo.DeviceId = obj["DeviceID"]?.ToString() ?? string.Empty;
portInfo.Manufacturer = obj["Manufacturer"]?.ToString() ?? string.Empty;
// 判断是否为USB设备
var deviceId = portInfo.DeviceId.ToUpperInvariant();
portInfo.IsUsbSerial = deviceId.Contains("USB");
// 提取VID和PID
if (portInfo.IsUsbSerial)
{
ExtractVidPid(portInfo.DeviceId, out var vid, out var pid);
portInfo.VendorId = vid;
portInfo.ProductId = pid;
}
break;
}
}
catch
{
// WMI查询失败时忽略
}
}
/// <summary>
/// 从设备ID中提取VID和PID
/// </summary>
private static void ExtractVidPid(string deviceId, out string vid, out string pid)
{
vid = string.Empty;
pid = string.Empty;
if (string.IsNullOrEmpty(deviceId))
return;
// 格式示例: USB\VID_0403&PID_6001\...
var vidIndex = deviceId.IndexOf("VID_", StringComparison.OrdinalIgnoreCase);
var pidIndex = deviceId.IndexOf("PID_", StringComparison.OrdinalIgnoreCase);
if (vidIndex >= 0)
{
var vidStart = vidIndex + 4;
var vidEnd = deviceId.IndexOf('&', vidStart);
if (vidEnd < 0) vidEnd = deviceId.IndexOf('\\', vidStart);
if (vidEnd < 0) vidEnd = deviceId.Length;
vid = deviceId.Substring(vidStart, vidEnd - vidStart);
}
if (pidIndex >= 0)
{
var pidStart = pidIndex + 4;
var pidEnd = deviceId.IndexOf('&', pidStart);
if (pidEnd < 0) pidEnd = deviceId.IndexOf('\\', pidStart);
if (pidEnd < 0) pidEnd = deviceId.Length;
pid = deviceId.Substring(pidStart, pidEnd - pidStart);
}
}
}
-263
View File
@@ -1,263 +0,0 @@
using System.IO.Ports;
using Common.Results;
using Ports.Abstractions;
using Ports.Models;
namespace Ports.Drivers;
/// <summary>
/// 串口驱动实现
/// </summary>
public class SerialPortDriver : ISerialPortDriver
{
private SerialPort? _serialPort;
private SerialPortOptions? _options;
private readonly SemaphoreSlim _lock = new(1, 1);
private bool _disposed;
/// <inheritdoc />
public bool IsOpen => _serialPort?.IsOpen ?? false;
/// <inheritdoc />
public string? PortName => _serialPort?.PortName;
/// <inheritdoc />
public int BytesToRead => _serialPort?.BytesToRead ?? 0;
/// <inheritdoc />
public int BytesToWrite => _serialPort?.BytesToWrite ?? 0;
/// <inheritdoc />
public async Task<Result> OpenAsync(SerialPortOptions options, CancellationToken cancellationToken = default)
{
if (options == null)
return Result.Failure("配置选项不能为空");
await _lock.WaitAsync(cancellationToken);
try
{
if (IsOpen)
return Result.Failure("串口已经打开");
// 验证配置
var validation = ValidateOptions(options);
if (validation.IsFailure)
return validation;
_options = options;
_serialPort = new SerialPort
{
PortName = options.PortName,
BaudRate = options.BaudRate,
DataBits = options.DataBits,
StopBits = options.StopBits,
Parity = options.Parity,
Handshake = options.Handshake,
ReadTimeout = options.ReadTimeout,
WriteTimeout = options.WriteTimeout,
Encoding = options.Encoding,
NewLine = options.NewLine,
ReadBufferSize = options.ReceiveBufferSize,
WriteBufferSize = options.TransmitBufferSize
};
return await Result.TryAsync(async () =>
{
await Task.Run(() => _serialPort.Open(), cancellationToken);
});
}
finally
{
_lock.Release();
}
}
/// <inheritdoc />
public async Task<Result> CloseAsync()
{
await _lock.WaitAsync();
try
{
if (!IsOpen)
return Result.Success();
return await Result.TryAsync(async () =>
{
await Task.Run(() =>
{
_serialPort?.Close();
_serialPort?.Dispose();
_serialPort = null;
});
});
}
finally
{
_lock.Release();
}
}
/// <inheritdoc />
public async Task<Result<byte[]>> ReadBytesAsync(int count, CancellationToken cancellationToken = default)
{
if (!IsOpen || _serialPort == null)
return Result.Failure<byte[]>("串口未打开");
if (count <= 0)
return Result.Failure<byte[]>("读取数量必须大于0");
return await Result.TryAsync(async () =>
{
var buffer = new byte[count];
var totalRead = 0;
while (totalRead < count && !cancellationToken.IsCancellationRequested)
{
var bytesToRead = Math.Min(count - totalRead, _serialPort.BytesToRead);
if (bytesToRead > 0)
{
var read = await Task.Run(() =>
_serialPort.Read(buffer, totalRead, bytesToRead), cancellationToken);
totalRead += read;
}
else
{
await Task.Delay(10, cancellationToken); // 短暂等待数据
}
}
if (totalRead < count)
{
var actualData = new byte[totalRead];
Array.Copy(buffer, actualData, totalRead);
return actualData;
}
return buffer;
});
}
/// <inheritdoc />
public async Task<Result> WriteBytesAsync(byte[] data, CancellationToken cancellationToken = default)
{
if (!IsOpen || _serialPort == null)
return Result.Failure("串口未打开");
if (data == null || data.Length == 0)
return Result.Failure("数据不能为空");
return await Result.TryAsync(async () =>
{
await Task.Run(() => _serialPort.Write(data, 0, data.Length), cancellationToken);
});
}
/// <inheritdoc />
public async Task<Result<string>> ReadLineAsync(CancellationToken cancellationToken = default)
{
if (!IsOpen || _serialPort == null)
return Result.Failure<string>("串口未打开");
return await Result.TryAsync(async () =>
{
return await Task.Run(() => _serialPort.ReadLine(), cancellationToken);
});
}
/// <inheritdoc />
public async Task<Result> WriteLineAsync(string data, CancellationToken cancellationToken = default)
{
if (!IsOpen || _serialPort == null)
return Result.Failure("串口未打开");
if (data == null)
return Result.Failure("数据不能为空");
return await Result.TryAsync(async () =>
{
await Task.Run(() => _serialPort.WriteLine(data), cancellationToken);
});
}
/// <inheritdoc />
public async Task<Result<byte[]>> ReadAvailableAsync()
{
if (!IsOpen || _serialPort == null)
return Result.Failure<byte[]>("串口未打开");
return await Result.TryAsync(async () =>
{
var count = _serialPort.BytesToRead;
if (count == 0)
return Array.Empty<byte>();
var buffer = new byte[count];
await Task.Run(() => _serialPort.Read(buffer, 0, count));
return buffer;
});
}
/// <inheritdoc />
public void ClearInputBuffer()
{
if (IsOpen && _serialPort != null)
{
_serialPort.DiscardInBuffer();
}
}
/// <inheritdoc />
public void ClearOutputBuffer()
{
if (IsOpen && _serialPort != null)
{
_serialPort.DiscardOutBuffer();
}
}
/// <summary>
/// 验证配置选项
/// </summary>
private Result ValidateOptions(SerialPortOptions options)
{
if (string.IsNullOrWhiteSpace(options.PortName))
return Result.Failure("端口名称不能为空");
if (options.BaudRate <= 0)
return Result.Failure("波特率必须大于0");
if (options.DataBits < 5 || options.DataBits > 8)
return Result.Failure("数据位必须在5-8之间");
if (options.ReadTimeout < 0)
return Result.Failure("读取超时不能为负数");
if (options.WriteTimeout < 0)
return Result.Failure("写入超时不能为负数");
return Result.Success();
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
return;
_lock.Wait();
try
{
_serialPort?.Close();
_serialPort?.Dispose();
_serialPort = null;
_disposed = true;
}
finally
{
_lock.Release();
_lock.Dispose();
}
GC.SuppressFinalize(this);
}
}
@@ -1,239 +0,0 @@
using Ports.Discovery;
using Ports.Drivers;
using Ports.Models;
using Ports.Services;
using Ports.Utils;
namespace Ports.Examples;
/// <summary>
/// 串口通信模块使用示例
/// </summary>
public class SerialPortExamples
{
/// <summary>
/// 示例1:发现串口
/// </summary>
public static void Example01_DiscoverPorts()
{
Console.WriteLine("=== 示例1:发现串口 ===\n");
// 获取所有可用串口
var portsResult = SerialPortDiscovery.GetAvailablePorts();
if (portsResult.IsSuccess && portsResult.Value != null)
{
Console.WriteLine($"找到 {portsResult.Value.Count} 个串口:\n");
foreach (var port in portsResult.Value)
{
Console.WriteLine($"端口: {port.PortName}");
Console.WriteLine($" 描述: {port.Description}");
Console.WriteLine($" 制造商: {port.Manufacturer}");
Console.WriteLine($" USB设备: {port.IsUsbSerial}");
Console.WriteLine($" 被占用: {port.IsInUse}");
if (port.IsUsbSerial)
{
Console.WriteLine($" VID: {port.VendorId}, PID: {port.ProductId}");
}
Console.WriteLine();
}
}
}
/// <summary>
/// 示例2:使用驱动层直接通信
/// </summary>
public static async Task Example02_UseDriver()
{
Console.WriteLine("=== 示例2:使用驱动层通信 ===\n");
using var driver = new SerialPortDriver();
var options = new SerialPortOptions
{
PortName = "COM1",
BaudRate = 9600,
DataBits = 8
};
// 打开连接
var openResult = await driver.OpenAsync(options);
if (openResult.IsSuccess)
{
Console.WriteLine("串口打开成功");
// 发送数据
var data = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
var sendResult = await driver.WriteBytesAsync(data);
if (sendResult.IsSuccess)
{
Console.WriteLine("数据发送成功");
// 等待响应
await Task.Delay(100);
// 读取响应
if (driver.BytesToRead > 0)
{
var readResult = await driver.ReadAvailableAsync();
if (readResult.IsSuccess && readResult.Value != null)
{
Console.WriteLine($"接收到 {readResult.Value.Length} 字节");
var hexResult = SerialDataConverter.BytesToHexString(readResult.Value);
Console.WriteLine($"数据: {hexResult.Value}");
}
}
}
await driver.CloseAsync();
}
else
{
Console.WriteLine($"打开串口失败: {openResult.ErrorMessage}");
}
}
/// <summary>
/// 示例3:使用服务层监听数据
/// </summary>
public static async Task Example03_UseService()
{
Console.WriteLine("=== 示例3:使用服务层监听 ===\n");
using var service = new SerialPortService(new SerialPortDriver());
// 订阅事件
service.DataReceived += (sender, e) =>
{
Console.WriteLine($"[{e.ReceivedAt:HH:mm:ss}] 收到数据: {e.Data.Length} 字节");
var hex = SerialDataConverter.BytesToHexString(e.Data);
Console.WriteLine($" 数据: {hex.Value}");
};
service.ErrorOccurred += (sender, e) =>
{
Console.WriteLine($"[错误] {e.ErrorMessage}");
};
service.ConnectionStateChanged += (sender, e) =>
{
Console.WriteLine($"[状态] {e.PreviousState} -> {e.CurrentState}");
};
var options = new SerialPortOptions
{
PortName = "COM1",
BaudRate = 9600
};
// 启动监听(支持自动重连)
var startResult = await service.StartListeningAsync(options, autoReconnect: true);
if (startResult.IsSuccess)
{
Console.WriteLine("监听已启动,按任意键发送测试数据...");
Console.ReadKey();
// 发送测试数据
await service.SendAsync(new byte[] { 0x01, 0x02, 0x03 });
Console.WriteLine("按任意键停止监听...");
Console.ReadKey();
await service.StopListeningAsync();
}
}
/// <summary>
/// 示例4:数据转换
/// </summary>
public static void Example04_DataConversion()
{
Console.WriteLine("=== 示例4:数据转换 ===\n");
// 整数转字节
var int32Bytes = SerialDataConverter.Int32ToBytes(12345, isBigEndian: true);
Console.WriteLine($"整数 12345 -> 字节: {SerialDataConverter.BytesToHexString(int32Bytes.Value!).Value}");
// 字节转整数
var intValue = SerialDataConverter.BytesToInt32(int32Bytes.Value!, isBigEndian: true);
Console.WriteLine($"字节 -> 整数: {intValue.Value}");
// 十六进制字符串
var hexBytes = SerialDataConverter.HexStringToBytes("01 02 03 04");
Console.WriteLine($"十六进制 '01 02 03 04' -> 字节数组: {hexBytes.Value!.Length} 字节");
// 字符串转换
var strBytes = SerialDataConverter.StringToBytes("Hello");
Console.WriteLine($"字符串 'Hello' -> 字节: {SerialDataConverter.BytesToHexString(strBytes.Value!).Value}");
}
/// <summary>
/// 示例5:校验计算
/// </summary>
public static void Example05_Checksum()
{
Console.WriteLine("=== 示例5:校验计算 ===\n");
var data = new byte[] { 0x01, 0x03, 0x00, 0x00, 0x00, 0x01 };
// CRC16(Modbus)
var crc16 = SerialChecksumHelper.CalculateCrc16(data);
Console.WriteLine($"CRC16: 0x{crc16.Value:X4}");
// LRC
var lrc = SerialChecksumHelper.CalculateLrc(data);
Console.WriteLine($"LRC: 0x{lrc.Value:X2}");
// XOR
var xor = SerialChecksumHelper.CalculateXor(data);
Console.WriteLine($"XOR: 0x{xor.Value:X2}");
// 累加和
var checksum = SerialChecksumHelper.CalculateChecksum(data);
Console.WriteLine($"Checksum: 0x{checksum.Value:X2}");
}
/// <summary>
/// 示例6:诊断工具
/// </summary>
public static async Task Example06_Diagnostics()
{
Console.WriteLine("=== 示例6:诊断工具 ===\n");
var options = new SerialPortOptions
{
PortName = "COM1",
BaudRate = 9600
};
// 测试连接
var testResult = await SerialPortDiagnostics.TestConnectionAsync(
"COM1",
options,
testData: new byte[] { 0x01, 0x02 }
);
if (testResult.IsSuccess)
{
Console.WriteLine("连接测试结果:");
Console.WriteLine(testResult.Value);
}
// 批量测试波特率
var baudRateResults = await SerialPortDiagnostics.TestBaudRatesAsync(
"COM1",
9600, 19200, 38400, 115200
);
if (baudRateResults.IsSuccess)
{
Console.WriteLine("\n波特率测试结果:");
foreach (var kvp in baudRateResults.Value!)
{
Console.WriteLine($" {kvp.Key} bps: {(kvp.Value ? "✓" : "✗")}");
}
}
}
}
-6
View File
@@ -1,6 +0,0 @@
// 全局命名空间引入
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
@@ -1,58 +0,0 @@
namespace Ports.Models;
/// <summary>
/// 连接状态
/// </summary>
public enum ConnectionState
{
/// <summary>
/// 已断开
/// </summary>
Disconnected,
/// <summary>
/// 连接中
/// </summary>
Connecting,
/// <summary>
/// 已连接
/// </summary>
Connected,
/// <summary>
/// 断开连接中
/// </summary>
Disconnecting,
/// <summary>
/// 错误
/// </summary>
Error
}
/// <summary>
/// 连接状态变化事件参数
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>
/// 之前的状态
/// </summary>
public ConnectionState PreviousState { get; init; }
/// <summary>
/// 当前状态
/// </summary>
public ConnectionState CurrentState { get; init; }
/// <summary>
/// 变化时间
/// </summary>
public DateTime ChangedAt { get; init; } = DateTime.Now;
/// <summary>
/// 端口名称
/// </summary>
public required string PortName { get; init; }
}
@@ -1,22 +0,0 @@
namespace Ports.Models;
/// <summary>
/// 数据接收事件参数
/// </summary>
public class DataReceivedEventArgs : EventArgs
{
/// <summary>
/// 接收到的数据
/// </summary>
public required byte[] Data { get; init; }
/// <summary>
/// 接收时间
/// </summary>
public DateTime ReceivedAt { get; init; } = DateTime.Now;
/// <summary>
/// 端口名称
/// </summary>
public required string PortName { get; init; }
}
-27
View File
@@ -1,27 +0,0 @@
namespace Ports.Models;
/// <summary>
/// 错误事件参数
/// </summary>
public class ErrorEventArgs : EventArgs
{
/// <summary>
/// 异常信息
/// </summary>
public required Exception Exception { get; init; }
/// <summary>
/// 错误消息
/// </summary>
public string ErrorMessage => Exception.Message;
/// <summary>
/// 发生时间
/// </summary>
public DateTime OccurredAt { get; init; } = DateTime.Now;
/// <summary>
/// 端口名称
/// </summary>
public required string PortName { get; init; }
}
-47
View File
@@ -1,47 +0,0 @@
namespace Ports.Models;
/// <summary>
/// 串口信息模型
/// </summary>
public class SerialPortInfo
{
/// <summary>
/// 端口名称(如 COM1, COM2)
/// </summary>
public required string PortName { get; set; }
/// <summary>
/// 端口描述
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// 制造商
/// </summary>
public string Manufacturer { get; set; } = string.Empty;
/// <summary>
/// 设备ID
/// </summary>
public string DeviceId { get; set; } = string.Empty;
/// <summary>
/// 是否被占用
/// </summary>
public bool IsInUse { get; set; }
/// <summary>
/// 厂商ID(VID)
/// </summary>
public string VendorId { get; set; } = string.Empty;
/// <summary>
/// 产品ID(PID)
/// </summary>
public string ProductId { get; set; } = string.Empty;
/// <summary>
/// 是否为USB转串口设备
/// </summary>
public bool IsUsbSerial { get; set; }
}
@@ -1,70 +0,0 @@
using System.IO.Ports;
using System.Text;
namespace Ports.Models;
/// <summary>
/// 串口配置选项
/// </summary>
public class SerialPortOptions
{
/// <summary>
/// 端口名称(必填)
/// </summary>
public required string PortName { get; set; }
/// <summary>
/// 波特率(默认 9600)
/// </summary>
public int BaudRate { get; set; } = 9600;
/// <summary>
/// 数据位(默认 8)
/// </summary>
public int DataBits { get; set; } = 8;
/// <summary>
/// 停止位(默认 One)
/// </summary>
public StopBits StopBits { get; set; } = StopBits.One;
/// <summary>
/// 校验位(默认 None)
/// </summary>
public Parity Parity { get; set; } = Parity.None;
/// <summary>
/// 流控制(默认 None)
/// </summary>
public Handshake Handshake { get; set; } = Handshake.None;
/// <summary>
/// 读取超时(毫秒,默认 500)
/// </summary>
public int ReadTimeout { get; set; } = 500;
/// <summary>
/// 写入超时(毫秒,默认 500)
/// </summary>
public int WriteTimeout { get; set; } = 500;
/// <summary>
/// 字符编码(默认 UTF8)
/// </summary>
public Encoding Encoding { get; set; } = Encoding.UTF8;
/// <summary>
/// 换行符(默认 \r\n)
/// </summary>
public string NewLine { get; set; } = "\r\n";
/// <summary>
/// 接收缓冲区大小(字节,默认 4096)
/// </summary>
public int ReceiveBufferSize { get; set; } = 4096;
/// <summary>
/// 发送缓冲区大小(字节,默认 2048)
/// </summary>
public int TransmitBufferSize { get; set; } = 2048;
}
-20
View File
@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Ports</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Common\Common.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
<PackageReference Include="System.Management" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
</ItemGroup>
</Project>
-300
View File
@@ -1,300 +0,0 @@
# Ports - 串口通信模块
通用的串口通信基础设施模块,提供发现、连接、数据传输和诊断等完整能力。
## 功能特性
### 第一层:发现与查询
- 枚举所有可用串口
- 检查串口是否存在和被占用
- 获取串口详细信息(描述、厂商、VID/PID)
- 智能识别USB转串口设备
### 第二层:连接与通信
- 打开/关闭串口连接
- 读写字节数据
- 读写行数据
- 缓冲区管理
- 超时控制
### 第三层:高级功能
- 自动监听数据
- 自动重连机制
- 数据队列发送
- 请求-响应模式
- 事件通知(数据接收、错误、状态变化)
### 第四层:工具扩展
- 数据转换(字节↔基本类型、十六进制)
- 校验计算(CRC16、LRC、XOR、累加和)
- 诊断工具(连接测试、流量监控、波特率测试)
## 快速开始
### 1. 发现串口
```csharp
using Ports.Discovery;
// 获取所有串口
var portsResult = SerialPortDiscovery.GetAvailablePorts();
if (portsResult.IsSuccess)
{
foreach (var port in portsResult.Value)
{
Console.WriteLine($"{port.PortName}: {port.Description}");
Console.WriteLine($" 制造商: {port.Manufacturer}");
Console.WriteLine($" 被占用: {port.IsInUse}");
}
}
// 获取USB串口
var usbPortsResult = SerialPortDiscovery.GetUsbSerialPorts();
// 检查端口是否存在
var existsResult = SerialPortDiscovery.PortExists("COM1");
```
### 2. 使用驱动层(低级API)
```csharp
using Ports.Abstractions;
using Ports.Drivers;
using Ports.Models;
// 创建驱动实例
using var driver = new SerialPortDriver();
// 配置串口
var options = new SerialPortOptions
{
PortName = "COM1",
BaudRate = 9600,
DataBits = 8,
StopBits = StopBits.One,
Parity = Parity.None
};
// 打开连接
var openResult = await driver.OpenAsync(options);
if (openResult.IsSuccess)
{
// 发送数据
var data = new byte[] { 0x01, 0x02, 0x03 };
await driver.WriteBytesAsync(data);
// 读取数据
var readResult = await driver.ReadBytesAsync(10);
if (readResult.IsSuccess)
{
Console.WriteLine($"读取到 {readResult.Value.Length} 字节");
}
// 关闭连接
await driver.CloseAsync();
}
```
### 3. 使用服务层(高级API)
```csharp
using Ports.Services;
// 创建服务实例
using var service = new SerialPortService(new SerialPortDriver());
// 订阅事件
service.DataReceived += (sender, e) =>
{
Console.WriteLine($"接收到数据: {e.Data.Length} 字节");
};
service.ErrorOccurred += (sender, e) =>
{
Console.WriteLine($"错误: {e.ErrorMessage}");
};
service.ConnectionStateChanged += (sender, e) =>
{
Console.WriteLine($"状态变化: {e.PreviousState} -> {e.CurrentState}");
};
// 启动监听(支持自动重连)
var options = new SerialPortOptions { PortName = "COM1" };
await service.StartListeningAsync(options, autoReconnect: true);
// 发送数据
await service.SendAsync(new byte[] { 0x01, 0x02 });
// 发送并等待响应
var response = await service.SendAndReceiveAsync(
new byte[] { 0x01, 0x03 },
TimeSpan.FromSeconds(5)
);
// 停止监听
await service.StopListeningAsync();
```
### 4. 使用依赖注入
```csharp
using Ports.DependencyInjection;
// 在Startup或Program.cs中注册
services.AddSerialPortService(); // 单例模式
// 或注册瞬时模式
services.AddSerialPortServiceTransient();
// 或注册命名服务(支持多实例)
services.AddNamedSerialPortService("Port1");
services.AddNamedSerialPortService("Port2");
// 在类中注入使用
public class MyService
{
private readonly ISerialPortService _serialPort;
public MyService(ISerialPortService serialPort)
{
_serialPort = serialPort;
}
}
```
### 5. 数据转换
```csharp
using Ports.Utils;
// 字节转整数
var int32Result = SerialDataConverter.BytesToInt32(bytes, isBigEndian: true);
// 整数转字节
var bytesResult = SerialDataConverter.Int32ToBytes(12345);
// 十六进制转换
var hexResult = SerialDataConverter.BytesToHexString(bytes, separator: " ");
var bytesFromHex = SerialDataConverter.HexStringToBytes("01 02 03");
// 字符串转换
var strBytes = SerialDataConverter.StringToBytes("Hello", Encoding.ASCII);
```
### 6. 校验计算
```csharp
using Ports.Utils;
var data = new byte[] { 0x01, 0x02, 0x03 };
// CRC16(Modbus)
var crc16 = SerialChecksumHelper.CalculateCrc16(data);
// LRC
var lrc = SerialChecksumHelper.CalculateLrc(data);
// XOR
var xor = SerialChecksumHelper.CalculateXor(data);
// 验证校验码
var isValid = SerialChecksumHelper.VerifyCrc16(dataWithCrc);
```
### 7. 诊断工具
```csharp
using Ports.Utils;
// 测试连接
var testResult = await SerialPortDiagnostics.TestConnectionAsync(
"COM1",
options,
testData: new byte[] { 0x01 }
);
Console.WriteLine(testResult.Value);
// 监控流量
var trafficResult = await SerialPortDiagnostics.MonitorTrafficAsync(
"COM1",
options,
onData: (data, isSent) => Console.WriteLine($"{(isSent ? "发送" : "接收")}: {data.Length} 字节"),
duration: TimeSpan.FromSeconds(10)
);
Console.WriteLine(trafficResult.Value);
// 批量测试波特率
var baudRateResults = await SerialPortDiagnostics.TestBaudRatesAsync(
"COM1",
9600, 19200, 38400, 115200
);
```
## 架构设计
```
Ports/
├── Models/ # 数据模型
│ ├── SerialPortInfo
│ ├── SerialPortOptions
│ └── EventArgs
├── Abstractions/ # 接口定义
│ ├── ISerialPortDriver
│ └── ISerialPortService
├── Discovery/ # 串口发现
│ └── SerialPortDiscovery
├── Drivers/ # 驱动层实现
│ └── SerialPortDriver
├── Services/ # 服务层实现
│ └── SerialPortService
├── Utils/ # 工具类
│ ├── SerialDataConverter
│ ├── SerialChecksumHelper
│ └── SerialPortDiagnostics
└── DependencyInjection/ # DI扩展
└── ServiceCollectionExtensions
```
## 最佳实践
1. **使用 Result 模式处理错误**
```csharp
var result = await driver.OpenAsync(options);
if (result.IsFailure)
{
Console.WriteLine($"错误: {result.ErrorMessage}");
return;
}
```
2. **始终释放资源**
```csharp
using var driver = new SerialPortDriver();
// 使用完自动释放
```
3. **处理自动重连**
```csharp
await service.StartListeningAsync(options, autoReconnect: true);
// 连接断开时会自动尝试重连
```
4. **订阅事件监听数据**
```csharp
service.DataReceived += (s, e) => ProcessData(e.Data);
service.ErrorOccurred += (s, e) => LogError(e.Exception);
```
## 平台支持
- Windows: 完整支持(包括WMI设备信息查询)
- Linux: 支持基础功能
- macOS: 支持基础功能
## 依赖项
- .NET 8.0+
- System.IO.Ports
- System.Management (仅Windows)
- Common (项目内部依赖)
@@ -1,391 +0,0 @@
using System.Collections.Concurrent;
using System.IO.Ports;
using Common.Results;
using Ports.Abstractions;
using Ports.Models;
namespace Ports.Services;
/// <summary>
/// 串口服务实现
/// </summary>
public class SerialPortService : ISerialPortService
{
private readonly ISerialPortDriver _driver;
private SerialPortOptions? _options;
private ConnectionState _state = ConnectionState.Disconnected;
private bool _autoReconnect;
private CancellationTokenSource? _listeningCts;
private Task? _listeningTask;
private readonly ConcurrentQueue<byte[]> _sendQueue = new();
private readonly SemaphoreSlim _sendSemaphore = new(0);
private Task? _sendTask;
private bool _disposed;
/// <inheritdoc />
public ConnectionState State => _state;
/// <inheritdoc />
public string? PortName => _driver.PortName;
/// <inheritdoc />
public event EventHandler<DataReceivedEventArgs>? DataReceived;
/// <inheritdoc />
public event EventHandler<Models.ErrorEventArgs>? ErrorOccurred;
/// <inheritdoc />
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
public SerialPortService(ISerialPortDriver driver)
{
_driver = driver ?? throw new ArgumentNullException(nameof(driver));
}
/// <inheritdoc />
public async Task<Result> StartListeningAsync(SerialPortOptions options, bool autoReconnect = false)
{
if (_state != ConnectionState.Disconnected)
return Result.Failure("服务已经启动");
_options = options ?? throw new ArgumentNullException(nameof(options));
_autoReconnect = autoReconnect;
ChangeState(ConnectionState.Connecting);
var openResult = await _driver.OpenAsync(options);
if (openResult.IsFailure)
{
ChangeState(ConnectionState.Error);
return openResult;
}
ChangeState(ConnectionState.Connected);
// 启动监听任务
_listeningCts = new CancellationTokenSource();
_listeningTask = Task.Run(() => ListeningLoop(_listeningCts.Token));
// 启动发送任务
_sendTask = Task.Run(() => SendLoop(_listeningCts.Token));
return Result.Success();
}
/// <inheritdoc />
public async Task<Result> StopListeningAsync()
{
if (_state == ConnectionState.Disconnected)
return Result.Success();
ChangeState(ConnectionState.Disconnecting);
// 停止监听和发送任务
_listeningCts?.Cancel();
// 等待任务完成,忽略取消异常
try
{
if (_listeningTask != null)
await _listeningTask;
}
catch (OperationCanceledException)
{
// 任务被取消是预期行为,忽略
}
try
{
if (_sendTask != null)
await _sendTask;
}
catch (OperationCanceledException)
{
// 任务被取消是预期行为,忽略
}
// 关闭驱动
var closeResult = await _driver.CloseAsync();
_listeningCts?.Dispose();
_listeningCts = null;
ChangeState(ConnectionState.Disconnected);
return closeResult;
}
/// <inheritdoc />
public Task<Result> SendAsync(byte[] data)
{
if (_state != ConnectionState.Connected)
return Task.FromResult(Result.Failure("服务未连接"));
if (data == null || data.Length == 0)
return Task.FromResult(Result.Failure("数据不能为空"));
_sendQueue.Enqueue(data);
_sendSemaphore.Release();
return Task.FromResult(Result.Success());
}
/// <inheritdoc />
public async Task<Result<byte[]>> SendAndReceiveAsync(byte[] data, TimeSpan timeout)
{
if (_state != ConnectionState.Connected)
return Result.Failure<byte[]>("服务未连接");
var responseReceived = false;
byte[]? responseData = null;
var tcs = new TaskCompletionSource<byte[]>();
void OnDataReceived(object? sender, DataReceivedEventArgs e)
{
if (!responseReceived)
{
responseReceived = true;
responseData = e.Data;
tcs.TrySetResult(e.Data);
}
}
try
{
DataReceived += OnDataReceived;
// 发送数据
var sendResult = await _driver.WriteBytesAsync(data);
if (sendResult.IsFailure)
return Result.Failure<byte[]>(sendResult.ErrorMessage ?? "发送失败");
// 等待响应
using var cts = new CancellationTokenSource(timeout);
cts.Token.Register(() => tcs.TrySetCanceled());
try
{
responseData = await tcs.Task;
return Result.Success(responseData);
}
catch (TaskCanceledException)
{
return Result.Failure<byte[]>("等待响应超时");
}
}
finally
{
DataReceived -= OnDataReceived;
}
}
/// <summary>
/// 监听循环
/// </summary>
private async Task ListeningLoop(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
if (!_driver.IsOpen)
{
if (_autoReconnect && _options != null)
{
await TryReconnectAsync(cancellationToken);
}
else
{
break;
}
}
if (_driver.BytesToRead > 0)
{
var result = await _driver.ReadAvailableAsync();
if (result.IsSuccess && result.Value != null && result.Value.Length > 0)
{
OnDataReceived(result.Value);
}
}
else
{
await Task.Delay(10, cancellationToken); // 短暂等待
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
OnError(ex);
if (_autoReconnect && _options != null)
{
await TryReconnectAsync(cancellationToken);
}
else
{
break;
}
}
}
}
/// <summary>
/// 发送循环
/// </summary>
private async Task SendLoop(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await _sendSemaphore.WaitAsync(cancellationToken);
if (_sendQueue.TryDequeue(out var data))
{
var result = await _driver.WriteBytesAsync(data, cancellationToken);
if (result.IsFailure)
{
OnError(new Exception(result.ErrorMessage));
}
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
OnError(ex);
}
}
}
/// <summary>
/// 尝试重连
/// </summary>
private async Task TryReconnectAsync(CancellationToken cancellationToken)
{
if (_options == null)
return;
ChangeState(ConnectionState.Disconnecting);
await _driver.CloseAsync();
var retryCount = 0;
var maxRetries = 5;
var retryDelay = TimeSpan.FromSeconds(2);
while (retryCount < maxRetries && !cancellationToken.IsCancellationRequested)
{
try
{
ChangeState(ConnectionState.Connecting);
await Task.Delay(retryDelay, cancellationToken);
var result = await _driver.OpenAsync(_options, cancellationToken);
if (result.IsSuccess)
{
ChangeState(ConnectionState.Connected);
return;
}
retryCount++;
retryDelay = TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, retryCount)));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
OnError(ex);
retryCount++;
}
}
ChangeState(ConnectionState.Error);
}
/// <summary>
/// 触发数据接收事件
/// </summary>
private void OnDataReceived(byte[] data)
{
if (_options == null)
return;
DataReceived?.Invoke(this, new DataReceivedEventArgs
{
Data = data,
PortName = _options.PortName,
ReceivedAt = DateTime.Now
});
}
/// <summary>
/// 触发错误事件
/// </summary>
private void OnError(Exception exception)
{
if (_options == null)
return;
ErrorOccurred?.Invoke(this, new Models.ErrorEventArgs
{
Exception = exception,
PortName = _options.PortName,
OccurredAt = DateTime.Now
});
}
/// <summary>
/// 改变状态
/// </summary>
private void ChangeState(ConnectionState newState)
{
if (_state == newState)
return;
var oldState = _state;
_state = newState;
if (_options != null)
{
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs
{
PreviousState = oldState,
CurrentState = newState,
PortName = _options.PortName,
ChangedAt = DateTime.Now
});
}
}
/// <inheritdoc />
public void Dispose()
{
if (_disposed)
return;
try
{
// 使用ConfigureAwait(false).GetAwaiter().GetResult()避免死锁
// 并捕获可能的取消异常
StopListeningAsync().ConfigureAwait(false).GetAwaiter().GetResult();
}
catch (OperationCanceledException)
{
// 忽略取消异常
}
catch
{
// 忽略其他清理异常
}
try
{
_driver.Dispose();
}
catch
{
// 忽略驱动释放异常
}
_sendSemaphore.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
}
@@ -1,199 +0,0 @@
using Common.Results;
namespace Ports.Utils;
/// <summary>
/// 串口校验工具
/// </summary>
public static class SerialChecksumHelper
{
/// <summary>
/// 计算CRC16(Modbus)
/// </summary>
/// <param name="data">数据</param>
/// <returns>CRC16校验码</returns>
public static Result<ushort> CalculateCrc16(byte[] data)
{
return Result.Try(() =>
{
if (data == null || data.Length == 0)
throw new ArgumentException("数据不能为空");
ushort crc = 0xFFFF;
foreach (var b in data)
{
crc ^= b;
for (int i = 0; i < 8; i++)
{
if ((crc & 0x0001) != 0)
{
crc >>= 1;
crc ^= 0xA001;
}
else
{
crc >>= 1;
}
}
}
return crc;
});
}
/// <summary>
/// 验证CRC16校验码
/// </summary>
/// <param name="data">数据(包含校验码)</param>
/// <returns>是否有效</returns>
public static Result<bool> VerifyCrc16(byte[] data)
{
return Result.Try(() =>
{
if (data == null || data.Length < 3)
return false;
var dataWithoutCrc = new byte[data.Length - 2];
Array.Copy(data, dataWithoutCrc, dataWithoutCrc.Length);
var calculatedCrc = CalculateCrc16(dataWithoutCrc).Value;
var providedCrc = (ushort)(data[^2] | (data[^1] << 8));
return calculatedCrc == providedCrc;
});
}
/// <summary>
/// 计算LRC(纵向冗余校验)
/// </summary>
/// <param name="data">数据</param>
/// <returns>LRC校验码</returns>
public static Result<byte> CalculateLrc(byte[] data)
{
return Result.Try(() =>
{
if (data == null || data.Length == 0)
throw new ArgumentException("数据不能为空");
byte lrc = 0;
foreach (var b in data)
{
lrc += b;
}
return (byte)(-lrc);
});
}
/// <summary>
/// 验证LRC校验码
/// </summary>
/// <param name="data">数据(包含校验码)</param>
/// <returns>是否有效</returns>
public static Result<bool> VerifyLrc(byte[] data)
{
return Result.Try(() =>
{
if (data == null || data.Length < 2)
return false;
var dataWithoutLrc = new byte[data.Length - 1];
Array.Copy(data, dataWithoutLrc, dataWithoutLrc.Length);
var calculatedLrc = CalculateLrc(dataWithoutLrc).Value;
var providedLrc = data[^1];
return calculatedLrc == providedLrc;
});
}
/// <summary>
/// 计算异或校验
/// </summary>
/// <param name="data">数据</param>
/// <returns>异或校验码</returns>
public static Result<byte> CalculateXor(byte[] data)
{
return Result.Try(() =>
{
if (data == null || data.Length == 0)
throw new ArgumentException("数据不能为空");
byte xor = 0;
foreach (var b in data)
{
xor ^= b;
}
return xor;
});
}
/// <summary>
/// 验证异或校验码
/// </summary>
/// <param name="data">数据(包含校验码)</param>
/// <returns>是否有效</returns>
public static Result<bool> VerifyXor(byte[] data)
{
return Result.Try(() =>
{
if (data == null || data.Length < 2)
return false;
var dataWithoutXor = new byte[data.Length - 1];
Array.Copy(data, dataWithoutXor, dataWithoutXor.Length);
var calculatedXor = CalculateXor(dataWithoutXor).Value;
var providedXor = data[^1];
return calculatedXor == providedXor;
});
}
/// <summary>
/// 计算简单累加和校验
/// </summary>
/// <param name="data">数据</param>
/// <returns>校验和</returns>
public static Result<byte> CalculateChecksum(byte[] data)
{
return Result.Try(() =>
{
if (data == null || data.Length == 0)
throw new ArgumentException("数据不能为空");
byte sum = 0;
foreach (var b in data)
{
sum += b;
}
return sum;
});
}
/// <summary>
/// 验证累加和校验码
/// </summary>
/// <param name="data">数据(包含校验码)</param>
/// <returns>是否有效</returns>
public static Result<bool> VerifyChecksum(byte[] data)
{
return Result.Try(() =>
{
if (data == null || data.Length < 2)
return false;
var dataWithoutChecksum = new byte[data.Length - 1];
Array.Copy(data, dataWithoutChecksum, dataWithoutChecksum.Length);
var calculatedChecksum = CalculateChecksum(dataWithoutChecksum).Value;
var providedChecksum = data[^1];
return calculatedChecksum == providedChecksum;
});
}
}
@@ -1,179 +0,0 @@
using System.Text;
using Common.Results;
namespace Ports.Utils;
/// <summary>
/// 串口数据转换工具
/// </summary>
public static class SerialDataConverter
{
/// <summary>
/// 字节数组转16位整数
/// </summary>
/// <param name="bytes">字节数组</param>
/// <param name="isBigEndian">是否大端序</param>
/// <returns>转换结果</returns>
public static Result<short> BytesToInt16(byte[] bytes, bool isBigEndian = false)
{
return Result.Try(() =>
{
if (bytes == null || bytes.Length < 2)
throw new ArgumentException("字节数组长度必须至少为2");
if (isBigEndian && BitConverter.IsLittleEndian)
Array.Reverse(bytes, 0, 2);
return BitConverter.ToInt16(bytes, 0);
});
}
/// <summary>
/// 字节数组转32位整数
/// </summary>
public static Result<int> BytesToInt32(byte[] bytes, bool isBigEndian = false)
{
return Result.Try(() =>
{
if (bytes == null || bytes.Length < 4)
throw new ArgumentException("字节数组长度必须至少为4");
if (isBigEndian && BitConverter.IsLittleEndian)
Array.Reverse(bytes, 0, 4);
return BitConverter.ToInt32(bytes, 0);
});
}
/// <summary>
/// 字节数组转单精度浮点数
/// </summary>
public static Result<float> BytesToFloat(byte[] bytes, bool isBigEndian = false)
{
return Result.Try(() =>
{
if (bytes == null || bytes.Length < 4)
throw new ArgumentException("字节数组长度必须至少为4");
if (isBigEndian && BitConverter.IsLittleEndian)
Array.Reverse(bytes, 0, 4);
return BitConverter.ToSingle(bytes, 0);
});
}
/// <summary>
/// 16位整数转字节数组
/// </summary>
public static Result<byte[]> Int16ToBytes(short value, bool isBigEndian = false)
{
return Result.Try(() =>
{
var bytes = BitConverter.GetBytes(value);
if (isBigEndian && BitConverter.IsLittleEndian)
Array.Reverse(bytes);
return bytes;
});
}
/// <summary>
/// 32位整数转字节数组
/// </summary>
public static Result<byte[]> Int32ToBytes(int value, bool isBigEndian = false)
{
return Result.Try(() =>
{
var bytes = BitConverter.GetBytes(value);
if (isBigEndian && BitConverter.IsLittleEndian)
Array.Reverse(bytes);
return bytes;
});
}
/// <summary>
/// 单精度浮点数转字节数组
/// </summary>
public static Result<byte[]> FloatToBytes(float value, bool isBigEndian = false)
{
return Result.Try(() =>
{
var bytes = BitConverter.GetBytes(value);
if (isBigEndian && BitConverter.IsLittleEndian)
Array.Reverse(bytes);
return bytes;
});
}
/// <summary>
/// 字节数组转十六进制字符串
/// </summary>
public static Result<string> BytesToHexString(byte[] bytes, string separator = " ")
{
return Result.Try(() =>
{
if (bytes == null || bytes.Length == 0)
return string.Empty;
return BitConverter.ToString(bytes).Replace("-", separator);
});
}
/// <summary>
/// 十六进制字符串转字节数组
/// </summary>
public static Result<byte[]> HexStringToBytes(string hex)
{
return Result.Try(() =>
{
if (string.IsNullOrWhiteSpace(hex))
return Array.Empty<byte>();
// 移除空格和分隔符
hex = hex.Replace(" ", "").Replace("-", "").Replace(":", "");
if (hex.Length % 2 != 0)
throw new ArgumentException("十六进制字符串长度必须为偶数");
var bytes = new byte[hex.Length / 2];
for (int i = 0; i < bytes.Length; i++)
{
bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
}
return bytes;
});
}
/// <summary>
/// 字符串转字节数组(使用指定编码)
/// </summary>
public static Result<byte[]> StringToBytes(string str, Encoding? encoding = null)
{
return Result.Try(() =>
{
encoding ??= Encoding.UTF8;
return encoding.GetBytes(str ?? string.Empty);
});
}
/// <summary>
/// 字节数组转字符串(使用指定编码)
/// </summary>
public static Result<string> BytesToString(byte[] bytes, Encoding? encoding = null)
{
return Result.Try(() =>
{
if (bytes == null || bytes.Length == 0)
return string.Empty;
encoding ??= Encoding.UTF8;
return encoding.GetString(bytes);
});
}
}
@@ -1,197 +0,0 @@
using Common.Results;
using Ports.Abstractions;
using Ports.Drivers;
using Ports.Models;
namespace Ports.Utils;
/// <summary>
/// 串口诊断工具
/// </summary>
public static class SerialPortDiagnostics
{
/// <summary>
/// 测试串口连接
/// </summary>
/// <param name="portName">端口名称</param>
/// <param name="options">配置选项</param>
/// <param name="testData">测试数据(可选)</param>
/// <returns>测试结果</returns>
public static async Task<Result<string>> TestConnectionAsync(
string portName,
SerialPortOptions options,
byte[]? testData = null)
{
return await Result.TryAsync(async () =>
{
using var driver = new SerialPortDriver();
// 测试打开连接
var openResult = await driver.OpenAsync(options);
if (openResult.IsFailure)
return $"打开失败: {openResult.ErrorMessage}";
var report = $"端口 {portName} 连接成功\n";
report += $"- 波特率: {options.BaudRate}\n";
report += $"- 数据位: {options.DataBits}\n";
report += $"- 停止位: {options.StopBits}\n";
report += $"- 校验位: {options.Parity}\n";
// 如果提供了测试数据,尝试发送
if (testData != null && testData.Length > 0)
{
var writeResult = await driver.WriteBytesAsync(testData);
if (writeResult.IsSuccess)
{
report += $"- 测试数据发送成功 ({testData.Length} 字节)\n";
// 尝试读取响应(等待500ms)
await Task.Delay(500);
if (driver.BytesToRead > 0)
{
var readResult = await driver.ReadAvailableAsync();
if (readResult.IsSuccess && readResult.Value != null)
{
report += $"- 接收到响应 ({readResult.Value.Length} 字节)\n";
}
}
else
{
report += "- 未接收到响应\n";
}
}
else
{
report += $"- 测试数据发送失败: {writeResult.ErrorMessage}\n";
}
}
await driver.CloseAsync();
report += "- 连接关闭成功";
return report;
});
}
/// <summary>
/// 监控串口流量
/// </summary>
/// <param name="portName">端口名称</param>
/// <param name="options">配置选项</param>
/// <param name="onData">数据回调(data, isSent)</param>
/// <param name="duration">监控时长</param>
/// <returns>监控结果</returns>
public static async Task<Result<TrafficReport>> MonitorTrafficAsync(
string portName,
SerialPortOptions options,
Action<byte[], bool> onData,
TimeSpan duration)
{
return await Result.TryAsync(async () =>
{
var report = new TrafficReport
{
PortName = portName,
StartTime = DateTime.Now
};
using var driver = new SerialPortDriver();
var openResult = await driver.OpenAsync(options);
if (openResult.IsFailure)
throw new Exception($"打开端口失败: {openResult.ErrorMessage}");
var cts = new CancellationTokenSource(duration);
try
{
while (!cts.Token.IsCancellationRequested)
{
if (driver.BytesToRead > 0)
{
var readResult = await driver.ReadAvailableAsync();
if (readResult.IsSuccess && readResult.Value != null)
{
report.BytesReceived += readResult.Value.Length;
report.PacketsReceived++;
onData?.Invoke(readResult.Value, false);
}
}
await Task.Delay(10, cts.Token);
}
}
catch (OperationCanceledException)
{
// 超时正常结束
}
await driver.CloseAsync();
report.EndTime = DateTime.Now;
report.Duration = report.EndTime - report.StartTime;
return report;
});
}
/// <summary>
/// 批量测试多个波特率
/// </summary>
/// <param name="portName">端口名称</param>
/// <param name="baudRates">要测试的波特率列表</param>
/// <returns>测试结果</returns>
public static async Task<Result<Dictionary<int, bool>>> TestBaudRatesAsync(
string portName,
params int[] baudRates)
{
return await Result.TryAsync(async () =>
{
var results = new Dictionary<int, bool>();
foreach (var baudRate in baudRates)
{
var options = new SerialPortOptions
{
PortName = portName,
BaudRate = baudRate
};
using var driver = new SerialPortDriver();
var openResult = await driver.OpenAsync(options);
results[baudRate] = openResult.IsSuccess;
if (openResult.IsSuccess)
{
await driver.CloseAsync();
}
}
return results;
});
}
}
/// <summary>
/// 流量监控报告
/// </summary>
public class TrafficReport
{
public string PortName { get; set; } = string.Empty;
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public TimeSpan Duration { get; set; }
public long BytesReceived { get; set; }
public long BytesSent { get; set; }
public int PacketsReceived { get; set; }
public int PacketsSent { get; set; }
public override string ToString()
{
return $"流量报告 [{PortName}]\n" +
$"监控时长: {Duration.TotalSeconds:F2}s\n" +
$"接收: {BytesReceived} 字节 ({PacketsReceived} 包)\n" +
$"发送: {BytesSent} 字节 ({PacketsSent} 包)";
}
}
+66 -3
View File
@@ -1,9 +1,12 @@
using System.Windows;
using System.IO;
using System.Windows;
using Microsoft.Extensions.Logging;
using Config;
using Sharp.Log;
using Sharp.Data;
using Sharp.Data.Context;
using Sharp.Data.Access;
using Sharp.Data.Access.Options;
using Server.Config;
using Server.Services;
using Server.Data;
@@ -20,6 +23,7 @@ namespace Server
public ILoggerFactory? LoggerFactory { get; private set; }
public ServerService? ServerService { get; private set; }
public IDataContext? DataContext { get; private set; }
public IAccessDataService? AccessDataService { get; private set; }
protected override void OnStartup(StartupEventArgs e)
{
@@ -36,7 +40,10 @@ namespace Server
// 3. 初始化数据库
InitializeDatabase();
// 4. 初始化服务器服务
// 4. 初始化 Access 数据服务
InitializeAccessDataService();
// 5. 初始化服务器服务
InitializeServerService();
var logger = LoggerFactory?.CreateLogger<App>();
@@ -74,7 +81,7 @@ namespace Server
options.Enrich.Application ??= "SharpServer";
});
}
private void InitializeDatabase()
{
if (LoggerFactory == null)
@@ -104,6 +111,62 @@ namespace Server
throw;
}
}
private void InitializeAccessDataService()
{
if (LoggerFactory == null)
{
throw new InvalidOperationException("日志系统未初始化");
}
var logger = LoggerFactory.CreateLogger<App>();
logger.LogInformation("正在初始化 Access 数据服务...");
try
{
// 诊断:输出当前目录信息
logger.LogInformation("AppContext.BaseDirectory: {BaseDirectory}", AppContext.BaseDirectory);
logger.LogInformation("Environment.CurrentDirectory: {CurrentDirectory}", Environment.CurrentDirectory);
logger.LogInformation("数据库文件在输出目录: {DbPath}",
Path.Combine(AppContext.BaseDirectory, "dbDwDmsSetup.accdb"));
logger.LogInformation("数据库文件是否存在: {Exists}",
File.Exists(Path.Combine(AppContext.BaseDirectory, "dbDwDmsSetup.accdb")));
// 配置 Access 数据库选项
var options = new AccessDatabaseOptions
{
DatabaseFile = "dbDwDmsSetup.accdb",
// 使用 ACE OLE DB 16.0(功能最完整,支持所有连接字符串参数)
// 注意:12.0 不支持 Connect Timeout 参数,会导致 0x80040E21 错误
ProviderName = AccessProviderNames.AceOleDb16,
// 如果数据库有密码,可以在这里设置:
// Password = "your_password",
// 或者使用加密密码(需要先注册自定义解密器):
// EncryptedPassword = "encrypted_password",
// 可选:调整稳健性配置
// MaxRetryCount = 3, // 最大重试次数
// RetryDelayMilliseconds = 100, // 重试延迟
// ConnectionTimeout = 15, // 连接超时(秒)
};
// 创建 Access 数据服务
AccessDataService = new AccessDataService(
options,
logger: LoggerFactory.CreateLogger<AccessDataService>());
logger.LogInformation("Access 数据服务初始化成功");
}
catch (Exception ex)
{
logger.LogError(ex, "Access 数据服务初始化失败");
// 不抛出异常,因为 Access 数据库是可选的
logger.LogWarning("Access 数据服务初始化失败,但应用程序将继续运行");
}
}
private void InitializeServerService()
{
+9 -13
View File
@@ -23,13 +23,13 @@ namespace Server.Controllers
public async Task GetAll(HttpContext context)
{
var items = _dataStore.Values.ToList();
var result = ResultExtensions.HttpSuccess(new
var data = new
{
Count = items.Count,
Items = items
});
};
await WriteSuccessResponseAsync(context, result);
await WriteSuccessResponseAsync(context, data);
}
/// <summary>
@@ -49,8 +49,7 @@ namespace Server.Controllers
if (_dataStore.TryGetValue(id, out var item))
{
var result = ResultExtensions.HttpSuccess(item);
await WriteSuccessResponseAsync(context, result);
await WriteSuccessResponseAsync(context, item);
}
else
{
@@ -87,8 +86,7 @@ namespace Server.Controllers
_dataStore[id] = item;
var result = ResultExtensions.HttpSuccess(item, "Data created successfully");
await WriteSuccessResponseAsync(context, result);
await WriteSuccessResponseAsync(context, item, "Data created successfully");
}
/// <summary>
@@ -129,8 +127,7 @@ namespace Server.Controllers
item.UpdatedAt = DateTime.UtcNow;
}
var result = ResultExtensions.HttpSuccess(item, "Data updated successfully");
await WriteSuccessResponseAsync(context, result);
await WriteSuccessResponseAsync(context, item, "Data updated successfully");
}
/// <summary>
@@ -150,8 +147,7 @@ namespace Server.Controllers
if (_dataStore.TryRemove(id, out var item))
{
var result = ResultExtensions.HttpSuccess(item, "Data deleted successfully");
await WriteSuccessResponseAsync(context, result);
await WriteSuccessResponseAsync(context, item, "Data deleted successfully");
}
else
{
@@ -169,8 +165,8 @@ namespace Server.Controllers
var count = _dataStore.Count;
_dataStore.Clear();
var result = ResultExtensions.HttpSuccess(new { DeletedCount = count }, "All data cleared");
await WriteSuccessResponseAsync(context, result);
var data = new { DeletedCount = count };
await WriteSuccessResponseAsync(context, data, "All data cleared");
}
}
@@ -19,14 +19,14 @@ namespace Server.Controllers
[HttpGet]
public async Task Check(HttpContext context)
{
var result = ResultExtensions.HttpSuccess(new
var data = new
{
Status = "Healthy",
Timestamp = DateTime.UtcNow,
Uptime = DateTime.UtcNow - Process.GetCurrentProcess().StartTime
});
};
await WriteSuccessResponseAsync(context, result);
await WriteSuccessResponseAsync(context, data);
}
/// <summary>
@@ -37,7 +37,7 @@ namespace Server.Controllers
public async Task DetailedCheck(HttpContext context)
{
var process = Process.GetCurrentProcess();
var result = ResultExtensions.HttpSuccess(new
var data = new
{
Status = "Healthy",
Timestamp = DateTime.UtcNow,
@@ -60,9 +60,9 @@ namespace Server.Controllers
},
Threads = process.Threads.Count
}
});
};
await WriteSuccessResponseAsync(context, result);
await WriteSuccessResponseAsync(context, data);
}
}
}
@@ -18,7 +18,7 @@ namespace Server.Controllers
[HttpGet("/info")]
public async Task GetSystemInfo(HttpContext context)
{
var result = ResultExtensions.HttpSuccess(new
var data = new
{
MachineName = Environment.MachineName,
UserName = Environment.UserName,
@@ -30,9 +30,9 @@ namespace Server.Controllers
CurrentDirectory = Environment.CurrentDirectory,
TickCount = Environment.TickCount64,
Version = Environment.Version.ToString()
});
};
await WriteSuccessResponseAsync(context, result);
await WriteSuccessResponseAsync(context, data);
}
/// <summary>
@@ -59,7 +59,7 @@ namespace Server.Controllers
return;
}
var successResult = ResultExtensions.HttpSuccess(new { Name = name, Value = value });
var successResult = new { Name = name, Value = value };
await WriteSuccessResponseAsync(context, successResult);
}
@@ -73,7 +73,7 @@ namespace Server.Controllers
var now = DateTime.Now;
var utcNow = DateTime.UtcNow;
var result = ResultExtensions.HttpSuccess(new
var data = new
{
Local = new
{
@@ -89,9 +89,9 @@ namespace Server.Controllers
Time = utcNow.TimeOfDay,
UnixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
}
});
};
await WriteSuccessResponseAsync(context, result);
await WriteSuccessResponseAsync(context, data);
}
}
}
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
namespace Server.Data.Access;
/// <summary>
/// Access 数据库上下文
/// </summary>
public class AccessDbContext : DbContext
{
public AccessDbContext(DbContextOptions<AccessDbContext> options) : base(options)
{
}
public DbSet<UserInfo> UserInfos => Set<UserInfo>();
public DbSet<UserPermission> UserPermissions => Set<UserPermission>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<UserInfo>(entity =>
{
entity.HasKey(e => e.UserName);
entity.Property(e => e.UserName).IsRequired().HasMaxLength(255);
entity.Property(e => e.Password).IsRequired().HasMaxLength(255);
});
modelBuilder.Entity<UserPermission>(entity =>
{
entity.HasKey(e => new { e.UserInfo_UserName, e.Permission_Code });
entity.Property(e => e.UserInfo_UserName).IsRequired().HasMaxLength(255);
entity.Property(e => e.Permission_Code).IsRequired().HasMaxLength(255);
entity.HasOne(e => e.UserInfo)
.WithMany(u => u.Permissions)
.HasForeignKey(e => e.UserInfo_UserName)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
@@ -0,0 +1,40 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Server.Data.Access;
/// <summary>
/// UserInfo 表实体
/// </summary>
[Table("UserInfo")]
public class UserInfo
{
[Key]
[Required]
[MaxLength(255)]
public string UserName { get; set; } = string.Empty;
[Required]
[MaxLength(255)]
public string Password { get; set; } = string.Empty;
public ICollection<UserPermission> Permissions { get; set; } = new List<UserPermission>();
}
/// <summary>
/// UserPermission 表实体
/// </summary>
[Table("UserPermission")]
public class UserPermission
{
[Required]
[MaxLength(255)]
public string UserInfo_UserName { get; set; } = string.Empty;
[Required]
[MaxLength(255)]
public string Permission_Code { get; set; } = string.Empty;
[ForeignKey(nameof(UserInfo_UserName))]
public UserInfo? UserInfo { get; set; }
}
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more