42个文件已修改
110个文件已添加
2 文件已重命名
1个文件已删除
20894 ■■■■■ 已修改文件
Aging.Infrastructure/Aging.Infrastructure.csproj 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Contracts/DeviceCapability.cs 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Contracts/IDeviceDriver.cs 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Contracts/IDeviceManager.cs 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Contracts/IDeviceService.cs 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Contracts/IOperationLease.cs 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Contracts/IResourceManager.cs 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Contracts/ResourceKey.cs 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Contracts/ResourceOwnerToken.cs 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Contracts/ResourceReservation.cs 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Core/DeviceManager.cs 226 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Core/OperationLease.cs 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Core/ResourceManager.cs 258 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Drivers/CanDriverAdapter.cs 576 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Drivers/DriverFactory.cs 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Drivers/ModBusDriverAdapter.cs 347 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Drivers/S7DriverAdapter.cs 407 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Drivers/ScpiDriverAdapter.cs 248 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Drivers/TcpRawDriverAdapter.cs 222 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/InfrastructureBootstrapper.cs 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Services/DeviceService.cs 331 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Services/PowerSupplyService.cs 543 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Services/SlotDeviceMappingResolver.cs 218 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
CanSimulator/CanBusSimulator.cs 403 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
CanSimulator/CanFrame.cs 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
CanSimulator/CanNode.cs 311 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
CanSimulator/CanSimulator.csproj 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Config/Database/database.db 补丁 | 查看 | 原始文档 | blame | 历史
Config/LogViewer/log_2026-10-23.db 补丁 | 查看 | 原始文档 | blame | 历史
HMI.Core/Setting/ParamSetting.cs 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HMI.Core/Util/LangUtil.cs 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HMI.UI/Resources/Font/iconfont.ttf 补丁 | 查看 | 原始文档 | blame | 历史
HMI.UI/Style/PanuonResource.xaml 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HMI.UnitTest/HMI.UnitTest.csproj 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HMI.UnitTest/MainWindow.xaml.cs 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
HMI.Util/IniFileHelper.cs 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
PLCHelper/BoolReader.cs 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
PLCHelper/IntReader.cs 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
PLCHelper/PLCHelper.csproj 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.BurnIn/ManualDebug/ManualDebug.xaml.cs 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.BurnIn/Plugin.BurnIn.csproj 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.BurnIn/TestResultView.xaml.cs 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.CurrentSensorControl/Entity/BaseConfig.cs 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/BatchGenerateModal.xaml 175 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/BatchGenerateModal.xaml.cs 252 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/DeviceConfig.xaml 293 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/DeviceConfig.xaml.cs 611 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/DeviceEditModal.xaml 182 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/DeviceEditModal.xaml.cs 368 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/Entity/DeviceChannel.cs 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/Entity/DeviceConfigVersion.cs 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/Entity/DeviceDefinition.cs 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/Entity/DeviceGroupTemplate.cs 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/Entity/SlotDeviceMapping.cs 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/Model/DeviceConfigModel.cs 645 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/Plugin.DeviceConfig.csproj 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/VersionManageModal.xaml 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceConfig/VersionManageModal.xaml.cs 207 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/DeviceDebug.xaml 177 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/DeviceDebug.xaml.cs 668 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/Entity/DebugCommand.cs 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/Entity/DebugOperation.cs 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/Entity/DebugSession.cs 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/Entity/DeviceRuntimeStatus.cs 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/Model/DebugSessionManager.cs 380 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/Model/DeviceDebugModel.cs 374 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/Panels/CanDebugPanel.xaml 220 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/Panels/CanDebugPanel.xaml.cs 543 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/Panels/PlcDebugPanel.xaml 176 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/Panels/PlcDebugPanel.xaml.cs 197 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/Panels/PowerSupplyDebugPanel.xaml 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/Panels/PowerSupplyDebugPanel.xaml.cs 125 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.DeviceDebug/Plugin.DeviceDebug.csproj 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.IntegrationTest/App.xaml 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.IntegrationTest/App.xaml.cs 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.IntegrationTest/MainWindow.xaml 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.IntegrationTest/MainWindow.xaml.cs 840 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.IntegrationTest/Plugin.IntegrationTest.csproj 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/AssemblyInfo.cs 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/Entity/TestStep.cs 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/Plugin.StandardTest.csproj 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/Scanner.xaml 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/Scanner.xaml.cs 209 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/ScannerManager.cs 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/ScreenGrid.xaml 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/ScreenGrid.xaml.cs 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/ScreenTouch.xaml 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/ScreenTouch.xaml.cs 179 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/ScreenTouchWin.xaml 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/ScreenTouchWin.xaml.cs 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/Setting/TcpServer.cs 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/Setting/TsSetting.cs 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/SingleControl.xaml 419 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/SingleControl.xaml.cs 916 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/StandardTestUI.xaml 315 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/StandardTestUI.xaml.cs 1906 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/WorkOrderWin.xaml 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.StandardTest/WorkOrderWin.xaml.cs 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TestPlanEditor/AssemblyInfo.cs 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TestPlanEditor/EditorUI.xaml 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TestPlanEditor/EditorUI.xaml.cs 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TestPlanEditor/Plugin.TestPlanEditor.csproj 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TestResult/ManualDebugWindow.xaml 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TestResult/ManualDebugWindow.xaml.cs 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TestResult/Plugin.TestResult.csproj 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TestResult/TestResultView.xaml.cs 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TsExecution/Plugin.TsExecution.csproj 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TsExecution/Properties/AssemblyInfo.cs 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TsExecution/SingleControl.xaml 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TsExecution/SingleControl.xaml.cs 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TsExecution/TsExecution.xaml 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TsExecution/TsExecution.xaml.cs 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TsSupport/ITestExecutionWrapper.cs 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TsSupport/Plugin.TsSupport.csproj 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TsSupport/TapWrapper.cs 405 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TsSupport/TestAppManager.cs 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TsSupport/TeststandWrapper.cs 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.TsSupport/TsExecutionWrapper.cs 228 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Plugin.UICollection/Plugin.UICollection.csproj 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
README.md 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
SimpleUtil/HMI.LogUtil/HMI.LogUtil.csproj 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/ManualCell.xaml 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/ManualCell.xaml.cs 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/ManualDebug.xaml 237 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/ManualDebug.xaml.cs 309 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/ManualDebugWindow.xaml 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/ManualDebugWindow.xaml.cs 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/Model/CellConfig.cs 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/Model/TcpServer.cs 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/OldManualDebug.xaml 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/OldManualDebug.xaml.cs 368 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/StatusBtn.xaml 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/StatusBtn.xaml.cs 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/StatusCell.xaml 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/StatusCell.xaml.cs 104 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Util.ManualDebug/Util.ManualDebug.csproj 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
WPFProject/App.config 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
WPFProject/App.xaml 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
WPFProject/App.xaml.cs 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
WPFProject/BuildCmd.bat 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
WPFProject/Config/Database/database.db 补丁 | 查看 | 原始文档 | blame | 历史
WPFProject/PanuonWindow.xaml 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
WPFProject/PanuonWindow.xaml.cs 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
WPFProject/TapProject.csproj 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
WPFProject/TapProject.sln 232 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
WPFProject/packages.config 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
check_parent.py 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
debug_check.py 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/TapProject_代码结构.drawio 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/TapProject_代码结构_01.drawio.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/TapProject_代码结构_01_预览.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/TapProject_代码结构_02.drawio.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/TapProject_代码结构_02_预览.png 补丁 | 查看 | 原始文档 | blame | 历史
docs/TapProject_代码结构分析.md 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
fix_menu.py 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Aging.Infrastructure/Aging.Infrastructure.csproj
New file
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net472</TargetFramework>
    <Nullable>enable</Nullable>
    <LangVersion>10.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
    <AutoGenerateBindingRedirects>True</AutoGenerateBindingRedirects>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="log4net" Version="3.0.4" />
    <PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
  </ItemGroup>
  <ItemGroup>
    <Reference Include="HslCommunication">
      <HintPath>..\Libs\HslCommunication.dll</HintPath>
    </Reference>
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\HMI.Core\HMI.Core.csproj" />
    <ProjectReference Include="..\HMI.Util\HMI.Util.csproj" />
    <ProjectReference Include="..\ConnectionUtil\ConnectionUtil.csproj" />
    <ProjectReference Include="..\PLCHelper\PLCHelper.csproj" />
    <ProjectReference Include="..\S7Driver\S7Driver.csproj" />
    <ProjectReference Include="..\ModBusTcpController\ModBusTcpController.csproj" />
    <ProjectReference Include="..\CanSimulator\CanSimulator.csproj" />
  </ItemGroup>
</Project>
Aging.Infrastructure/Contracts/DeviceCapability.cs
New file
@@ -0,0 +1,48 @@
namespace Aging.Infrastructure.Contracts
{
    /// <summary>
    /// 设备能力枚举,标识硬件设备支持的功能类型
    /// </summary>
    public enum DeviceCapability
    {
        /// <summary>
        /// 电源设备
        /// </summary>
        PowerSupply,
        /// <summary>
        /// CAN总线设备
        /// </summary>
        CAN,
        /// <summary>
        /// LIN总线设备
        /// </summary>
        LIN,
        /// <summary>
        /// PLC数字IO设备
        /// </summary>
        PLC_IO,
        /// <summary>
        /// 温箱设备
        /// </summary>
        Chamber,
        /// <summary>
        /// 数据采集设备
        /// </summary>
        DAQ,
        /// <summary>
        /// 串口设备
        /// </summary>
        Serial,
        /// <summary>
        /// 通用设备
        /// </summary>
        Generic
    }
}
Aging.Infrastructure/Contracts/IDeviceDriver.cs
New file
@@ -0,0 +1,114 @@
namespace Aging.Infrastructure.Contracts
{
    /// <summary>
    /// 设备驱动状态信息
    /// </summary>
    public sealed class DeviceDriverStatus
    {
        /// <summary>
        /// 设备是否已连接
        /// </summary>
        public bool IsConnected { get; set; }
        /// <summary>
        /// 设备是否健康(无故障)
        /// </summary>
        public bool IsHealthy { get; set; }
        /// <summary>
        /// 最后一次错误信息
        /// </summary>
        public string LastError { get; set; } = "";
        /// <summary>
        /// 最后一次错误发生的时间
        /// </summary>
        public DateTime? LastErrorTime { get; set; }
        /// <summary>
        /// 通信延迟(毫秒)
        /// </summary>
        public double LatencyMs { get; set; }
        /// <summary>
        /// 重连次数
        /// </summary>
        public int ReconnectCount { get; set; }
    }
    /// <summary>
    /// 设备连接状态变化事件参数
    /// </summary>
    public sealed class DeviceConnectionEventArgs : EventArgs
    {
        /// <summary>
        /// 设备标识符
        /// </summary>
        public string DeviceId { get; set; } = "";
        /// <summary>
        /// 是否已连接
        /// </summary>
        public bool IsConnected { get; set; }
        /// <summary>
        /// 状态变化原因(可选)
        /// </summary>
        public string? Reason { get; set; }
    }
    /// <summary>
    /// 设备驱动统一接口。
    /// 所有硬件设备驱动必须实现此接口,提供连接、断开、命令发送和查询功能。
    /// </summary>
    public interface IDeviceDriver : IDisposable
    {
        /// <summary>
        /// 设备唯一标识符
        /// </summary>
        string DeviceId { get; }
        /// <summary>
        /// 设备当前是否已连接
        /// </summary>
        bool IsConnected { get; }
        /// <summary>
        /// 异步连接设备
        /// </summary>
        /// <param name="ct">取消令牌</param>
        Task ConnectAsync(CancellationToken ct);
        /// <summary>
        /// 异步断开设备连接
        /// </summary>
        Task DisconnectAsync();
        /// <summary>
        /// 发送命令到设备
        /// </summary>
        /// <param name="command">命令字符串</param>
        /// <param name="ct">取消令牌</param>
        /// <returns>设备响应字符串</returns>
        Task<string> SendCommandAsync(string command, CancellationToken ct);
        /// <summary>
        /// 向设备发送查询并获取响应
        /// </summary>
        /// <param name="query">查询字符串</param>
        /// <param name="ct">取消令牌</param>
        /// <returns>设备查询响应字符串</returns>
        Task<string> QueryAsync(string query, CancellationToken ct);
        /// <summary>
        /// 获取设备当前状态信息
        /// </summary>
        /// <returns>设备驱动状态</returns>
        DeviceDriverStatus GetStatus();
        /// <summary>
        /// 设备连接状态变化事件
        /// </summary>
        event EventHandler<DeviceConnectionEventArgs>? ConnectionChanged;
    }
}
Aging.Infrastructure/Contracts/IDeviceManager.cs
New file
@@ -0,0 +1,65 @@
using System.Threading;
using System.Threading.Tasks;
namespace Aging.Infrastructure.Contracts
{
    /// <summary>
    /// 设备管理接口。
    /// 提供设备驱动的注册、连接管理和状态查询功能。
    /// </summary>
    public interface IDeviceManager
    {
        /// <summary>
        /// 根据设备ID获取设备驱动实例
        /// </summary>
        /// <param name="deviceId">设备标识符</param>
        /// <returns>设备驱动实例,不存在时返回 null</returns>
        IDeviceDriver? GetDriver(string deviceId);
        /// <summary>
        /// 获取设备当前状态
        /// </summary>
        /// <param name="deviceId">设备标识符</param>
        /// <returns>设备驱动状态</returns>
        DeviceDriverStatus GetStatus(string deviceId);
        /// <summary>
        /// 注册设备驱动
        /// </summary>
        /// <param name="deviceId">设备标识符</param>
        /// <param name="driver">设备驱动实例</param>
        void RegisterDriver(string deviceId, IDeviceDriver driver);
        /// <summary>
        /// 注销设备驱动
        /// </summary>
        /// <param name="deviceId">设备标识符</param>
        void UnregisterDriver(string deviceId);
        /// <summary>
        /// 异步连接设备
        /// </summary>
        /// <param name="deviceId">设备标识符</param>
        /// <param name="ct">取消令牌</param>
        Task ConnectAsync(string deviceId, CancellationToken ct);
        /// <summary>
        /// 异步断开设备连接
        /// </summary>
        /// <param name="deviceId">设备标识符</param>
        Task DisconnectAsync(string deviceId);
        /// <summary>
        /// 获取所有已注册设备的ID列表
        /// </summary>
        /// <returns>设备ID只读列表</returns>
        IReadOnlyList<string> GetRegisteredDevices();
        /// <summary>
        /// 检查设备是否已注册
        /// </summary>
        /// <param name="deviceId">设备标识符</param>
        /// <returns>设备是否已注册</returns>
        bool IsDeviceRegistered(string deviceId);
    }
}
Aging.Infrastructure/Contracts/IDeviceService.cs
New file
@@ -0,0 +1,81 @@
using System.Threading;
using System.Threading.Tasks;
namespace Aging.Infrastructure.Contracts
{
    /// <summary>
    /// 设备命令执行结果
    /// </summary>
    public sealed class DeviceCommandResult
    {
        /// <summary>
        /// 命令是否执行成功
        /// </summary>
        public bool Success { get; set; }
        /// <summary>
        /// 设备原始响应字符串
        /// </summary>
        public string? RawResponse { get; set; }
        /// <summary>
        /// 回读值(解析后的有效数据)
        /// </summary>
        public string? Readback { get; set; }
        /// <summary>
        /// 错误代码
        /// </summary>
        public string? ErrorCode { get; set; }
        /// <summary>
        /// 错误信息
        /// </summary>
        public string? ErrorMessage { get; set; }
        /// <summary>
        /// 命令执行延迟(毫秒)
        /// </summary>
        public double LatencyMs { get; set; }
    }
    /// <summary>
    /// 设备服务接口。
    /// 提供基于资源预约的命令执行和安全关机功能,协调资源管理和设备驱动。
    /// </summary>
    public interface IDeviceService
    {
        /// <summary>
        /// 异步执行设备命令。
        /// 需要有效的资源预约才能执行。
        /// </summary>
        /// <param name="deviceId">设备标识符</param>
        /// <param name="command">命令字符串</param>
        /// <param name="owner">所有者令牌</param>
        /// <param name="ct">取消令牌</param>
        /// <returns>命令执行结果</returns>
        Task<DeviceCommandResult> ExecuteCommandAsync(
            string deviceId, string command, ResourceOwnerToken owner, CancellationToken ct);
        /// <summary>
        /// 异步执行设备查询。
        /// 需要有效的资源预约才能执行。
        /// </summary>
        /// <param name="deviceId">设备标识符</param>
        /// <param name="query">查询字符串</param>
        /// <param name="owner">所有者令牌</param>
        /// <param name="ct">取消令牌</param>
        /// <returns>命令执行结果</returns>
        Task<DeviceCommandResult> ExecuteQueryAsync(
            string deviceId, string query, ResourceOwnerToken owner, CancellationToken ct);
        /// <summary>
        /// 异步安全关闭设备。
        /// 执行设备特定的关机序列,确保设备处于安全状态。
        /// </summary>
        /// <param name="deviceId">设备标识符</param>
        /// <param name="owner">所有者令牌</param>
        /// <param name="ct">取消令牌</param>
        Task SafeShutdownAsync(string deviceId, ResourceOwnerToken owner, CancellationToken ct);
    }
}
Aging.Infrastructure/Contracts/IOperationLease.cs
New file
@@ -0,0 +1,40 @@
namespace Aging.Infrastructure.Contracts
{
    /// <summary>
    /// 短期操作租约接口。
    /// 在执行具体硬件操作前获取,确保操作期间资源不被抢占。
    /// 支持同步和异步释放。
    /// </summary>
    public interface IOperationLease : IDisposable, IAsyncDisposable
    {
        /// <summary>
        /// 租约唯一标识
        /// </summary>
        string LeaseId { get; }
        /// <summary>
        /// 租约关联的资源键
        /// </summary>
        ResourceKey Resource { get; }
        /// <summary>
        /// 租约所有者令牌
        /// </summary>
        ResourceOwnerToken Owner { get; }
        /// <summary>
        /// 租约获取时间
        /// </summary>
        DateTime AcquiredAt { get; }
        /// <summary>
        /// 租约过期时间(可选)
        /// </summary>
        DateTime? ExpiresAt { get; }
        /// <summary>
        /// 租约是否仍然有效
        /// </summary>
        bool IsValid { get; }
    }
}
Aging.Infrastructure/Contracts/IResourceManager.cs
New file
@@ -0,0 +1,84 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Aging.Infrastructure.Contracts
{
    /// <summary>
    /// 资源冲突异常。
    /// 当尝试预约已被其他所有者占用的资源时抛出。
    /// </summary>
    public sealed class ResourceConflictException : Exception
    {
        /// <summary>
        /// 初始化资源冲突异常实例
        /// </summary>
        /// <param name="message">冲突描述信息</param>
        public ResourceConflictException(string message) : base(message)
        {
        }
        /// <summary>
        /// 初始化资源冲突异常实例
        /// </summary>
        /// <param name="message">冲突描述信息</param>
        /// <param name="innerException">内部异常</param>
        public ResourceConflictException(string message, Exception innerException) : base(message, innerException)
        {
        }
    }
    /// <summary>
    /// 资源管理接口。
    /// 提供硬件资源的预约、租约获取和释放功能,确保资源不被多个会话同时占用。
    /// </summary>
    public interface IResourceManager
    {
        /// <summary>
        /// 异步预约资源。
        /// 创建长期预约,阻止其他所有者使用该资源。
        /// </summary>
        /// <param name="owner">所有者令牌</param>
        /// <param name="resource">资源键</param>
        /// <param name="ct">取消令牌</param>
        /// <returns>资源预约实体</returns>
        /// <exception cref="ResourceConflictException">资源已被其他所有者预约时抛出</exception>
        Task<ResourceReservation> ReserveAsync(ResourceOwnerToken owner, ResourceKey resource, CancellationToken ct);
        /// <summary>
        /// 异步获取操作租约。
        /// 在执行具体硬件操作前调用,确保操作期间资源不被抢占。
        /// </summary>
        /// <param name="owner">所有者令牌</param>
        /// <param name="resource">资源键</param>
        /// <param name="ct">取消令牌</param>
        /// <returns>操作租约</returns>
        /// <exception cref="InvalidOperationException">资源未预约或所有者不匹配时抛出</exception>
        Task<IOperationLease> AcquireOperationAsync(ResourceOwnerToken owner, ResourceKey resource, CancellationToken ct);
        /// <summary>
        /// 异步释放资源预约。
        /// 取消对资源的占用,允许其他所有者预约。
        /// </summary>
        /// <param name="owner">所有者令牌</param>
        /// <param name="resource">资源键</param>
        /// <param name="ct">取消令牌</param>
        Task ReleaseReservationAsync(ResourceOwnerToken owner, ResourceKey resource, CancellationToken ct);
        /// <summary>
        /// 检查资源是否已被预约
        /// </summary>
        /// <param name="resource">资源键</param>
        /// <param name="reservation">输出预约实体(如果已预约)</param>
        /// <returns>资源是否已被预约</returns>
        bool IsReserved(ResourceKey resource, out ResourceReservation? reservation);
        /// <summary>
        /// 验证所有者令牌是否匹配指定资源的预约
        /// </summary>
        /// <param name="owner">所有者令牌</param>
        /// <param name="resource">资源键</param>
        /// <returns>所有者是否匹配</returns>
        bool ValidateOwner(ResourceOwnerToken owner, ResourceKey resource);
    }
}
Aging.Infrastructure/Contracts/ResourceKey.cs
New file
@@ -0,0 +1,134 @@
using System;
namespace Aging.Infrastructure.Contracts
{
    /// <summary>
    /// 资源键值对象,唯一标识一个可管理的硬件资源。
    /// 格式: "DeviceId:Channel",如 "PSU024:CH1"、"CAN_CARD01:CH2"、"PLC01:OUTPUT_GROUP_A"
    /// </summary>
    public sealed class ResourceKey : IEquatable<ResourceKey>
    {
        /// <summary>
        /// 设备标识符
        /// </summary>
        public string DeviceId { get; }
        /// <summary>
        /// 通道标识(可选)
        /// </summary>
        public string? Channel { get; }
        /// <summary>
        /// 资源类型(PowerSupply/CAN/PLC/Chamber等)
        /// </summary>
        public string ResourceType { get; }
        /// <summary>
        /// 初始化资源键实例
        /// </summary>
        /// <param name="deviceId">设备标识符</param>
        /// <param name="resourceType">资源类型</param>
        /// <param name="channel">通道标识(可选)</param>
        public ResourceKey(string deviceId, string resourceType, string? channel = null)
        {
            DeviceId = deviceId ?? throw new ArgumentNullException(nameof(deviceId));
            ResourceType = resourceType ?? throw new ArgumentNullException(nameof(resourceType));
            Channel = channel;
        }
        /// <summary>
        /// 从字符串解析资源键,格式为 "DeviceId:Channel"
        /// </summary>
        /// <param name="key">资源键字符串</param>
        /// <returns>解析后的资源键实例</returns>
        /// <exception cref="ArgumentException">格式无效时抛出</exception>
        public static ResourceKey FromString(string key)
        {
            if (string.IsNullOrWhiteSpace(key))
                throw new ArgumentException("资源键不能为空", nameof(key));
            var parts = key.Split(':');
            if (parts.Length < 2)
                throw new ArgumentException($"无效的资源键格式: '{key}',期望格式为 'DeviceId:Channel'");
            var deviceId = parts[0].Trim();
            var channel = string.Join(":", parts.Skip(1)).Trim();
            if (string.IsNullOrEmpty(deviceId))
                throw new ArgumentException($"无效的资源键格式: '{key}',DeviceId 不能为空");
            // 根据设备ID前缀推断资源类型
            var resourceType = InferResourceType(deviceId);
            return new ResourceKey(deviceId, resourceType, string.IsNullOrEmpty(channel) ? null : channel);
        }
        /// <summary>
        /// 获取操作资源键,用于物理会话锁
        /// </summary>
        public string OperationResourceKey => $"{DeviceId}:SESSION";
        /// <summary>
        /// 根据设备ID前缀推断资源类型
        /// </summary>
        private static string InferResourceType(string deviceId)
        {
            var upper = deviceId.ToUpperInvariant();
            if (upper.StartsWith("PSU") || upper.StartsWith("POWER"))
                return "PowerSupply";
            if (upper.StartsWith("CAN"))
                return "CAN";
            if (upper.StartsWith("PLC"))
                return "PLC";
            if (upper.StartsWith("CHAMBER") || upper.StartsWith("TEMP"))
                return "Chamber";
            if (upper.StartsWith("DAQ"))
                return "DAQ";
            if (upper.StartsWith("SERIAL") || upper.StartsWith("COM"))
                return "Serial";
            return "Generic";
        }
        /// <inheritdoc/>
        public bool Equals(ResourceKey? other)
        {
            if (other is null) return false;
            if (ReferenceEquals(this, other)) return true;
            return string.Equals(DeviceId, other.DeviceId, StringComparison.OrdinalIgnoreCase) &&
                   string.Equals(Channel, other.Channel, StringComparison.OrdinalIgnoreCase) &&
                   string.Equals(ResourceType, other.ResourceType, StringComparison.OrdinalIgnoreCase);
        }
        /// <inheritdoc/>
        public override bool Equals(object? obj) => Equals(obj as ResourceKey);
        /// <inheritdoc/>
        public override int GetHashCode()
        {
            unchecked
            {
                int hash = 17;
                hash = hash * 31 + (DeviceId?.ToUpperInvariant().GetHashCode() ?? 0);
                hash = hash * 31 + (Channel?.ToUpperInvariant().GetHashCode() ?? 0);
                hash = hash * 31 + (ResourceType?.ToUpperInvariant().GetHashCode() ?? 0);
                return hash;
            }
        }
        /// <inheritdoc/>
        public override string ToString() =>
            string.IsNullOrEmpty(Channel) ? DeviceId : $"{DeviceId}:{Channel}";
        /// <summary>
        /// 相等运算符重载
        /// </summary>
        public static bool operator ==(ResourceKey? left, ResourceKey? right) =>
            Equals(left, right);
        /// <summary>
        /// 不等运算符重载
        /// </summary>
        public static bool operator !=(ResourceKey? left, ResourceKey? right) =>
            !Equals(left, right);
    }
}
Aging.Infrastructure/Contracts/ResourceOwnerToken.cs
New file
@@ -0,0 +1,84 @@
using System;
namespace Aging.Infrastructure.Contracts
{
    /// <summary>
    /// 所有者类型枚举
    /// </summary>
    public enum OwnerType
    {
        /// <summary>
        /// 生产模式(正常老化测试流程)
        /// </summary>
        Production,
        /// <summary>
        /// 维护模式(调试/维护会话)
        /// </summary>
        Maintenance
    }
    /// <summary>
    /// 所有者令牌,标识资源预约的归属。
    /// 用于区分不同会话对硬件资源的所有权。
    /// </summary>
    public sealed class ResourceOwnerToken
    {
        /// <summary>
        /// 令牌唯一标识
        /// </summary>
        public string TokenId { get; }
        /// <summary>
        /// 所有者类型(生产/维护)
        /// </summary>
        public OwnerType OwnerType { get; }
        /// <summary>
        /// 所有者标识(RunID 或 DebugSessionID)
        /// </summary>
        public string OwnerId { get; }
        /// <summary>
        /// 世代号,每次新预约递增,防止重放攻击
        /// </summary>
        public long Generation { get; set; }
        /// <summary>
        /// 令牌签发时间
        /// </summary>
        public DateTime IssuedAt { get; }
        /// <summary>
        /// 令牌过期时间(可选)
        /// </summary>
        public DateTime? ExpiresAt { get; }
        /// <summary>
        /// 初始化所有者令牌实例
        /// </summary>
        /// <param name="ownerType">所有者类型</param>
        /// <param name="ownerId">所有者标识</param>
        /// <param name="expiresAt">过期时间(可选)</param>
        public ResourceOwnerToken(OwnerType ownerType, string ownerId, DateTime? expiresAt = null)
        {
            TokenId = Guid.NewGuid().ToString();
            OwnerType = ownerType;
            OwnerId = ownerId ?? throw new ArgumentNullException(nameof(ownerId));
            Generation = 0;
            IssuedAt = DateTime.UtcNow;
            ExpiresAt = expiresAt;
        }
        /// <summary>
        /// 检查令牌是否已过期
        /// </summary>
        /// <returns>过期返回 true;未设置过期时间或尚未过期返回 false</returns>
        public bool IsExpired()
        {
            if (!ExpiresAt.HasValue)
                return false;
            return DateTime.UtcNow > ExpiresAt.Value;
        }
    }
}
Aging.Infrastructure/Contracts/ResourceReservation.cs
New file
@@ -0,0 +1,78 @@
using System;
namespace Aging.Infrastructure.Contracts
{
    /// <summary>
    /// 预约状态枚举
    /// </summary>
    public enum ReservationState
    {
        /// <summary>
        /// 活跃状态
        /// </summary>
        Active,
        /// <summary>
        /// 已释放
        /// </summary>
        Released,
        /// <summary>
        /// 冲突状态
        /// </summary>
        Conflicted
    }
    /// <summary>
    /// 长期预约实体,表示对某个硬件资源的持久占用。
    /// 预约期间,其他所有者无法获取该资源。
    /// </summary>
    public sealed class ResourceReservation
    {
        /// <summary>
        /// 预约唯一标识
        /// </summary>
        public string ReservationId { get; }
        /// <summary>
        /// 被预约的资源键
        /// </summary>
        public ResourceKey Resource { get; }
        /// <summary>
        /// 预约所有者令牌
        /// </summary>
        public ResourceOwnerToken Owner { get; }
        /// <summary>
        /// 预约状态
        /// </summary>
        public ReservationState State { get; set; }
        /// <summary>
        /// 预约创建时间
        /// </summary>
        public DateTime ReservedAt { get; }
        /// <summary>
        /// 预约描述(可选)
        /// </summary>
        public string? Description { get; set; }
        /// <summary>
        /// 初始化资源预约实例
        /// </summary>
        /// <param name="resource">资源键</param>
        /// <param name="owner">所有者令牌</param>
        /// <param name="description">预约描述(可选)</param>
        public ResourceReservation(ResourceKey resource, ResourceOwnerToken owner, string? description = null)
        {
            ReservationId = Guid.NewGuid().ToString();
            Resource = resource ?? throw new ArgumentNullException(nameof(resource));
            Owner = owner ?? throw new ArgumentNullException(nameof(owner));
            State = ReservationState.Active;
            ReservedAt = DateTime.UtcNow;
            Description = description;
        }
    }
}
Aging.Infrastructure/Core/DeviceManager.cs
New file
@@ -0,0 +1,226 @@
using System.Collections.Concurrent;
using Aging.Infrastructure.Contracts;
using log4net;
namespace Aging.Infrastructure.Core
{
    /// <summary>
    /// 设备管理实现。
    /// 提供设备驱动的注册、连接管理和状态查询功能。
    /// 使用 ConcurrentDictionary 管理驱动实例和缓存状态。
    /// </summary>
    public sealed class DeviceManager : IDeviceManager
    {
        private static readonly ILog Log = LogManager.GetLogger(typeof(DeviceManager));
        /// <summary>
        /// 设备驱动字典(key = deviceId)
        /// </summary>
        private readonly ConcurrentDictionary<string, IDeviceDriver> _drivers = new();
        /// <summary>
        /// 设备状态缓存字典(key = deviceId)
        /// </summary>
        private readonly ConcurrentDictionary<string, DeviceDriverStatus> _statusCache = new();
        /// <inheritdoc/>
        public void RegisterDriver(string deviceId, IDeviceDriver driver)
        {
            if (string.IsNullOrWhiteSpace(deviceId))
                throw new ArgumentException("设备ID不能为空", nameof(deviceId));
            if (driver == null) throw new ArgumentNullException(nameof(driver));
            if (_drivers.ContainsKey(deviceId))
            {
                Log.Warn($"[DeviceManager] 设备 {deviceId} 已注册,将覆盖现有驱动");
                UnregisterDriver(deviceId);
            }
            _drivers[deviceId] = driver;
            // 初始化状态缓存
            var initialStatus = driver.GetStatus();
            _statusCache[deviceId] = initialStatus;
            // 订阅连接状态变化事件
            driver.ConnectionChanged += OnConnectionChanged;
            Log.Info($"[DeviceManager] 设备 {deviceId} 驱动注册成功,当前连接状态: {initialStatus.IsConnected}");
        }
        /// <inheritdoc/>
        public void UnregisterDriver(string deviceId)
        {
            if (string.IsNullOrWhiteSpace(deviceId))
                throw new ArgumentException("设备ID不能为空", nameof(deviceId));
            if (_drivers.TryRemove(deviceId, out var driver))
            {
                // 取消事件订阅
                driver.ConnectionChanged -= OnConnectionChanged;
                // 清理状态缓存
                _statusCache.TryRemove(deviceId, out _);
                Log.Info($"[DeviceManager] 设备 {deviceId} 驱动已注销");
            }
            else
            {
                Log.Warn($"[DeviceManager] 尝试注销不存在的设备: {deviceId}");
            }
        }
        /// <inheritdoc/>
        public IDeviceDriver? GetDriver(string deviceId)
        {
            if (string.IsNullOrWhiteSpace(deviceId))
                throw new ArgumentException("设备ID不能为空", nameof(deviceId));
            _drivers.TryGetValue(deviceId, out var driver);
            return driver;
        }
        /// <inheritdoc/>
        public DeviceDriverStatus GetStatus(string deviceId)
        {
            if (string.IsNullOrWhiteSpace(deviceId))
                throw new ArgumentException("设备ID不能为空", nameof(deviceId));
            if (_statusCache.TryGetValue(deviceId, out var status))
            {
                return status;
            }
            // 如果缓存中没有,返回默认状态
            Log.Warn($"[DeviceManager] 设备 {deviceId} 状态缓存不存在,返回默认状态");
            return new DeviceDriverStatus
            {
                IsConnected = false,
                IsHealthy = false,
                LastError = "设备未注册或状态未初始化"
            };
        }
        /// <inheritdoc/>
        public async Task ConnectAsync(string deviceId, CancellationToken ct)
        {
            if (string.IsNullOrWhiteSpace(deviceId))
                throw new ArgumentException("设备ID不能为空", nameof(deviceId));
            if (!_drivers.TryGetValue(deviceId, out var driver))
            {
                throw new InvalidOperationException($"设备 {deviceId} 未注册驱动");
            }
            try
            {
                Log.Info($"[DeviceManager] 正在连接设备: {deviceId}");
                await driver.ConnectAsync(ct).ConfigureAwait(false);
                // 更新状态缓存
                _statusCache[deviceId] = driver.GetStatus();
                Log.Info($"[DeviceManager] 设备 {deviceId} 连接成功");
            }
            catch (Exception ex)
            {
                // 更新错误状态
                if (_statusCache.TryGetValue(deviceId, out var status))
                {
                    status.IsConnected = false;
                    status.IsHealthy = false;
                    status.LastError = ex.Message;
                    status.LastErrorTime = DateTime.UtcNow;
                    _statusCache[deviceId] = status;
                }
                Log.Error($"[DeviceManager] 设备 {deviceId} 连接失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public async Task DisconnectAsync(string deviceId)
        {
            if (string.IsNullOrWhiteSpace(deviceId))
                throw new ArgumentException("设备ID不能为空", nameof(deviceId));
            if (!_drivers.TryGetValue(deviceId, out var driver))
            {
                Log.Warn($"[DeviceManager] 尝试断开未注册的设备: {deviceId}");
                return;
            }
            try
            {
                Log.Info($"[DeviceManager] 正在断开设备: {deviceId}");
                await driver.DisconnectAsync().ConfigureAwait(false);
                // 更新状态缓存
                _statusCache[deviceId] = driver.GetStatus();
                Log.Info($"[DeviceManager] 设备 {deviceId} 已断开连接");
            }
            catch (Exception ex)
            {
                Log.Error($"[DeviceManager] 设备 {deviceId} 断开连接失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public IReadOnlyList<string> GetRegisteredDevices()
        {
            return _drivers.Keys.ToList().AsReadOnly();
        }
        /// <inheritdoc/>
        public bool IsDeviceRegistered(string deviceId)
        {
            if (string.IsNullOrWhiteSpace(deviceId))
                return false;
            return _drivers.ContainsKey(deviceId);
        }
        /// <summary>
        /// 处理设备连接状态变化事件
        /// </summary>
        /// <param name="sender">事件发送者</param>
        /// <param name="e">事件参数</param>
        private void OnConnectionChanged(object? sender, DeviceConnectionEventArgs e)
        {
            var deviceId = e.DeviceId;
            if (string.IsNullOrWhiteSpace(deviceId))
            {
                Log.Warn("[DeviceManager] 收到连接变化事件但 DeviceId 为空");
                return;
            }
            if (_statusCache.TryGetValue(deviceId, out var status))
            {
                status.IsConnected = e.IsConnected;
                if (!e.IsConnected)
                {
                    status.IsHealthy = false;
                    if (!string.IsNullOrEmpty(e.Reason))
                    {
                        status.LastError = e.Reason;
                        status.LastErrorTime = DateTime.UtcNow;
                    }
                }
                else
                {
                    status.IsHealthy = true;
                    status.LastError = "";
                }
                _statusCache[deviceId] = status;
                Log.Info($"[DeviceManager] 设备 {deviceId} 连接状态变化: {(e.IsConnected ? "已连接" : "已断开")}" +
                        (string.IsNullOrEmpty(e.Reason) ? "" : $", 原因: {e.Reason}"));
            }
        }
    }
}
Aging.Infrastructure/Core/OperationLease.cs
New file
@@ -0,0 +1,82 @@
using Aging.Infrastructure.Contracts;
namespace Aging.Infrastructure.Core
{
    /// <summary>
    /// 操作租约实现。
    /// 提供短期资源占用保证,支持同步和异步释放。
    /// 释放时通过回调通知 ResourceManager。
    /// </summary>
    public sealed class OperationLease : IOperationLease
    {
        private readonly Action? _onRelease;
        private bool _disposed;
        /// <inheritdoc/>
        public string LeaseId { get; }
        /// <inheritdoc/>
        public ResourceKey Resource { get; }
        /// <inheritdoc/>
        public ResourceOwnerToken Owner { get; }
        /// <inheritdoc/>
        public DateTime AcquiredAt { get; }
        /// <inheritdoc/>
        public DateTime? ExpiresAt { get; }
        /// <inheritdoc/>
        public bool IsValid => !_disposed && !IsExpired();
        /// <summary>
        /// 初始化操作租约实例
        /// </summary>
        /// <param name="resource">资源键</param>
        /// <param name="owner">所有者令牌</param>
        /// <param name="onRelease">释放时的回调通知</param>
        /// <param name="leaseDurationSeconds">租约有效期(秒),默认30秒</param>
        public OperationLease(
            ResourceKey resource,
            ResourceOwnerToken owner,
            Action? onRelease = null,
            int leaseDurationSeconds = 30)
        {
            LeaseId = Guid.NewGuid().ToString();
            Resource = resource ?? throw new ArgumentNullException(nameof(resource));
            Owner = owner ?? throw new ArgumentNullException(nameof(owner));
            AcquiredAt = DateTime.UtcNow;
            ExpiresAt = leaseDurationSeconds > 0
                ? AcquiredAt.AddSeconds(leaseDurationSeconds)
                : null;
            _onRelease = onRelease;
            _disposed = false;
        }
        /// <summary>
        /// 检查租约是否已过期
        /// </summary>
        private bool IsExpired()
        {
            if (!ExpiresAt.HasValue)
                return false;
            return DateTime.UtcNow > ExpiresAt.Value;
        }
        /// <inheritdoc/>
        public void Dispose()
        {
            if (_disposed) return;
            _disposed = true;
            _onRelease?.Invoke();
        }
        /// <inheritdoc/>
        public ValueTask DisposeAsync()
        {
            Dispose();
            return default;
        }
    }
}
Aging.Infrastructure/Core/ResourceManager.cs
New file
@@ -0,0 +1,258 @@
using System.Collections.Concurrent;
using Aging.Infrastructure.Contracts;
using log4net;
namespace Aging.Infrastructure.Core
{
    /// <summary>
    /// 资源管理实现。
    /// 提供线程安全的资源预约、租约获取和释放功能。
    /// 使用 ConcurrentDictionary 存储活跃预约,使用 SemaphoreSlim 实现 per-resource 锁。
    /// </summary>
    public sealed class ResourceManager : IResourceManager
    {
        private static readonly ILog Log = LogManager.GetLogger(typeof(ResourceManager));
        /// <summary>
        /// 活跃预约字典(key = ResourceKey.ToString())
        /// </summary>
        private readonly ConcurrentDictionary<string, ResourceReservation> _reservations = new();
        /// <summary>
        /// 每资源信号量字典,用于实现细粒度锁
        /// </summary>
        private readonly ConcurrentDictionary<string, SemaphoreSlim> _resourceLocks = new();
        /// <summary>
        /// 全局世代号,每次新预约递增
        /// </summary>
        private long _generation;
        /// <summary>
        /// 默认租约有效期(秒)
        /// </summary>
        private const int DefaultLeaseDurationSeconds = 30;
        /// <inheritdoc/>
        public async Task<ResourceReservation> ReserveAsync(
            ResourceOwnerToken owner, ResourceKey resource, CancellationToken ct)
        {
            if (owner == null) throw new ArgumentNullException(nameof(owner));
            if (resource == null) throw new ArgumentNullException(nameof(resource));
            var resourceKey = resource.ToString();
            var semaphore = _resourceLocks.GetOrAdd(resourceKey, _ => new SemaphoreSlim(1, 1));
            try
            {
                await semaphore.WaitAsync(ct).ConfigureAwait(false);
                try
                {
                    // 检查是否已有 Active 预约
                    if (_reservations.TryGetValue(resourceKey, out var existingReservation))
                    {
                        if (existingReservation.State == ReservationState.Active)
                        {
                            // 检查是否为同一 Owner(续期场景)
                            if (existingReservation.Owner.TokenId == owner.TokenId)
                            {
                                // 同一所有者续期:更新世代号
                                var renewedGeneration = Interlocked.Increment(ref _generation);
                                owner.Generation = renewedGeneration;
                                existingReservation.Owner.Generation = renewedGeneration;
                                Log.Info($"[ResourceManager] 资源 {resourceKey} 续期成功,新世代号: {renewedGeneration}");
                                return existingReservation;
                            }
                            // 不同所有者冲突
                            var conflictMsg = $"资源 {resourceKey} 已被所有者 {existingReservation.Owner.OwnerId} 预约," +
                                             $"当前请求者: {owner.OwnerId}";
                            Log.Warn($"[ResourceManager] 资源冲突: {conflictMsg}");
                            throw new ResourceConflictException(conflictMsg);
                        }
                    }
                    // 创建新预约
                    var newGeneration = Interlocked.Increment(ref _generation);
                    owner.Generation = newGeneration;
                    var reservation = new ResourceReservation(resource, owner)
                    {
                        State = ReservationState.Active
                    };
                    _reservations[resourceKey] = reservation;
                    Log.Info($"[ResourceManager] 资源 {resourceKey} 预约成功,所有者: {owner.OwnerId},世代号: {newGeneration}");
                    return reservation;
                }
                finally
                {
                    semaphore.Release();
                }
            }
            catch (OperationCanceledException)
            {
                Log.Warn($"[ResourceManager] 资源 {resourceKey} 预约被取消");
                throw;
            }
            catch (ResourceConflictException)
            {
                throw;
            }
            catch (Exception ex)
            {
                Log.Error($"[ResourceManager] 资源 {resourceKey} 预约失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public async Task<IOperationLease> AcquireOperationAsync(
            ResourceOwnerToken owner, ResourceKey resource, CancellationToken ct)
        {
            if (owner == null) throw new ArgumentNullException(nameof(owner));
            if (resource == null) throw new ArgumentNullException(nameof(resource));
            var resourceKey = resource.ToString();
            try
            {
                // 检查资源是否有有效预约
                if (!_reservations.TryGetValue(resourceKey, out var reservation))
                {
                    throw new InvalidOperationException($"资源 {resourceKey} 未被预约,无法获取操作租约");
                }
                if (reservation.State != ReservationState.Active)
                {
                    throw new InvalidOperationException($"资源 {resourceKey} 预约状态为 {reservation.State},无法获取操作租约");
                }
                // 验证 Owner 匹配(TokenId + Generation)
                if (reservation.Owner.TokenId != owner.TokenId || reservation.Owner.Generation != owner.Generation)
                {
                    throw new InvalidOperationException(
                        $"资源 {resourceKey} 所有者不匹配,期望 TokenId: {reservation.Owner.TokenId},Generation: {reservation.Owner.Generation}");
                }
                // 创建 OperationLease(短期,默认30秒过期)
                var lease = new OperationLease(
                    resource,
                    owner,
                    onRelease: () => Log.Debug($"[ResourceManager] 租约释放: {resourceKey}"),
                    leaseDurationSeconds: DefaultLeaseDurationSeconds);
                Log.Info($"[ResourceManager] 获取操作租约: {resourceKey},所有者: {owner.OwnerId},有效期: {DefaultLeaseDurationSeconds}秒");
                await Task.CompletedTask; // 保持异步签名
                return lease;
            }
            catch (InvalidOperationException)
            {
                throw;
            }
            catch (Exception ex)
            {
                Log.Error($"[ResourceManager] 获取操作租约失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public async Task ReleaseReservationAsync(
            ResourceOwnerToken owner, ResourceKey resource, CancellationToken ct)
        {
            if (owner == null) throw new ArgumentNullException(nameof(owner));
            if (resource == null) throw new ArgumentNullException(nameof(resource));
            var resourceKey = resource.ToString();
            var semaphore = _resourceLocks.GetOrAdd(resourceKey, _ => new SemaphoreSlim(1, 1));
            try
            {
                await semaphore.WaitAsync(ct).ConfigureAwait(false);
                try
                {
                    if (!_reservations.TryGetValue(resourceKey, out var reservation))
                    {
                        Log.Warn($"[ResourceManager] 尝试释放不存在的资源预约: {resourceKey}");
                        return;
                    }
                    // 验证 Owner 匹配
                    if (reservation.Owner.TokenId != owner.TokenId)
                    {
                        throw new InvalidOperationException(
                            $"资源 {resourceKey} 释放失败:所有者不匹配,期望 TokenId: {reservation.Owner.TokenId}");
                    }
                    // 标记预约为 Released
                    reservation.State = ReservationState.Released;
                    _reservations.TryRemove(resourceKey, out _);
                    Log.Info($"[ResourceManager] 资源 {resourceKey} 预约已释放,所有者: {owner.OwnerId}");
                }
                finally
                {
                    semaphore.Release();
                }
                await Task.CompletedTask; // 保持异步签名
            }
            catch (InvalidOperationException)
            {
                throw;
            }
            catch (OperationCanceledException)
            {
                Log.Warn($"[ResourceManager] 资源 {resourceKey} 释放操作被取消");
                throw;
            }
            catch (Exception ex)
            {
                Log.Error($"[ResourceManager] 释放资源 {resourceKey} 失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public bool IsReserved(ResourceKey resource, out ResourceReservation? reservation)
        {
            if (resource == null) throw new ArgumentNullException(nameof(resource));
            var resourceKey = resource.ToString();
            var result = _reservations.TryGetValue(resourceKey, out var res);
            if (result && res != null && res.State == ReservationState.Active)
            {
                reservation = res;
                return true;
            }
            reservation = null;
            return false;
        }
        /// <inheritdoc/>
        public bool ValidateOwner(ResourceOwnerToken owner, ResourceKey resource)
        {
            if (owner == null) throw new ArgumentNullException(nameof(owner));
            if (resource == null) throw new ArgumentNullException(nameof(resource));
            var resourceKey = resource.ToString();
            if (!_reservations.TryGetValue(resourceKey, out var reservation))
                return false;
            if (reservation.State != ReservationState.Active)
                return false;
            return reservation.Owner.TokenId == owner.TokenId &&
                   reservation.Owner.Generation == owner.Generation;
        }
    }
}
Aging.Infrastructure/Drivers/CanDriverAdapter.cs
New file
@@ -0,0 +1,576 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Aging.Infrastructure.Contracts;
using CanSimulator;
using log4net;
namespace Aging.Infrastructure.Drivers
{
    /// <summary>
    /// CAN 通讯驱动适配器。
    /// 封装 CanSimulator,实现统一的 IDeviceDriver 接口。
    /// 命令格式约定:
    ///   发送: "SEND:frameId:data"  如 "SEND:0x100:01 02 03 04"
    ///   查询:
    ///     "RECV"           - 获取接收邮箱中的下一帧
    ///     "RECV_ALL"       - 获取接收邮箱中的所有帧
    ///     "STATUS"         - 获取节点/总线状态
    ///     "FILTER:id:mask" - 设置接收滤波器
    ///     "FILTER_CLEAR"   - 清除所有滤波器
    ///     "PROMISCUOUS"    - 设置混杂模式(接收所有帧)
    ///     "LOG"            - 获取总线日志
    ///     "BUS_STATUS"     - 获取总线状态摘要
    /// </summary>
    public sealed class CanDriverAdapter : IDeviceDriver
    {
        private static readonly ILog Log = LogManager.GetLogger(typeof(CanDriverAdapter));
        /// <summary>
        /// 全局 CAN 总线模拟器实例(所有 CAN 驱动共享)
        /// </summary>
        private static readonly Dictionary<string, CanBusSimulator> _sharedBuses = new();
        private static readonly object _busLock = new();
        private readonly string _ip;      // 总线名称(用 ip 字段映射)
        private readonly int _port;       // 节点编号(用 port 字段映射)
        private CanBusSimulator? _bus;
        private CanNode? _node;
        private bool _disposed;
        private readonly Stopwatch _latencyStopwatch = new();
        private double _lastLatencyMs;
        private string _lastError = string.Empty;
        private DateTime? _lastErrorTime;
        private int _reconnectCount;
        /// <summary>
        /// 接收到的帧缓存(用于 RECV 查询)
        /// </summary>
        private readonly Queue<CanFrame> _recvBuffer = new();
        /// <inheritdoc/>
        public string DeviceId { get; }
        /// <inheritdoc/>
        public bool IsConnected => _node != null && _node.IsActive;
        /// <inheritdoc/>
        public event EventHandler<DeviceConnectionEventArgs>? ConnectionChanged;
        /// <summary>
        /// 初始化 CAN 驱动适配器
        /// </summary>
        /// <param name="deviceId">设备唯一标识符</param>
        /// <param name="busName">总线名称(如 CAN_BUS_1)</param>
        /// <param name="nodeIndex">节点编号(用于区分同一总线上的不同设备)</param>
        public CanDriverAdapter(string deviceId, string busName, int nodeIndex)
        {
            DeviceId = deviceId ?? throw new ArgumentNullException(nameof(deviceId));
            _ip = busName ?? "CAN_BUS_1";
            _port = nodeIndex;
        }
        /// <summary>
        /// 获取或创建共享总线
        /// </summary>
        private CanBusSimulator GetOrCreateBus(string busName)
        {
            lock (_busLock)
            {
                if (!_sharedBuses.TryGetValue(busName, out var bus))
                {
                    bus = new CanBusSimulator(busName);
                    _sharedBuses[busName] = bus;
                    Log.Info($"[CAN] 创建新的虚拟总线: {busName}");
                }
                return bus;
            }
        }
        /// <inheritdoc/>
        public Task ConnectAsync(CancellationToken ct)
        {
            ThrowIfDisposed();
            Log.Info($"[CAN] 设备 {DeviceId} 正在连接到总线 {_ip} (节点: {_port})...");
            try
            {
                // 获取或创建共享总线
                _bus = GetOrCreateBus(_ip);
                // 如果总线未启动,自动启动
                if (_bus.State != CanBusState.Running)
                {
                    _bus.Start();
                }
                // 注册节点
                string nodeId = $"{DeviceId}_node{_port}";
                _node = _bus.RegisterNode(nodeId, DeviceId);
                // 设置默认滤波器(接收所有帧)
                _node.SetPromiscuousMode();
                // 激活节点
                _node.Activate();
                // 订阅接收事件
                _node.FrameReceived += OnFrameReceived;
                _lastError = string.Empty;
                Log.Info($"[CAN] 设备 {DeviceId} 已成功连接到总线 {_ip} (节点: {_node.NodeId})");
                OnConnectionChanged(true, "连接成功");
            }
            catch (Exception ex)
            {
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[CAN] 设备 {DeviceId} 连接异常: {ex.Message}", ex);
                _node?.Deactivate();
                _node = null;
                throw;
            }
            return Task.CompletedTask;
        }
        /// <inheritdoc/>
        public Task DisconnectAsync()
        {
            ThrowIfDisposed();
            Log.Info($"[CAN] 设备 {DeviceId} 正在断开连接...");
            try
            {
                if (_node != null)
                {
                    _node.FrameReceived -= OnFrameReceived;
                    _node.Deactivate();
                    _bus?.UnregisterNode(_node.NodeId);
                    _node = null;
                }
                Log.Info($"[CAN] 设备 {DeviceId} 已断开连接");
                OnConnectionChanged(false, "主动断开");
            }
            catch (Exception ex)
            {
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[CAN] 设备 {DeviceId} 断开连接异常: {ex.Message}", ex);
                _node = null;
            }
            return Task.CompletedTask;
        }
        /// <inheritdoc/>
        public Task<string> SendCommandAsync(string command, CancellationToken ct)
        {
            ThrowIfDisposed();
            if (!IsConnected) throw new InvalidOperationException("[CAN] 设备未连接");
            _latencyStopwatch.Restart();
            try
            {
                Log.Debug($"[CAN] 设备 {DeviceId} 发送命令: {command}");
                // 解析命令格式: "SEND:frameId:data"
                string[] parts = command.Split(':');
                if (parts.Length < 3)
                {
                    throw new ArgumentException($"[CAN] 命令格式错误,应为 SEND:frameId:data,实际: {command}");
                }
                string operation = parts[0].ToUpper();
                if (operation != "SEND")
                {
                    throw new ArgumentException($"[CAN] 不支持的操作类型: {operation},应为 SEND");
                }
                // 解析帧 ID
                string frameIdStr = parts[1];
                uint frameId = CanFrame.ParseFrameId(frameIdStr);
                // 解析数据
                string dataStr = parts[2];
                byte[] data = CanFrame.ParseData(dataStr);
                // 验证数据长度
                if (data.Length > 8)
                {
                    throw new ArgumentException($"[CAN] 数据长度超限: {data.Length} 字节(最大 8 字节)");
                }
                // 创建并发送帧
                var frame = new CanFrame
                {
                    FrameId = frameId,
                    Format = frameId > 0x7FF ? CanFrameFormat.Extended : CanFrameFormat.Standard,
                    Type = CanFrameType.Data,
                    Data = data
                };
                _node!.SendFrame(frame);
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                string response = $"OK:TX:{frame.FrameIdHex}:{frame.Dlc}";
                Log.Debug($"[CAN] 设备 {DeviceId} 发送成功 (延迟 {_lastLatencyMs:F2}ms): {response}");
                return Task.FromResult(response);
            }
            catch (Exception ex)
            {
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[CAN] 设备 {DeviceId} 发送命令失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public Task<string> QueryAsync(string query, CancellationToken ct)
        {
            ThrowIfDisposed();
            if (!IsConnected) throw new InvalidOperationException("[CAN] 设备未连接");
            _latencyStopwatch.Restart();
            try
            {
                Log.Debug($"[CAN] 设备 {DeviceId} 查询: {query}");
                string[] parts = query.Split(':');
                string operation = parts[0].ToUpper();
                string response;
                switch (operation)
                {
                    case "RECV":
                        response = HandleRecv();
                        break;
                    case "RECV_ALL":
                        response = HandleRecvAll();
                        break;
                    case "STATUS":
                        response = HandleStatus();
                        break;
                    case "FILTER":
                        response = HandleFilter(parts);
                        break;
                    case "FILTER_CLEAR":
                        _node!.ClearFilters();
                        response = "OK:滤波器已清除";
                        break;
                    case "PROMISCUOUS":
                        _node!.SetPromiscuousMode();
                        response = "OK:混杂模式已启用";
                        break;
                    case "AUTO_RESPONSE":
                        response = HandleAutoResponse(parts);
                        break;
                    case "RESPONSE_DELAY":
                        response = HandleResponseDelay(parts);
                        break;
                    case "LOG":
                        response = HandleLog();
                        break;
                    case "BUS_STATUS":
                        response = _bus?.GetStatusSummary() ?? "总线未初始化";
                        break;
                    default:
                        throw new ArgumentException($"[CAN] 不支持的查询类型: {operation}");
                }
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                Log.Debug($"[CAN] 设备 {DeviceId} 查询响应: {response} (延迟 {_lastLatencyMs:F2}ms)");
                return Task.FromResult(response);
            }
            catch (Exception ex)
            {
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[CAN] 设备 {DeviceId} 查询失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <summary>
        /// 处理 RECV 查询 - 获取下一帧
        /// </summary>
        private string HandleRecv()
        {
            // 先从缓冲区取
            if (_recvBuffer.Count > 0)
            {
                var frame = _recvBuffer.Dequeue();
                return $"RECV:{frame.FrameIdHex}:{frame.DataHex}:{frame.Timestamp:HH:mm:ss.fff}";
            }
            // 从节点邮箱取
            if (_node!.TryDequeueReceived(out var frame2))
            {
                return $"RECV:{frame2!.FrameIdHex}:{frame2.DataHex}:{frame2.Timestamp:HH:mm:ss.fff}";
            }
            return "RECV:EMPTY";
        }
        /// <summary>
        /// 处理 RECV_ALL 查询 - 获取所有帧
        /// </summary>
        private string HandleRecvAll()
        {
            var frames = new List<string>();
            // 先取缓冲区
            while (_recvBuffer.Count > 0)
            {
                var f = _recvBuffer.Dequeue();
                frames.Add($"{f.FrameIdHex}:{f.DataHex}");
            }
            // 再取节点邮箱
            var mailbox = _node!.PeekReceiveMailbox();
            _node.ClearReceiveMailbox();
            foreach (var f in mailbox)
            {
                frames.Add($"{f.FrameIdHex}:{f.DataHex}:{f.Timestamp:HH:mm:ss.fff}");
            }
            return frames.Count > 0 ? $"RECV_ALL:{frames.Count}:{string.Join("|", frames)}" : "RECV_ALL:0";
        }
        /// <summary>
        /// 处理 STATUS 查询
        /// </summary>
        private string HandleStatus()
        {
            var nodeStatus = _node?.GetStatusSummary() ?? "节点未初始化";
            var busStatus = _bus?.GetStatusSummary() ?? "总线未初始化";
            return $"STATUS:{nodeStatus}|{busStatus}";
        }
        /// <summary>
        /// 处理 FILTER 设置
        /// </summary>
        private string HandleFilter(string[] parts)
        {
            if (parts.Length < 3)
            {
                throw new ArgumentException("[CAN] FILTER 命令格式错误,应为 FILTER:id:mask");
            }
            uint filterId = CanFrame.ParseFrameId(parts[1]);
            uint mask = CanFrame.ParseFrameId(parts[2]);
            _node!.AddFilter(new AcceptanceFilter
            {
                FilterId = filterId,
                Mask = mask,
                IsExtended = filterId > 0x7FF
            });
            return $"OK:滤波器已添加 ID=0x{filterId:X3}, Mask=0x{mask:X3}";
        }
        /// <summary>
        /// 处理 AUTO_RESPONSE 命令
        /// 格式: AUTO_RESPONSE:on|off
        /// </summary>
        private string HandleAutoResponse(string[] parts)
        {
            if (parts.Length < 2)
            {
                // 查询当前状态
                bool current = _node!.AutoResponseEnabled;
                return $"AUTO_RESPONSE:{(current ? "ON" : "OFF")}";
            }
            string mode = parts[1].Trim().ToUpper();
            switch (mode)
            {
                case "ON":
                case "1":
                case "TRUE":
                case "ENABLE":
                    _node!.AutoResponseEnabled = true;
                    Log.Info($"[CAN] 设备 {DeviceId} 启用自动响应模式");
                    return "OK:自动响应模式已启用";
                case "OFF":
                case "0":
                case "FALSE":
                case "DISABLE":
                    _node!.AutoResponseEnabled = false;
                    Log.Info($"[CAN] 设备 {DeviceId} 禁用自动响应模式");
                    return "OK:自动响应模式已禁用";
                default:
                    throw new ArgumentException($"[CAN] AUTO_RESPONSE 参数错误: {mode},应为 on/off");
            }
        }
        /// <summary>
        /// 处理 RESPONSE_DELAY 命令
        /// 格式: RESPONSE_DELAY:毫秒数
        /// </summary>
        private string HandleResponseDelay(string[] parts)
        {
            if (parts.Length < 2)
            {
                // 查询当前延迟
                return $"RESPONSE_DELAY:{_node!.AutoResponseDelayMs}ms";
            }
            if (!int.TryParse(parts[1].Trim(), out int delayMs) || delayMs < 0)
            {
                throw new ArgumentException($"[CAN] RESPONSE_DELAY 参数错误: {parts[1]},应为非负整数(毫秒)");
            }
            _node!.AutoResponseDelayMs = delayMs;
            Log.Info($"[CAN] 设备 {DeviceId} 设置响应延迟: {delayMs}ms");
            return $"OK:响应延迟已设置为 {delayMs}ms";
        }
        /// <summary>
        /// 处理 LOG 查询
        /// </summary>
        private string HandleLog()
        {
            var log = _bus?.GetBusLog(50) ?? new List<CanFrame>();
            if (log.Count == 0) return "LOG:EMPTY";
            var entries = log.Select(f =>
                $"[{f.Timestamp:HH:mm:ss.fff}]{f.SourceNodeId}>{f.FrameIdHex}:{f.DataHex}");
            return $"LOG:{log.Count}:{string.Join("|", entries)}";
        }
        /// <summary>
        /// 帧接收事件(公开给外部订阅)
        /// </summary>
        public event EventHandler<CanFrame>? DriverFrameReceived;
        /// <summary>
        /// 帧接收事件处理
        /// </summary>
        private void OnFrameReceived(object? sender, CanFrame frame)
        {
            // 放入接收缓冲区(用于 RECV/RECV_ALL 查询)
            while (_recvBuffer.Count >= 1000)
            {
                _recvBuffer.Dequeue();
            }
            _recvBuffer.Enqueue(frame);
            // 触发公开事件(用于即时显示)
            DriverFrameReceived?.Invoke(this, frame);
        }
        /// <inheritdoc/>
        public DeviceDriverStatus GetStatus()
        {
            return new DeviceDriverStatus
            {
                IsConnected = IsConnected,
                IsHealthy = IsConnected && string.IsNullOrEmpty(_lastError),
                LastError = _lastError,
                LastErrorTime = _lastErrorTime,
                LatencyMs = _lastLatencyMs,
                ReconnectCount = _reconnectCount
            };
        }
        /// <inheritdoc/>
        public void Dispose()
        {
            if (_disposed) return;
            _disposed = true;
            try
            {
                if (_node != null)
                {
                    _node.FrameReceived -= OnFrameReceived;
                    _node.Deactivate();
                    _bus?.UnregisterNode(_node.NodeId);
                }
            }
            catch (Exception ex)
            {
                Log.Error($"[CAN] 设备 {DeviceId} 释放资源异常: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 触发所有总线和节点(用于测试清理)
        /// </summary>
        public static void ResetAllBuses()
        {
            lock (_busLock)
            {
                foreach (var bus in _sharedBuses.Values)
                {
                    bus.Dispose();
                }
                _sharedBuses.Clear();
                Log.Info("[CAN] 所有虚拟总线已重置");
            }
        }
        /// <summary>
        /// 触发所有总线实例(供外部查询)
        /// </summary>
        public static IReadOnlyDictionary<string, CanBusSimulator> GetAllBuses()
        {
            lock (_busLock)
            {
                return new Dictionary<string, CanBusSimulator>(_sharedBuses);
            }
        }
        /// <summary>
        /// 触发连接状态变化事件
        /// </summary>
        private void OnConnectionChanged(bool isConnected, string reason)
        {
            ConnectionChanged?.Invoke(this, new DeviceConnectionEventArgs
            {
                DeviceId = DeviceId,
                IsConnected = isConnected,
                Reason = reason
            });
            if (!isConnected)
            {
                _reconnectCount++;
            }
        }
        /// <summary>
        /// 检查是否已释放
        /// </summary>
        private void ThrowIfDisposed()
        {
            if (_disposed) throw new ObjectDisposedException(nameof(CanDriverAdapter));
        }
    }
}
Aging.Infrastructure/Drivers/DriverFactory.cs
New file
@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using Aging.Infrastructure.Contracts;
using log4net;
namespace Aging.Infrastructure.Drivers
{
    /// <summary>
    /// 设备驱动工厂。
    /// 根据传输协议类型和端点信息创建对应的驱动适配器实例。
    /// </summary>
    public static class DriverFactory
    {
        private static readonly ILog Log = LogManager.GetLogger(typeof(DriverFactory));
        /// <summary>
        /// 根据传输协议类型和端点创建驱动适配器
        /// </summary>
        /// <param name="deviceId">设备唯一标识符</param>
        /// <param name="transport">传输协议类型: "Tcp", "SCPI", "ModBus", "S7", "Serial"</param>
        /// <param name="endpoint">端点信息,格式: "ip:port" 或 "ip:port:extra"</param>
        /// <returns>IDeviceDriver 实例</returns>
        /// <exception cref="NotSupportedException">不支持的传输协议类型</exception>
        /// <exception cref="ArgumentException">端点格式错误</exception>
        public static IDeviceDriver CreateDriver(string deviceId, string transport, string endpoint)
        {
            return CreateDriver(deviceId, transport, endpoint, null);
        }
        /// <summary>
        /// 根据传输协议类型、端点和额外参数创建驱动适配器
        /// </summary>
        /// <param name="deviceId">设备唯一标识符</param>
        /// <param name="transport">传输协议类型: "Tcp", "SCPI", "ModBus", "S7", "Serial"</param>
        /// <param name="endpoint">端点信息,格式: "ip:port" 或 "ip:port:extra"</param>
        /// <param name="extraParams">额外参数(可选)</param>
        /// <returns>IDeviceDriver 实例</returns>
        /// <exception cref="NotSupportedException">不支持的传输协议类型</exception>
        /// <exception cref="ArgumentException">端点格式错误</exception>
        public static IDeviceDriver CreateDriver(string deviceId, string transport, string endpoint, Dictionary<string, string>? extraParams)
        {
            if (string.IsNullOrWhiteSpace(deviceId))
                throw new ArgumentNullException(nameof(deviceId));
            if (string.IsNullOrWhiteSpace(transport))
                throw new ArgumentNullException(nameof(transport));
            if (string.IsNullOrWhiteSpace(endpoint))
                throw new ArgumentNullException(nameof(endpoint));
            Log.Info($"[DriverFactory] 创建设备驱动: DeviceId={deviceId}, Transport={transport}, Endpoint={endpoint}");
            string[] parts = endpoint.Split(':');
            if (parts.Length < 2)
            {
                throw new ArgumentException($"端点格式错误,应为 'ip:port' 或 'ip:port:extra',实际: {endpoint}");
            }
            string ip = parts[0];
            if (!int.TryParse(parts[1], out int port))
            {
                throw new ArgumentException($"端口号格式错误: {parts[1]}");
            }
            switch (transport.ToUpper())
            {
                case "TCP":
                    Log.Info($"[DriverFactory] 创建 TCP 原始通信驱动: {ip}:{port}");
                    return new TcpRawDriverAdapter(deviceId, ip, port);
                case "SCPI":
                    Log.Info($"[DriverFactory] 创建 SCPI 协议驱动: {ip}:{port}");
                    return new ScpiDriverAdapter(deviceId, ip, port);
                case "MODBUS":
                    byte slaveId = 1;
                    if (extraParams != null && extraParams.TryGetValue("slaveId", out string slaveIdStr))
                    {
                        if (!byte.TryParse(slaveIdStr, out slaveId))
                        {
                            slaveId = 1;
                        }
                    }
                    // 如果 endpoint 有第三部分,作为 slaveId
                    else if (parts.Length >= 3 && byte.TryParse(parts[2], out byte parsedSlaveId))
                    {
                        slaveId = parsedSlaveId;
                    }
                    Log.Info($"[DriverFactory] 创建 ModBus TCP 驱动: {ip}:{port} (从站: {slaveId})");
                    return new ModBusDriverAdapter(deviceId, ip, port, slaveId);
                case "S7":
                    Log.Info($"[DriverFactory] 创建 S7 PLC 驱动: {ip}:{port}");
                    return new S7DriverAdapter(deviceId, ip, port);
                case "SERIAL":
                    // TODO: 实现串口驱动适配器
                    throw new NotSupportedException("串口驱动适配器尚未实现");
                case "CAN":
                    // CAN 驱动: endpoint 格式为 "busName:nodeIndex" 如 "CAN_BUS_1:0"
                    string busName = ip; // ip 字段用作总线名称
                    int nodeIndex = port; // port 字段用作节点编号
                    Log.Info($"[DriverFactory] 创建 CAN 虚拟驱动: Bus={busName}, Node={nodeIndex}");
                    return new CanDriverAdapter(deviceId, busName, nodeIndex);
                default:
                    throw new NotSupportedException($"不支持的传输协议类型: {transport}。支持的类型: Tcp, SCPI, ModBus, S7, Serial, CAN");
            }
        }
    }
}
Aging.Infrastructure/Drivers/ModBusDriverAdapter.cs
New file
@@ -0,0 +1,347 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Aging.Infrastructure.Contracts;
using HslCommunication;
using HslCommunication.ModBus;
using log4net;
namespace Aging.Infrastructure.Drivers
{
    /// <summary>
    /// ModBus TCP 驱动适配器。
    /// 封装 HslCommunication.ModBusTcpNet,实现统一的 IDeviceDriver 接口。
    /// 命令格式约定:
    ///   写入: "WRITE:address:value" 如 "WRITE:40001:100" 或 "WRITE:00001:TRUE"
    ///   读取: "READ:address" 如 "READ:00001" 或 "READ:40001"
    /// </summary>
    public sealed class ModBusDriverAdapter : IDeviceDriver
    {
        private static readonly ILog Log = LogManager.GetLogger(typeof(ModBusDriverAdapter));
        private readonly string _ip;
        private readonly int _port;
        private readonly byte _slaveId;
        private ModbusTcpNet? _client;
        private bool _disposed;
        private readonly Stopwatch _latencyStopwatch = new Stopwatch();
        private double _lastLatencyMs;
        private string _lastError = string.Empty;
        private DateTime? _lastErrorTime;
        private int _reconnectCount;
        /// <inheritdoc/>
        public string DeviceId { get; }
        /// <inheritdoc/>
        public bool IsConnected => _client != null;
        /// <inheritdoc/>
        public event EventHandler<DeviceConnectionEventArgs>? ConnectionChanged;
        /// <summary>
        /// 初始化 ModBus TCP 驱动适配器
        /// </summary>
        /// <param name="deviceId">设备唯一标识符</param>
        /// <param name="ip">目标 IP 地址</param>
        /// <param name="port">目标端口号</param>
        /// <param name="slaveId">从站地址(默认为 1)</param>
        public ModBusDriverAdapter(string deviceId, string ip, int port, byte slaveId = 1)
        {
            DeviceId = deviceId ?? throw new ArgumentNullException(nameof(deviceId));
            _ip = ip ?? throw new ArgumentNullException(nameof(ip));
            _port = port;
            _slaveId = slaveId;
        }
        /// <inheritdoc/>
        public Task ConnectAsync(CancellationToken ct)
        {
            ThrowIfDisposed();
            Log.Info($"[ModBus] 设备 {DeviceId} 正在连接 {_ip}:{_port} (从站: {_slaveId})...");
            try
            {
                _client = new ModbusTcpNet(_ip, _port, _slaveId);
                OperateResult result = _client.ConnectServer();
                if (!result.IsSuccess)
                {
                    _lastError = result.Message;
                    _lastErrorTime = DateTime.Now;
                    throw new InvalidOperationException($"[ModBus] 设备 {DeviceId} 连接失败: {result.Message}");
                }
                _lastError = string.Empty;
                Log.Info($"[ModBus] 设备 {DeviceId} 已成功连接到 {_ip}:{_port}");
                OnConnectionChanged(true, "连接成功");
            }
            catch (Exception ex)
            {
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[ModBus] 设备 {DeviceId} 连接异常: {ex.Message}", ex);
                _client?.ConnectClose();
                _client?.Dispose();
                _client = null;
                throw;
            }
            return Task.CompletedTask;
        }
        /// <inheritdoc/>
        public Task DisconnectAsync()
        {
            ThrowIfDisposed();
            Log.Info($"[ModBus] 设备 {DeviceId} 正在断开连接...");
            try
            {
                if (_client != null)
                {
                    _client.ConnectClose();
                    _client.Dispose();
                    _client = null;
                }
                Log.Info($"[ModBus] 设备 {DeviceId} 已断开连接");
                OnConnectionChanged(false, "主动断开");
            }
            catch (Exception ex)
            {
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[ModBus] 设备 {DeviceId} 断开连接异常: {ex.Message}", ex);
                _client = null;
            }
            return Task.CompletedTask;
        }
        /// <inheritdoc/>
        public Task<string> SendCommandAsync(string command, CancellationToken ct)
        {
            ThrowIfDisposed();
            if (!IsConnected) throw new InvalidOperationException("[ModBus] 设备未连接");
            _latencyStopwatch.Restart();
            try
            {
                Log.Debug($"[ModBus] 设备 {DeviceId} 发送命令: {command}");
                // 解析命令格式: "WRITE:address:value"
                string[] parts = command.Split(':');
                if (parts.Length < 3)
                {
                    throw new ArgumentException($"[ModBus] 命令格式错误,应为 WRITE:address:value,实际: {command}");
                }
                string operation = parts[0].ToUpper();
                string address = parts[1];
                string value = parts[2];
                if (operation != "WRITE")
                {
                    throw new ArgumentException($"[ModBus] 不支持的操作类型: {operation},应为 WRITE");
                }
                // 根据值判断数据类型并写入
                OperateResult result;
                if (bool.TryParse(value, out bool boolVal))
                {
                    result = _client.Write(address, boolVal);
                }
                else if (short.TryParse(value, out short shortVal))
                {
                    result = _client.Write(address, shortVal);
                }
                else if (int.TryParse(value, out int intVal))
                {
                    result = _client.Write(address, intVal);
                }
                else if (float.TryParse(value, out float floatVal))
                {
                    result = _client.Write(address, floatVal);
                }
                else
                {
                    throw new ArgumentException($"[ModBus] 无法解析的值: {value}");
                }
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                if (!result.IsSuccess)
                {
                    _lastError = result.Message;
                    _lastErrorTime = DateTime.Now;
                    throw new InvalidOperationException($"[ModBus] 写入失败: {result.Message}");
                }
                string response = "OK";
                Log.Debug($"[ModBus] 设备 {DeviceId} 写入成功 (延迟 {_lastLatencyMs:F2}ms)");
                return Task.FromResult(response);
            }
            catch (Exception ex)
            {
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[ModBus] 设备 {DeviceId} 发送命令失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public Task<string> QueryAsync(string query, CancellationToken ct)
        {
            ThrowIfDisposed();
            if (!IsConnected) throw new InvalidOperationException("[ModBus] 设备未连接");
            _latencyStopwatch.Restart();
            try
            {
                Log.Debug($"[ModBus] 设备 {DeviceId} 发送查询: {query}");
                // 解析查询格式: "READ:address"
                string[] parts = query.Split(':');
                if (parts.Length < 2)
                {
                    throw new ArgumentException($"[ModBus] 查询格式错误,应为 READ:address,实际: {query}");
                }
                string operation = parts[0].ToUpper();
                string address = parts[1];
                if (operation != "READ")
                {
                    throw new ArgumentException($"[ModBus] 不支持的操作类型: {operation},应为 READ");
                }
                // 尝试读取不同类型(根据地址范围自动判断)
                // 00001-09999: 线圈(BOOL)
                // 10001-19999: 离散输入(BOOL)
                // 30001-39999: 输入寄存器(INT16)
                // 40001-49999: 保持寄存器(INT16)
                string response;
                int addrNum;
                if (int.TryParse(address, out addrNum))
                {
                    if (addrNum >= 1 && addrNum <= 9999)
                    {
                        // 线圈读取
                        var result = _client.ReadBool(address);
                        response = result.IsSuccess ? result.Content.ToString() : $"ERROR: {result.Message}";
                    }
                    else if (addrNum >= 10001 && addrNum <= 19999)
                    {
                        // 离散输入读取
                        var result = _client.ReadBool(address);
                        response = result.IsSuccess ? result.Content.ToString() : $"ERROR: {result.Message}";
                    }
                    else if (addrNum >= 30001 && addrNum <= 39999)
                    {
                        // 输入寄存器读取
                        var result = _client.ReadInt16(address);
                        response = result.IsSuccess ? result.Content.ToString() : $"ERROR: {result.Message}";
                    }
                    else if (addrNum >= 40001 && addrNum <= 49999)
                    {
                        // 保持寄存器读取
                        var result = _client.ReadInt16(address);
                        response = result.IsSuccess ? result.Content.ToString() : $"ERROR: {result.Message}";
                    }
                    else
                    {
                        // 默认读取 INT16
                        var result = _client.ReadInt16(address);
                        response = result.IsSuccess ? result.Content.ToString() : $"ERROR: {result.Message}";
                    }
                }
                else
                {
                    // 非数字地址,尝试读取 INT16
                    var result = _client.ReadInt16(address);
                    response = result.IsSuccess ? result.Content.ToString() : $"ERROR: {result.Message}";
                }
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                Log.Debug($"[ModBus] 设备 {DeviceId} 查询响应: {response} (延迟 {_lastLatencyMs:F2}ms)");
                return Task.FromResult(response);
            }
            catch (Exception ex)
            {
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[ModBus] 设备 {DeviceId} 查询失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public DeviceDriverStatus GetStatus()
        {
            return new DeviceDriverStatus
            {
                IsConnected = IsConnected,
                IsHealthy = IsConnected && string.IsNullOrEmpty(_lastError),
                LastError = _lastError,
                LastErrorTime = _lastErrorTime,
                LatencyMs = _lastLatencyMs,
                ReconnectCount = _reconnectCount
            };
        }
        /// <inheritdoc/>
        public void Dispose()
        {
            if (_disposed) return;
            _disposed = true;
            try
            {
                if (_client != null)
                {
                    _client.ConnectClose();
                    _client.Dispose();
                }
            }
            catch (Exception ex)
            {
                Log.Error($"[ModBus] 设备 {DeviceId} 释放资源异常: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 触发连接状态变化事件
        /// </summary>
        private void OnConnectionChanged(bool isConnected, string reason)
        {
            ConnectionChanged?.Invoke(this, new DeviceConnectionEventArgs
            {
                DeviceId = DeviceId,
                IsConnected = isConnected,
                Reason = reason
            });
            if (!isConnected)
            {
                _reconnectCount++;
            }
        }
        /// <summary>
        /// 检查是否已释放
        /// </summary>
        private void ThrowIfDisposed()
        {
            if (_disposed) throw new ObjectDisposedException(nameof(ModBusDriverAdapter));
        }
    }
}
Aging.Infrastructure/Drivers/S7DriverAdapter.cs
New file
@@ -0,0 +1,407 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Aging.Infrastructure.Contracts;
using log4net;
using S7Driver;
namespace Aging.Infrastructure.Drivers
{
    /// <summary>
    /// 西门子 S7 PLC 驱动适配器。
    /// 封装 S7Driver.PLCS7Driver,实现统一的 IDeviceDriver 接口。
    /// 命令格式约定:
    ///   写入: "WRITE:DBx.DBXy.z:value" 如 "WRITE:DB1.DBX0.0:TRUE"
    ///   读取: "READ:DBx.DBWy" 如 "READ:DB1.DBD0"
    /// 支持数据类型: BOOL, INT, REAL
    /// </summary>
    public sealed class S7DriverAdapter : IDeviceDriver
    {
        private static readonly ILog Log = LogManager.GetLogger(typeof(S7DriverAdapter));
        private readonly string _ip;
        private readonly int _port;
        private PLCS7Driver? _driver;
        private bool _disposed;
        private readonly Stopwatch _latencyStopwatch = new Stopwatch();
        private double _lastLatencyMs;
        private string _lastError = string.Empty;
        private DateTime? _lastErrorTime;
        private int _reconnectCount;
        /// <inheritdoc/>
        public string DeviceId { get; }
        /// <inheritdoc/>
        public bool IsConnected => _driver != null;
        /// <inheritdoc/>
        public event EventHandler<DeviceConnectionEventArgs>? ConnectionChanged;
        /// <summary>
        /// 初始化 S7 PLC 驱动适配器
        /// </summary>
        /// <param name="deviceId">设备唯一标识符</param>
        /// <param name="ip">PLC IP 地址</param>
        /// <param name="port">PLC 端口号(通常为 102)</param>
        public S7DriverAdapter(string deviceId, string ip, int port)
        {
            DeviceId = deviceId ?? throw new ArgumentNullException(nameof(deviceId));
            _ip = ip ?? throw new ArgumentNullException(nameof(ip));
            _port = port;
        }
        /// <inheritdoc/>
        public Task ConnectAsync(CancellationToken ct)
        {
            ThrowIfDisposed();
            Log.Info($"[S7] 设备 {DeviceId} 正在连接 {_ip}:{_port}...");
            try
            {
                _driver = new PLCS7Driver(_ip, _port);
                bool success = _driver.Connect(out string errMsg);
                if (!success)
                {
                    _lastError = errMsg;
                    _lastErrorTime = DateTime.Now;
                    throw new InvalidOperationException($"[S7] 设备 {DeviceId} 连接失败: {errMsg}");
                }
                _lastError = string.Empty;
                Log.Info($"[S7] 设备 {DeviceId} 已成功连接到 {_ip}:{_port}");
                OnConnectionChanged(true, "连接成功");
            }
            catch (Exception ex)
            {
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[S7] 设备 {DeviceId} 连接异常: {ex.Message}", ex);
                _driver?.Dispose();
                _driver = null;
                throw;
            }
            return Task.CompletedTask;
        }
        /// <inheritdoc/>
        public Task DisconnectAsync()
        {
            ThrowIfDisposed();
            Log.Info($"[S7] 设备 {DeviceId} 正在断开连接...");
            try
            {
                if (_driver != null)
                {
                    _driver.DisConnect(out string errMsg);
                    _driver.Dispose();
                    _driver = null;
                }
                Log.Info($"[S7] 设备 {DeviceId} 已断开连接");
                OnConnectionChanged(false, "主动断开");
            }
            catch (Exception ex)
            {
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[S7] 设备 {DeviceId} 断开连接异常: {ex.Message}", ex);
                _driver?.Dispose();
                _driver = null;
            }
            return Task.CompletedTask;
        }
        /// <inheritdoc/>
        public Task<string> SendCommandAsync(string command, CancellationToken ct)
        {
            ThrowIfDisposed();
            if (!IsConnected) throw new InvalidOperationException("[S7] 设备未连接");
            _latencyStopwatch.Restart();
            try
            {
                Log.Debug($"[S7] 设备 {DeviceId} 发送命令: {command}");
                // 解析命令格式: "WRITE:DBx.DBXy.z:value" 或 "WRITE:DBx.DBWy:value"
                string[] parts = command.Split(':');
                if (parts.Length < 3)
                {
                    throw new ArgumentException($"[S7] 命令格式错误,应为 WRITE:address:value,实际: {command}");
                }
                string operation = parts[0].ToUpper();
                string address = parts[1];
                string value = parts[2];
                if (operation != "WRITE")
                {
                    throw new ArgumentException($"[S7] 不支持的操作类型: {operation},应为 WRITE");
                }
                // 解析地址和值,执行写入
                bool success = ParseAndWrite(address, value, out string msg);
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                if (!success)
                {
                    _lastError = msg;
                    _lastErrorTime = DateTime.Now;
                    throw new InvalidOperationException($"[S7] 写入失败: {msg}");
                }
                string response = "OK";
                Log.Debug($"[S7] 设备 {DeviceId} 写入成功 (延迟 {_lastLatencyMs:F2}ms)");
                return Task.FromResult(response);
            }
            catch (Exception ex)
            {
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[S7] 设备 {DeviceId} 发送命令失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public Task<string> QueryAsync(string query, CancellationToken ct)
        {
            ThrowIfDisposed();
            if (!IsConnected) throw new InvalidOperationException("[S7] 设备未连接");
            _latencyStopwatch.Restart();
            try
            {
                Log.Debug($"[S7] 设备 {DeviceId} 发送查询: {query}");
                // 解析查询格式: "READ:DBx.DBWy" 或 "READ:DBx.DBXy.z"
                string[] parts = query.Split(':');
                if (parts.Length < 2)
                {
                    throw new ArgumentException($"[S7] 查询格式错误,应为 READ:address,实际: {query}");
                }
                string operation = parts[0].ToUpper();
                string address = parts[1];
                if (operation != "READ")
                {
                    throw new ArgumentException($"[S7] 不支持的操作类型: {operation},应为 READ");
                }
                // 解析地址并执行读取
                string response = ParseAndRead(address);
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                Log.Debug($"[S7] 设备 {DeviceId} 查询响应: {response} (延迟 {_lastLatencyMs:F2}ms)");
                return Task.FromResult(response);
            }
            catch (Exception ex)
            {
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[S7] 设备 {DeviceId} 查询失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public DeviceDriverStatus GetStatus()
        {
            return new DeviceDriverStatus
            {
                IsConnected = IsConnected,
                IsHealthy = IsConnected && string.IsNullOrEmpty(_lastError),
                LastError = _lastError,
                LastErrorTime = _lastErrorTime,
                LatencyMs = _lastLatencyMs,
                ReconnectCount = _reconnectCount
            };
        }
        /// <inheritdoc/>
        public void Dispose()
        {
            if (_disposed) return;
            _disposed = true;
            try
            {
                _driver?.Dispose();
            }
            catch (Exception ex)
            {
                Log.Error($"[S7] 设备 {DeviceId} 释放资源异常: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 解析地址并执行写入操作
        /// </summary>
        /// <param name="address">S7 地址,如 "DB1.DBX0.0" 或 "DB1.DBW10"</param>
        /// <param name="value">要写入的值</param>
        /// <param name="msg">错误信息</param>
        /// <returns>是否成功</returns>
        private bool ParseAndWrite(string address, string value, out string msg)
        {
            msg = null;
            try
            {
                // 解析地址格式: DBx.DBXy.z (BOOL) 或 DBx.DBWy (INT/REAL)
                if (address.Contains(".DBX"))
                {
                    // BOOL 类型: DB1.DBX0.0
                    if (bool.TryParse(value, out bool boolVal))
                    {
                        var addrParts = ParseBoolAddress(address);
                        return _driver.Simens_S7_WriteBool(addrParts.db, addrParts.startAddr, addrParts.pos, boolVal, out msg);
                    }
                    throw new ArgumentException($"[S7] BOOL 值解析失败: {value}");
                }
                else if (address.Contains(".DBW"))
                {
                    // 尝试解析为不同类型
                    if (float.TryParse(value, out float realVal))
                    {
                        var addrParts = ParseWordAddress(address);
                        return _driver.Simens_S7_WriteReal(addrParts.db, addrParts.startAddr, realVal, out msg);
                    }
                    else if (short.TryParse(value, out short shortVal))
                    {
                        var addrParts = ParseWordAddress(address);
                        return _driver.Simens_S7_WriteInt(addrParts.db, addrParts.startAddr, shortVal, out msg);
                    }
                    throw new ArgumentException($"[S7] 值解析失败: {value}");
                }
                else
                {
                    msg = $"[S7] 不支持的地址格式: {address}";
                    return false;
                }
            }
            catch (Exception ex)
            {
                msg = ex.Message;
                return false;
            }
        }
        /// <summary>
        /// 解析地址并执行读取操作
        /// </summary>
        /// <param name="address">S7 地址,如 "DB1.DBX0.0" 或 "DB1.DBW10"</param>
        /// <returns>读取的值字符串</returns>
        private string ParseAndRead(string address)
        {
            if (address.Contains(".DBX"))
            {
                // BOOL 类型读取
                var addrParts = ParseBoolAddress(address);
                if (_driver.Simens_S7_ReadBool(addrParts.db, addrParts.startAddr, addrParts.pos, out bool value, out string msg))
                {
                    return value.ToString();
                }
                throw new InvalidOperationException($"[S7] 读取失败: {msg}");
            }
            else if (address.Contains(".DBD"))
            {
                // REAL 类型读取 (DBD = Double Word)
                var addrParts = ParseWordAddress(address);
                if (_driver.Simens_S7_ReadReal(addrParts.db, addrParts.startAddr, out float realValue, out string msg))
                {
                    return realValue.ToString();
                }
                throw new InvalidOperationException($"[S7] 读取失败: {msg}");
            }
            else if (address.Contains(".DBW"))
            {
                // INT 类型读取
                var addrParts = ParseWordAddress(address);
                if (_driver.Simens_S7_ReadInt(addrParts.db, addrParts.startAddr, out int value, out string msg))
                {
                    return value.ToString();
                }
                throw new InvalidOperationException($"[S7] 读取失败: {msg}");
            }
            else
            {
                throw new ArgumentException($"[S7] 不支持的地址格式: {address}");
            }
        }
        /// <summary>
        /// 解析 BOOL 地址格式: DBx.DBXy.z
        /// </summary>
        private (int db, int startAddr, int pos) ParseBoolAddress(string address)
        {
            // 格式: DB1.DBX0.0
            string upper = address.ToUpper();
            int dbStart = upper.IndexOf("DB") + 2;
            int dbxStart = upper.IndexOf(".DBX");
            int db = int.Parse(upper.Substring(dbStart, dbxStart - dbStart));
            int posStart = dbxStart + 5; // .DBX 后
            int dotPos = address.IndexOf('.', posStart);
            int startAddr = int.Parse(address.Substring(posStart, dotPos - posStart));
            int pos = int.Parse(address.Substring(dotPos + 1));
            return (db, startAddr, pos);
        }
        /// <summary>
        /// 解析 Word 地址格式: DBx.DBWy 或 DBx.DBDy
        /// </summary>
        private (int db, int startAddr) ParseWordAddress(string address)
        {
            // 格式: DB1.DBW10 或 DB1.DBD0
            string upper = address.ToUpper();
            int dbStart = upper.IndexOf("DB") + 2;
            int dbwStart = upper.Contains(".DBW") ? upper.IndexOf(".DBW") : upper.IndexOf(".DBD");
            int db = int.Parse(upper.Substring(dbStart, dbwStart - dbStart));
            int addrStart = dbwStart + 5; // .DBW 或 .DBD 后
            int startAddr = int.Parse(address.Substring(addrStart));
            return (db, startAddr);
        }
        /// <summary>
        /// 触发连接状态变化事件
        /// </summary>
        private void OnConnectionChanged(bool isConnected, string reason)
        {
            ConnectionChanged?.Invoke(this, new DeviceConnectionEventArgs
            {
                DeviceId = DeviceId,
                IsConnected = isConnected,
                Reason = reason
            });
            if (!isConnected)
            {
                _reconnectCount++;
            }
        }
        /// <summary>
        /// 检查是否已释放
        /// </summary>
        private void ThrowIfDisposed()
        {
            if (_disposed) throw new ObjectDisposedException(nameof(S7DriverAdapter));
        }
    }
}
Aging.Infrastructure/Drivers/ScpiDriverAdapter.cs
New file
@@ -0,0 +1,248 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Aging.Infrastructure.Contracts;
using log4net;
using PLCHelper;
namespace Aging.Infrastructure.Drivers
{
    /// <summary>
    /// SCPI 协议驱动适配器。
    /// 封装 PLCHelper.RestTcpClient,实现统一的 IDeviceDriver 接口。
    /// 用于控制支持 SCPI 命令的仪器(如电源、电子负载等)。
    /// </summary>
    public sealed class ScpiDriverAdapter : IDeviceDriver
    {
        private static readonly ILog Log = LogManager.GetLogger(typeof(ScpiDriverAdapter));
        private readonly string _ip;
        private readonly int _port;
        private RestTcpClient? _client;
        private bool _disposed;
        private readonly Stopwatch _latencyStopwatch = new Stopwatch();
        private double _lastLatencyMs;
        private string _lastError = string.Empty;
        private DateTime? _lastErrorTime;
        private int _reconnectCount;
        /// <inheritdoc/>
        public string DeviceId { get; }
        /// <inheritdoc/>
        public bool IsConnected => _client != null;
        /// <inheritdoc/>
        public event EventHandler<DeviceConnectionEventArgs>? ConnectionChanged;
        /// <summary>
        /// 初始化 SCPI 协议驱动适配器
        /// </summary>
        /// <param name="deviceId">设备唯一标识符</param>
        /// <param name="ip">目标 IP 地址</param>
        /// <param name="port">目标端口号</param>
        public ScpiDriverAdapter(string deviceId, string ip, int port)
        {
            DeviceId = deviceId ?? throw new ArgumentNullException(nameof(deviceId));
            _ip = ip ?? throw new ArgumentNullException(nameof(ip));
            _port = port;
        }
        /// <inheritdoc/>
        public Task ConnectAsync(CancellationToken ct)
        {
            ThrowIfDisposed();
            Log.Info($"[SCPI] 设备 {DeviceId} 正在连接 {_ip}:{_port}...");
            try
            {
                _client = new RestTcpClient(_ip, _port);
                _client.ReceiveTimeout = 5000;
                _client.ConnectServer();
                // 发送复位命令验证连接
                if (_client.Request("*RST", out string response, out string errMsg))
                {
                    Log.Info($"[SCPI] 设备 {DeviceId} 复位响应: {response}");
                }
                else
                {
                    Log.Warn($"[SCPI] 设备 {DeviceId} 复位失败: {errMsg}");
                }
                _lastError = string.Empty;
                Log.Info($"[SCPI] 设备 {DeviceId} 已成功连接到 {_ip}:{_port}");
                OnConnectionChanged(true, "连接成功");
            }
            catch (Exception ex)
            {
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[SCPI] 设备 {DeviceId} 连接异常: {ex.Message}", ex);
                _client?.Dispose();
                _client = null;
                throw;
            }
            return Task.CompletedTask;
        }
        /// <inheritdoc/>
        public Task DisconnectAsync()
        {
            ThrowIfDisposed();
            Log.Info($"[SCPI] 设备 {DeviceId} 正在断开连接...");
            try
            {
                _client?.Dispose();
                _client = null;
                Log.Info($"[SCPI] 设备 {DeviceId} 已断开连接");
                OnConnectionChanged(false, "主动断开");
            }
            catch (Exception ex)
            {
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[SCPI] 设备 {DeviceId} 断开连接异常: {ex.Message}", ex);
                _client = null;
            }
            return Task.CompletedTask;
        }
        /// <inheritdoc/>
        public Task<string> SendCommandAsync(string command, CancellationToken ct)
        {
            ThrowIfDisposed();
            if (!IsConnected) throw new InvalidOperationException("[SCPI] 设备未连接");
            _latencyStopwatch.Restart();
            try
            {
                Log.Debug($"[SCPI] 设备 {DeviceId} 发送命令: {command}");
                // SCPI 命令以 \r 结尾(RestTcpClient 协议自动处理)
                bool success = _client.Request(command, out string response, out string errMsg);
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                if (!success)
                {
                    _lastError = errMsg;
                    _lastErrorTime = DateTime.Now;
                    throw new InvalidOperationException($"[SCPI] 命令执行失败: {errMsg}");
                }
                Log.Debug($"[SCPI] 设备 {DeviceId} 命令响应: {response} (延迟 {_lastLatencyMs:F2}ms)");
                return Task.FromResult(response);
            }
            catch (Exception ex)
            {
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[SCPI] 设备 {DeviceId} 发送命令失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public Task<string> QueryAsync(string query, CancellationToken ct)
        {
            ThrowIfDisposed();
            if (!IsConnected) throw new InvalidOperationException("[SCPI] 设备未连接");
            _latencyStopwatch.Restart();
            try
            {
                Log.Debug($"[SCPI] 设备 {DeviceId} 发送查询: {query}");
                // 发送查询命令并等待回读(如 "MEAS:CURR?")
                bool success = _client.Request(query, out string response, out string errMsg);
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                if (!success)
                {
                    _lastError = errMsg;
                    _lastErrorTime = DateTime.Now;
                    throw new TimeoutException($"[SCPI] 查询超时或失败: {errMsg}");
                }
                Log.Debug($"[SCPI] 设备 {DeviceId} 查询响应: {response} (延迟 {_lastLatencyMs:F2}ms)");
                return Task.FromResult(response);
            }
            catch (Exception ex)
            {
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[SCPI] 设备 {DeviceId} 查询失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public DeviceDriverStatus GetStatus()
        {
            return new DeviceDriverStatus
            {
                IsConnected = IsConnected,
                IsHealthy = IsConnected && string.IsNullOrEmpty(_lastError),
                LastError = _lastError,
                LastErrorTime = _lastErrorTime,
                LatencyMs = _lastLatencyMs,
                ReconnectCount = _reconnectCount
            };
        }
        /// <inheritdoc/>
        public void Dispose()
        {
            if (_disposed) return;
            _disposed = true;
            try
            {
                _client?.Dispose();
            }
            catch (Exception ex)
            {
                Log.Error($"[SCPI] 设备 {DeviceId} 释放资源异常: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 触发连接状态变化事件
        /// </summary>
        private void OnConnectionChanged(bool isConnected, string reason)
        {
            ConnectionChanged?.Invoke(this, new DeviceConnectionEventArgs
            {
                DeviceId = DeviceId,
                IsConnected = isConnected,
                Reason = reason
            });
            if (!isConnected)
            {
                _reconnectCount++;
            }
        }
        /// <summary>
        /// 检查是否已释放
        /// </summary>
        private void ThrowIfDisposed()
        {
            if (_disposed) throw new ObjectDisposedException(nameof(ScpiDriverAdapter));
        }
    }
}
Aging.Infrastructure/Drivers/TcpRawDriverAdapter.cs
New file
@@ -0,0 +1,222 @@
using System;
using System.Diagnostics;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Aging.Infrastructure.Contracts;
using ConnectionUtil;
using log4net;
namespace Aging.Infrastructure.Drivers
{
    /// <summary>
    /// TCP 原始通信驱动适配器。
    /// 封装 ConnectionUtil.TcpClientWrapper,实现统一的 IDeviceDriver 接口。
    /// </summary>
    public sealed class TcpRawDriverAdapter : IDeviceDriver
    {
        private static readonly ILog Log = LogManager.GetLogger(typeof(TcpRawDriverAdapter));
        private readonly string _ip;
        private readonly int _port;
        private TcpClientWrapper? _client;
        private bool _disposed;
        private readonly Stopwatch _latencyStopwatch = new Stopwatch();
        private double _lastLatencyMs;
        private string _lastError = string.Empty;
        private DateTime? _lastErrorTime;
        private int _reconnectCount;
        /// <inheritdoc/>
        public string DeviceId { get; }
        /// <inheritdoc/>
        public bool IsConnected => _client?.IsConnected ?? false;
        /// <inheritdoc/>
        public event EventHandler<DeviceConnectionEventArgs>? ConnectionChanged;
        /// <summary>
        /// 初始化 TCP 原始通信驱动适配器
        /// </summary>
        /// <param name="deviceId">设备唯一标识符</param>
        /// <param name="ip">目标 IP 地址</param>
        /// <param name="port">目标端口号</param>
        public TcpRawDriverAdapter(string deviceId, string ip, int port)
        {
            DeviceId = deviceId ?? throw new ArgumentNullException(nameof(deviceId));
            _ip = ip ?? throw new ArgumentNullException(nameof(ip));
            _port = port;
        }
        /// <inheritdoc/>
        public async Task ConnectAsync(CancellationToken ct)
        {
            ThrowIfDisposed();
            Log.Info($"[TCP] 设备 {DeviceId} 正在连接 {_ip}:{_port}...");
            try
            {
                _client = new TcpClientWrapper(_ip, _port);
                bool success = await _client.ConnectAsync();
                if (!success)
                {
                    _lastError = "连接失败";
                    _lastErrorTime = DateTime.Now;
                    throw new InvalidOperationException($"[TCP] 设备 {DeviceId} 连接 {_ip}:{_port} 失败");
                }
                _lastError = string.Empty;
                Log.Info($"[TCP] 设备 {DeviceId} 已成功连接到 {_ip}:{_port}");
                OnConnectionChanged(true, "连接成功");
            }
            catch (Exception ex)
            {
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[TCP] 设备 {DeviceId} 连接异常: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public Task DisconnectAsync()
        {
            ThrowIfDisposed();
            Log.Info($"[TCP] 设备 {DeviceId} 正在断开连接...");
            try
            {
                if (_client != null)
                {
                    _client?.Close();
                    _client = null;
                }
                Log.Info($"[TCP] 设备 {DeviceId} 已断开连接");
                OnConnectionChanged(false, "主动断开");
            }
            catch (Exception ex)
            {
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[TCP] 设备 {DeviceId} 断开连接异常: {ex.Message}", ex);
            }
            return Task.CompletedTask;
        }
        /// <inheritdoc/>
        public async Task<string> SendCommandAsync(string command, CancellationToken ct)
        {
            ThrowIfDisposed();
            if (!IsConnected) throw new InvalidOperationException("[TCP] 设备未连接");
            _latencyStopwatch.Restart();
            try
            {
                Log.Debug($"[TCP] 设备 {DeviceId} 发送命令: {command}");
                string response = await _client.SendMessageAsync(command);
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                Log.Debug($"[TCP] 设备 {DeviceId} 收到响应: {response} (延迟 {_lastLatencyMs:F2}ms)");
                return response;
            }
            catch (Exception ex)
            {
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[TCP] 设备 {DeviceId} 发送命令失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public async Task<string> QueryAsync(string query, CancellationToken ct)
        {
            ThrowIfDisposed();
            if (!IsConnected) throw new InvalidOperationException("[TCP] 设备未连接");
            _latencyStopwatch.Restart();
            try
            {
                Log.Debug($"[TCP] 设备 {DeviceId} 发送查询: {query}");
                // 使用较短超时进行查询操作
                string response = await _client.SendMessageAsync(query, TimeSpan.FromSeconds(5));
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                Log.Debug($"[TCP] 设备 {DeviceId} 查询响应: {response} (延迟 {_lastLatencyMs:F2}ms)");
                return response;
            }
            catch (Exception ex)
            {
                _latencyStopwatch.Stop();
                _lastLatencyMs = _latencyStopwatch.Elapsed.TotalMilliseconds;
                _lastError = ex.Message;
                _lastErrorTime = DateTime.Now;
                Log.Error($"[TCP] 设备 {DeviceId} 查询失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <inheritdoc/>
        public DeviceDriverStatus GetStatus()
        {
            return new DeviceDriverStatus
            {
                IsConnected = IsConnected,
                IsHealthy = IsConnected && string.IsNullOrEmpty(_lastError),
                LastError = _lastError,
                LastErrorTime = _lastErrorTime,
                LatencyMs = _lastLatencyMs,
                ReconnectCount = _reconnectCount
            };
        }
        /// <inheritdoc/>
        public void Dispose()
        {
            if (_disposed) return;
            _disposed = true;
            try
            {
                _client?.Dispose();
            }
            catch (Exception ex)
            {
                Log.Error($"[TCP] 设备 {DeviceId} 释放资源异常: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 触发连接状态变化事件
        /// </summary>
        private void OnConnectionChanged(bool isConnected, string reason)
        {
            ConnectionChanged?.Invoke(this, new DeviceConnectionEventArgs
            {
                DeviceId = DeviceId,
                IsConnected = isConnected,
                Reason = reason
            });
            if (!isConnected)
            {
                _reconnectCount++;
            }
        }
        /// <summary>
        /// 检查是否已释放
        /// </summary>
        private void ThrowIfDisposed()
        {
            if (_disposed) throw new ObjectDisposedException(nameof(TcpRawDriverAdapter));
        }
    }
}
Aging.Infrastructure/InfrastructureBootstrapper.cs
New file
@@ -0,0 +1,152 @@
using log4net;
using HMI.Core.Util;
using Aging.Infrastructure.Contracts;
using Aging.Infrastructure.Core;
using Aging.Infrastructure.Services;
using Aging.Infrastructure.Drivers;
namespace Aging.Infrastructure
{
    /// <summary>
    /// 基础设施启动引导 - 初始化并注册所有核心服务到 UtilContainer
    /// </summary>
    public static class InfrastructureBootstrapper
    {
        private static readonly ILog log = LogManager.GetLogger(typeof(InfrastructureBootstrapper));
        private static bool _initialized = false;
        /// <summary>
        /// 初始化基础设施服务
        /// 应在 WPF 主程序启动时或插件 InitFinish 中调用
        /// </summary>
        /// <param name="deviceConfigDbPath">DeviceConfig 插件的 SQLite 数据库路径</param>
        public static void Initialize(string deviceConfigDbPath)
        {
            if (_initialized)
            {
                log.Warn("基础设施已初始化,跳过重复初始化");
                return;
            }
            try
            {
                log.Info("开始初始化基础设施服务...");
                // 1. 创建 ResourceManager 单例
                var resourceManager = new ResourceManager();
                UtilContainer.RegisterUtil("ResourceManager", resourceManager);
                log.Info("ResourceManager 已注册");
                // 2. 创建 DeviceManager 单例
                var deviceManager = new DeviceManager();
                UtilContainer.RegisterUtil("DeviceManager", deviceManager);
                log.Info("DeviceManager 已注册");
                // 3. 创建 SlotDeviceMappingResolver(需要 DeviceConfig DB 路径)
                var mappingResolver = new SlotDeviceMappingResolver(deviceConfigDbPath);
                UtilContainer.RegisterUtil("SlotDeviceMappingResolver", mappingResolver);
                log.Info("SlotDeviceMappingResolver 已注册");
                // 4. 创建 DeviceService
                var deviceService = new DeviceService(resourceManager, deviceManager, mappingResolver);
                UtilContainer.RegisterUtil("DeviceService", deviceService);
                log.Info("DeviceService 已注册");
                // 5. 创建 PowerSupplyService
                var powerSupplyService = new PowerSupplyService(resourceManager, deviceManager, mappingResolver);
                UtilContainer.RegisterUtil("PowerSupplyService", powerSupplyService);
                log.Info("PowerSupplyService 已注册");
                // 6. 从数据库加载设备定义并注册驱动
                LoadDevicesFromConfig(deviceManager, mappingResolver);
                _initialized = true;
                log.Info("基础设施服务初始化完成");
            }
            catch (Exception ex)
            {
                log.Error("基础设施初始化失败", ex);
                throw;
            }
        }
        /// <summary>
        /// 从 DeviceConfig 数据库加载设备定义并注册驱动
        /// </summary>
        private static void LoadDevicesFromConfig(IDeviceManager deviceManager, SlotDeviceMappingResolver mappingResolver)
        {
            try
            {
                // 读取所有设备映射
                var mappings = mappingResolver.GetAllMappings();
                // 从映射中提取唯一的设备ID
                var deviceIds = mappings
                    .Select(m => m.DeviceId)
                    .Distinct()
                    .ToList();
                int loaded = 0;
                foreach (var deviceId in deviceIds)
                {
                    try
                    {
                        // 这里暂时使用通用 TCP 驱动作为占位
                        // 实际应该从 device_definition 表读取 transport 和 endpoint
                        // 然后通过 DriverFactory.CreateDriver 创建对应驱动
                        //
                        // TODO: 从数据库读取设备详细信息(transport, endpoint)
                        // var device = GetDeviceDefinition(deviceId);
                        // var driver = DriverFactory.CreateDriver(deviceId, device.Transport, device.Endpoint);
                        log.Debug($"设备 {deviceId} 驱动注册待配置详细信息");
                        loaded++;
                    }
                    catch (Exception ex)
                    {
                        log.Error($"注册设备 {deviceId} 驱动失败: {ex.Message}", ex);
                    }
                }
                log.Info($"从设备配置加载了 {loaded} 个设备驱动");
            }
            catch (Exception ex)
            {
                log.Error("加载设备配置失败", ex);
            }
        }
        /// <summary>
        /// 注册单个设备驱动(供外部调用,用于动态添加设备)
        /// </summary>
        public static void RegisterDevice(string deviceId, string transport, string endpoint)
        {
            var container = new UtilContainer();
            var deviceManager = container.GetUtil<IDeviceManager>("DeviceManager");
            if (deviceManager == null)
            {
                log.Error("DeviceManager 未初始化");
                return;
            }
            try
            {
                var driver = DriverFactory.CreateDriver(deviceId, transport, endpoint);
                deviceManager.RegisterDriver(deviceId, driver);
                log.Info($"设备 {deviceId} ({transport}) 驱动已注册");
            }
            catch (Exception ex)
            {
                log.Error($"注册设备 {deviceId} 失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 重置基础设施(用于测试或重新初始化)
        /// </summary>
        public static void Reset()
        {
            _initialized = false;
            log.Info("基础设施已重置");
        }
    }
}
Aging.Infrastructure/Services/DeviceService.cs
New file
@@ -0,0 +1,331 @@
using System.Diagnostics;
using Aging.Infrastructure.Contracts;
using log4net;
namespace Aging.Infrastructure.Services
{
    /// <summary>
    /// 设备服务实现。
    /// 协调 ResourceManager、DeviceManager 和 SlotMapping,
    /// 提供面向 DeviceId 的命令执行、查询和安全关机功能。
    /// 所有操作均需通过资源预约验证和租约管理,确保硬件资源的安全访问。
    /// </summary>
    public sealed class DeviceService : IDeviceService
    {
        private static readonly ILog Log = LogManager.GetLogger(typeof(DeviceService));
        /// <summary>
        /// 资源管理器
        /// </summary>
        private readonly IResourceManager _resourceManager;
        /// <summary>
        /// 设备管理器
        /// </summary>
        private readonly IDeviceManager _deviceManager;
        /// <summary>
        /// Slot-设备-通道映射解析器
        /// </summary>
        private readonly SlotDeviceMappingResolver _slotMappingResolver;
        /// <summary>
        /// 初始化设备服务实例
        /// </summary>
        /// <param name="resourceManager">资源管理器</param>
        /// <param name="deviceManager">设备管理器</param>
        /// <param name="slotMappingResolver">Slot 映射解析器</param>
        public DeviceService(
            IResourceManager resourceManager,
            IDeviceManager deviceManager,
            SlotDeviceMappingResolver slotMappingResolver)
        {
            _resourceManager = resourceManager ?? throw new ArgumentNullException(nameof(resourceManager));
            _deviceManager = deviceManager ?? throw new ArgumentNullException(nameof(deviceManager));
            _slotMappingResolver = slotMappingResolver ?? throw new ArgumentNullException(nameof(slotMappingResolver));
        }
        /// <inheritdoc/>
        public async Task<DeviceCommandResult> ExecuteCommandAsync(
            string deviceId, string command, ResourceOwnerToken owner, CancellationToken ct)
        {
            if (string.IsNullOrWhiteSpace(deviceId))
                throw new ArgumentException("设备ID不能为空", nameof(deviceId));
            if (string.IsNullOrWhiteSpace(command))
                throw new ArgumentException("命令不能为空", nameof(command));
            if (owner == null)
                throw new ArgumentNullException(nameof(owner));
            var stopwatch = Stopwatch.StartNew();
            var resourceKey = new ResourceKey(deviceId, "Generic");
            try
            {
                Log.Info($"[DeviceService] 执行命令: Device={deviceId}, Command={command}, Owner={owner.OwnerId}");
                // 1. 获取设备驱动
                var driver = _deviceManager.GetDriver(deviceId);
                if (driver == null)
                {
                    stopwatch.Stop();
                    Log.Warn($"[DeviceService] 设备 {deviceId} 驱动不存在");
                    return new DeviceCommandResult
                    {
                        Success = false,
                        ErrorCode = "DRIVER_NOT_FOUND",
                        ErrorMessage = $"设备 {deviceId} 的驱动未注册",
                        LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                    };
                }
                // 2. 验证预约
                if (!_resourceManager.ValidateOwner(owner, resourceKey))
                {
                    stopwatch.Stop();
                    Log.Warn($"[DeviceService] 权限验证失败: Device={deviceId}, Owner={owner.OwnerId}");
                    return new DeviceCommandResult
                    {
                        Success = false,
                        ErrorCode = "PERMISSION_DENIED",
                        ErrorMessage = $"所有者 {owner.OwnerId} 未预约设备 {deviceId} 或令牌已过期",
                        LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                    };
                }
                // 3. 获取租约并执行命令
                using var lease = await _resourceManager.AcquireOperationAsync(owner, resourceKey, ct).ConfigureAwait(false);
                var response = await driver.SendCommandAsync(command, ct).ConfigureAwait(false);
                stopwatch.Stop();
                Log.Info($"[DeviceService] 命令执行成功: Device={deviceId}, Command={command}, 延迟={stopwatch.Elapsed.TotalMilliseconds:F2}ms");
                return new DeviceCommandResult
                {
                    Success = true,
                    RawResponse = response,
                    Readback = response,
                    LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                };
            }
            catch (ResourceConflictException ex)
            {
                stopwatch.Stop();
                Log.Error($"[DeviceService] 资源冲突: Device={deviceId}, 错误: {ex.Message}", ex);
                return new DeviceCommandResult
                {
                    Success = false,
                    ErrorCode = "RESOURCE_CONFLICT",
                    ErrorMessage = ex.Message,
                    LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                };
            }
            catch (OperationCanceledException)
            {
                stopwatch.Stop();
                Log.Warn($"[DeviceService] 命令执行被取消: Device={deviceId}");
                return new DeviceCommandResult
                {
                    Success = false,
                    ErrorCode = "OPERATION_CANCELLED",
                    ErrorMessage = "操作已被取消",
                    LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                };
            }
            catch (TimeoutException ex)
            {
                stopwatch.Stop();
                Log.Error($"[DeviceService] 命令执行超时: Device={deviceId}, 错误: {ex.Message}", ex);
                return new DeviceCommandResult
                {
                    Success = false,
                    ErrorCode = "TIMEOUT",
                    ErrorMessage = $"设备 {deviceId} 命令执行超时: {ex.Message}",
                    LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                };
            }
            catch (Exception ex)
            {
                stopwatch.Stop();
                Log.Error($"[DeviceService] 命令执行异常: Device={deviceId}, 错误: {ex.Message}", ex);
                return new DeviceCommandResult
                {
                    Success = false,
                    ErrorCode = "UNKNOWN_ERROR",
                    ErrorMessage = $"设备 {deviceId} 命令执行失败: {ex.Message}",
                    LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                };
            }
        }
        /// <inheritdoc/>
        public async Task<DeviceCommandResult> ExecuteQueryAsync(
            string deviceId, string query, ResourceOwnerToken owner, CancellationToken ct)
        {
            if (string.IsNullOrWhiteSpace(deviceId))
                throw new ArgumentException("设备ID不能为空", nameof(deviceId));
            if (string.IsNullOrWhiteSpace(query))
                throw new ArgumentException("查询命令不能为空", nameof(query));
            if (owner == null)
                throw new ArgumentNullException(nameof(owner));
            var stopwatch = Stopwatch.StartNew();
            var resourceKey = new ResourceKey(deviceId, "Generic");
            try
            {
                Log.Info($"[DeviceService] 执行查询: Device={deviceId}, Query={query}, Owner={owner.OwnerId}");
                // 1. 获取设备驱动
                var driver = _deviceManager.GetDriver(deviceId);
                if (driver == null)
                {
                    stopwatch.Stop();
                    Log.Warn($"[DeviceService] 设备 {deviceId} 驱动不存在");
                    return new DeviceCommandResult
                    {
                        Success = false,
                        ErrorCode = "DRIVER_NOT_FOUND",
                        ErrorMessage = $"设备 {deviceId} 的驱动未注册",
                        LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                    };
                }
                // 2. 验证预约
                if (!_resourceManager.ValidateOwner(owner, resourceKey))
                {
                    stopwatch.Stop();
                    Log.Warn($"[DeviceService] 权限验证失败: Device={deviceId}, Owner={owner.OwnerId}");
                    return new DeviceCommandResult
                    {
                        Success = false,
                        ErrorCode = "PERMISSION_DENIED",
                        ErrorMessage = $"所有者 {owner.OwnerId} 未预约设备 {deviceId} 或令牌已过期",
                        LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                    };
                }
                // 3. 获取租约并执行查询
                using var lease = await _resourceManager.AcquireOperationAsync(owner, resourceKey, ct).ConfigureAwait(false);
                var response = await driver.QueryAsync(query, ct).ConfigureAwait(false);
                stopwatch.Stop();
                Log.Info($"[DeviceService] 查询执行成功: Device={deviceId}, Query={query}, 延迟={stopwatch.Elapsed.TotalMilliseconds:F2}ms");
                return new DeviceCommandResult
                {
                    Success = true,
                    RawResponse = response,
                    Readback = response,
                    LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                };
            }
            catch (ResourceConflictException ex)
            {
                stopwatch.Stop();
                Log.Error($"[DeviceService] 资源冲突: Device={deviceId}, 错误: {ex.Message}", ex);
                return new DeviceCommandResult
                {
                    Success = false,
                    ErrorCode = "RESOURCE_CONFLICT",
                    ErrorMessage = ex.Message,
                    LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                };
            }
            catch (OperationCanceledException)
            {
                stopwatch.Stop();
                Log.Warn($"[DeviceService] 查询执行被取消: Device={deviceId}");
                return new DeviceCommandResult
                {
                    Success = false,
                    ErrorCode = "OPERATION_CANCELLED",
                    ErrorMessage = "操作已被取消",
                    LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                };
            }
            catch (TimeoutException ex)
            {
                stopwatch.Stop();
                Log.Error($"[DeviceService] 查询执行超时: Device={deviceId}, 错误: {ex.Message}", ex);
                return new DeviceCommandResult
                {
                    Success = false,
                    ErrorCode = "TIMEOUT",
                    ErrorMessage = $"设备 {deviceId} 查询执行超时: {ex.Message}",
                    LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                };
            }
            catch (Exception ex)
            {
                stopwatch.Stop();
                Log.Error($"[DeviceService] 查询执行异常: Device={deviceId}, 错误: {ex.Message}", ex);
                return new DeviceCommandResult
                {
                    Success = false,
                    ErrorCode = "UNKNOWN_ERROR",
                    ErrorMessage = $"设备 {deviceId} 查询执行失败: {ex.Message}",
                    LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                };
            }
        }
        /// <inheritdoc/>
        public async Task SafeShutdownAsync(string deviceId, ResourceOwnerToken owner, CancellationToken ct)
        {
            if (string.IsNullOrWhiteSpace(deviceId))
                throw new ArgumentException("设备ID不能为空", nameof(deviceId));
            if (owner == null)
                throw new ArgumentNullException(nameof(owner));
            var resourceKey = new ResourceKey(deviceId, "Generic");
            try
            {
                Log.Info($"[DeviceService] 开始安全关机: Device={deviceId}, Owner={owner.OwnerId}");
                // 1. 获取驱动
                var driver = _deviceManager.GetDriver(deviceId);
                if (driver == null)
                {
                    Log.Warn($"[DeviceService] 安全关机失败: 设备 {deviceId} 驱动不存在");
                    return;
                }
                // 2. 验证预约
                if (!_resourceManager.ValidateOwner(owner, resourceKey))
                {
                    Log.Warn($"[DeviceService] 安全关机权限验证失败: Device={deviceId}, Owner={owner.OwnerId}");
                    return;
                }
                // 3. 获取租约
                using var lease = await _resourceManager.AcquireOperationAsync(owner, resourceKey, ct).ConfigureAwait(false);
                // 4. 发送安全关闭命令
                Log.Info($"[DeviceService] 发送安全关闭命令: Device={deviceId}");
                await driver.SendCommandAsync("OUTP OFF", ct).ConfigureAwait(false);
                // 5. 等待确认(短暂延迟确保命令执行完成)
                await Task.Delay(500, ct).ConfigureAwait(false);
                // 6. 验证关闭状态
                var statusResponse = await driver.QueryAsync("OUTP?", ct).ConfigureAwait(false);
                Log.Info($"[DeviceService] 设备 {deviceId} 关闭状态确认: {statusResponse}");
                Log.Info($"[DeviceService] 安全关机完成: Device={deviceId}");
            }
            catch (OperationCanceledException)
            {
                Log.Warn($"[DeviceService] 安全关机操作被取消: Device={deviceId}");
                throw;
            }
            catch (Exception ex)
            {
                Log.Error($"[DeviceService] 安全关机异常: Device={deviceId}, 错误: {ex.Message}", ex);
                throw;
            }
        }
    }
}
Aging.Infrastructure/Services/PowerSupplyService.cs
New file
@@ -0,0 +1,543 @@
using System.Diagnostics;
using Aging.Infrastructure.Contracts;
using log4net;
namespace Aging.Infrastructure.Services
{
    /// <summary>
    /// 电源配置命令
    /// </summary>
    public sealed class PowerCommand
    {
        /// <summary>
        /// 目标电压值(可选,单位: V)
        /// </summary>
        public double? Voltage { get; set; }
        /// <summary>
        /// 目标电流限制值(可选,单位: A)
        /// </summary>
        public double? CurrentLimit { get; set; }
        /// <summary>
        /// 输出开关状态(true=ON, false=OFF, null=不改变)
        /// </summary>
        public bool? Output { get; set; }
    }
    /// <summary>
    /// 电源操作结果
    /// </summary>
    public sealed class PowerOperationResult
    {
        /// <summary>
        /// 操作是否成功
        /// </summary>
        public bool Success { get; set; }
        /// <summary>
        /// 实测电压值(单位: V)
        /// </summary>
        public double? MeasuredVoltage { get; set; }
        /// <summary>
        /// 实测电流值(单位: A)
        /// </summary>
        public double? MeasuredCurrent { get; set; }
        /// <summary>
        /// 设备原始响应字符串
        /// </summary>
        public string? RawResponse { get; set; }
        /// <summary>
        /// 错误代码
        /// </summary>
        public string? ErrorCode { get; set; }
        /// <summary>
        /// 错误信息
        /// </summary>
        public string? ErrorMessage { get; set; }
        /// <summary>
        /// 操作延迟(毫秒)
        /// </summary>
        public double LatencyMs { get; set; }
        /// <summary>
        /// 创建一个成功的结果
        /// </summary>
        public static PowerOperationResult Succeed(double? voltage, double? current, string? rawResponse, double latencyMs)
        {
            return new PowerOperationResult
            {
                Success = true,
                MeasuredVoltage = voltage,
                MeasuredCurrent = current,
                RawResponse = rawResponse,
                LatencyMs = latencyMs
            };
        }
        /// <summary>
        /// 创建一个失败的结果
        /// </summary>
        public static PowerOperationResult Failed(string errorCode, string errorMessage, double latencyMs)
        {
            return new PowerOperationResult
            {
                Success = false,
                ErrorCode = errorCode,
                ErrorMessage = errorMessage,
                LatencyMs = latencyMs
            };
        }
    }
    /// <summary>
    /// 电源配置服务 - 面向 Slot 的高层电源操作。
    /// 通过 SlotDeviceMappingResolver 解析 Slot 到具体设备端点的映射,
    /// 协调 ResourceManager 和 DeviceManager 完成电源配置、测量和状态查询。
    /// </summary>
    public sealed class PowerSupplyService
    {
        private static readonly ILog Log = LogManager.GetLogger(typeof(PowerSupplyService));
        /// <summary>
        /// 资源管理器
        /// </summary>
        private readonly IResourceManager _resourceManager;
        /// <summary>
        /// 设备管理器
        /// </summary>
        private readonly IDeviceManager _deviceManager;
        /// <summary>
        /// Slot-设备-通道映射解析器
        /// </summary>
        private readonly SlotDeviceMappingResolver _slotMappingResolver;
        /// <summary>
        /// 初始化电源配置服务实例
        /// </summary>
        /// <param name="resourceManager">资源管理器</param>
        /// <param name="deviceManager">设备管理器</param>
        /// <param name="slotMappingResolver">Slot 映射解析器</param>
        public PowerSupplyService(
            IResourceManager resourceManager,
            IDeviceManager deviceManager,
            SlotDeviceMappingResolver slotMappingResolver)
        {
            _resourceManager = resourceManager ?? throw new ArgumentNullException(nameof(resourceManager));
            _deviceManager = deviceManager ?? throw new ArgumentNullException(nameof(deviceManager));
            _slotMappingResolver = slotMappingResolver ?? throw new ArgumentNullException(nameof(slotMappingResolver));
        }
        /// <summary>
        /// 异步配置 Slot 对应的电源参数。
        /// 根据 SlotId 解析映射获取设备端点,验证资源预约后发送 SCPI 命令配置电源。
        /// </summary>
        /// <param name="slotId">Slot 标识符</param>
        /// <param name="command">电源配置命令</param>
        /// <param name="owner">所有者令牌</param>
        /// <param name="ct">取消令牌</param>
        /// <returns>电源操作结果,包含测量值</returns>
        public async Task<PowerOperationResult> ConfigureAsync(
            string slotId, PowerCommand command, ResourceOwnerToken owner, CancellationToken ct)
        {
            if (string.IsNullOrWhiteSpace(slotId))
                throw new ArgumentException("SlotId 不能为空", nameof(slotId));
            if (command == null)
                throw new ArgumentNullException(nameof(command));
            if (owner == null)
                throw new ArgumentNullException(nameof(owner));
            var stopwatch = Stopwatch.StartNew();
            try
            {
                Log.Info($"[PowerSupplyService] 配置电源: Slot={slotId}, Owner={owner.OwnerId}");
                // 1. 解析 Slot 映射
                var endpoint = _slotMappingResolver.ResolveEndpoint(slotId, "PowerSupply");
                if (endpoint == null)
                {
                    stopwatch.Stop();
                    Log.Warn($"[PowerSupplyService] Slot {slotId} 未找到 PowerSupply 映射");
                    return PowerOperationResult.Failed("MAPPING_NOT_FOUND",
                        $"Slot {slotId} 未找到 PowerSupply 类型的设备映射", stopwatch.Elapsed.TotalMilliseconds);
                }
                var resourceKey = endpoint.ToResourceKey();
                // 2. 验证预约
                if (!_resourceManager.ValidateOwner(owner, resourceKey))
                {
                    stopwatch.Stop();
                    Log.Warn($"[PowerSupplyService] 权限验证失败: Slot={slotId}, Device={endpoint.DeviceId}, Owner={owner.OwnerId}");
                    return PowerOperationResult.Failed("PERMISSION_DENIED",
                        $"所有者 {owner.OwnerId} 未预约 Slot {slotId} 对应的设备 {endpoint.DeviceId} 或令牌已过期",
                        stopwatch.Elapsed.TotalMilliseconds);
                }
                // 3. 获取租约
                using var lease = await _resourceManager.AcquireOperationAsync(owner, resourceKey, ct).ConfigureAwait(false);
                // 4. 获取设备驱动
                var driver = _deviceManager.GetDriver(endpoint.DeviceId);
                if (driver == null)
                {
                    stopwatch.Stop();
                    Log.Warn($"[PowerSupplyService] 设备 {endpoint.DeviceId} 驱动不存在");
                    return PowerOperationResult.Failed("DRIVER_NOT_FOUND",
                        $"设备 {endpoint.DeviceId} 的驱动未注册", stopwatch.Elapsed.TotalMilliseconds);
                }
                // 5. 构建并发送 SCPI 命令
                var scpiCommands = BuildScpiCommands(command);
                foreach (var scpiCommand in scpiCommands)
                {
                    Log.Info($"[PowerSupplyService] 发送 SCPI 命令: Device={endpoint.DeviceId}, Command={scpiCommand}");
                    await driver.SendCommandAsync(scpiCommand, ct).ConfigureAwait(false);
                }
                // 6. 回读测量值
                string rawResponse;
                try
                {
                    // 优先尝试 MEAS:ALL? 一次性获取所有测量值
                    rawResponse = await driver.QueryAsync("MEAS:ALL?", ct).ConfigureAwait(false);
                    Log.Info($"[PowerSupplyService] MEAS:ALL? 响应: {rawResponse}");
                }
                catch
                {
                    // 如果 MEAS:ALL? 失败,分别查询电压和电流
                    Log.Info($"[PowerSupplyService] MEAS:ALL? 失败,尝试分别查询 VOLT? 和 CURR?");
                    var voltageResponse = await driver.QueryAsync("VOLT?", ct).ConfigureAwait(false);
                    var currentResponse = await driver.QueryAsync("CURR?", ct).ConfigureAwait(false);
                    rawResponse = $"{voltageResponse},{currentResponse}";
                }
                // 7. 解析回读值
                ParseMeasurementResponse(rawResponse, out var measuredVoltage, out var measuredCurrent);
                stopwatch.Stop();
                Log.Info($"[PowerSupplyService] 电源配置完成: Slot={slotId}, Device={endpoint.DeviceId}, " +
                         $"V={measuredVoltage}, I={measuredCurrent}, 延迟={stopwatch.Elapsed.TotalMilliseconds:F2}ms");
                return PowerOperationResult.Succeed(measuredVoltage, measuredCurrent, rawResponse,
                    stopwatch.Elapsed.TotalMilliseconds);
            }
            catch (ResourceConflictException ex)
            {
                stopwatch.Stop();
                Log.Error($"[PowerSupplyService] 资源冲突: Slot={slotId}, 错误: {ex.Message}", ex);
                return PowerOperationResult.Failed("RESOURCE_CONFLICT", ex.Message, stopwatch.Elapsed.TotalMilliseconds);
            }
            catch (OperationCanceledException)
            {
                stopwatch.Stop();
                Log.Warn($"[PowerSupplyService] 电源配置被取消: Slot={slotId}");
                return PowerOperationResult.Failed("OPERATION_CANCELLED", "操作已被取消",
                    stopwatch.Elapsed.TotalMilliseconds);
            }
            catch (TimeoutException ex)
            {
                stopwatch.Stop();
                Log.Error($"[PowerSupplyService] 电源配置超时: Slot={slotId}, 错误: {ex.Message}", ex);
                return PowerOperationResult.Failed("TIMEOUT", $"电源配置超时: {ex.Message}",
                    stopwatch.Elapsed.TotalMilliseconds);
            }
            catch (Exception ex)
            {
                stopwatch.Stop();
                Log.Error($"[PowerSupplyService] 电源配置异常: Slot={slotId}, 错误: {ex.Message}", ex);
                return PowerOperationResult.Failed("UNKNOWN_ERROR", $"电源配置失败: {ex.Message}",
                    stopwatch.Elapsed.TotalMilliseconds);
            }
        }
        /// <summary>
        /// 异步测量 Slot 对应电源的输出值(电压、电流)。
        /// 根据 SlotId 解析映射获取设备端点,查询实时测量值。
        /// </summary>
        /// <param name="slotId">Slot 标识符</param>
        /// <param name="owner">所有者令牌</param>
        /// <param name="ct">取消令牌</param>
        /// <returns>电源操作结果,包含测量值</returns>
        public async Task<PowerOperationResult> MeasureAsync(
            string slotId, ResourceOwnerToken owner, CancellationToken ct)
        {
            if (string.IsNullOrWhiteSpace(slotId))
                throw new ArgumentException("SlotId 不能为空", nameof(slotId));
            if (owner == null)
                throw new ArgumentNullException(nameof(owner));
            var stopwatch = Stopwatch.StartNew();
            try
            {
                Log.Info($"[PowerSupplyService] 测量电源: Slot={slotId}, Owner={owner.OwnerId}");
                // 1. 解析 Slot 映射
                var endpoint = _slotMappingResolver.ResolveEndpoint(slotId, "PowerSupply");
                if (endpoint == null)
                {
                    stopwatch.Stop();
                    Log.Warn($"[PowerSupplyService] Slot {slotId} 未找到 PowerSupply 映射");
                    return PowerOperationResult.Failed("MAPPING_NOT_FOUND",
                        $"Slot {slotId} 未找到 PowerSupply 类型的设备映射", stopwatch.Elapsed.TotalMilliseconds);
                }
                var resourceKey = endpoint.ToResourceKey();
                // 2. 验证预约
                if (!_resourceManager.ValidateOwner(owner, resourceKey))
                {
                    stopwatch.Stop();
                    Log.Warn($"[PowerSupplyService] 权限验证失败: Slot={slotId}, Device={endpoint.DeviceId}, Owner={owner.OwnerId}");
                    return PowerOperationResult.Failed("PERMISSION_DENIED",
                        $"所有者 {owner.OwnerId} 未预约 Slot {slotId} 对应的设备 {endpoint.DeviceId} 或令牌已过期",
                        stopwatch.Elapsed.TotalMilliseconds);
                }
                // 3. 获取租约
                using var lease = await _resourceManager.AcquireOperationAsync(owner, resourceKey, ct).ConfigureAwait(false);
                // 4. 获取设备驱动
                var driver = _deviceManager.GetDriver(endpoint.DeviceId);
                if (driver == null)
                {
                    stopwatch.Stop();
                    Log.Warn($"[PowerSupplyService] 设备 {endpoint.DeviceId} 驱动不存在");
                    return PowerOperationResult.Failed("DRIVER_NOT_FOUND",
                        $"设备 {endpoint.DeviceId} 的驱动未注册", stopwatch.Elapsed.TotalMilliseconds);
                }
                // 5. 查询测量值
                string rawResponse;
                try
                {
                    rawResponse = await driver.QueryAsync("MEAS:ALL?", ct).ConfigureAwait(false);
                }
                catch
                {
                    var voltageResponse = await driver.QueryAsync("VOLT?", ct).ConfigureAwait(false);
                    var currentResponse = await driver.QueryAsync("CURR?", ct).ConfigureAwait(false);
                    rawResponse = $"{voltageResponse},{currentResponse}";
                }
                // 6. 解析回读值
                ParseMeasurementResponse(rawResponse, out var measuredVoltage, out var measuredCurrent);
                stopwatch.Stop();
                Log.Info($"[PowerSupplyService] 测量完成: Slot={slotId}, Device={endpoint.DeviceId}, " +
                         $"V={measuredVoltage}, I={measuredCurrent}, 延迟={stopwatch.Elapsed.TotalMilliseconds:F2}ms");
                return PowerOperationResult.Succeed(measuredVoltage, measuredCurrent, rawResponse,
                    stopwatch.Elapsed.TotalMilliseconds);
            }
            catch (ResourceConflictException ex)
            {
                stopwatch.Stop();
                Log.Error($"[PowerSupplyService] 资源冲突: Slot={slotId}, 错误: {ex.Message}", ex);
                return PowerOperationResult.Failed("RESOURCE_CONFLICT", ex.Message, stopwatch.Elapsed.TotalMilliseconds);
            }
            catch (OperationCanceledException)
            {
                stopwatch.Stop();
                Log.Warn($"[PowerSupplyService] 测量操作被取消: Slot={slotId}");
                return PowerOperationResult.Failed("OPERATION_CANCELLED", "操作已被取消",
                    stopwatch.Elapsed.TotalMilliseconds);
            }
            catch (TimeoutException ex)
            {
                stopwatch.Stop();
                Log.Error($"[PowerSupplyService] 测量操作超时: Slot={slotId}, 错误: {ex.Message}", ex);
                return PowerOperationResult.Failed("TIMEOUT", $"测量操作超时: {ex.Message}",
                    stopwatch.Elapsed.TotalMilliseconds);
            }
            catch (Exception ex)
            {
                stopwatch.Stop();
                Log.Error($"[PowerSupplyService] 测量操作异常: Slot={slotId}, 错误: {ex.Message}", ex);
                return PowerOperationResult.Failed("UNKNOWN_ERROR", $"测量操作失败: {ex.Message}",
                    stopwatch.Elapsed.TotalMilliseconds);
            }
        }
        /// <summary>
        /// 异步获取 Slot 对应电源的当前状态(电压、电流、输出开关状态)。
        /// </summary>
        /// <param name="slotId">Slot 标识符</param>
        /// <param name="owner">所有者令牌</param>
        /// <param name="ct">取消令牌</param>
        /// <returns>电源操作结果,包含状态信息</returns>
        public async Task<PowerOperationResult> GetPowerStatusAsync(
            string slotId, ResourceOwnerToken owner, CancellationToken ct)
        {
            if (string.IsNullOrWhiteSpace(slotId))
                throw new ArgumentException("SlotId 不能为空", nameof(slotId));
            if (owner == null)
                throw new ArgumentNullException(nameof(owner));
            var stopwatch = Stopwatch.StartNew();
            try
            {
                Log.Info($"[PowerSupplyService] 查询电源状态: Slot={slotId}, Owner={owner.OwnerId}");
                // 1. 解析 Slot 映射
                var endpoint = _slotMappingResolver.ResolveEndpoint(slotId, "PowerSupply");
                if (endpoint == null)
                {
                    stopwatch.Stop();
                    Log.Warn($"[PowerSupplyService] Slot {slotId} 未找到 PowerSupply 映射");
                    return PowerOperationResult.Failed("MAPPING_NOT_FOUND",
                        $"Slot {slotId} 未找到 PowerSupply 类型的设备映射", stopwatch.Elapsed.TotalMilliseconds);
                }
                var resourceKey = endpoint.ToResourceKey();
                // 2. 验证预约
                if (!_resourceManager.ValidateOwner(owner, resourceKey))
                {
                    stopwatch.Stop();
                    Log.Warn($"[PowerSupplyService] 权限验证失败: Slot={slotId}, Device={endpoint.DeviceId}, Owner={owner.OwnerId}");
                    return PowerOperationResult.Failed("PERMISSION_DENIED",
                        $"所有者 {owner.OwnerId} 未预约 Slot {slotId} 对应的设备 {endpoint.DeviceId} 或令牌已过期",
                        stopwatch.Elapsed.TotalMilliseconds);
                }
                // 3. 获取租约
                using var lease = await _resourceManager.AcquireOperationAsync(owner, resourceKey, ct).ConfigureAwait(false);
                // 4. 获取设备驱动
                var driver = _deviceManager.GetDriver(endpoint.DeviceId);
                if (driver == null)
                {
                    stopwatch.Stop();
                    Log.Warn($"[PowerSupplyService] 设备 {endpoint.DeviceId} 驱动不存在");
                    return PowerOperationResult.Failed("DRIVER_NOT_FOUND",
                        $"设备 {endpoint.DeviceId} 的驱动未注册", stopwatch.Elapsed.TotalMilliseconds);
                }
                // 5. 查询电压、电流和输出状态
                var voltageResponse = await driver.QueryAsync("VOLT?", ct).ConfigureAwait(false);
                var currentResponse = await driver.QueryAsync("CURR?", ct).ConfigureAwait(false);
                var outputResponse = await driver.QueryAsync("OUTP?", ct).ConfigureAwait(false);
                var rawResponse = $"VOLT={voltageResponse},CURR={currentResponse},OUTP={outputResponse}";
                // 6. 解析测量值
                ParseMeasurementResponse($"{voltageResponse},{currentResponse}",
                    out var measuredVoltage, out var measuredCurrent);
                stopwatch.Stop();
                Log.Info($"[PowerSupplyService] 电源状态查询完成: Slot={slotId}, Device={endpoint.DeviceId}, " +
                         $"V={measuredVoltage}, I={measuredCurrent}, OUTP={outputResponse}, " +
                         $"延迟={stopwatch.Elapsed.TotalMilliseconds:F2}ms");
                return new PowerOperationResult
                {
                    Success = true,
                    MeasuredVoltage = measuredVoltage,
                    MeasuredCurrent = measuredCurrent,
                    RawResponse = rawResponse,
                    LatencyMs = stopwatch.Elapsed.TotalMilliseconds
                };
            }
            catch (ResourceConflictException ex)
            {
                stopwatch.Stop();
                Log.Error($"[PowerSupplyService] 资源冲突: Slot={slotId}, 错误: {ex.Message}", ex);
                return PowerOperationResult.Failed("RESOURCE_CONFLICT", ex.Message, stopwatch.Elapsed.TotalMilliseconds);
            }
            catch (OperationCanceledException)
            {
                stopwatch.Stop();
                Log.Warn($"[PowerSupplyService] 状态查询被取消: Slot={slotId}");
                return PowerOperationResult.Failed("OPERATION_CANCELLED", "操作已被取消",
                    stopwatch.Elapsed.TotalMilliseconds);
            }
            catch (TimeoutException ex)
            {
                stopwatch.Stop();
                Log.Error($"[PowerSupplyService] 状态查询超时: Slot={slotId}, 错误: {ex.Message}", ex);
                return PowerOperationResult.Failed("TIMEOUT", $"状态查询超时: {ex.Message}",
                    stopwatch.Elapsed.TotalMilliseconds);
            }
            catch (Exception ex)
            {
                stopwatch.Stop();
                Log.Error($"[PowerSupplyService] 状态查询异常: Slot={slotId}, 错误: {ex.Message}", ex);
                return PowerOperationResult.Failed("UNKNOWN_ERROR", $"状态查询失败: {ex.Message}",
                    stopwatch.Elapsed.TotalMilliseconds);
            }
        }
        /// <summary>
        /// 根据电源配置命令构建 SCPI 命令列表
        /// </summary>
        /// <param name="command">电源配置命令</param>
        /// <returns>SCPI 命令字符串列表</returns>
        private static List<string> BuildScpiCommands(PowerCommand command)
        {
            var commands = new List<string>();
            if (command.Voltage.HasValue)
            {
                commands.Add($"VOLT {command.Voltage.Value:G}");
            }
            if (command.CurrentLimit.HasValue)
            {
                commands.Add($"CURR {command.CurrentLimit.Value:G}");
            }
            if (command.Output.HasValue)
            {
                commands.Add(command.Output.Value ? "OUTP ON" : "OUTP OFF");
            }
            return commands;
        }
        /// <summary>
        /// 解析设备返回的测量值响应字符串。
        /// 支持格式: "voltage,current" 或单个数值。
        /// </summary>
        /// <param name="rawResponse">原始响应字符串</param>
        /// <param name="voltage">解析后的电压值</param>
        /// <param name="current">解析后的电流值</param>
        private static void ParseMeasurementResponse(string rawResponse, out double? voltage, out double? current)
        {
            voltage = null;
            current = null;
            if (string.IsNullOrWhiteSpace(rawResponse))
                return;
            try
            {
                var parts = rawResponse.Split(',');
                if (parts.Length >= 1 && double.TryParse(parts[0].Trim(), out var v))
                {
                    voltage = v;
                }
                if (parts.Length >= 2 && double.TryParse(parts[1].Trim(), out var c))
                {
                    current = c;
                }
            }
            catch (Exception)
            {
                // 解析失败时保持 null 值
            }
        }
    }
}
Aging.Infrastructure/Services/SlotDeviceMappingResolver.cs
New file
@@ -0,0 +1,218 @@
using System.Data;
using Aging.Infrastructure.Contracts;
using HMI.Core.Util;
using log4net;
namespace Aging.Infrastructure.Services
{
    /// <summary>
    /// 电源端点信息,描述 Slot 映射到的具体设备通道
    /// </summary>
    public sealed class PowerEndpoint
    {
        /// <summary>
        /// 设备标识符
        /// </summary>
        public string DeviceId { get; }
        /// <summary>
        /// 通道标识
        /// </summary>
        public int ChannelId { get; }
        /// <summary>
        /// 生效版本号
        /// </summary>
        public string EffectiveVersion { get; }
        /// <summary>
        /// 初始化电源端点实例
        /// </summary>
        /// <param name="deviceId">设备标识符</param>
        /// <param name="channelId">通道标识</param>
        /// <param name="effectiveVersion">生效版本号</param>
        public PowerEndpoint(string deviceId, int channelId, string effectiveVersion)
        {
            DeviceId = deviceId ?? throw new ArgumentNullException(nameof(deviceId));
            ChannelId = channelId;
            EffectiveVersion = effectiveVersion ?? string.Empty;
        }
        /// <summary>
        /// 转换为资源键,用于资源管理器的预约和租约操作
        /// </summary>
        /// <returns>对应的资源键实例</returns>
        public ResourceKey ToResourceKey()
        {
            return new ResourceKey(DeviceId, "PowerSupply", ChannelId.ToString());
        }
        /// <inheritdoc/>
        public override string ToString() => $"{DeviceId}:CH{ChannelId}(v{EffectiveVersion})";
    }
    /// <summary>
    /// Slot-设备-通道映射解析器。
    /// 从 Plugin.DeviceConfig 的 SQLite 数据库读取 Slot-Device-Channel 映射关系,
    /// 提供根据 SlotId 和能力类型解析具体设备端点的功能。
    /// </summary>
    public sealed class SlotDeviceMappingResolver
    {
        private static readonly ILog Log = LogManager.GetLogger(typeof(SlotDeviceMappingResolver));
        /// <summary>
        /// DeviceConfig 的 SQLite 数据库文件路径
        /// </summary>
        private readonly string _deviceConfigDbPath;
        /// <summary>
        /// 初始化映射解析器实例
        /// </summary>
        /// <param name="deviceConfigDbPath">DeviceConfig 的 SQLite 数据库文件路径</param>
        public SlotDeviceMappingResolver(string deviceConfigDbPath)
        {
            _deviceConfigDbPath = deviceConfigDbPath ?? throw new ArgumentNullException(nameof(deviceConfigDbPath));
            Log.Info($"[SlotDeviceMappingResolver] 初始化完成,数据库路径: {deviceConfigDbPath}");
        }
        /// <summary>
        /// 根据 SlotId 和能力类型解析具体的电源端点。
        /// 从 slot_device_mapping 表查询匹配的最新版本映射记录。
        /// </summary>
        /// <param name="slotId">Slot 标识符</param>
        /// <param name="capability">能力类型(如 "PowerSupply")</param>
        /// <returns>解析后的电源端点;未找到匹配映射时返回 null</returns>
        public PowerEndpoint? ResolveEndpoint(string slotId, string capability)
        {
            if (string.IsNullOrWhiteSpace(slotId))
                throw new ArgumentException("SlotId 不能为空", nameof(slotId));
            if (string.IsNullOrWhiteSpace(capability))
                throw new ArgumentException("能力类型不能为空", nameof(capability));
            SqliteHelper? sqliteHelper = null;
            try
            {
                sqliteHelper = new SqliteHelper(_deviceConfigDbPath);
                var sql = $"SELECT slot_id, capability, version, device_id, channel_id, mapping_version " +
                          $"FROM slot_device_mapping " +
                          $"WHERE slot_id = '{slotId}' AND capability = '{capability}' " +
                          $"ORDER BY mapping_version DESC LIMIT 1";
                var dataTable = sqliteHelper.GetDataTable(sql);
                if (dataTable.Rows.Count == 0)
                {
                    Log.Warn($"[SlotDeviceMappingResolver] 未找到 Slot={slotId}, Capability={capability} 的映射记录");
                    return null;
                }
                var row = dataTable.Rows[0];
                var deviceId = row["device_id"]?.ToString() ?? string.Empty;
                var channelId = Convert.ToInt32(row["channel_id"]);
                var effectiveVersion = row["mapping_version"]?.ToString() ?? string.Empty;
                var endpoint = new PowerEndpoint(deviceId, channelId, effectiveVersion);
                Log.Info($"[SlotDeviceMappingResolver] 解析映射: Slot={slotId}, Capability={capability} → {endpoint}");
                return endpoint;
            }
            catch (Exception ex)
            {
                Log.Error($"[SlotDeviceMappingResolver] 解析映射失败: Slot={slotId}, Capability={capability}, 错误: {ex.Message}", ex);
                throw;
            }
            finally
            {
                sqliteHelper?.Close();
            }
        }
        /// <summary>
        /// 获取数据库中所有的 Slot-设备-通道映射记录
        /// </summary>
        /// <returns>所有映射记录的列表</returns>
        public List<PowerEndpoint> GetAllMappings()
        {
            SqliteHelper? sqliteHelper = null;
            try
            {
                sqliteHelper = new SqliteHelper(_deviceConfigDbPath);
                var sql = "SELECT slot_id, capability, version, device_id, channel_id, mapping_version " +
                          "FROM slot_device_mapping " +
                          "ORDER BY slot_id, capability, mapping_version DESC";
                var dataTable = sqliteHelper.GetDataTable(sql);
                var results = new List<PowerEndpoint>();
                foreach (DataRow row in dataTable.Rows)
                {
                    var deviceId = row["device_id"]?.ToString() ?? string.Empty;
                    var channelId = Convert.ToInt32(row["channel_id"]);
                    var effectiveVersion = row["mapping_version"]?.ToString() ?? string.Empty;
                    results.Add(new PowerEndpoint(deviceId, channelId, effectiveVersion));
                }
                Log.Info($"[SlotDeviceMappingResolver] 获取全部映射记录,共 {results.Count} 条");
                return results;
            }
            catch (Exception ex)
            {
                Log.Error($"[SlotDeviceMappingResolver] 获取全部映射记录失败: {ex.Message}", ex);
                throw;
            }
            finally
            {
                sqliteHelper?.Close();
            }
        }
        /// <summary>
        /// 按能力类型筛选映射记录
        /// </summary>
        /// <param name="capability">能力类型(如 "PowerSupply"、"CAN" 等)</param>
        /// <returns>匹配指定能力类型的映射记录列表</returns>
        public List<PowerEndpoint> GetMappingsByCapability(string capability)
        {
            if (string.IsNullOrWhiteSpace(capability))
                throw new ArgumentException("能力类型不能为空", nameof(capability));
            SqliteHelper? sqliteHelper = null;
            try
            {
                sqliteHelper = new SqliteHelper(_deviceConfigDbPath);
                var sql = $"SELECT slot_id, capability, version, device_id, channel_id, mapping_version " +
                          $"FROM slot_device_mapping " +
                          $"WHERE capability = '{capability}' " +
                          $"ORDER BY slot_id, mapping_version DESC";
                var dataTable = sqliteHelper.GetDataTable(sql);
                var results = new List<PowerEndpoint>();
                foreach (DataRow row in dataTable.Rows)
                {
                    var deviceId = row["device_id"]?.ToString() ?? string.Empty;
                    var channelId = Convert.ToInt32(row["channel_id"]);
                    var effectiveVersion = row["mapping_version"]?.ToString() ?? string.Empty;
                    results.Add(new PowerEndpoint(deviceId, channelId, effectiveVersion));
                }
                Log.Info($"[SlotDeviceMappingResolver] 按能力类型 {capability} 筛选映射记录,共 {results.Count} 条");
                return results;
            }
            catch (Exception ex)
            {
                Log.Error($"[SlotDeviceMappingResolver] 按能力类型筛选映射记录失败: {ex.Message}", ex);
                throw;
            }
            finally
            {
                sqliteHelper?.Close();
            }
        }
    }
}
CanSimulator/CanBusSimulator.cs
New file
@@ -0,0 +1,403 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using log4net;
namespace CanSimulator
{
    /// <summary>
    /// CAN 总线状态
    /// </summary>
    public enum CanBusState
    {
        /// <summary>停止状态</summary>
        Stopped,
        /// <summary>运行中</summary>
        Running,
        /// <summary>暂停</summary>
        Paused,
        /// <summary>错误</summary>
        Error
    }
    /// <summary>
    /// CAN 总线模拟器事件参数
    /// </summary>
    public sealed class CanBusFrameEventArgs : EventArgs
    {
        public CanFrame Frame { get; }
        public string SourceNodeId { get; }
        public CanBusFrameEventArgs(CanFrame frame, string sourceNodeId)
        {
            Frame = frame;
            SourceNodeId = sourceNodeId;
        }
    }
    /// <summary>
    /// CAN 总线模拟器
    /// 模拟一条虚拟 CAN 总线,多个虚拟节点可以挂载到总线上进行通讯
    /// </summary>
    public sealed class CanBusSimulator : IDisposable
    {
        private static readonly ILog Log = LogManager.GetLogger(typeof(CanBusSimulator));
        /// <summary>
        /// 总线名称
        /// </summary>
        public string BusName { get; }
        /// <summary>
        /// 总线状态
        /// </summary>
        public CanBusState State { get; private set; } = CanBusState.Stopped;
        /// <summary>
        /// 波特率(仅用于模拟显示,不影响实际逻辑)
        /// </summary>
        public int BaudRate { get; set; } = 500000; // 默认 500kbps
        /// <summary>
        /// 模拟延迟(毫秒)- 模拟真实总线传输延迟
        /// </summary>
        public int SimulatedLatencyMs { get; set; } = 0;
        /// <summary>
        /// 已注册的节点
        /// </summary>
        private readonly ConcurrentDictionary<string, CanNode> _nodes = new();
        /// <summary>
        /// 总线日志(最近 N 条帧)
        /// </summary>
        private readonly ConcurrentQueue<CanFrame> _busLog = new();
        private int _maxLogSize = 5000;
        /// <summary>
        /// 总线统计
        /// </summary>
        public long TotalFramesTransmitted { get; private set; }
        public long TotalFramesReceived { get; private set; }
        public DateTime? LastActivityTime { get; private set; }
        /// <summary>
        /// 帧在总线上传输事件(可用于监控/日志)
        /// </summary>
        public event EventHandler<CanBusFrameEventArgs>? FrameTransmitted;
        /// <summary>
        /// 总线状态变化事件
        /// </summary>
        public event EventHandler<CanBusState>? StateChanged;
        public CanBusSimulator(string busName = "CAN_BUS_1")
        {
            BusName = busName ?? throw new ArgumentNullException(nameof(busName));
        }
        /// <summary>
        /// 启动总线
        /// </summary>
        public void Start()
        {
            if (State == CanBusState.Running)
            {
                Log.Warn($"[CanBus] 总线 {BusName} 已在运行中");
                return;
            }
            State = CanBusState.Running;
            Log.Info($"[CanBus] 总线 {BusName} 已启动 (波特率: {BaudRate}bps)");
            StateChanged?.Invoke(this, State);
        }
        /// <summary>
        /// 停止总线
        /// </summary>
        public void Stop()
        {
            if (State == CanBusState.Stopped) return;
            State = CanBusState.Stopped;
            Log.Info($"[CanBus] 总线 {BusName} 已停止");
            StateChanged?.Invoke(this, State);
        }
        /// <summary>
        /// 暂停总线(帧仍在保留但不路由)
        /// </summary>
        public void Pause()
        {
            if (State != CanBusState.Running) return;
            State = CanBusState.Paused;
            Log.Info($"[CanBus] 总线 {BusName} 已暂停");
            StateChanged?.Invoke(this, State);
        }
        /// <summary>
        /// 恢复总线
        /// </summary>
        public void Resume()
        {
            if (State != CanBusState.Paused) return;
            State = CanBusState.Running;
            Log.Info($"[CanBus] 总线 {BusName} 已恢复");
            StateChanged?.Invoke(this, State);
        }
        /// <summary>
        /// 注册节点到总线
        /// </summary>
        public CanNode RegisterNode(string nodeId, string? name = null)
        {
            if (string.IsNullOrWhiteSpace(nodeId))
                throw new ArgumentException("节点 ID 不能为空", nameof(nodeId));
            var node = new CanNode(nodeId, name);
            node.FrameSent += OnNodeFrameSent;
            if (!_nodes.TryAdd(nodeId, node))
            {
                Log.Warn($"[CanBus] 节点 {nodeId} 已存在,将被替换");
                _nodes.TryRemove(nodeId, out var oldNode);
                oldNode?.Deactivate();
                _nodes.TryAdd(nodeId, node);
            }
            // 如果总线已运行,自动激活节点
            if (State == CanBusState.Running)
            {
                node.Activate();
            }
            Log.Info($"[CanBus] 节点 {nodeId} ({name ?? nodeId}) 已注册到总线 {BusName}");
            return node;
        }
        /// <summary>
        /// 注销节点
        /// </summary>
        public bool UnregisterNode(string nodeId)
        {
            if (_nodes.TryRemove(nodeId, out var node))
            {
                node.FrameSent -= OnNodeFrameSent;
                node.Deactivate();
                Log.Info($"[CanBus] 节点 {nodeId} 已从总线 {BusName} 注销");
                return true;
            }
            return false;
        }
        /// <summary>
        /// 获取节点
        /// </summary>
        public CanNode? GetNode(string nodeId)
        {
            _nodes.TryGetValue(nodeId, out var node);
            return node;
        }
        /// <summary>
        /// 获取所有已注册节点
        /// </summary>
        public IReadOnlyList<CanNode> GetAllNodes()
        {
            return _nodes.Values.ToList();
        }
        /// <summary>
        /// 向总线发送帧(由节点发送,总线负责路由)
        /// </summary>
        private void OnNodeFrameSent(object? sender, CanFrame frame)
        {
            if (State != CanBusState.Running) return;
            // 模拟延迟
            if (SimulatedLatencyMs > 0)
            {
                Thread.Sleep(SimulatedLatencyMs);
            }
            // 记录到总线日志
            while (_busLog.Count >= _maxLogSize)
            {
                _busLog.TryDequeue(out _);
            }
            _busLog.Enqueue(frame);
            TotalFramesTransmitted++;
            LastActivityTime = DateTime.Now;
            // 路由到所有其他节点
            int routed = 0;
            foreach (var kvp in _nodes)
            {
                if (kvp.Key == frame.SourceNodeId) continue; // 不路由回源节点
                try
                {
                    kvp.Value.ReceiveFrame(frame);
                    routed++;
                    TotalFramesReceived++;
                }
                catch (Exception ex)
                {
                    Log.Error($"[CanBus] 路由帧到节点 {kvp.Key} 失败: {ex.Message}");
                }
            }
            // 检查源节点是否启用自动响应模式
            if (_nodes.TryGetValue(frame.SourceNodeId, out var sourceNode) && sourceNode.AutoResponseEnabled)
            {
                try
                {
                    // 模拟响应延迟
                    if (sourceNode.AutoResponseDelayMs > 0)
                    {
                        Thread.Sleep(sourceNode.AutoResponseDelayMs);
                    }
                    // 创建响应帧(复制原始帧数据,来源为源节点)
                    var responseFrame = new CanFrame
                    {
                        FrameId = frame.FrameId,
                        Format = frame.Format,
                        Type = frame.Type,
                        Data = (byte[])frame.Data.Clone(),
                        SourceNodeId = sourceNode.NodeId,
                        Timestamp = DateTime.Now
                    };
                    // 将响应帧路由到所有其他节点(不包括响应源自身)
                    foreach (var kvp in _nodes)
                    {
                        if (kvp.Key == sourceNode.NodeId) continue;
                        try
                        {
                            kvp.Value.ReceiveFrame(responseFrame);
                            TotalFramesReceived++;
                        }
                        catch (Exception ex)
                        {
                            Log.Error($"[CanBus] 路由响应帧到节点 {kvp.Key} 失败: {ex.Message}");
                        }
                    }
                    // 将响应帧送入源节点的接收邮箱(让源节点能看到自己的响应)
                    sourceNode.ReceiveFrame(responseFrame);
                    TotalFramesReceived++;
                    // 记录响应帧到总线日志
                    _busLog.Enqueue(responseFrame);
                    TotalFramesTransmitted++;
                    Log.Debug($"[CanBus] 节点 {sourceNode.NodeId} 自动响应帧 {responseFrame.FrameIdHex}");
                }
                catch (Exception ex)
                {
                    Log.Error($"[CanBus] 源节点自动响应失败: {ex.Message}");
                }
            }
            Log.Debug($"[CanBus] 帧 {frame.FrameIdHex} 从 {frame.SourceNodeId} 路由到 {routed} 个节点");
            FrameTransmitted?.Invoke(this, new CanBusFrameEventArgs(frame, frame.SourceNodeId));
        }
        /// <summary>
        /// 直接注入帧到总线(用于模拟外部设备发送)
        /// </summary>
        public void InjectFrame(CanFrame frame)
        {
            if (State != CanBusState.Running)
            {
                Log.Warn($"[CanBus] 总线未运行,无法注入帧");
                return;
            }
            frame.Timestamp = DateTime.Now;
            // 记录到日志
            while (_busLog.Count >= _maxLogSize)
            {
                _busLog.TryDequeue(out _);
            }
            _busLog.Enqueue(frame);
            TotalFramesTransmitted++;
            LastActivityTime = DateTime.Now;
            // 路由到所有节点
            foreach (var kvp in _nodes)
            {
                try
                {
                    kvp.Value.ReceiveFrame(frame);
                    TotalFramesReceived++;
                }
                catch (Exception ex)
                {
                    Log.Error($"[CanBus] 注入帧路由到节点 {kvp.Key} 失败: {ex.Message}");
                }
            }
            FrameTransmitted?.Invoke(this, new CanBusFrameEventArgs(frame, frame.SourceNodeId));
            Log.Debug($"[CanBus] 注入帧 {frame.FrameIdHex} 到总线");
        }
        /// <summary>
        /// 获取总线日志
        /// </summary>
        public List<CanFrame> GetBusLog(int maxCount = 100)
        {
            var all = _busLog.ToList();
            if (all.Count <= maxCount) return all;
            return all.Skip(all.Count - maxCount).ToList();
        }
        /// <summary>
        /// 清空总线日志
        /// </summary>
        public void ClearBusLog()
        {
            while (_busLog.TryDequeue(out _)) { }
        }
        /// <summary>
        /// 重置所有统计
        /// </summary>
        public void ResetStatistics()
        {
            TotalFramesTransmitted = 0;
            TotalFramesReceived = 0;
            LastActivityTime = null;
            foreach (var node in _nodes.Values)
            {
                node.ResetCounters();
            }
        }
        /// <summary>
        /// 获取总线状态摘要
        /// </summary>
        public string GetStatusSummary()
        {
            return $"Bus={BusName}, State={State}, BaudRate={BaudRate}, Nodes={_nodes.Count}, " +
                   $"Tx={TotalFramesTransmitted}, Rx={TotalFramesReceived}, " +
                   $"LastActivity={LastActivityTime?.ToString("HH:mm:ss") ?? "N/A"}";
        }
        public void Dispose()
        {
            Stop();
            foreach (var node in _nodes.Values)
            {
                node.Deactivate();
            }
            _nodes.Clear();
        }
    }
}
CanSimulator/CanFrame.cs
New file
@@ -0,0 +1,130 @@
using System;
namespace CanSimulator
{
    /// <summary>
    /// CAN 帧格式类型
    /// </summary>
    public enum CanFrameFormat
    {
        /// <summary>标准帧 (11-bit ID)</summary>
        Standard,
        /// <summary>扩展帧 (29-bit ID)</summary>
        Extended
    }
    /// <summary>
    /// CAN 帧类型
    /// </summary>
    public enum CanFrameType
    {
        /// <summary>数据帧</summary>
        Data,
        /// <summary>远程帧</summary>
        Remote
    }
    /// <summary>
    /// CAN 总线帧数据结构
    /// </summary>
    public sealed class CanFrame
    {
        /// <summary>
        /// 帧 ID(标准帧 11-bit,扩展帧 29-bit)
        /// </summary>
        public uint FrameId { get; set; }
        /// <summary>
        /// 帧格式(标准/扩展)
        /// </summary>
        public CanFrameFormat Format { get; set; } = CanFrameFormat.Standard;
        /// <summary>
        /// 帧类型(数据帧/远程帧)
        /// </summary>
        public CanFrameType Type { get; set; } = CanFrameType.Data;
        /// <summary>
        /// 数据负载(最大 8 字节,CAN FD 可达 64 字节)
        /// </summary>
        public byte[] Data { get; set; } = Array.Empty<byte>();
        /// <summary>
        /// 时间戳
        /// </summary>
        public DateTime Timestamp { get; set; } = DateTime.Now;
        /// <summary>
        /// 发送节点标识
        /// </summary>
        public string SourceNodeId { get; set; } = string.Empty;
        /// <summary>
        /// DLC (Data Length Code)
        /// </summary>
        public int Dlc => Data.Length;
        /// <summary>
        /// 将帧 ID 格式化为十六进制字符串
        /// </summary>
        public string FrameIdHex => Format == CanFrameFormat.Extended
            ? $"0x{FrameId:X8}"
            : $"0x{FrameId:X3}";
        /// <summary>
        /// 将数据格式化为十六进制字符串
        /// </summary>
        public string DataHex => BitConverter.ToString(Data).Replace("-", " ");
        /// <summary>
        /// 从十六进制字符串解析帧 ID
        /// </summary>
        public static uint ParseFrameId(string frameIdStr)
        {
            if (string.IsNullOrWhiteSpace(frameIdStr))
                throw new ArgumentException("帧 ID 不能为空", nameof(frameIdStr));
            if (frameIdStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
            {
                if (!uint.TryParse(frameIdStr.Substring(2), System.Globalization.NumberStyles.HexNumber, null, out uint id))
                    throw new ArgumentException($"无法解析帧 ID: {frameIdStr}");
                return id;
            }
            else
            {
                if (!uint.TryParse(frameIdStr, out uint id))
                    throw new ArgumentException($"无法解析帧 ID: {frameIdStr}");
                return id;
            }
        }
        /// <summary>
        /// 从十六进制字符串解析数据
        /// </summary>
        public static byte[] ParseData(string dataStr)
        {
            if (string.IsNullOrWhiteSpace(dataStr))
                return Array.Empty<byte>();
            try
            {
                var parts = dataStr.Split(new[] { ' ', '-', ',' }, StringSplitOptions.RemoveEmptyEntries);
                var bytes = new byte[parts.Length];
                for (int i = 0; i < parts.Length; i++)
                {
                    bytes[i] = byte.Parse(parts[i], System.Globalization.NumberStyles.HexNumber);
                }
                return bytes;
            }
            catch (Exception ex)
            {
                throw new ArgumentException($"无法解析数据字符串: {dataStr}, 错误: {ex.Message}");
            }
        }
        public override string ToString()
        {
            return $"[{Timestamp:HH:mm:ss.fff}] {FrameIdHex} [{Dlc}] {DataHex} (from {SourceNodeId})";
        }
    }
}
CanSimulator/CanNode.cs
New file
@@ -0,0 +1,311 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using log4net;
namespace CanSimulator
{
    /// <summary>
    /// 接收滤波器配置
    /// </summary>
    public sealed class AcceptanceFilter
    {
        /// <summary>
        /// 滤波器 ID(匹配值)
        /// </summary>
        public uint FilterId { get; set; }
        /// <summary>
        /// 掩码(为 1 的位参与比较)
        /// </summary>
        public uint Mask { get; set; } = 0x7FF; // 默认标准帧全匹配
        /// <summary>
        /// 是否为扩展帧滤波器
        /// </summary>
        public bool IsExtended { get; set; }
        /// <summary>
        /// 检查给定帧 ID 是否通过此滤波器
        /// </summary>
        public bool Accepts(uint frameId, bool isExtended)
        {
            if (IsExtended != isExtended) return false;
            return (frameId & Mask) == (FilterId & Mask);
        }
    }
    /// <summary>
    /// 虚拟 CAN 节点
    /// 模拟一个 CAN 通讯卡的端点,具有发送和接收功能
    /// </summary>
    public sealed class CanNode
    {
        private static readonly ILog Log = LogManager.GetLogger(typeof(CanNode));
        /// <summary>
        /// 节点唯一标识
        /// </summary>
        public string NodeId { get; }
        /// <summary>
        /// 节点名称(可选描述)
        /// </summary>
        public string Name { get; set; }
        /// <summary>
        /// 节点是否已激活(连接到总线)
        /// </summary>
        public bool IsActive { get; private set; }
        /// <summary>
        /// 接收滤波器列表
        /// </summary>
        private readonly List<AcceptanceFilter> _filters = new();
        /// <summary>
        /// 接收邮箱(FIFO 队列)
        /// </summary>
        private readonly ConcurrentQueue<CanFrame> _receiveMailbox = new();
        /// <summary>
        /// 发送历史记录
        /// </summary>
        private readonly ConcurrentQueue<CanFrame> _sendHistory = new();
        /// <summary>
        /// 最大邮箱容量
        /// </summary>
        public int MaxMailboxSize { get; set; } = 1000;
        /// <summary>
        /// 最大历史记录容量
        /// </summary>
        public int MaxHistorySize { get; set; } = 500;
        /// <summary>
        /// 接收帧计数器
        /// </summary>
        public long RxCount { get; private set; }
        /// <summary>
        /// 发送帧计数器
        /// </summary>
        public long TxCount { get; private set; }
        /// <summary>
        /// 错误计数器
        /// </summary>
        public long ErrorCount { get; private set; }
        /// <summary>
        /// 是否启用自动响应模式(收到帧后原封不动回复)
        /// </summary>
        public bool AutoResponseEnabled { get; set; }
        /// <summary>
        /// 自动响应延迟(毫秒)- 模拟真实设备响应时间
        /// </summary>
        public int AutoResponseDelayMs { get; set; } = 0;
        /// <summary>
        /// 自动响应事件(用于日志/监控)
        /// </summary>
        public event EventHandler<CanFrame>? AutoResponseSent;
        /// <summary>
        /// 帧接收事件
        /// </summary>
        public event EventHandler<CanFrame>? FrameReceived;
        /// <summary>
        /// 帧发送事件
        /// </summary>
        public event EventHandler<CanFrame>? FrameSent;
        public CanNode(string nodeId, string? name = null)
        {
            NodeId = nodeId ?? throw new ArgumentNullException(nameof(nodeId));
            Name = name ?? nodeId;
        }
        /// <summary>
        /// 激活节点(连接到总线)
        /// </summary>
        public void Activate()
        {
            IsActive = true;
            Log.Info($"[CanNode] 节点 {NodeId} ({Name}) 已激活");
        }
        /// <summary>
        /// 停用节点(从总线断开)
        /// </summary>
        public void Deactivate()
        {
            IsActive = false;
            Log.Info($"[CanNode] 节点 {NodeId} ({Name}) 已停用");
        }
        /// <summary>
        /// 添加接收滤波器
        /// </summary>
        public void AddFilter(AcceptanceFilter filter)
        {
            lock (_filters)
            {
                _filters.Add(filter);
            }
            Log.Debug($"[CanNode] 节点 {NodeId} 添加滤波器: ID=0x{filter.FilterId:X3}, Mask=0x{filter.Mask:X3}");
        }
        /// <summary>
        /// 清除所有滤波器
        /// </summary>
        public void ClearFilters()
        {
            lock (_filters)
            {
                _filters.Clear();
            }
            Log.Debug($"[CanNode] 节点 {NodeId} 清除所有滤波器");
        }
        /// <summary>
        /// 设置通配符滤波器(接收所有帧)
        /// </summary>
        public void SetPromiscuousMode()
        {
            lock (_filters)
            {
                _filters.Clear();
                _filters.Add(new AcceptanceFilter { FilterId = 0, Mask = 0, IsExtended = false });
                _filters.Add(new AcceptanceFilter { FilterId = 0, Mask = 0, IsExtended = true });
            }
            Log.Debug($"[CanNode] 节点 {NodeId} 设置为混杂模式(接收所有帧)");
        }
        /// <summary>
        /// 检查帧是否通过滤波器
        /// </summary>
        internal bool AcceptsFrame(CanFrame frame)
        {
            lock (_filters)
            {
                // 如果没有设置滤波器,默认拒绝
                if (_filters.Count == 0) return false;
                // 任一滤波器匹配即接受
                return _filters.Any(f => f.Accepts(frame.FrameId, frame.Format == CanFrameFormat.Extended));
            }
        }
        /// <summary>
        /// 接收帧(由总线调用)
        /// </summary>
        internal void ReceiveFrame(CanFrame frame)
        {
            if (!IsActive) return;
            if (!AcceptsFrame(frame)) return;
            // 入队接收邮箱
            while (_receiveMailbox.Count >= MaxMailboxSize)
            {
                _receiveMailbox.TryDequeue(out _); // 丢弃最旧的帧
            }
            _receiveMailbox.Enqueue(frame);
            RxCount++;
            // 触发事件
            FrameReceived?.Invoke(this, frame);
            Log.Debug($"[CanNode] 节点 {NodeId} 接收帧: {frame}");
        }
        /// <summary>
        /// 发送帧
        /// </summary>
        public void SendFrame(CanFrame frame)
        {
            if (!IsActive)
            {
                ErrorCount++;
                throw new InvalidOperationException($"节点 {NodeId} 未激活,无法发送帧");
            }
            frame.SourceNodeId = NodeId;
            frame.Timestamp = DateTime.Now;
            // 记录发送历史
            while (_sendHistory.Count >= MaxHistorySize)
            {
                _sendHistory.TryDequeue(out _);
            }
            _sendHistory.Enqueue(frame);
            TxCount++;
            // 触发事件(由总线处理路由)
            FrameSent?.Invoke(this, frame);
            Log.Debug($"[CanNode] 节点 {NodeId} 发送帧: {frame}");
        }
        /// <summary>
        /// 从接收邮箱中取出一帧
        /// </summary>
        public bool TryDequeueReceived(out CanFrame? frame)
        {
            return _receiveMailbox.TryDequeue(out frame);
        }
        /// <summary>
        /// 查看接收邮箱中的所有帧(不移除)
        /// </summary>
        public List<CanFrame> PeekReceiveMailbox()
        {
            return _receiveMailbox.ToList();
        }
        /// <summary>
        /// 清空接收邮箱
        /// </summary>
        public void ClearReceiveMailbox()
        {
            while (_receiveMailbox.TryDequeue(out _)) { }
            Log.Debug($"[CanNode] 节点 {NodeId} 清空接收邮箱");
        }
        /// <summary>
        /// 获取发送历史
        /// </summary>
        public List<CanFrame> GetSendHistory()
        {
            return _sendHistory.ToList();
        }
        /// <summary>
        /// 清空发送历史
        /// </summary>
        public void ClearSendHistory()
        {
            while (_sendHistory.TryDequeue(out _)) { }
        }
        /// <summary>
        /// 重置节点统计
        /// </summary>
        public void ResetCounters()
        {
            RxCount = 0;
            TxCount = 0;
            ErrorCount = 0;
        }
        /// <summary>
        /// 获取节点状态摘要
        /// </summary>
        public string GetStatusSummary()
        {
            return $"Node={NodeId}, Active={IsActive}, Rx={RxCount}, Tx={TxCount}, Err={ErrorCount}, Mailbox={_receiveMailbox.Count}";
        }
    }
}
CanSimulator/CanSimulator.csproj
New file
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net472</TargetFramework>
    <Nullable>enable</Nullable>
    <LangVersion>10.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="log4net" Version="3.0.4" />
  </ItemGroup>
</Project>
Config/Database/database.db
Config/LogViewer/log_2026-10-23.db
Binary files differ
HMI.Core/Setting/ParamSetting.cs
@@ -65,6 +65,7 @@
        public static bool ShowBurnInAbort { get; private set; } = true;
        public static string SocketNames { get; private set; } = string.Empty;
        public static string StationName { get; private set; } = string.Empty;
        public static string MainSeqPath { get; private set; } = string.Empty;
        public static string EngineType
@@ -225,6 +226,16 @@
            {
            }
            try
            {
                MainSeqPath = ParameterManager.GetCommonParameterByName<string>("BurnInMainSeq", string.Empty);
            }
            catch
            {
            }
        }
    }
}
HMI.Core/Util/LangUtil.cs
@@ -13,8 +13,9 @@
{
    public class LangUtil
    {
        public const string DefaultLang = "zh-CN";
        public static List<LangInfo> langs = new List<LangInfo>() {
            new LangInfo("zh-CN","中文"),
            new LangInfo(DefaultLang,"中文"),
            new LangInfo("en-US","English")
        };
@@ -32,7 +33,7 @@
        private static string GetLang()
        {
            var lang = SystemSetting.Instance.CurrentLanguage;
            if (string.IsNullOrEmpty(lang)) lang = CultureInfo.CurrentUICulture.ToString();
            if (string.IsNullOrEmpty(lang)) lang = "zh-CN";
            return lang;
        }
@@ -57,10 +58,7 @@
        {
            try
            {
                /// 按Json加载
                //LoadAllDataByJson();
                /// 下面是按xaml加载
                LoadAllDataByXaml();
                LoadAllData();
                LoadLang();
            }
            finally
@@ -132,56 +130,41 @@
            ResourceMap[lang] = resource;
        }
        private static void LoadDataByXaml(string lang)
        private static void LoadXamlFromJson2(string lang)
        {
            string langFolder = PathHelper.LangFolder;
            string folder = Path.Combine(langFolder, lang);
            if (!Directory.Exists(folder))
            LoadDataByJson(lang);
            if (!JsonMap.ContainsKey(lang)) return;
            JObject json = JsonMap[lang];
            var resource = new ResourceDictionary();
            foreach (var item in json)
            {
                return;
                resource.Add(item.Key, item.Value.ToString());
            }
            string[] files = Directory.GetFiles(folder, "*.xaml", SearchOption.AllDirectories);
            foreach (var path in files)
            ResourceMap[lang] = resource;
            if (lang != DefaultLang)
            {
                try
                var defaultMap = new ResourceDictionary();
                foreach (var item in json)
                {
                    using (FileStream stream = File.Open(path, FileMode.Open, FileAccess.Read))
                    {
                        var resource = (ResourceDictionary)XamlReader.Load(stream);
                        if (!ResourceMap.ContainsKey(lang))
                        {
                            ResourceMap[lang] = resource;
                        }
                        else
                        {
                            ResourceMap[lang].MergedDictionaries.Add(resource);
                        }
                    }
                    defaultMap.Add(item.Key, item.Key);
                }
                catch
                {
                }
                ResourceMap[DefaultLang] = defaultMap;
            }
        }
        private static void LoadAllDataByJson()
        {
            foreach (var lang in langs)
            {
                LoadDataByJson(lang.Code);
            }
        }
        private static void LoadAllDataByXaml()
        private static void LoadAllData()
        {
            foreach (var lang in langs)
            {
                LoadXamlFromJson(lang.Code);
            }
        }
        public static string Format(string key, params object[] datas)
        {
            return string.Format(Get(key), datas);
        }
    }
    public static class LangReader
HMI.UI/Resources/Font/iconfont.ttf
Binary files differ
HMI.UI/Style/PanuonResource.xaml
@@ -11,7 +11,7 @@
             xmlns:uic="clr-namespace:HMI.UI.Converter;assembly=HMI.UI"
             xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI">
    <ResourceDictionary.MergedDictionaries>
        <pu:StyleDictionary Includes="PasswordBox,TextBox,ComboBox,Button,TreeView,Menu,ListBox,ContextMenu,TabControl" />
        <pu:StyleDictionary Includes="PasswordBox,TextBox,ComboBox,Button,TreeView,ListBox" />
        <ResourceDictionary Source="/HMI.UI;component/Style/PanuonDataGrid.xaml"/>
        <ResourceDictionary Source="/HMI.UI;component/Style/ScrollBarResource.xaml"/>
        <ResourceDictionary Source="/HMI.UI;component/Resources/Font/Font.xaml"/>
@@ -146,6 +146,13 @@
    </Style>
    <Style x:Key="CommonMenu" TargetType="Menu" BasedOn="{StaticResource {x:Static pu:StyleKeys.MenuStyle}}">
        <Style.Resources>
            <ResourceDictionary>
                <ResourceDictionary.MergedDictionaries>
                    <pu:StyleDictionary Includes="Menu" />
                </ResourceDictionary.MergedDictionaries>
            </ResourceDictionary>
        </Style.Resources>
        <Setter Property="Background" Value="{DynamicResource PrimaryColor}"/>
        <Setter Property="Foreground" Value="#FFF"/>
        <Setter Property="pu:IconHelper.FontFamily" Value="{StaticResource iconfont}"/>
HMI.UnitTest/HMI.UnitTest.csproj
@@ -55,6 +55,9 @@
    <Prefer32Bit>true</Prefer32Bit>
  </PropertyGroup>
  <ItemGroup>
    <Reference Include="HslCommunication">
      <HintPath>..\Libs\HslCommunication.dll</HintPath>
    </Reference>
    <Reference Include="log4net, Version=3.0.4.0, Culture=neutral, PublicKeyToken=669e0ddf0bb1aa2a, processorArchitecture=MSIL">
      <HintPath>..\WPFProject\packages\log4net.3.0.4\lib\net462\log4net.dll</HintPath>
    </Reference>
@@ -131,10 +134,6 @@
    <None Include="App.config" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\UILib\UILib.csproj">
      <Project>{27325fe0-3ce2-4cd5-8eb5-7e45cc441479}</Project>
      <Name>UILib</Name>
    </ProjectReference>
    <ProjectReference Include="..\ConnectionUtil\ConnectionUtil.csproj">
      <Project>{d0806fa6-3f6d-4304-81b8-ead9da7cb5b2}</Project>
      <Name>ConnectionUtil</Name>
@@ -159,6 +158,10 @@
      <Project>{f17de6b5-af1d-48c6-94a0-6b763a6546a6}</Project>
      <Name>HMI.UI</Name>
    </ProjectReference>
    <ProjectReference Include="..\PLCHelper\PLCHelper.csproj">
      <Project>{C3F17E03-EF05-4203-8D29-3AD984F3BD9C}</Project>
      <Name>PLCHelper</Name>
    </ProjectReference>
    <ProjectReference Include="..\Plugin.DataBoard\Plugin.DataBoard.csproj">
      <Project>{be42ab26-3270-43e1-84b8-6929d862ff8d}</Project>
      <Name>Plugin.DataBoard</Name>
@@ -175,6 +178,10 @@
      <Project>{22dc8d91-50b2-47ca-86a8-29a6da48f388}</Project>
      <Name>Plugin.UICollection</Name>
    </ProjectReference>
    <ProjectReference Include="..\S7Driver\S7Driver.csproj">
      <Project>{628ef12b-9ed2-4b20-950f-dc3ecc3fd15f}</Project>
      <Name>S7Driver</Name>
    </ProjectReference>
    <ProjectReference Include="..\SimpleUtil\HMI.Simple.Util\HMI.Simple.Util.csproj">
      <Project>{cf5bebe9-2803-49c3-b773-73d082e0984a}</Project>
      <Name>HMI.Simple.Util</Name>
HMI.UnitTest/MainWindow.xaml.cs
@@ -2,8 +2,11 @@
using HMI.Core.Api;
using HMI.Core.Util;
using HMI.Simple.Util;
using HslCommunication.Core.Pipe;
using HslCommunication.Profinet.Siemens;
using log4net;
using Panuon.WPF.UI;
using PLCHelper;
using Plugin.DataBoard;
using Plugin.TestResult_WPF;
using Plugin.TsExecution;
@@ -11,7 +14,7 @@
using System;
using System.IO;
using System.Windows;
using Util.ManualDebug;
using System.Xml.Linq;
namespace UnitTest
{
    /// <summary>
@@ -22,16 +25,32 @@
        private ILog log = LogManager.GetLogger("UnitTest");
        public MainWindow()
        {
            ProductModelHelper.GetModelBySNR("cc123", out string model, out string msg);
            LangUtil.Load();
            InitializeComponent();
            //RebuildDb();
            border2.Child = new TcpDebugger();
            //ProductModelHelper.GetModelBySNR("cc123", out string model, out string msg);
            //LangUtil.Load();
            //InitializeComponent();
            ////RebuildDb();
            //border2.Child = new TcpDebugger();
            var client = new SiemensS7Net(SiemensPLCS.S1500);
            PipeTcpNet pipeTcpNet = new PipeTcpNet("127.0.0.1", 102);
            pipeTcpNet.ConnectTimeOut = 200;
            pipeTcpNet.ReceiveTimeOut = 200;
            client.CommunicationPipe = pipeTcpNet;
            client.ConnectServer();
            var ss = new IntReader(client, "DB10.DBW100", 0);
            ss.DataChanged += Ss_DataChanged;
            ss.Run();
            //page.Children.Add(new UserControl1());
        }
        private void Ss_DataChanged(object sender, DataChangedArg<int> e)
        {
            MessageBox.Show($"{e.oldValue} => {e.newValue}");
        }
        TcpClientWrapper tcpClient;
@@ -128,12 +147,13 @@
        private void Button_Click(object sender, RoutedEventArgs e)
        {
            var win = new ManualDebug()
            {
                seqPath = "C:\\Users\\chrry\\Desktop\\手动调试示例.seq"
            };
            //var win = new ManualDebug()
            //{
            //    seqPath = "C:\\Users\\chrry\\Desktop\\手动调试示例.seq"
            //};
            win.Show();
            //win.Show();
            //Task.Run(() =>
            //{
            //    for (int j = 0; j < 1000; j++)
HMI.Util/IniFileHelper.cs
@@ -89,7 +89,7 @@
                return temp.ToString();
            }
            else
                return String.Empty;
                return NoText;
        }
        #endregion
PLCHelper/BoolReader.cs
New file
@@ -0,0 +1,60 @@
using HslCommunication.Profinet.Siemens;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace PLCHelper
{
    public class BoolReader
    {
        private bool lastVallue = false;
        private string Address = string.Empty;
        private int Timeout = 1000;
        private SiemensS7Net client;
        private volatile bool isRunning = false;
        public event EventHandler<DataChangedArg<bool>> DataChanged;
        public BoolReader(SiemensS7Net client, string address, bool initValue = false)
        {
            this.client = client;
            this.Address = address;
            this.lastVallue = initValue;
        }
        public void Stop()
        {
            isRunning = false;
        }
        public void Run()
        {
            isRunning = true;
            Task.Run(() =>
            {
                while (isRunning)
                {
                    var res = client.ReadBool(Address);
                    if (res.IsSuccess)
                    {
                        bool val = res.Content;
                        if (val != lastVallue)
                        {
                            DataChanged?.Invoke(this, new DataChangedArg<bool>
                            {
                                oldValue = lastVallue,
                                newValue = val
                            });
                            lastVallue = val;
                        }
                    }
                    Thread.Sleep(Timeout);
                }
            });
        }
    }
}
PLCHelper/IntReader.cs
New file
@@ -0,0 +1,65 @@
using HslCommunication.Profinet.Siemens;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace PLCHelper
{
    public class DataChangedArg<T>
    {
        public T oldValue { get; set; }
        public T newValue { get; set; }
    }
    public class IntReader
    {
        private int lastVallue = 0;
        private string Address = string.Empty;
        private int Timeout = 1000;
        private SiemensS7Net client;
        private volatile bool isRunning = false;
        public event EventHandler<DataChangedArg<int>> DataChanged;
        public IntReader(SiemensS7Net client, string address, int initValue = 0)
        {
            this.client = client;
            this.Address = address;
            this.lastVallue = initValue;
        }
        public void Stop()
        {
            isRunning = false;
        }
        public void Run()
        {
            isRunning = true;
            Task.Run(() =>
            {
                while (isRunning)
                {
                    var res = client.ReadInt16(Address);
                    if (res.IsSuccess)
                    {
                        int val = res.Content;
                        if (val != lastVallue)
                        {
                            DataChanged?.Invoke(this, new DataChangedArg<int>
                            {
                                oldValue = lastVallue,
                                newValue = val
                            });
                            lastVallue = val;
                        }
                    }
                    Thread.Sleep(Timeout);
                }
            });
        }
    }
}
PLCHelper/PLCHelper.csproj
@@ -45,6 +45,8 @@
  </ItemGroup>
  <ItemGroup>
    <Compile Include="Entity\PLCTagData.cs" />
    <Compile Include="BoolReader.cs" />
    <Compile Include="IntReader.cs" />
    <Compile Include="ModbusHelper.cs" />
    <Compile Include="NXI_5010Helper.cs" />
    <Compile Include="Properties\AssemblyInfo.cs" />
Plugin.BurnIn/ManualDebug/ManualDebug.xaml.cs
@@ -6,14 +6,18 @@
using Plugin.BurnIn.Model;
using Plugin.TsSupport;
using SuperSocket.SocketBase.Config;
using System.Collections;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Configuration;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Media;
using System.Xml.Linq;
namespace Plugin.BurnIn
{
@@ -219,16 +223,39 @@
            });
        }
        private int GetConfigPort()
        {
            try
            {
                var customConfig = (Hashtable)ConfigurationManager.GetSection("TestResultConfig");
                if (customConfig.ContainsKey("PortStart"))
                {
                    int port = int.Parse(customConfig["PortStart"].ToString()) - 1;
                    return port;
                }
                else
                {
                    return 6659;
                }
            }
            catch (Exception ex)
            {
                AppendLog(LogLevel.Error, $"读取起始端口错误: {ex.Message} {ex.StackTrace} 使用默认端口6659");
                return 6659;
            }
        }
        /// <summary>
        /// 启动Tcp服务器
        /// </summary>
        [RelayCommand]
        private void StartTcpServer()
        {
            int port = GetConfigPort();
            var serverConfig = new ServerConfig()
            {
                MaxRequestLength = 1024 * 1024,
                Port = 7777
                Port = port
            };
            try
            {
@@ -247,6 +274,7 @@
                        return;
                    }
                    ChangeTcpServerStatus("RUNNING", this.lang("Tr.TcpServer.Running"));
                    AppendLog(LogLevel.Debug, $"数据接收服务器启动,端口:{port}");
                }
                else if (tcpServer.State != SuperSocket.SocketBase.ServerState.Running)
                {
@@ -261,11 +289,13 @@
                        return;
                    }
                    ChangeTcpServerStatus("RUNNING", this.lang("Tr.TcpServer.Running"));
                    AppendLog(LogLevel.Debug, $"数据接收服务器启动,端口:{port}");
                }
            }
            catch (Exception ex)
            {
                ChangeTcpServerStatus("ERROR", $"{ex.Message}");
                AppendLog(LogLevel.Error, $"数据接收服务器启动错误: {ex.Message} {ex.StackTrace}");
                tcpServer?.Stop();
                tcpServer = null;
            }
@@ -613,6 +643,7 @@
            {
                executionWrapper?.SetFileGlobalValue("Running.StopTest", 1, true);
                executionWrapper?.WaitForExit();
                executionWrapper?.Dispose();
                tcpServer?.Stop();
            }
            catch (Exception ex)
Plugin.BurnIn/Plugin.BurnIn.csproj
@@ -8,8 +8,8 @@
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
    <Platforms>AnyCPU;x64</Platforms>
    <AssemblyVersion>1.0.0.2</AssemblyVersion>
    <FileVersion>1.0.0.2</FileVersion>
    <AssemblyVersion>1.0.0.3</AssemblyVersion>
    <FileVersion>1.0.0.3</FileVersion>
    <IncludeSymbols>False</IncludeSymbols>
  </PropertyGroup>
Plugin.BurnIn/TestResultView.xaml.cs
@@ -216,11 +216,11 @@
                }
                else
                {
                    executionWrapper = new TsExecutionWrapper();
                    executionWrapper = new TeststandWrapper();
                }
                executionWrapper.UIMessageEvent += ExecutionWrapper_UIMessageEvent;
                if (executionWrapper is TsExecutionWrapper tsWrapper)
                if (executionWrapper is TeststandWrapper tsWrapper)
                {
                    ((System.ComponentModel.ISupportInitialize)(tsWrapper.AxApplicationMgr)).BeginInit();
                    host.Child = tsWrapper.AxApplicationMgr;
@@ -631,7 +631,7 @@
        public void ReleaseResource()
        {
            executionWrapper.UIMessageEvent -= ExecutionWrapper_UIMessageEvent;
            executionWrapper.Dispose();
            foreach (var table in tables)
            {
                table.RelaseResource();
Plugin.CurrentSensorControl/Entity/BaseConfig.cs
@@ -45,7 +45,7 @@
            try
            {
                value = ParameterManager.GetCommonParameterByName("BurnInOvenPort");
                config.PortStart = int.Parse(value.Value);
                config.PortStart = value == null ? config.PortStart : int.Parse(value.Value);
            }
            catch (Exception ex)
            {
Plugin.DeviceConfig/BatchGenerateModal.xaml
New file
@@ -0,0 +1,175 @@
<pu:WindowX x:Class="Plugin.DeviceConfig.BatchGenerateModal"
            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:pw="https://opensource.panuon.com/wpf"
            xmlns:pu="https://opensource.panuon.com/wpf-ui"
            xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
            xmlns:local="clr-namespace:Plugin.DeviceConfig"
            mc:Ignorable="d"
            Title="批量生成设备"
            SizeToContent="Height"
            Width="780"
            WindowStartupLocation="CenterOwner"
            Style="{StaticResource ModalWindow}">
    <pu:WindowX.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/HMI.UI;component/Style/PanuonListView.xaml"/>
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </pu:WindowX.Resources>
    <Grid Margin="16">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <!-- 参数输入区 -->
        <GroupBox Grid.Row="0" Header="生成参数" FontWeight="SemiBold" Margin="0,0,0,12">
            <Grid Margin="8">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="120"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="120"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                <Grid.RowDefinitions>
                    <RowDefinition Height="36"/>
                    <RowDefinition Height="36"/>
                    <RowDefinition Height="36"/>
                    <RowDefinition Height="36"/>
                    <RowDefinition Height="36"/>
                </Grid.RowDefinitions>
                <TextBlock Grid.Row="0" Grid.Column="0" Text="设备类型 *" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                <ComboBox Grid.Row="0" Grid.Column="1" x:Name="cbDeviceType" VerticalAlignment="Center" Margin="4,0"/>
                <TextBlock Grid.Row="0" Grid.Column="2" Text="驱动类型" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                <TextBox Grid.Row="0" Grid.Column="3" x:Name="tbDriverType" VerticalAlignment="Center"
                         Style="{StaticResource EditorTextBoxStyle}" Margin="4,0"/>
                <TextBlock Grid.Row="1" Grid.Column="0" Text="传输方式 *" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                <ComboBox Grid.Row="1" Grid.Column="1" x:Name="cbTransport" VerticalAlignment="Center" Margin="4,0"/>
                <TextBlock Grid.Row="1" Grid.Column="2" Text="设备数量 *" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                <pu:NumberInput Grid.Row="1" Grid.Column="3" x:Name="niDeviceCount" VerticalAlignment="Center"
                                Style="{StaticResource FormNumberInput}" Margin="4,0" Minimum="1" Maximum="100" Value="1"/>
                <TextBlock Grid.Row="2" Grid.Column="0" Text="每设备通道数" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                <pu:NumberInput Grid.Row="2" Grid.Column="1" x:Name="niChannelsPerDevice" VerticalAlignment="Center"
                                Style="{StaticResource FormNumberInput}" Margin="4,0" Minimum="0" Maximum="64" Value="1"/>
                <TextBlock Grid.Row="2" Grid.Column="2" Text="起始端口号" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                <pu:NumberInput Grid.Row="2" Grid.Column="3" x:Name="niStartPort" VerticalAlignment="Center"
                                Style="{StaticResource FormNumberInput}" Margin="4,0" Minimum="1" Maximum="65535" Value="30000"/>
                <TextBlock Grid.Row="3" Grid.Column="0" Text="地址生成规则" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                <TextBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="3" x:Name="tbAddressRule" VerticalAlignment="Center"
                         Style="{StaticResource EditorTextBoxStyle}" Margin="4,0"
                         pu:TextBoxHelper.Watermark="例: 192.168.10.{0},{0}为序号占位符"/>
                <TextBlock Grid.Row="4" Grid.Column="0" Text="Slot映射起点" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                <TextBox Grid.Row="4" Grid.Column="1" x:Name="tbSlotStart" VerticalAlignment="Center"
                         Style="{StaticResource EditorTextBoxStyle}" Margin="4,0"
                         pu:TextBoxHelper.Watermark="例: Slot001"/>
                <TextBlock Grid.Row="4" Grid.Column="2" Text="设备ID前缀" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                <TextBox Grid.Row="4" Grid.Column="3" x:Name="tbDeviceIdPrefix" VerticalAlignment="Center"
                         Style="{StaticResource EditorTextBoxStyle}" Margin="4,0"
                         pu:TextBoxHelper.Watermark="例: PSU"/>
            </Grid>
        </GroupBox>
        <!-- 预览区 -->
        <GroupBox Grid.Row="1" Header="生成预览" FontWeight="SemiBold" Margin="0,0,0,12">
            <Grid Margin="8">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="200"/>
                </Grid.RowDefinitions>
                <TextBlock Grid.Row="0" x:Name="tbConflictMsg" Foreground="Red" Margin="0,0,0,4"
                           TextWrapping="Wrap" Visibility="Collapsed"/>
                <ListView Grid.Row="1" Style="{StaticResource DefaultListTable}" x:Name="previewListView"
                          VirtualizingPanel.IsVirtualizing="True"
                          VirtualizingPanel.VirtualizationMode="Standard">
                    <ListView.View>
                        <GridView AllowsColumnReorder="False">
                            <GridViewColumn Width="50" Header="#">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}"
                                                Text="{Binding Converter={StaticResource lvRowNumber},
                                                RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=ListViewItem,AncestorLevel=1}}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="140" Header="DeviceId">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding DeviceId}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="100" Header="Kind">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding Kind}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="180" Header="Endpoint">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding Endpoint}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="80" Header="通道数">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding ChannelCount}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="100" Header="Slot起点">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding SlotStart}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                        </GridView>
                    </ListView.View>
                </ListView>
            </Grid>
        </GroupBox>
        <!-- 底部按钮 -->
        <StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" dp:StackPanelHelper.ChildSpace="10" Margin="0,12,0,0">
            <Button x:Name="previewBtn" Click="PreviewBtn_Click" Content="生成预览"
                    Style="{StaticResource RfhBtn}" Width="90"/>
            <Button x:Name="saveBtn" Click="SaveBtn_Click" Content="确认保存"
                    Style="{StaticResource AddBtn}" Width="90"/>
            <Button x:Name="cancelBtn" Click="CancelBtn_Click" Content="取消"
                    Style="{StaticResource RfhBtn}" Width="90"/>
        </StackPanel>
    </Grid>
</pu:WindowX>
Plugin.DeviceConfig/BatchGenerateModal.xaml.cs
New file
@@ -0,0 +1,252 @@
using HMI.UI.Util;
using Panuon.WPF.UI;
using Plugin.DeviceConfig.Entity;
using Plugin.DeviceConfig.Model;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows;
namespace Plugin.DeviceConfig
{
    /// <summary>
    /// BatchGenerateModal.xaml 的交互逻辑
    /// </summary>
    public partial class BatchGenerateModal : WindowX
    {
        private DeviceConfigModel _model;
        private ObservableCollection<BatchPreviewItem> _previewItems = new();
        public BatchGenerateModal()
        {
            DataContext = this;
            InitializeComponent();
        }
        /// <summary>
        /// 打开弹窗
        /// </summary>
        public bool? Open(DeviceConfigModel model)
        {
            _model = model;
            // 初始化枚举下拉
            cbDeviceType.ItemsSource = Enum.GetValues(typeof(DeviceKind)).Cast<DeviceKind>().ToList();
            cbTransport.ItemsSource = Enum.GetValues(typeof(TransportType)).Cast<TransportType>().ToList();
            // 默认值
            cbDeviceType.SelectedIndex = 0;
            cbTransport.SelectedIndex = 0;
            tbAddressRule.Text = "192.168.10.{0}";
            tbSlotStart.Text = "Slot001";
            tbDeviceIdPrefix.Text = "DEV";
            previewListView.ItemsSource = _previewItems;
            return ShowDialog();
        }
        #region 预览
        private void PreviewBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                GeneratePreview();
            }
            catch (Exception ex)
            {
                tbConflictMsg.Text = $"生成预览失败: {ex.Message}";
                tbConflictMsg.Visibility = Visibility.Visible;
            }
        }
        private void GeneratePreview()
        {
            _previewItems.Clear();
            tbConflictMsg.Visibility = Visibility.Collapsed;
            if (cbDeviceType.SelectedItem is not DeviceKind kind)
            {
                ShowConflict("请选择设备类型");
                return;
            }
            if (cbTransport.SelectedItem is not TransportType transport)
            {
                ShowConflict("请选择传输方式");
                return;
            }
            int count = (int)(niDeviceCount.Value ?? 1);
            int channelsPerDevice = (int)(niChannelsPerDevice.Value ?? 1);
            int startPort = (int)(niStartPort.Value ?? 30000);
            string addressRule = tbAddressRule.Text?.Trim() ?? string.Empty;
            string slotStart = tbSlotStart.Text?.Trim() ?? string.Empty;
            string prefix = tbDeviceIdPrefix.Text?.Trim() ?? "DEV";
            string driverType = tbDriverType.Text?.Trim() ?? string.Empty;
            // 检测已有设备ID
            var existingDevices = _model.GetAllDevices();
            var existingIds = new HashSet<string>(existingDevices.Select(d => d.DeviceId));
            var existingEndpoints = new HashSet<string>(existingDevices.Select(d => d.Endpoint));
            var conflicts = new List<string>();
            var generatedIds = new HashSet<string>();
            var generatedEndpoints = new HashSet<string>();
            for (int i = 1; i <= count; i++)
            {
                var deviceId = $"{prefix}{i:D3}";
                var endpoint = string.Format(addressRule, i);
                if (transport == TransportType.Tcp && !string.IsNullOrEmpty(addressRule))
                {
                    endpoint = $"{endpoint}:{startPort + i - 1}";
                }
                else if (transport == TransportType.Serial)
                {
                    endpoint = $"COM{i}";
                }
                // 解析 SlotStart 的序号部分并递增
                var slotPrefix = new string(slotStart.TakeWhile(c => !char.IsDigit(c)).ToArray());
                var slotNumStr = new string(slotStart.SkipWhile(c => !char.IsDigit(c)).ToArray());
                int.TryParse(slotNumStr, out int startNum);
                var slotId = $"{slotPrefix}{(startNum + i - 1):D3}";
                // 检测冲突
                if (existingIds.Contains(deviceId) || generatedIds.Contains(deviceId))
                    conflicts.Add($"设备ID冲突: {deviceId}");
                if (existingEndpoints.Contains(endpoint) || generatedEndpoints.Contains(endpoint))
                    conflicts.Add($"Endpoint冲突: {endpoint}");
                generatedIds.Add(deviceId);
                generatedEndpoints.Add(endpoint);
                _previewItems.Add(new BatchPreviewItem
                {
                    DeviceId = deviceId,
                    Kind = kind,
                    DriverType = driverType,
                    Transport = transport,
                    Endpoint = endpoint,
                    ChannelCount = channelsPerDevice,
                    SlotStart = slotId,
                });
            }
            if (conflicts.Count > 0)
            {
                ShowConflict(string.Join("\n", conflicts));
            }
        }
        private void ShowConflict(string msg)
        {
            tbConflictMsg.Text = msg;
            tbConflictMsg.Visibility = Visibility.Visible;
        }
        #endregion
        #region 保存
        private void SaveBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                if (_previewItems.Count == 0)
                {
                    ToastHelper.Warning("请先生成预览");
                    return;
                }
                if (tbConflictMsg.Visibility == Visibility.Visible)
                {
                    if (MsgBoxHelper.WarnMessage(this, "存在冲突项,是否仍要继续保存?") != MessageBoxResult.OK)
                    {
                        return;
                    }
                }
                int channelsPerDevice = (int)(niChannelsPerDevice.Value ?? 1);
                string slotStart = tbSlotStart.Text?.Trim() ?? string.Empty;
                foreach (var item in _previewItems)
                {
                    // 保存设备
                    var device = new DeviceDefinition
                    {
                        DeviceId = item.DeviceId,
                        Kind = item.Kind,
                        DriverType = item.DriverType,
                        Transport = item.Transport,
                        Endpoint = item.Endpoint,
                        CreatedAt = DateTime.Now,
                        UpdatedAt = DateTime.Now,
                    };
                    _model.SaveDevice(device);
                    // 保存通道
                    for (int ch = 0; ch < channelsPerDevice; ch++)
                    {
                        var channel = new DeviceChannel
                        {
                            DeviceId = item.DeviceId,
                            ChannelId = ch,
                            Capability = $"CH{ch}",
                            MaxVoltage = 0,
                            MaxCurrent = 0,
                        };
                        _model.SaveChannel(channel);
                    }
                    // 保存 Slot 映射
                    if (!string.IsNullOrEmpty(item.SlotStart))
                    {
                        var mapping = new SlotDeviceMapping
                        {
                            SlotId = item.SlotStart,
                            Capability = "Default",
                            Version = "1.0",
                            DeviceId = item.DeviceId,
                            ChannelId = 0,
                            MappingVersion = "1.0",
                        };
                        _model.SaveSlotMapping(mapping);
                    }
                }
                ToastHelper.Success($"成功生成 {_previewItems.Count} 个设备");
                DialogResult = true;
                Close();
            }
            catch (Exception ex)
            {
                ToastHelper.Error($"保存失败: {ex.Message}");
            }
        }
        private void CancelBtn_Click(object sender, RoutedEventArgs e)
        {
            DialogResult = false;
            Close();
        }
        #endregion
    }
    /// <summary>
    /// 批量生成预览项
    /// </summary>
    public class BatchPreviewItem
    {
        public string DeviceId { get; set; } = string.Empty;
        public DeviceKind Kind { get; set; }
        public string DriverType { get; set; } = string.Empty;
        public TransportType Transport { get; set; }
        public string Endpoint { get; set; } = string.Empty;
        public int ChannelCount { get; set; }
        public string SlotStart { get; set; } = string.Empty;
    }
}
Plugin.DeviceConfig/DeviceConfig.xaml
New file
@@ -0,0 +1,293 @@
<hb:SimpleMaskControl x:Class="Plugin.DeviceConfig.DeviceConfig"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:hb="clr-namespace:HMI.Base;assembly=HMI.Base"
             xmlns:ui="clr-namespace:HMI.UI;assembly=HMI.UI"
             xmlns:pw="https://opensource.panuon.com/wpf"
             xmlns:pu="https://opensource.panuon.com/wpf-ui"
             xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
             xmlns:local="clr-namespace:Plugin.DeviceConfig"
             mc:Ignorable="d"
             Loaded="SimpleMaskControl_Loaded"
             d:DesignHeight="700" d:DesignWidth="1200">
    <hb:SimpleMaskControl.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/HMI.UI;component/Style/PanuonListView.xaml"/>
            </ResourceDictionary.MergedDictionaries>
            <Style x:Key="TitleBox" TargetType="TextBlock">
                <Setter Property="FontSize" Value="14"/>
                <Setter Property="FontWeight" Value="SemiBold"/>
                <Setter Property="Padding" Value="8,4"/>
                <Setter Property="VerticalAlignment" Value="Center"/>
            </Style>
        </ResourceDictionary>
    </hb:SimpleMaskControl.Resources>
    <Grid dp:GridHelper.Space="8">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <!-- 顶部工具栏 -->
        <DockPanel Grid.Row="0">
            <StackPanel dp:StackPanelHelper.ChildSpace="10" DockPanel.Dock="Right" Orientation="Horizontal">
                <Button Style="{StaticResource AddBtn}" x:Name="addBtn" Click="Button_Click"/>
                <Button Style="{StaticResource EditBtn}" x:Name="editBtn" Content="编辑" Click="Button_Click"/>
                <Button Style="{StaticResource DelBtn}" x:Name="delBtn" Content="删除" Click="Button_Click"/>
                <Button x:Name="batchBtn" Click="Button_Click" Content="批量生成"
                        Style="{StaticResource AddBtn}"/>
                <Button Style="{StaticResource ImportBtn}" x:Name="importBtn" Click="Button_Click"/>
                <Button Style="{StaticResource ExportBtn}" x:Name="exportBtn" Click="Button_Click"/>
                <Button x:Name="versionBtn" Click="Button_Click" Content="版本管理"
                        Style="{StaticResource RfhBtn}"/>
                <Button Style="{StaticResource RfhBtn}" x:Name="rfhBtn" Click="Button_Click"/>
            </StackPanel>
            <TextBlock Style="{StaticResource TitleBox}" Text="仪器仪表注册"/>
        </DockPanel>
        <!-- 主体三栏区域 -->
        <Grid Grid.Row="1">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="250"/>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="300"/>
            </Grid.ColumnDefinitions>
            <!-- 左栏:设备分类树 -->
            <Border Grid.Column="0" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="4" Margin="0,0,4,0">
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinition Height="Auto"/>
                        <RowDefinition Height="*"/>
                    </Grid.RowDefinitions>
                    <TextBox x:Name="searchBox" Grid.Row="0" Margin="6"
                             pu:TextBoxHelper.Watermark="搜索设备..."
                             Style="{StaticResource EditorTextBoxStyle}"
                             TextChanged="SearchBox_TextChanged"/>
                    <TreeView x:Name="deviceTree" Grid.Row="1" Margin="4"
                              SelectedItemChanged="DeviceTree_SelectedItemChanged">
                        <TreeView.ItemTemplate>
                            <HierarchicalDataTemplate ItemsSource="{Binding Children}">
                                <TextBlock Text="{Binding Name}" FontWeight="SemiBold"/>
                            </HierarchicalDataTemplate>
                        </TreeView.ItemTemplate>
                    </TreeView>
                </Grid>
            </Border>
            <!-- 中栏:设备详情 -->
            <Border Grid.Column="1" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="4" Margin="2,0,2,0">
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinition Height="Auto"/>
                        <RowDefinition Height="*"/>
                    </Grid.RowDefinitions>
                    <!-- 设备基本信息表单 -->
                    <GroupBox Grid.Row="0" Header="设备信息" Margin="6" FontWeight="SemiBold">
                        <Grid Margin="8">
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="120"/>
                                <ColumnDefinition Width="*"/>
                                <ColumnDefinition Width="120"/>
                                <ColumnDefinition Width="*"/>
                            </Grid.ColumnDefinitions>
                            <Grid.RowDefinitions>
                                <RowDefinition Height="36"/>
                                <RowDefinition Height="36"/>
                                <RowDefinition Height="36"/>
                            </Grid.RowDefinitions>
                            <TextBlock Grid.Row="0" Grid.Column="0" Text="设备ID" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                            <TextBox Grid.Row="0" Grid.Column="1" x:Name="tbDeviceId" VerticalAlignment="Center"
                                     Style="{StaticResource EditorTextBoxStyle}" IsReadOnly="True" Margin="4,0"/>
                            <TextBlock Grid.Row="0" Grid.Column="2" Text="设备类型" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                            <TextBox Grid.Row="0" Grid.Column="3" x:Name="tbKind" VerticalAlignment="Center"
                                     Style="{StaticResource EditorTextBoxStyle}" IsReadOnly="True" Margin="4,0"/>
                            <TextBlock Grid.Row="1" Grid.Column="0" Text="驱动类型" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                            <TextBox Grid.Row="1" Grid.Column="1" x:Name="tbDriverType" VerticalAlignment="Center"
                                     Style="{StaticResource EditorTextBoxStyle}" IsReadOnly="True" Margin="4,0"/>
                            <TextBlock Grid.Row="1" Grid.Column="2" Text="传输方式" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                            <TextBox Grid.Row="1" Grid.Column="3" x:Name="tbTransport" VerticalAlignment="Center"
                                     Style="{StaticResource EditorTextBoxStyle}" IsReadOnly="True" Margin="4,0"/>
                            <TextBlock Grid.Row="2" Grid.Column="0" Text="连接端点" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                            <TextBox Grid.Row="2" Grid.Column="1" x:Name="tbEndpoint" VerticalAlignment="Center"
                                     Style="{StaticResource EditorTextBoxStyle}" IsReadOnly="True" Margin="4,0"/>
                            <TextBlock Grid.Row="2" Grid.Column="2" Text="生效版本" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                            <TextBox Grid.Row="2" Grid.Column="3" x:Name="tbEffectiveVersion" VerticalAlignment="Center"
                                     Style="{StaticResource EditorTextBoxStyle}" IsReadOnly="True" Margin="4,0"/>
                        </Grid>
                    </GroupBox>
                    <!-- 通道映射表 -->
                    <Grid Grid.Row="1" Margin="6">
                        <Grid.RowDefinitions>
                            <RowDefinition Height="Auto"/>
                            <RowDefinition Height="*"/>
                        </Grid.RowDefinitions>
                        <TextBlock Grid.Row="0" Text="通道列表" FontWeight="SemiBold" Margin="0,0,0,4"/>
                        <ListView Grid.Row="1" Style="{StaticResource DefaultListTable}" x:Name="channelListView"
                                  ItemsSource="{Binding Channels}"
                                  VirtualizingPanel.IsVirtualizing="True"
                                  VirtualizingPanel.VirtualizationMode="Standard">
                            <ListView.View>
                                <GridView AllowsColumnReorder="False">
                                    <GridViewColumn Width="50" Header="#">
                                        <GridViewColumn.CellTemplate>
                                            <DataTemplate>
                                                <Border Style="{StaticResource CellBorder}">
                                                    <TextBlock Style="{StaticResource ListTableTextBlock}"
                                                        Text="{Binding Converter={StaticResource lvRowNumber},
                                                        RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=ListViewItem,AncestorLevel=1}}"/>
                                                </Border>
                                            </DataTemplate>
                                        </GridViewColumn.CellTemplate>
                                    </GridViewColumn>
                                    <GridViewColumn Width="150" Header="DeviceId">
                                        <GridViewColumn.CellTemplate>
                                            <DataTemplate>
                                                <Border Style="{StaticResource CellBorder}">
                                                    <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding DeviceId}"/>
                                                </Border>
                                            </DataTemplate>
                                        </GridViewColumn.CellTemplate>
                                    </GridViewColumn>
                                    <GridViewColumn Width="80" Header="ChannelId">
                                        <GridViewColumn.CellTemplate>
                                            <DataTemplate>
                                                <Border Style="{StaticResource CellBorder}">
                                                    <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding ChannelId}"/>
                                                </Border>
                                            </DataTemplate>
                                        </GridViewColumn.CellTemplate>
                                    </GridViewColumn>
                                    <GridViewColumn Width="200" Header="Capability">
                                        <GridViewColumn.CellTemplate>
                                            <DataTemplate>
                                                <Border Style="{StaticResource CellBorder}">
                                                    <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding Capability}"/>
                                                </Border>
                                            </DataTemplate>
                                        </GridViewColumn.CellTemplate>
                                    </GridViewColumn>
                                    <GridViewColumn Width="100" Header="MaxVoltage">
                                        <GridViewColumn.CellTemplate>
                                            <DataTemplate>
                                                <Border Style="{StaticResource CellBorder}">
                                                    <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding MaxVoltage, StringFormat=F2}"/>
                                                </Border>
                                            </DataTemplate>
                                        </GridViewColumn.CellTemplate>
                                    </GridViewColumn>
                                    <GridViewColumn Width="100" Header="MaxCurrent">
                                        <GridViewColumn.CellTemplate>
                                            <DataTemplate>
                                                <Border Style="{StaticResource CellBorder}">
                                                    <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding MaxCurrent, StringFormat=F2}"/>
                                                </Border>
                                            </DataTemplate>
                                        </GridViewColumn.CellTemplate>
                                    </GridViewColumn>
                                </GridView>
                            </ListView.View>
                        </ListView>
                    </Grid>
                </Grid>
            </Border>
            <!-- 右栏:Slot映射表 -->
            <Border Grid.Column="2" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="4" Margin="4,0,0,0">
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinition Height="Auto"/>
                        <RowDefinition Height="*"/>
                    </Grid.RowDefinitions>
                    <TextBlock Grid.Row="0" Text="Slot 映射" FontWeight="SemiBold" Margin="6"/>
                    <ListView Grid.Row="1" Style="{StaticResource DefaultListTable}" x:Name="slotListView"
                              ItemsSource="{Binding SlotMappings}"
                              VirtualizingPanel.IsVirtualizing="True"
                              VirtualizingPanel.VirtualizationMode="Standard"
                              Margin="4">
                        <ListView.View>
                            <GridView AllowsColumnReorder="False">
                                <GridViewColumn Width="80" Header="SlotId">
                                    <GridViewColumn.CellTemplate>
                                        <DataTemplate>
                                            <Border Style="{StaticResource CellBorder}">
                                                <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding SlotId}"/>
                                            </Border>
                                        </DataTemplate>
                                    </GridViewColumn.CellTemplate>
                                </GridViewColumn>
                                <GridViewColumn Width="80" Header="Capability">
                                    <GridViewColumn.CellTemplate>
                                        <DataTemplate>
                                            <Border Style="{StaticResource CellBorder}">
                                                <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding Capability}"/>
                                            </Border>
                                        </DataTemplate>
                                    </GridViewColumn.CellTemplate>
                                </GridViewColumn>
                                <GridViewColumn Width="100" Header="DeviceId">
                                    <GridViewColumn.CellTemplate>
                                        <DataTemplate>
                                            <Border Style="{StaticResource CellBorder}">
                                                <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding DeviceId}"/>
                                            </Border>
                                        </DataTemplate>
                                    </GridViewColumn.CellTemplate>
                                </GridViewColumn>
                                <GridViewColumn Width="60" Header="ChId">
                                    <GridViewColumn.CellTemplate>
                                        <DataTemplate>
                                            <Border Style="{StaticResource CellBorder}">
                                                <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding ChannelId}"/>
                                            </Border>
                                        </DataTemplate>
                                    </GridViewColumn.CellTemplate>
                                </GridViewColumn>
                                <GridViewColumn Width="80" Header="MapVer">
                                    <GridViewColumn.CellTemplate>
                                        <DataTemplate>
                                            <Border Style="{StaticResource CellBorder}">
                                                <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding MappingVersion}"/>
                                            </Border>
                                        </DataTemplate>
                                    </GridViewColumn.CellTemplate>
                                </GridViewColumn>
                            </GridView>
                        </ListView.View>
                    </ListView>
                </Grid>
            </Border>
        </Grid>
        <!-- 底部状态栏 -->
        <DockPanel Grid.Row="2" Margin="0,4,0,0">
            <StackPanel DockPanel.Dock="Left" Orientation="Horizontal" VerticalAlignment="Center" dp:StackPanelHelper.ChildSpace="6">
                <TextBlock Text="当前版本状态:" FontWeight="SemiBold" VerticalAlignment="Center"/>
                <Border Background="{DynamicResource SecondPrimaryColor}" CornerRadius="3" Padding="8,2">
                    <TextBlock x:Name="tbVersionStatus" Text="{Binding CurrentVersionStatus}" Foreground="White" FontWeight="Bold"/>
                </Border>
            </StackPanel>
            <StackPanel DockPanel.Dock="Right" Orientation="Horizontal" HorizontalAlignment="Right" dp:StackPanelHelper.ChildSpace="10">
                <Button x:Name="saveDraftBtn" Click="Button_Click" Content="保存草稿"
                        Style="{StaticResource RfhBtn}" Width="90"/>
                <Button x:Name="publishBtn" Click="Button_Click" Content="发布"
                        Style="{StaticResource AddBtn}" Width="70"/>
                <Button x:Name="applyBtn" Click="Button_Click" Content="应用"
                        Style="{StaticResource AddBtn}" Width="70"/>
            </StackPanel>
        </DockPanel>
    </Grid>
</hb:SimpleMaskControl>
Plugin.DeviceConfig/DeviceConfig.xaml.cs
New file
@@ -0,0 +1,611 @@
using CommunityToolkit.Mvvm.ComponentModel;
using HMI.Base;
using HMI.Core.Util;
using HMI.UI.Util;
using Plugin.DeviceConfig.Entity;
using Plugin.DeviceConfig.Model;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
namespace Plugin.DeviceConfig
{
    /// <summary>
    /// DeviceConfig.xaml 的交互逻辑
    /// </summary>
    [ObservableObject]
    public partial class DeviceConfig : SimpleMaskControl
    {
        private DeviceConfigModel _model;
        [ObservableProperty]
        private ObservableCollection<DeviceDefinition> devices = new();
        [ObservableProperty]
        private ObservableCollection<DeviceChannel> channels = new();
        [ObservableProperty]
        private ObservableCollection<SlotDeviceMapping> slotMappings = new();
        [ObservableProperty]
        private DeviceDefinition selectedDevice;
        [ObservableProperty]
        private string currentVersionStatus = "Draft";
        [ObservableProperty]
        private ObservableCollection<TreeNode> treeCategories = new();
        public DeviceConfig()
        {
            DataContext = this;
            InitializeComponent();
        }
        /// <summary>
        /// 强制刷新数据(供集成测试预置设备后调用)
        /// </summary>
        public void ForceRefreshForTest()
        {
            try { RefreshAllData(); }
            catch (Exception ex) { log.Error("ForceRefreshForTest Error.", ex); }
        }
        public override void InitFinish()
        {
            base.InitFinish();
            try
            {
                _model = new DeviceConfigModel();
                var dbPath = Path.Combine(BaseFolder, "data.db");
                _model.InitDatabase(dbPath);
                LoadTreeCategories();
                RefreshAllData();
            }
            catch (Exception ex)
            {
                log.Error("InitFinish Error.", ex);
                ToastHelper.Error($"初始化失败: {ex.Message}");
            }
        }
        #region 树分类加载
        private void LoadTreeCategories()
        {
            TreeCategories = new ObservableCollection<TreeNode>
            {
                new TreeNode { Name = "全部设备", Kind = null, Children = new ObservableCollection<TreeNode>() },
                new TreeNode { Name = "电源系统", Kind = DeviceKind.PowerSupply, Children = new ObservableCollection<TreeNode>() },
                new TreeNode { Name = "CAN/LIN", Kind = DeviceKind.CanDevice, Children = new ObservableCollection<TreeNode>() },
                new TreeNode { Name = "PLC/IO", Kind = DeviceKind.PlcDevice, Children = new ObservableCollection<TreeNode>() },
                new TreeNode { Name = "温箱/DAQ", Kind = null, Children = new ObservableCollection<TreeNode>(),
                    FilterKinds = new[] { DeviceKind.Chamber, DeviceKind.DAQ } },
            };
            deviceTree.ItemsSource = TreeCategories;
        }
        #endregion
        #region 数据加载
        private void RefreshAllData()
        {
            try
            {
                var deviceList = _model.GetAllDevices();
                Devices = new ObservableCollection<DeviceDefinition>(deviceList);
                SlotMappings = new ObservableCollection<SlotDeviceMapping>(_model.GetSlotMappings());
                RefreshTreeDevices();
                LoadLatestVersionStatus();
            }
            catch (Exception ex)
            {
                log.Error("RefreshAllData Error.", ex);
                ToastHelper.Error($"加载数据失败: {ex.Message}");
            }
        }
        private void RefreshTreeDevices()
        {
            foreach (var cat in TreeCategories)
            {
                cat.Children.Clear();
                IEnumerable<DeviceDefinition> filtered = Devices;
                if (cat.Kind.HasValue)
                {
                    filtered = Devices.Where(d => d.Kind == cat.Kind.Value);
                }
                else if (cat.FilterKinds != null)
                {
                    filtered = Devices.Where(d => cat.FilterKinds.Contains(d.Kind));
                }
                foreach (var dev in filtered)
                {
                    cat.Children.Add(new TreeNode { Name = $"{dev.DeviceId} ({dev.Endpoint})", Tag = dev });
                }
            }
        }
        private void LoadLatestVersionStatus()
        {
            try
            {
                var versions = _model.GetConfigVersions();
                var effective = versions.FirstOrDefault(v => v.Status == VersionStatus.Effective);
                if (effective != null)
                {
                    CurrentVersionStatus = "Effective";
                }
                else if (versions.Any(v => v.Status == VersionStatus.Published))
                {
                    CurrentVersionStatus = "Published";
                }
                else
                {
                    CurrentVersionStatus = "Draft";
                }
            }
            catch (Exception ex)
            {
                log.Error("LoadLatestVersionStatus Error.", ex);
            }
        }
        #endregion
        #region 事件处理
        private void SimpleMaskControl_Loaded(object sender, RoutedEventArgs e)
        {
            // InitFinish 已加载数据,此处仅做一次性 UI 初始化
            Loaded -= SimpleMaskControl_Loaded;
        }
        private void DeviceTree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
        {
            if (e.NewValue is TreeNode node && node.Tag is DeviceDefinition dev)
            {
                SelectDevice(dev);
            }
        }
        private void SearchBox_TextChanged(object sender, TextChangedEventArgs e)
        {
            try
            {
                var keyword = searchBox.Text?.Trim() ?? string.Empty;
                if (string.IsNullOrEmpty(keyword))
                {
                    RefreshTreeDevices();
                    return;
                }
                foreach (var cat in TreeCategories)
                {
                    cat.Children.Clear();
                    var matched = Devices.Where(d =>
                        d.DeviceId.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0 ||
                        d.Endpoint.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0 ||
                        d.DriverType.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0);
                    if (cat.Kind.HasValue)
                        matched = matched.Where(d => d.Kind == cat.Kind.Value);
                    else if (cat.FilterKinds != null)
                        matched = matched.Where(d => cat.FilterKinds.Contains(d.Kind));
                    foreach (var dev in matched)
                        cat.Children.Add(new TreeNode { Name = $"{dev.DeviceId} ({dev.Endpoint})", Tag = dev });
                }
            }
            catch (Exception ex)
            {
                log.Error("SearchBox_TextChanged Error.", ex);
            }
        }
        private void SelectDevice(DeviceDefinition dev)
        {
            try
            {
                SelectedDevice = dev;
                tbDeviceId.Text = dev.DeviceId;
                tbKind.Text = dev.Kind.ToString();
                tbDriverType.Text = dev.DriverType;
                tbTransport.Text = dev.Transport.ToString();
                tbEndpoint.Text = dev.Endpoint;
                tbEffectiveVersion.Text = dev.EffectiveVersion;
                var chList = _model.GetChannelsByDevice(dev.DeviceId);
                Channels = new ObservableCollection<DeviceChannel>(chList);
            }
            catch (Exception ex)
            {
                log.Error("SelectDevice Error.", ex);
                ToastHelper.Error($"加载设备详情失败: {ex.Message}");
            }
        }
        private void Button_Click(object sender, RoutedEventArgs e)
        {
            Button btn = sender as Button;
            try
            {
                switch (btn?.Name)
                {
                    case "addBtn":
                        OpenDeviceEditModal(null);
                        break;
                    case "editBtn":
                        EditSelectedDevice();
                        break;
                    case "delBtn":
                        DeleteSelectedDevice();
                        break;
                    case "batchBtn":
                        OpenBatchGenerateModal();
                        break;
                    case "importBtn":
                        ImportDevices();
                        break;
                    case "exportBtn":
                        ExportDevices();
                        break;
                    case "versionBtn":
                        OpenVersionManageModal();
                        break;
                    case "rfhBtn":
                        RefreshAllData();
                        ToastHelper.Success("刷新成功");
                        break;
                    case "saveDraftBtn":
                        SaveDraft();
                        break;
                    case "publishBtn":
                        PublishVersion();
                        break;
                    case "applyBtn":
                        ApplyVersion();
                        break;
                }
            }
            catch (Exception ex)
            {
                log.Error("Button_Click Error.", ex);
                ToastHelper.Error($"操作失败: {ex.Message}");
            }
        }
        #endregion
        #region 弹窗
        private void OpenDeviceEditModal(DeviceDefinition device)
        {
            try
            {
                device ??= new DeviceDefinition();
                var win = new DeviceEditModal
                {
                    Title = string.IsNullOrEmpty(device.DeviceId) ? "新增设备" : "编辑设备",
                    Owner = Window.GetWindow(this)
                };
                if (win.Open(device, _model) == true)
                {
                    RefreshAllData();
                }
            }
            catch (Exception ex)
            {
                log.Error("OpenDeviceEditModal Error.", ex);
                ToastHelper.Error($"打开编辑弹窗失败: {ex.Message}");
            }
        }
        /// <summary>
        /// 编辑当前选中的设备
        /// </summary>
        private void EditSelectedDevice()
        {
            try
            {
                if (SelectedDevice == null)
                {
                    ToastHelper.Warning("请先在左侧设备树中选择一个设备");
                    return;
                }
                // 使用当前设备信息的副本避免直接修改内部数据
                var editingDevice = new DeviceDefinition
                {
                    DeviceId = SelectedDevice.DeviceId,
                    Kind = SelectedDevice.Kind,
                    DriverType = SelectedDevice.DriverType,
                    Transport = SelectedDevice.Transport,
                    Endpoint = SelectedDevice.Endpoint,
                    EffectiveVersion = SelectedDevice.EffectiveVersion,
                    CreatedAt = SelectedDevice.CreatedAt,
                    UpdatedAt = SelectedDevice.UpdatedAt
                };
                var win = new DeviceEditModal
                {
                    Title = $"编辑设备 - {SelectedDevice.DeviceId}",
                    Owner = Window.GetWindow(this)
                };
                if (win.Open(editingDevice, _model) == true)
                {
                    RefreshAllData();
                    ToastHelper.Success($"设备 {editingDevice.DeviceId} 已更新");
                }
            }
            catch (Exception ex)
            {
                log.Error("EditSelectedDevice Error.", ex);
                ToastHelper.Error($"编辑设备失败: {ex.Message}");
            }
        }
        /// <summary>
        /// 删除当前选中的设备(同时清理相关通道与 Slot 映射)
        /// </summary>
        private void DeleteSelectedDevice()
        {
            try
            {
                if (SelectedDevice == null)
                {
                    ToastHelper.Warning("请先在左侧设备树中选择一个设备");
                    return;
                }
                var deviceId = SelectedDevice.DeviceId;
                if (MsgBoxHelper.WarnMessage(Window.GetWindow(this),
                    $"确定要删除设备 [{deviceId}] 吗?\n该操作将同时删除相关的通道与 Slot 映射,且不可恢复。") != MessageBoxResult.OK)
                {
                    return;
                }
                // 先删除关联的通道与 Slot 映射
                var channels = _model.GetChannelsByDevice(deviceId);
                foreach (var ch in channels)
                {
                    _model.DeleteChannel(deviceId, ch.ChannelId);
                }
                var mappings = _model.GetSlotMappings();
                foreach (var m in mappings.Where(x => x.DeviceId == deviceId))
                {
                    _model.DeleteSlotMapping(m.SlotId, m.Capability, m.Version);
                }
                // 删除设备本身
                _model.DeleteDevice(deviceId);
                SelectedDevice = null;
                ClearDeviceDetails();
                RefreshAllData();
                ToastHelper.Success($"设备 {deviceId} 已删除");
            }
            catch (Exception ex)
            {
                log.Error("DeleteSelectedDevice Error.", ex);
                ToastHelper.Error($"删除设备失败: {ex.Message}");
            }
        }
        /// <summary>
        /// 清空中栏设备详情展示
        /// </summary>
        private void ClearDeviceDetails()
        {
            tbDeviceId.Text = string.Empty;
            tbKind.Text = string.Empty;
            tbDriverType.Text = string.Empty;
            tbTransport.Text = string.Empty;
            tbEndpoint.Text = string.Empty;
            tbEffectiveVersion.Text = string.Empty;
            Channels = new ObservableCollection<DeviceChannel>();
        }
        private void OpenBatchGenerateModal()
        {
            try
            {
                var win = new BatchGenerateModal
                {
                    Title = "批量生成设备",
                    Owner = Window.GetWindow(this)
                };
                if (win.Open(_model) == true)
                {
                    RefreshAllData();
                }
            }
            catch (Exception ex)
            {
                log.Error("OpenBatchGenerateModal Error.", ex);
                ToastHelper.Error($"打开批量生成弹窗失败: {ex.Message}");
            }
        }
        private void OpenVersionManageModal()
        {
            try
            {
                var win = new VersionManageModal
                {
                    Title = "版本管理",
                    Owner = Window.GetWindow(this)
                };
                if (win.Open(_model) == true)
                {
                    RefreshAllData();
                }
            }
            catch (Exception ex)
            {
                log.Error("OpenVersionManageModal Error.", ex);
                ToastHelper.Error($"打开版本管理弹窗失败: {ex.Message}");
            }
        }
        #endregion
        #region 版本操作
        private void SaveDraft()
        {
            try
            {
                var versionId = $"V{DateTime.Now:yyyyMMddHHmmss}";
                var hash = _model.ComputeConfigHash();
                var ver = new DeviceConfigVersion
                {
                    VersionId = versionId,
                    Status = VersionStatus.Draft,
                    Hash = hash,
                    Author = Environment.UserName,
                    Timestamp = DateTime.Now,
                    DiffDescription = "保存草稿"
                };
                _model.SaveConfigVersion(ver);
                CurrentVersionStatus = "Draft";
                ToastHelper.Success($"草稿已保存: {versionId}");
            }
            catch (Exception ex)
            {
                log.Error("SaveDraft Error.", ex);
                ToastHelper.Error($"保存草稿失败: {ex.Message}");
            }
        }
        private void PublishVersion()
        {
            try
            {
                var versions = _model.GetConfigVersions();
                var draft = versions.FirstOrDefault(v => v.Status == VersionStatus.Draft);
                if (draft == null)
                {
                    ToastHelper.Warning("没有可发布的草稿版本");
                    return;
                }
                var hash = _model.ComputeConfigHash();
                draft.Hash = hash;
                draft.Status = VersionStatus.Published;
                draft.Timestamp = DateTime.Now;
                _model.SaveConfigVersion(draft);
                CurrentVersionStatus = "Published";
                ToastHelper.Success($"版本 {draft.VersionId} 已发布");
            }
            catch (Exception ex)
            {
                log.Error("PublishVersion Error.", ex);
                ToastHelper.Error($"发布失败: {ex.Message}");
            }
        }
        private void ApplyVersion()
        {
            try
            {
                var versions = _model.GetConfigVersions();
                var published = versions.FirstOrDefault(v => v.Status == VersionStatus.Published);
                if (published == null)
                {
                    ToastHelper.Warning("没有可应用的已发布版本");
                    return;
                }
                var effective = versions.FirstOrDefault(v => v.Status == VersionStatus.Effective);
                if (effective != null)
                {
                    if (MsgBoxHelper.WarnMessage(Window.GetWindow(this),
                        $"当前已有生效版本 [{effective.VersionId}],应用新版本将替换它,是否继续?") != MessageBoxResult.OK)
                    {
                        return;
                    }
                    effective.Status = VersionStatus.Published;
                    _model.SaveConfigVersion(effective);
                }
                published.Status = VersionStatus.Effective;
                published.Timestamp = DateTime.Now;
                _model.SaveConfigVersion(published);
                CurrentVersionStatus = "Effective";
                ToastHelper.Success($"版本 {published.VersionId} 已应用为生效版本");
            }
            catch (Exception ex)
            {
                log.Error("ApplyVersion Error.", ex);
                ToastHelper.Error($"应用版本失败: {ex.Message}");
            }
        }
        #endregion
        #region 导入导出
        private void ImportDevices()
        {
            try
            {
                var ofd = new Microsoft.Win32.OpenFileDialog
                {
                    Filter = "XML文件|*.xml|JSON文件|*.json"
                };
                if (ofd.ShowDialog() == true)
                {
                    // TODO: 实现导入逻辑
                    ToastHelper.Success($"已从 {Path.GetFileName(ofd.FileName)} 导入");
                    RefreshAllData();
                }
            }
            catch (Exception ex)
            {
                log.Error("ImportDevices Error.", ex);
                ToastHelper.Error($"导入失败: {ex.Message}");
            }
        }
        private void ExportDevices()
        {
            try
            {
                var sfd = new Microsoft.Win32.SaveFileDialog
                {
                    FileName = "DeviceConfig",
                    Filter = "XML文件|*.xml"
                };
                if (sfd.ShowDialog() == true)
                {
                    // TODO: 实现导出逻辑
                    ToastHelper.Success("导出成功");
                }
            }
            catch (Exception ex)
            {
                log.Error("ExportDevices Error.", ex);
                ToastHelper.Error($"导出失败: {ex.Message}");
            }
        }
        #endregion
    }
    /// <summary>
    /// 树节点模型
    /// </summary>
    public class TreeNode
    {
        public string Name { get; set; } = string.Empty;
        public DeviceKind? Kind { get; set; }
        public DeviceKind[] FilterKinds { get; set; }
        public object Tag { get; set; }
        public ObservableCollection<TreeNode> Children { get; set; } = new();
    }
}
Plugin.DeviceConfig/DeviceEditModal.xaml
New file
@@ -0,0 +1,182 @@
<pu:WindowX x:Class="Plugin.DeviceConfig.DeviceEditModal"
            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:pw="https://opensource.panuon.com/wpf"
            xmlns:pu="https://opensource.panuon.com/wpf-ui"
            xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
            xmlns:local="clr-namespace:Plugin.DeviceConfig"
            mc:Ignorable="d"
            Title="设备编辑"
            SizeToContent="Height"
            Width="720"
            WindowStartupLocation="CenterOwner"
            Style="{StaticResource ModalWindow}">
    <pu:WindowX.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/HMI.UI;component/Style/PanuonListView.xaml"/>
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </pu:WindowX.Resources>
    <Grid Margin="16">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <!-- 设备基本信息 -->
        <GroupBox Grid.Row="0" Header="设备信息" FontWeight="SemiBold" Margin="0,0,0,12">
            <Grid Margin="8">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="100"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="100"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                <Grid.RowDefinitions>
                    <RowDefinition Height="36"/>
                    <RowDefinition Height="36"/>
                    <RowDefinition Height="36"/>
                </Grid.RowDefinitions>
                <TextBlock Grid.Row="0" Grid.Column="0" Text="设备ID *" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                <TextBox Grid.Row="0" Grid.Column="1" x:Name="tbDeviceId" VerticalAlignment="Center"
                         Style="{StaticResource EditorTextBoxStyle}" Margin="4,0"/>
                <TextBlock Grid.Row="0" Grid.Column="2" Text="设备类型 *" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                <ComboBox Grid.Row="0" Grid.Column="3" x:Name="cbKind" VerticalAlignment="Center" Margin="4,0"/>
                <TextBlock Grid.Row="1" Grid.Column="0" Text="驱动类型" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                <TextBox Grid.Row="1" Grid.Column="1" x:Name="tbDriverType" VerticalAlignment="Center"
                         Style="{StaticResource EditorTextBoxStyle}" Margin="4,0"/>
                <TextBlock Grid.Row="1" Grid.Column="2" Text="传输方式 *" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                <ComboBox Grid.Row="1" Grid.Column="3" x:Name="cbTransport" VerticalAlignment="Center" Margin="4,0"/>
                <TextBlock Grid.Row="2" Grid.Column="0" Text="连接端点 *" VerticalAlignment="Center" Style="{StaticResource ListTableTextBlock}"/>
                <TextBox Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="3" x:Name="tbEndpoint" VerticalAlignment="Center"
                         Style="{StaticResource EditorTextBoxStyle}" Margin="4,0"
                         pu:TextBoxHelper.Watermark="例: 192.168.10.124:30000 或 COM3"/>
            </Grid>
        </GroupBox>
        <!-- 通道列表 -->
        <GroupBox Grid.Row="1" Header="通道列表" FontWeight="SemiBold" Margin="0,0,0,12">
            <Grid Margin="8">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="180"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>
                <StackPanel Grid.Row="0" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,0,0,4"
                            dp:StackPanelHelper.ChildSpace="8">
                    <Button x:Name="addChannelBtn" Click="AddChannel_Click" Content="+ 新增通道"
                            Style="{StaticResource AddBtn}" Width="100"/>
                    <Button x:Name="delChannelBtn" Click="DelChannel_Click" Content="删除通道"
                            Style="{StaticResource DelBtn}" Width="100"/>
                </StackPanel>
                <ListView Grid.Row="1" Style="{StaticResource DefaultListTable}" x:Name="channelListView"
                          VirtualizingPanel.IsVirtualizing="True"
                          VirtualizingPanel.VirtualizationMode="Standard">
                    <ListView.View>
                        <GridView AllowsColumnReorder="False">
                            <GridViewColumn Width="50" Header="#">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}"
                                                Text="{Binding Converter={StaticResource lvRowNumber},
                                                RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=ListViewItem,AncestorLevel=1}}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="80" Header="ChannelId">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding ChannelId}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="200" Header="Capability">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding Capability}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="100" Header="MaxVoltage">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding MaxVoltage, StringFormat=F2}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="100" Header="MaxCurrent">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding MaxCurrent, StringFormat=F2}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="100" Header="操作">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <StackPanel Orientation="Horizontal" dp:StackPanelHelper.ChildSpace="6">
                                            <Button x:Name="editChBtn" Style="{StaticResource EditLink}" Click="EditChannel_Click" Tag="{Binding}"/>
                                            <Button x:Name="delChBtn" Style="{StaticResource DeleteLink}" Click="DeleteChannel_Click" Tag="{Binding}"/>
                                        </StackPanel>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                        </GridView>
                    </ListView.View>
                </ListView>
                <!-- 通道编辑行 -->
                <Grid Grid.Row="2" Margin="0,6,0,0">
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="80"/>
                        <ColumnDefinition Width="*"/>
                        <ColumnDefinition Width="100"/>
                        <ColumnDefinition Width="100"/>
                        <ColumnDefinition Width="80"/>
                    </Grid.ColumnDefinitions>
                    <TextBox Grid.Column="0" x:Name="tbChId" Style="{StaticResource EditorTextBoxStyle}"
                             pu:TextBoxHelper.Watermark="编号" Margin="0,0,4,0"/>
                    <TextBox Grid.Column="1" x:Name="tbChCap" Style="{StaticResource EditorTextBoxStyle}"
                             pu:TextBoxHelper.Watermark="能力描述" Margin="0,0,4,0"/>
                    <TextBox Grid.Column="2" x:Name="tbChVolt" Style="{StaticResource EditorTextBoxStyle}"
                             pu:TextBoxHelper.Watermark="最大电压" Margin="0,0,4,0"/>
                    <TextBox Grid.Column="3" x:Name="tbChCurr" Style="{StaticResource EditorTextBoxStyle}"
                             pu:TextBoxHelper.Watermark="最大电流" Margin="0,0,4,0"/>
                    <Button Grid.Column="4" x:Name="saveChBtn" Click="SaveChannel_Click" Content="保存"
                            Style="{StaticResource AddBtn}" Width="70"/>
                </Grid>
            </Grid>
        </GroupBox>
        <!-- 底部按钮 -->
        <StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" dp:StackPanelHelper.ChildSpace="10" Margin="0,12,0,0">
            <Button x:Name="saveBtn" Click="SaveBtn_Click" Content="保存"
                    Style="{StaticResource AddBtn}" Width="90"/>
            <Button x:Name="cancelBtn" Click="CancelBtn_Click" Content="取消"
                    Style="{StaticResource RfhBtn}" Width="90"/>
        </StackPanel>
    </Grid>
</pu:WindowX>
Plugin.DeviceConfig/DeviceEditModal.xaml.cs
New file
@@ -0,0 +1,368 @@
using HMI.UI.Util;
using Panuon.WPF.UI;
using Plugin.DeviceConfig.Entity;
using Plugin.DeviceConfig.Model;
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
namespace Plugin.DeviceConfig
{
    /// <summary>
    /// DeviceEditModal.xaml 的交互逻辑
    /// </summary>
    public partial class DeviceEditModal : WindowX
    {
        private DeviceConfigModel _model;
        private DeviceDefinition _device;
        private ObservableCollection<DeviceChannel> _channels = new();
        private bool _isNew;
        public DeviceEditModal()
        {
            DataContext = this;
            InitializeComponent();
        }
        /// <summary>
        /// 打开弹窗
        /// </summary>
        public bool? Open(DeviceDefinition device, DeviceConfigModel model)
        {
            _model = model;
            _device = device;
            _isNew = string.IsNullOrEmpty(device.DeviceId);
            // 初始化枚举下拉
            cbKind.ItemsSource = Enum.GetValues(typeof(DeviceKind)).Cast<DeviceKind>().ToList();
            cbTransport.ItemsSource = Enum.GetValues(typeof(TransportType)).Cast<TransportType>().ToList();
            cbTransport.SelectionChanged += CbTransport_SelectionChanged;
            // 填充数据
            tbDeviceId.Text = device.DeviceId;
            tbDeviceId.IsReadOnly = !_isNew;
            cbKind.SelectedItem = device.Kind;
            tbDriverType.Text = device.DriverType;
            cbTransport.SelectedItem = device.Transport;
            tbEndpoint.Text = device.Endpoint;
            // 默认驱动类型建议 & 端点提示
            UpdateDriverHint();
            UpdateEndpointHint();
            // 加载通道
            if (!_isNew && !string.IsNullOrEmpty(device.DeviceId))
            {
                var chList = _model.GetChannelsByDevice(device.DeviceId);
                _channels = new ObservableCollection<DeviceChannel>(chList);
            }
            else
            {
                _channels = new ObservableCollection<DeviceChannel>();
            }
            channelListView.ItemsSource = _channels;
            return ShowDialog();
        }
        #region 通道操作
        private void AddChannel_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                if (!int.TryParse(tbChId.Text, out int chId) || chId < 0)
                {
                    ToastHelper.Warning("请输入有效的通道编号(非负整数)");
                    return;
                }
                if (_channels.Any(c => c.ChannelId == chId))
                {
                    ToastHelper.Warning($"通道 {chId} 已存在");
                    return;
                }
                double.TryParse(tbChVolt.Text, out double volt);
                double.TryParse(tbChCurr.Text, out double curr);
                var ch = new DeviceChannel
                {
                    DeviceId = _device.DeviceId ?? tbDeviceId.Text,
                    ChannelId = chId,
                    Capability = tbChCap.Text ?? string.Empty,
                    MaxVoltage = volt,
                    MaxCurrent = curr,
                };
                _channels.Add(ch);
                ClearChannelInputs();
            }
            catch (Exception ex)
            {
                ToastHelper.Error($"新增通道失败: {ex.Message}");
            }
        }
        private void EditChannel_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                if (sender is Button btn && btn.Tag is DeviceChannel ch)
                {
                    tbChId.Text = ch.ChannelId.ToString();
                    tbChCap.Text = ch.Capability;
                    tbChVolt.Text = ch.MaxVoltage.ToString("F2");
                    tbChCurr.Text = ch.MaxCurrent.ToString("F2");
                }
            }
            catch (Exception ex)
            {
                ToastHelper.Error($"加载通道数据失败: {ex.Message}");
            }
        }
        private void DeleteChannel_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                if (sender is Button btn && btn.Tag is DeviceChannel ch)
                {
                    if (MsgBoxHelper.WarnMessage(this, $"确定要删除通道 {ch.ChannelId} 吗?") == MessageBoxResult.OK)
                    {
                        _channels.Remove(ch);
                    }
                }
            }
            catch (Exception ex)
            {
                ToastHelper.Error($"删除通道失败: {ex.Message}");
            }
        }
        private void SaveChannel_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                if (!int.TryParse(tbChId.Text, out int chId) || chId < 0)
                {
                    ToastHelper.Warning("请输入有效的通道编号");
                    return;
                }
                double.TryParse(tbChVolt.Text, out double volt);
                double.TryParse(tbChCurr.Text, out double curr);
                var existing = _channels.FirstOrDefault(c => c.ChannelId == chId);
                if (existing != null)
                {
                    existing.Capability = tbChCap.Text ?? string.Empty;
                    existing.MaxVoltage = volt;
                    existing.MaxCurrent = curr;
                }
                else
                {
                    _channels.Add(new DeviceChannel
                    {
                        DeviceId = _device.DeviceId ?? tbDeviceId.Text,
                        ChannelId = chId,
                        Capability = tbChCap.Text ?? string.Empty,
                        MaxVoltage = volt,
                        MaxCurrent = curr,
                    });
                }
                ClearChannelInputs();
                ToastHelper.Success("通道已保存");
            }
            catch (Exception ex)
            {
                ToastHelper.Error($"保存通道失败: {ex.Message}");
            }
        }
        private void DelChannel_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                var selected = channelListView.SelectedItem as DeviceChannel;
                if (selected == null)
                {
                    ToastHelper.Warning("请先选中要删除的通道");
                    return;
                }
                if (MsgBoxHelper.WarnMessage(this, $"确定要删除通道 {selected.ChannelId} 吗?") == MessageBoxResult.OK)
                {
                    _channels.Remove(selected);
                }
            }
            catch (Exception ex)
            {
                ToastHelper.Error($"删除通道失败: {ex.Message}");
            }
        }
        private void ClearChannelInputs()
        {
            tbChId.Text = string.Empty;
            tbChCap.Text = string.Empty;
            tbChVolt.Text = string.Empty;
            tbChCurr.Text = string.Empty;
        }
        #endregion
        #region 保存/取消
        private void SaveBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                // 验证
                var deviceId = tbDeviceId.Text?.Trim();
                if (string.IsNullOrEmpty(deviceId))
                {
                    ToastHelper.Warning("设备ID不能为空");
                    return;
                }
                if (cbKind.SelectedItem is not DeviceKind kind)
                {
                    ToastHelper.Warning("请选择设备类型");
                    return;
                }
                if (cbTransport.SelectedItem is not TransportType transport)
                {
                    ToastHelper.Warning("请选择传输方式");
                    return;
                }
                var endpoint = tbEndpoint.Text?.Trim();
                if (string.IsNullOrEmpty(endpoint))
                {
                    ToastHelper.Warning("连接端点不能为空");
                    return;
                }
                // 验证 Endpoint 格式
                if (transport == TransportType.Tcp)
                {
                    if (!endpoint.Contains(":") && !endpoint.Contains("."))
                    {
                        ToastHelper.Warning("TCP端点格式不正确,应为 IP:Port 格式");
                        return;
                    }
                }
                else if (transport == TransportType.Can)
                {
                    // CAN 端点格式:总线名称:节点编号 如 "CAN_BUS_1:0"
                    var parts = endpoint.Split(':');
                    if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || !int.TryParse(parts[1], out int nodeIdx) || nodeIdx < 0)
                    {
                        ToastHelper.Warning("CAN端点格式不正确,应为 总线名称:节点编号(如 CAN_BUS_1:0)");
                        return;
                    }
                }
                // 检查 DeviceId 是否重复(新增时)
                if (_isNew)
                {
                    var existing = _model.GetDeviceById(deviceId);
                    if (existing != null)
                    {
                        ToastHelper.Warning($"设备ID [{deviceId}] 已存在");
                        return;
                    }
                }
                // 保存设备
                _device.DeviceId = deviceId;
                _device.Kind = kind;
                _device.DriverType = tbDriverType.Text?.Trim() ?? string.Empty;
                _device.Transport = transport;
                _device.Endpoint = endpoint;
                _model.SaveDevice(_device);
                // 保存通道
                foreach (var ch in _channels)
                {
                    ch.DeviceId = deviceId;
                    _model.SaveChannel(ch);
                }
                DialogResult = true;
                Close();
            }
            catch (Exception ex)
            {
                ToastHelper.Error($"保存失败: {ex.Message}");
            }
        }
        private void CancelBtn_Click(object sender, RoutedEventArgs e)
        {
            DialogResult = false;
            Close();
        }
        private void CbTransport_SelectionChanged(object sender, SelectionChangedEventArgs e)
        {
            UpdateEndpointHint();
        }
        /// <summary>
        /// 根据传输方式更新端点输入框提示
        /// </summary>
        private void UpdateEndpointHint()
        {
            if (cbTransport.SelectedItem is TransportType tt)
            {
                switch (tt)
                {
                    case TransportType.Can:
                        Panuon.WPF.UI.TextBoxHelper.SetWatermark(tbEndpoint, "CAN总线端点,格式: 总线名称:节点编号(如 CAN_BUS_1:0)");
                        break;
                    case TransportType.Serial:
                        Panuon.WPF.UI.TextBoxHelper.SetWatermark(tbEndpoint, "串口号,如 COM3:115200:8:N:1");
                        break;
                    case TransportType.Visa:
                        Panuon.WPF.UI.TextBoxHelper.SetWatermark(tbEndpoint, "VISG 地址,如 TCPIP0::192.168.1.100::inst0::INSTR");
                        break;
                    case TransportType.SDK:
                        Panuon.WPF.UI.TextBoxHelper.SetWatermark(tbEndpoint, "SDK 设备标识,如 SDK://DeviceName/Instance");
                        break;
                    default:
                        Panuon.WPF.UI.TextBoxHelper.SetWatermark(tbEndpoint, "例: 192.168.10.124:30000 或 COM3");
                        break;
                }
            }
        }
        /// <summary>
        /// 根据设备类型建议默认驱动类型
        /// </summary>
        private void UpdateDriverHint()
        {
            // 保留现有驱动类型,不自动修改。仅当为空时提示默认值
            if (string.IsNullOrWhiteSpace(tbDriverType.Text) && cbKind.SelectedItem is DeviceKind kind)
            {
                string hint = kind switch
                {
                    DeviceKind.CanDevice => "CAN",
                    DeviceKind.PowerSupply => "SCPI",
                    DeviceKind.PlcDevice => "S7",
                    DeviceKind.Chamber => "Tcp",
                    DeviceKind.DAQ => "Tcp",
                    _ => ""
                };
                if (!string.IsNullOrEmpty(hint))
                {
                    tbDriverType.Text = hint;
                }
            }
        }
        #endregion
    }
}
Plugin.DeviceConfig/Entity/DeviceChannel.cs
New file
@@ -0,0 +1,57 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Plugin.DeviceConfig.Entity
{
    /// <summary>
    /// 设备通道实体
    /// </summary>
    public partial class DeviceChannel : ObservableObject
    {
        /// <summary>
        /// 无参构造函数(用于数据绑定)
        /// </summary>
        public DeviceChannel() { }
        /// <summary>
        /// 所属设备ID(联合主键之一)
        /// </summary>
        [ObservableProperty]
        private string deviceId = string.Empty;
        /// <summary>
        /// 通道编号(联合主键之一)
        /// </summary>
        [ObservableProperty]
        private int channelId;
        /// <summary>
        /// 通道能力描述
        /// </summary>
        [ObservableProperty]
        private string capability = string.Empty;
        /// <summary>
        /// 最大电压
        /// </summary>
        [ObservableProperty]
        private double maxVoltage;
        /// <summary>
        /// 最大电流
        /// </summary>
        [ObservableProperty]
        private double maxCurrent;
        /// <summary>
        /// 限制条件(JSON格式)
        /// </summary>
        [ObservableProperty]
        private string limits = "{}";
        /// <summary>
        /// 会话密钥
        /// </summary>
        [ObservableProperty]
        private string sessionKey = string.Empty;
    }
}
Plugin.DeviceConfig/Entity/DeviceConfigVersion.cs
New file
@@ -0,0 +1,62 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Plugin.DeviceConfig.Entity
{
    /// <summary>
    /// 配置版本状态枚举
    /// </summary>
    public enum VersionStatus
    {
        Draft,
        Published,
        Effective
    }
    /// <summary>
    /// 配置版本实体
    /// </summary>
    public partial class DeviceConfigVersion : ObservableObject
    {
        /// <summary>
        /// 无参构造函数(用于数据绑定)
        /// </summary>
        public DeviceConfigVersion() { }
        /// <summary>
        /// 版本唯一标识(主键)
        /// </summary>
        [ObservableProperty]
        private string versionId = string.Empty;
        /// <summary>
        /// 版本状态: Draft / Published / Effective
        /// </summary>
        [ObservableProperty]
        private VersionStatus status;
        /// <summary>
        /// 配置哈希(SHA256)
        /// </summary>
        [ObservableProperty]
        private string hash = string.Empty;
        /// <summary>
        /// 作者
        /// </summary>
        [ObservableProperty]
        private string author = string.Empty;
        /// <summary>
        /// 时间戳
        /// </summary>
        [ObservableProperty]
        private DateTime timestamp = DateTime.Now;
        /// <summary>
        /// 差异描述
        /// </summary>
        [ObservableProperty]
        private string diffDescription = string.Empty;
    }
}
Plugin.DeviceConfig/Entity/DeviceDefinition.cs
New file
@@ -0,0 +1,88 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Plugin.DeviceConfig.Entity
{
    /// <summary>
    /// 设备种类枚举
    /// </summary>
    public enum DeviceKind
    {
        PowerSupply,
        CanDevice,
        PlcDevice,
        Chamber,
        DAQ
    }
    /// <summary>
    /// 传输方式枚举
    /// </summary>
    public enum TransportType
    {
        Tcp,
        Serial,
        Visa,
        SDK,
        Can
    }
    /// <summary>
    /// 设备定义实体
    /// </summary>
    public partial class DeviceDefinition : ObservableObject
    {
        /// <summary>
        /// 无参构造函数(用于数据绑定)
        /// </summary>
        public DeviceDefinition() { }
        /// <summary>
        /// 设备唯一标识(主键)
        /// </summary>
        [ObservableProperty]
        private string deviceId = string.Empty;
        /// <summary>
        /// 设备种类: PowerSupply / CanDevice / PlcDevice / Chamber / DAQ
        /// </summary>
        [ObservableProperty]
        private DeviceKind kind;
        /// <summary>
        /// 驱动类型名称
        /// </summary>
        [ObservableProperty]
        private string driverType = string.Empty;
        /// <summary>
        /// 传输方式: Tcp / Serial / Visa / SDK
        /// </summary>
        [ObservableProperty]
        private TransportType transport;
        /// <summary>
        /// 连接端点(IP:Port 或 COM口)
        /// </summary>
        [ObservableProperty]
        private string endpoint = string.Empty;
        /// <summary>
        /// 生效版本号
        /// </summary>
        [ObservableProperty]
        private string effectiveVersion = string.Empty;
        /// <summary>
        /// 创建时间
        /// </summary>
        [ObservableProperty]
        private DateTime createdAt = DateTime.Now;
        /// <summary>
        /// 更新时间
        /// </summary>
        [ObservableProperty]
        private DateTime updatedAt = DateTime.Now;
    }
}
Plugin.DeviceConfig/Entity/DeviceGroupTemplate.cs
New file
@@ -0,0 +1,63 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Plugin.DeviceConfig.Entity
{
    /// <summary>
    /// 设备组模板实体
    /// </summary>
    public partial class DeviceGroupTemplate : ObservableObject
    {
        /// <summary>
        /// 无参构造函数(用于数据绑定)
        /// </summary>
        public DeviceGroupTemplate() { }
        /// <summary>
        /// 模板唯一标识(主键)
        /// </summary>
        [ObservableProperty]
        private string templateId = string.Empty;
        /// <summary>
        /// 设备类型
        /// </summary>
        [ObservableProperty]
        private string deviceType = string.Empty;
        /// <summary>
        /// 驱动类型
        /// </summary>
        [ObservableProperty]
        private string driverType = string.Empty;
        /// <summary>
        /// 传输方式
        /// </summary>
        [ObservableProperty]
        private string transport = string.Empty;
        /// <summary>
        /// 设备数量
        /// </summary>
        [ObservableProperty]
        private int deviceCount;
        /// <summary>
        /// 每设备通道数
        /// </summary>
        [ObservableProperty]
        private int channelsPerDevice;
        /// <summary>
        /// 地址规则(如IP模板 192.168.10.{0})
        /// </summary>
        [ObservableProperty]
        private string addressRule = string.Empty;
        /// <summary>
        /// 起始Slot
        /// </summary>
        [ObservableProperty]
        private int slotStart;
    }
}
Plugin.DeviceConfig/Entity/SlotDeviceMapping.cs
New file
@@ -0,0 +1,51 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Plugin.DeviceConfig.Entity
{
    /// <summary>
    /// Slot映射实体
    /// </summary>
    public partial class SlotDeviceMapping : ObservableObject
    {
        /// <summary>
        /// 无参构造函数(用于数据绑定)
        /// </summary>
        public SlotDeviceMapping() { }
        /// <summary>
        /// Slot标识(联合主键之一)
        /// </summary>
        [ObservableProperty]
        private string slotId = string.Empty;
        /// <summary>
        /// 能力描述(联合主键之一)
        /// </summary>
        [ObservableProperty]
        private string capability = string.Empty;
        /// <summary>
        /// 版本(联合主键之一)
        /// </summary>
        [ObservableProperty]
        private string version = string.Empty;
        /// <summary>
        /// 关联设备ID
        /// </summary>
        [ObservableProperty]
        private string deviceId = string.Empty;
        /// <summary>
        /// 关联通道ID
        /// </summary>
        [ObservableProperty]
        private int channelId;
        /// <summary>
        /// 映射版本
        /// </summary>
        [ObservableProperty]
        private string mappingVersion = string.Empty;
    }
}
Plugin.DeviceConfig/Model/DeviceConfigModel.cs
New file
@@ -0,0 +1,645 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using HMI.Core.Util;
using Plugin.DeviceConfig.Entity;
namespace Plugin.DeviceConfig.Model
{
    /// <summary>
    /// 设备配置数据访问层,基于 SQLite 实现
    /// </summary>
    public class DeviceConfigModel
    {
        private string _dbPath = string.Empty;
        /// <summary>
        /// 初始化数据库,创建所有表
        /// </summary>
        /// <param name="dbPath">数据库文件路径</param>
        public void InitDatabase(string dbPath)
        {
            _dbPath = dbPath;
            try
            {
                // 确保目录存在
                string? dir = System.IO.Path.GetDirectoryName(dbPath);
                if (!string.IsNullOrEmpty(dir) && !System.IO.Directory.Exists(dir))
                {
                    System.IO.Directory.CreateDirectory(dir);
                }
                // 仅在数据库文件不存在时创建(避免重启后抛异常或丢失已有数据)
                if (!System.IO.File.Exists(dbPath))
                {
                    SqliteHelper.CreateDatabase(dbPath);
                }
                using var helper = new SqliteHelper(dbPath);
                var sqls = new List<string>
                {
                    // 设备定义表
                    @"CREATE TABLE IF NOT EXISTS DeviceDefinition (
                        DeviceId TEXT PRIMARY KEY,
                        Kind TEXT NOT NULL,
                        DriverType TEXT NOT NULL,
                        Transport TEXT NOT NULL,
                        Endpoint TEXT NOT NULL,
                        EffectiveVersion TEXT NOT NULL DEFAULT '',
                        CreatedAt TEXT NOT NULL,
                        UpdatedAt TEXT NOT NULL
                    );",
                    // 设备通道表
                    @"CREATE TABLE IF NOT EXISTS DeviceChannel (
                        DeviceId TEXT NOT NULL,
                        ChannelId INTEGER NOT NULL,
                        Capability TEXT NOT NULL DEFAULT '',
                        MaxVoltage REAL NOT NULL DEFAULT 0,
                        MaxCurrent REAL NOT NULL DEFAULT 0,
                        Limits TEXT NOT NULL DEFAULT '{}',
                        SessionKey TEXT NOT NULL DEFAULT '',
                        PRIMARY KEY (DeviceId, ChannelId)
                    );",
                    // Slot映射表
                    @"CREATE TABLE IF NOT EXISTS SlotDeviceMapping (
                        SlotId TEXT NOT NULL,
                        Capability TEXT NOT NULL,
                        Version TEXT NOT NULL,
                        DeviceId TEXT NOT NULL,
                        ChannelId INTEGER NOT NULL DEFAULT 0,
                        MappingVersion TEXT NOT NULL DEFAULT '',
                        PRIMARY KEY (SlotId, Capability, Version)
                    );",
                    // 配置版本表
                    @"CREATE TABLE IF NOT EXISTS DeviceConfigVersion (
                        VersionId TEXT PRIMARY KEY,
                        Status TEXT NOT NULL,
                        Hash TEXT NOT NULL DEFAULT '',
                        Author TEXT NOT NULL DEFAULT '',
                        Timestamp TEXT NOT NULL,
                        DiffDescription TEXT NOT NULL DEFAULT ''
                    );",
                    // 设备组模板表
                    @"CREATE TABLE IF NOT EXISTS DeviceGroupTemplate (
                        TemplateId TEXT PRIMARY KEY,
                        DeviceType TEXT NOT NULL DEFAULT '',
                        DriverType TEXT NOT NULL DEFAULT '',
                        Transport TEXT NOT NULL DEFAULT '',
                        DeviceCount INTEGER NOT NULL DEFAULT 0,
                        ChannelsPerDevice INTEGER NOT NULL DEFAULT 0,
                        AddressRule TEXT NOT NULL DEFAULT '',
                        SlotStart INTEGER NOT NULL DEFAULT 0
                    );"
                };
                helper.ExecuteBatchSql(sqls);
            }
            catch (Exception ex)
            {
                throw new Exception($"初始化设备配置数据库失败: {ex.Message}", ex);
            }
        }
        #region DeviceDefinition CRUD
        /// <summary>
        /// 获取所有设备定义
        /// </summary>
        public List<DeviceDefinition> GetAllDevices()
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                var dt = helper.GetDataTable("SELECT * FROM DeviceDefinition ORDER BY CreatedAt DESC;");
                return MapToDeviceList(dt);
            }
            catch (Exception ex)
            {
                throw new Exception($"获取所有设备定义失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 根据ID获取设备定义
        /// </summary>
        public DeviceDefinition? GetDeviceById(string deviceId)
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                var dt = helper.GetDataTable($"SELECT * FROM DeviceDefinition WHERE DeviceId = '{Escape(deviceId)}';");
                var list = MapToDeviceList(dt);
                return list.Count > 0 ? list[0] : null;
            }
            catch (Exception ex)
            {
                throw new Exception($"获取设备定义[{deviceId}]失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 保存设备定义(新增或更新)
        /// </summary>
        public void SaveDevice(DeviceDefinition device)
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                device.UpdatedAt = DateTime.Now;
                var sql = $@"INSERT OR REPLACE INTO DeviceDefinition
                    (DeviceId, Kind, DriverType, Transport, Endpoint, EffectiveVersion, CreatedAt, UpdatedAt)
                    VALUES (
                        '{Escape(device.DeviceId)}',
                        '{device.Kind}',
                        '{Escape(device.DriverType)}',
                        '{device.Transport}',
                        '{Escape(device.Endpoint)}',
                        '{Escape(device.EffectiveVersion)}',
                        '{device.CreatedAt:yyyy-MM-dd HH:mm:ss}',
                        '{device.UpdatedAt:yyyy-MM-dd HH:mm:ss}'
                    );";
                helper.ExecuteSql(sql);
            }
            catch (Exception ex)
            {
                throw new Exception($"保存设备定义[{device.DeviceId}]失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 删除设备定义
        /// </summary>
        public void DeleteDevice(string deviceId)
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                helper.ExecuteSql($"DELETE FROM DeviceDefinition WHERE DeviceId = '{Escape(deviceId)}';");
            }
            catch (Exception ex)
            {
                throw new Exception($"删除设备定义[{deviceId}]失败: {ex.Message}", ex);
            }
        }
        private List<DeviceDefinition> MapToDeviceList(DataTable dt)
        {
            var list = new List<DeviceDefinition>();
            foreach (DataRow row in dt.Rows)
            {
                list.Add(new DeviceDefinition
                {
                    DeviceId = row["DeviceId"].ToString() ?? string.Empty,
                    Kind = Enum.TryParse<DeviceKind>(row["Kind"].ToString(), out var kind) ? kind : DeviceKind.PowerSupply,
                    DriverType = row["DriverType"].ToString() ?? string.Empty,
                    Transport = Enum.TryParse<TransportType>(row["Transport"].ToString(), out var tp) ? tp : TransportType.Tcp,
                    Endpoint = row["Endpoint"].ToString() ?? string.Empty,
                    EffectiveVersion = row["EffectiveVersion"].ToString() ?? string.Empty,
                    CreatedAt = DateTime.TryParse(row["CreatedAt"].ToString(), out var ca) ? ca : DateTime.Now,
                    UpdatedAt = DateTime.TryParse(row["UpdatedAt"].ToString(), out var ua) ? ua : DateTime.Now,
                });
            }
            return list;
        }
        #endregion
        #region DeviceChannel CRUD
        /// <summary>
        /// 获取指定设备的所有通道
        /// </summary>
        public List<DeviceChannel> GetChannelsByDevice(string deviceId)
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                var dt = helper.GetDataTable($"SELECT * FROM DeviceChannel WHERE DeviceId = '{Escape(deviceId)}' ORDER BY ChannelId;");
                return MapToChannelList(dt);
            }
            catch (Exception ex)
            {
                throw new Exception($"获取设备[{deviceId}]通道失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 保存设备通道(新增或更新)
        /// </summary>
        public void SaveChannel(DeviceChannel channel)
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                var sql = $@"INSERT OR REPLACE INTO DeviceChannel
                    (DeviceId, ChannelId, Capability, MaxVoltage, MaxCurrent, Limits, SessionKey)
                    VALUES (
                        '{Escape(channel.DeviceId)}',
                        {channel.ChannelId},
                        '{Escape(channel.Capability)}',
                        {channel.MaxVoltage},
                        {channel.MaxCurrent},
                        '{Escape(channel.Limits)}',
                        '{Escape(channel.SessionKey)}'
                    );";
                helper.ExecuteSql(sql);
            }
            catch (Exception ex)
            {
                throw new Exception($"保存通道[{channel.DeviceId}:{channel.ChannelId}]失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 删除设备通道
        /// </summary>
        public void DeleteChannel(string deviceId, int channelId)
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                helper.ExecuteSql($"DELETE FROM DeviceChannel WHERE DeviceId = '{Escape(deviceId)}' AND ChannelId = {channelId};");
            }
            catch (Exception ex)
            {
                throw new Exception($"删除通道[{deviceId}:{channelId}]失败: {ex.Message}", ex);
            }
        }
        private List<DeviceChannel> MapToChannelList(DataTable dt)
        {
            var list = new List<DeviceChannel>();
            foreach (DataRow row in dt.Rows)
            {
                list.Add(new DeviceChannel
                {
                    DeviceId = row["DeviceId"].ToString() ?? string.Empty,
                    ChannelId = Convert.ToInt32(row["ChannelId"]),
                    Capability = row["Capability"].ToString() ?? string.Empty,
                    MaxVoltage = Convert.ToDouble(row["MaxVoltage"]),
                    MaxCurrent = Convert.ToDouble(row["MaxCurrent"]),
                    Limits = row["Limits"].ToString() ?? "{}",
                    SessionKey = row["SessionKey"].ToString() ?? string.Empty,
                });
            }
            return list;
        }
        #endregion
        #region SlotDeviceMapping CRUD
        /// <summary>
        /// 获取所有Slot映射
        /// </summary>
        public List<SlotDeviceMapping> GetSlotMappings()
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                var dt = helper.GetDataTable("SELECT * FROM SlotDeviceMapping ORDER BY SlotId;");
                return MapToSlotMappingList(dt);
            }
            catch (Exception ex)
            {
                throw new Exception($"获取Slot映射失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 保存Slot映射(新增或更新)
        /// </summary>
        public void SaveSlotMapping(SlotDeviceMapping mapping)
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                var sql = $@"INSERT OR REPLACE INTO SlotDeviceMapping
                    (SlotId, Capability, Version, DeviceId, ChannelId, MappingVersion)
                    VALUES (
                        '{Escape(mapping.SlotId)}',
                        '{Escape(mapping.Capability)}',
                        '{Escape(mapping.Version)}',
                        '{Escape(mapping.DeviceId)}',
                        {mapping.ChannelId},
                        '{Escape(mapping.MappingVersion)}'
                    );";
                helper.ExecuteSql(sql);
            }
            catch (Exception ex)
            {
                throw new Exception($"保存Slot映射[{mapping.SlotId}]失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 删除Slot映射
        /// </summary>
        public void DeleteSlotMapping(string slotId, string capability, string version)
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                helper.ExecuteSql($@"DELETE FROM SlotDeviceMapping
                    WHERE SlotId = '{Escape(slotId)}'
                    AND Capability = '{Escape(capability)}'
                    AND Version = '{Escape(version)}';");
            }
            catch (Exception ex)
            {
                throw new Exception($"删除Slot映射[{slotId}:{capability}:{version}]失败: {ex.Message}", ex);
            }
        }
        private List<SlotDeviceMapping> MapToSlotMappingList(DataTable dt)
        {
            var list = new List<SlotDeviceMapping>();
            foreach (DataRow row in dt.Rows)
            {
                list.Add(new SlotDeviceMapping
                {
                    SlotId = row["SlotId"].ToString() ?? string.Empty,
                    Capability = row["Capability"].ToString() ?? string.Empty,
                    Version = row["Version"].ToString() ?? string.Empty,
                    DeviceId = row["DeviceId"].ToString() ?? string.Empty,
                    ChannelId = Convert.ToInt32(row["ChannelId"]),
                    MappingVersion = row["MappingVersion"].ToString() ?? string.Empty,
                });
            }
            return list;
        }
        #endregion
        #region DeviceConfigVersion CRUD
        /// <summary>
        /// 获取所有配置版本
        /// </summary>
        public List<DeviceConfigVersion> GetConfigVersions()
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                var dt = helper.GetDataTable("SELECT * FROM DeviceConfigVersion ORDER BY Timestamp DESC;");
                return MapToVersionList(dt);
            }
            catch (Exception ex)
            {
                throw new Exception($"获取配置版本失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 保存配置版本(新增或更新)
        /// </summary>
        public void SaveConfigVersion(DeviceConfigVersion version)
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                var sql = $@"INSERT OR REPLACE INTO DeviceConfigVersion
                    (VersionId, Status, Hash, Author, Timestamp, DiffDescription)
                    VALUES (
                        '{Escape(version.VersionId)}',
                        '{version.Status}',
                        '{Escape(version.Hash)}',
                        '{Escape(version.Author)}',
                        '{version.Timestamp:yyyy-MM-dd HH:mm:ss}',
                        '{Escape(version.DiffDescription)}'
                    );";
                helper.ExecuteSql(sql);
            }
            catch (Exception ex)
            {
                throw new Exception($"保存配置版本[{version.VersionId}]失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 更新版本状态
        /// </summary>
        public void UpdateVersionStatus(string versionId, string newStatus)
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                helper.ExecuteSql($@"UPDATE DeviceConfigVersion
                    SET Status = '{Escape(newStatus)}'
                    WHERE VersionId = '{Escape(versionId)}';");
            }
            catch (Exception ex)
            {
                throw new Exception($"更新版本状态[{versionId}]失败: {ex.Message}", ex);
            }
        }
        private List<DeviceConfigVersion> MapToVersionList(DataTable dt)
        {
            var list = new List<DeviceConfigVersion>();
            foreach (DataRow row in dt.Rows)
            {
                list.Add(new DeviceConfigVersion
                {
                    VersionId = row["VersionId"].ToString() ?? string.Empty,
                    Status = Enum.TryParse<VersionStatus>(row["Status"].ToString(), out var st) ? st : VersionStatus.Draft,
                    Hash = row["Hash"].ToString() ?? string.Empty,
                    Author = row["Author"].ToString() ?? string.Empty,
                    Timestamp = DateTime.TryParse(row["Timestamp"].ToString(), out var ts) ? ts : DateTime.Now,
                    DiffDescription = row["DiffDescription"].ToString() ?? string.Empty,
                });
            }
            return list;
        }
        #endregion
        #region DeviceGroupTemplate CRUD
        /// <summary>
        /// 获取所有设备组模板
        /// </summary>
        public List<DeviceGroupTemplate> GetGroupTemplates()
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                var dt = helper.GetDataTable("SELECT * FROM DeviceGroupTemplate ORDER BY DeviceType;");
                return MapToTemplateList(dt);
            }
            catch (Exception ex)
            {
                throw new Exception($"获取设备组模板失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 保存设备组模板(新增或更新)
        /// </summary>
        public void SaveGroupTemplate(DeviceGroupTemplate template)
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                var sql = $@"INSERT OR REPLACE INTO DeviceGroupTemplate
                    (TemplateId, DeviceType, DriverType, Transport, DeviceCount, ChannelsPerDevice, AddressRule, SlotStart)
                    VALUES (
                        '{Escape(template.TemplateId)}',
                        '{Escape(template.DeviceType)}',
                        '{Escape(template.DriverType)}',
                        '{Escape(template.Transport)}',
                        {template.DeviceCount},
                        {template.ChannelsPerDevice},
                        '{Escape(template.AddressRule)}',
                        {template.SlotStart}
                    );";
                helper.ExecuteSql(sql);
            }
            catch (Exception ex)
            {
                throw new Exception($"保存设备组模板[{template.TemplateId}]失败: {ex.Message}", ex);
            }
        }
        private List<DeviceGroupTemplate> MapToTemplateList(DataTable dt)
        {
            var list = new List<DeviceGroupTemplate>();
            foreach (DataRow row in dt.Rows)
            {
                list.Add(new DeviceGroupTemplate
                {
                    TemplateId = row["TemplateId"].ToString() ?? string.Empty,
                    DeviceType = row["DeviceType"].ToString() ?? string.Empty,
                    DriverType = row["DriverType"].ToString() ?? string.Empty,
                    Transport = row["Transport"].ToString() ?? string.Empty,
                    DeviceCount = Convert.ToInt32(row["DeviceCount"]),
                    ChannelsPerDevice = Convert.ToInt32(row["ChannelsPerDevice"]),
                    AddressRule = row["AddressRule"].ToString() ?? string.Empty,
                    SlotStart = Convert.ToInt32(row["SlotStart"]),
                });
            }
            return list;
        }
        #endregion
        #region 冲突检测与哈希计算
        /// <summary>
        /// 检测配置冲突:IP冲突、Slot映射重复、通道越界等
        /// </summary>
        /// <returns>冲突描述列表,为空则表示无冲突</returns>
        public List<string> DetectConflicts()
        {
            var conflicts = new List<string>();
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                // 1. 检测IP/Endpoint冲突(相同Endpoint的不同设备)
                var ipSql = @"SELECT Endpoint, COUNT(*) as Cnt
                    FROM DeviceDefinition
                    WHERE Transport = 'Tcp'
                    GROUP BY Endpoint
                    HAVING Cnt > 1;";
                var ipDt = helper.GetDataTable(ipSql);
                foreach (DataRow row in ipDt.Rows)
                {
                    conflicts.Add($"IP/Endpoint冲突: {row["Endpoint"]} 被 {row["Cnt"]} 个设备使用");
                }
                // 2. 检测Slot映射重复(相同Slot+Capability+Version映射到不同设备)
                var slotSql = @"SELECT SlotId, Capability, Version, COUNT(*) as Cnt
                    FROM SlotDeviceMapping
                    GROUP BY SlotId, Capability, Version
                    HAVING Cnt > 1;";
                var slotDt = helper.GetDataTable(slotSql);
                foreach (DataRow row in slotDt.Rows)
                {
                    conflicts.Add($"Slot映射重复: Slot={row["SlotId"]}, Capability={row["Capability"]}, Version={row["Version"]}");
                }
                // 3. 检测通道越界(MaxVoltage或MaxCurrent为负数)
                var channelSql = @"SELECT DeviceId, ChannelId, MaxVoltage, MaxCurrent
                    FROM DeviceChannel
                    WHERE MaxVoltage < 0 OR MaxCurrent < 0;";
                var channelDt = helper.GetDataTable(channelSql);
                foreach (DataRow row in channelDt.Rows)
                {
                    conflicts.Add($"通道参数越界: 设备={row["DeviceId"]}, 通道={row["ChannelId"]}, " +
                                  $"电压={row["MaxVoltage"]}, 电流={row["MaxCurrent"]}");
                }
            }
            catch (Exception ex)
            {
                throw new Exception($"冲突检测失败: {ex.Message}", ex);
            }
            return conflicts;
        }
        /// <summary>
        /// 计算当前配置的SHA256哈希
        /// </summary>
        /// <returns>SHA256哈希字符串(小写十六进制)</returns>
        public string ComputeConfigHash()
        {
            try
            {
                using var helper = new SqliteHelper(_dbPath);
                // 拼接所有设备定义的关键信息作为哈希输入
                var sb = new StringBuilder();
                var deviceDt = helper.GetDataTable("SELECT * FROM DeviceDefinition ORDER BY DeviceId;");
                foreach (DataRow row in deviceDt.Rows)
                {
                    sb.Append($"DEVICE:{row["DeviceId"]}|{row["Kind"]}|{row["DriverType"]}|{row["Transport"]}|{row["Endpoint"]};");
                }
                var channelDt = helper.GetDataTable("SELECT * FROM DeviceChannel ORDER BY DeviceId, ChannelId;");
                foreach (DataRow row in channelDt.Rows)
                {
                    sb.Append($"CHANNEL:{row["DeviceId"]}|{row["ChannelId"]}|{row["Capability"]}|{row["MaxVoltage"]}|{row["MaxCurrent"]};");
                }
                var slotDt = helper.GetDataTable("SELECT * FROM SlotDeviceMapping ORDER BY SlotId, Capability, Version;");
                foreach (DataRow row in slotDt.Rows)
                {
                    sb.Append($"SLOT:{row["SlotId"]}|{row["Capability"]}|{row["Version"]}|{row["DeviceId"]}|{row["ChannelId"]};");
                }
                using var sha256 = SHA256.Create();
                var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString()));
                return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
            }
            catch (Exception ex)
            {
                throw new Exception($"计算配置哈希失败: {ex.Message}", ex);
            }
        }
        #endregion
        #region 辅助方法
        /// <summary>
        /// SQL字符串转义(防止注入)
        /// </summary>
        private static string Escape(string value)
        {
            return value?.Replace("'", "''") ?? string.Empty;
        }
        #endregion
    }
}
Plugin.DeviceConfig/Plugin.DeviceConfig.csproj
New file
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net472</TargetFramework>
    <Nullable>enable</Nullable>
    <LangVersion>10.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
    <AutoGenerateBindingRedirects>True</AutoGenerateBindingRedirects>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
    <PackageReference Include="log4net" Version="3.0.4" />
    <PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.135" />
    <PackageReference Include="Panuon.WPF.UI" Version="1.3.0.2" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\HMI.Base\HMI.Base.csproj" />
    <ProjectReference Include="..\HMI.Core\HMI.Core.csproj" />
    <ProjectReference Include="..\HMI.UI\HMI.UI.csproj" />
    <ProjectReference Include="..\HMI.Util\HMI.Util.csproj" />
  </ItemGroup>
</Project>
Plugin.DeviceConfig/VersionManageModal.xaml
New file
@@ -0,0 +1,166 @@
<pu:WindowX x:Class="Plugin.DeviceConfig.VersionManageModal"
            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:pw="https://opensource.panuon.com/wpf"
            xmlns:pu="https://opensource.panuon.com/wpf-ui"
            xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
            xmlns:local="clr-namespace:Plugin.DeviceConfig"
            mc:Ignorable="d"
            Title="版本管理"
            Height="520"
            Width="800"
            WindowStartupLocation="CenterOwner"
            Style="{StaticResource ModalWindow}">
    <pu:WindowX.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/HMI.UI;component/Style/PanuonListView.xaml"/>
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </pu:WindowX.Resources>
    <Grid Margin="16">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <!-- 操作按钮栏 -->
        <StackPanel Grid.Row="0" Orientation="Horizontal" dp:StackPanelHelper.ChildSpace="10" Margin="0,0,0,8">
            <Button x:Name="publishBtn" Click="PublishBtn_Click" Content="发布选中版本"
                    Style="{StaticResource AddBtn}" Width="110"/>
            <Button x:Name="applyBtn" Click="ApplyBtn_Click" Content="应用选中版本"
                    Style="{StaticResource AddBtn}" Width="110"/>
            <Button x:Name="detailBtn" Click="DetailBtn_Click" Content="版本详情"
                    Style="{StaticResource RfhBtn}" Width="90"/>
            <Button x:Name="rfhBtn" Click="RfhBtn_Click" Content="刷新"
                    Style="{StaticResource RfhBtn}" Width="70"/>
        </StackPanel>
        <!-- 版本列表 -->
        <ListView Grid.Row="1" Style="{StaticResource DefaultListTable}" x:Name="versionListView"
                  VirtualizingPanel.IsVirtualizing="True"
                  VirtualizingPanel.VirtualizationMode="Standard">
            <ListView.View>
                <GridView AllowsColumnReorder="False">
                    <GridViewColumn Width="50" Header="#">
                        <GridViewColumn.CellTemplate>
                            <DataTemplate>
                                <Border Style="{StaticResource CellBorder}">
                                    <TextBlock Style="{StaticResource ListTableTextBlock}"
                                        Text="{Binding Converter={StaticResource lvRowNumber},
                                        RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=ListViewItem,AncestorLevel=1}}"/>
                                </Border>
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>
                    <GridViewColumn Width="180" Header="VersionId">
                        <GridViewColumn.CellTemplate>
                            <DataTemplate>
                                <Border Style="{StaticResource CellBorder}">
                                    <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding VersionId}"/>
                                </Border>
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>
                    <GridViewColumn Width="100" Header="Status">
                        <GridViewColumn.CellTemplate>
                            <DataTemplate>
                                <Border Style="{StaticResource CellBorder}">
                                    <StackPanel Orientation="Horizontal">
                                        <Border Width="10" Height="10" CornerRadius="5" Margin="0,0,6,0"
                                                VerticalAlignment="Center">
                                            <Border.Style>
                                                <Style TargetType="Border">
                                                    <Setter Property="Background" Value="Gray"/>
                                                    <Style.Triggers>
                                                        <DataTrigger Binding="{Binding Status}" Value="Draft">
                                                            <Setter Property="Background" Value="Gray"/>
                                                        </DataTrigger>
                                                        <DataTrigger Binding="{Binding Status}" Value="Published">
                                                            <Setter Property="Background" Value="#2196F3"/>
                                                        </DataTrigger>
                                                        <DataTrigger Binding="{Binding Status}" Value="Effective">
                                                            <Setter Property="Background" Value="#4CAF50"/>
                                                        </DataTrigger>
                                                    </Style.Triggers>
                                                </Style>
                                            </Border.Style>
                                        </Border>
                                        <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding Status}" VerticalAlignment="Center"/>
                                    </StackPanel>
                                </Border>
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>
                    <GridViewColumn Width="200" Header="Hash">
                        <GridViewColumn.CellTemplate>
                            <DataTemplate>
                                <Border Style="{StaticResource CellBorder}">
                                    <TextBlock Style="{StaticResource ListTableTextBlock}"
                                               Text="{Binding HashShort}"
                                               ToolTip="{Binding Hash}"/>
                                </Border>
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>
                    <GridViewColumn Width="100" Header="Author">
                        <GridViewColumn.CellTemplate>
                            <DataTemplate>
                                <Border Style="{StaticResource CellBorder}">
                                    <TextBlock Style="{StaticResource ListTableTextBlock}" Text="{Binding Author}"/>
                                </Border>
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>
                    <GridViewColumn Width="160" Header="Timestamp">
                        <GridViewColumn.CellTemplate>
                            <DataTemplate>
                                <Border Style="{StaticResource CellBorder}">
                                    <TextBlock Style="{StaticResource ListTableTextBlock}"
                                               Text="{Binding Timestamp, StringFormat=yyyy-MM-dd HH:mm:ss}"/>
                                </Border>
                            </DataTemplate>
                        </GridViewColumn.CellTemplate>
                    </GridViewColumn>
                </GridView>
            </ListView.View>
        </ListView>
        <!-- 版本详情区 -->
        <Border Grid.Row="2" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="4"
                Margin="0,8,0,0" Padding="10" x:Name="detailPanel" Visibility="Collapsed">
            <Grid>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="80"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>
                <TextBlock Grid.Row="0" Grid.Column="0" Text="完整Hash:" FontWeight="SemiBold" VerticalAlignment="Center"/>
                <TextBlock Grid.Row="0" Grid.Column="1" x:Name="tbDetailHash" TextWrapping="Wrap"
                           FontFamily="Consolas" Margin="4,2"/>
                <TextBlock Grid.Row="1" Grid.Column="0" Text="差异描述:" FontWeight="SemiBold" VerticalAlignment="Center"/>
                <TextBlock Grid.Row="1" Grid.Column="1" x:Name="tbDetailDiff" TextWrapping="Wrap" Margin="4,2"/>
                <TextBlock Grid.Row="2" Grid.Column="0" Text="作者:" FontWeight="SemiBold" VerticalAlignment="Center"/>
                <TextBlock Grid.Row="2" Grid.Column="1" x:Name="tbDetailAuthor" Margin="4,2"/>
            </Grid>
        </Border>
        <!-- 底部按钮 -->
        <StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
            <Button x:Name="closeBtn" Click="CloseBtn_Click" Content="关闭"
                    Style="{StaticResource RfhBtn}" Width="90"/>
        </StackPanel>
    </Grid>
</pu:WindowX>
Plugin.DeviceConfig/VersionManageModal.xaml.cs
New file
@@ -0,0 +1,207 @@
using HMI.UI.Util;
using Panuon.WPF.UI;
using Plugin.DeviceConfig.Entity;
using Plugin.DeviceConfig.Model;
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows;
namespace Plugin.DeviceConfig
{
    /// <summary>
    /// VersionManageModal.xaml 的交互逻辑
    /// </summary>
    public partial class VersionManageModal : WindowX
    {
        private DeviceConfigModel _model;
        private ObservableCollection<VersionDisplayItem> _versions = new();
        private bool _changed;
        public VersionManageModal()
        {
            DataContext = this;
            InitializeComponent();
        }
        /// <summary>
        /// 打开弹窗
        /// </summary>
        public bool? Open(DeviceConfigModel model)
        {
            _model = model;
            versionListView.ItemsSource = _versions;
            LoadVersions();
            return ShowDialog();
        }
        private void LoadVersions()
        {
            try
            {
                var list = _model.GetConfigVersions();
                _versions.Clear();
                foreach (var v in list)
                {
                    _versions.Add(new VersionDisplayItem
                    {
                        VersionId = v.VersionId,
                        Status = v.Status,
                        Hash = v.Hash,
                        HashShort = v.Hash.Length > 16 ? v.Hash.Substring(0, 16) + "..." : v.Hash,
                        Author = v.Author,
                        Timestamp = v.Timestamp,
                        DiffDescription = v.DiffDescription,
                    });
                }
            }
            catch (Exception ex)
            {
                ToastHelper.Error($"加载版本列表失败: {ex.Message}");
            }
        }
        #region 操作
        private void PublishBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                var selected = versionListView.SelectedItem as VersionDisplayItem;
                if (selected == null)
                {
                    ToastHelper.Warning("请先选中一个版本");
                    return;
                }
                if (selected.Status != VersionStatus.Draft)
                {
                    ToastHelper.Warning("只有 Draft 状态的版本可以发布");
                    return;
                }
                if (MsgBoxHelper.WarnMessage(this, $"确定要发布版本 [{selected.VersionId}] 吗?") != MessageBoxResult.OK)
                    return;
                // 计算最新 Hash
                var hash = _model.ComputeConfigHash();
                selected.Hash = hash;
                selected.HashShort = hash.Length > 16 ? hash.Substring(0, 16) + "..." : hash;
                selected.Status = VersionStatus.Published;
                selected.Timestamp = DateTime.Now;
                var ver = new DeviceConfigVersion
                {
                    VersionId = selected.VersionId,
                    Status = VersionStatus.Published,
                    Hash = hash,
                    Author = selected.Author,
                    Timestamp = DateTime.Now,
                    DiffDescription = selected.DiffDescription,
                };
                _model.SaveConfigVersion(ver);
                _changed = true;
                LoadVersions();
                ToastHelper.Success($"版本 [{selected.VersionId}] 已发布");
            }
            catch (Exception ex)
            {
                ToastHelper.Error($"发布失败: {ex.Message}");
            }
        }
        private void ApplyBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                var selected = versionListView.SelectedItem as VersionDisplayItem;
                if (selected == null)
                {
                    ToastHelper.Warning("请先选中一个版本");
                    return;
                }
                if (selected.Status != VersionStatus.Published)
                {
                    ToastHelper.Warning("只有 Published 状态的版本可以应用");
                    return;
                }
                // 检查当前 Effective 版本
                var currentEffective = _versions.FirstOrDefault(v => v.Status == VersionStatus.Effective);
                if (currentEffective != null)
                {
                    if (MsgBoxHelper.WarnMessage(this,
                        $"当前已有生效版本 [{currentEffective.VersionId}],应用新版本将替换它。\n是否继续?") != MessageBoxResult.OK)
                    {
                        return;
                    }
                    // 将原 Effective 改回 Published
                    _model.UpdateVersionStatus(currentEffective.VersionId, VersionStatus.Published.ToString());
                }
                if (MsgBoxHelper.WarnMessage(this, $"确定要将 [{selected.VersionId}] 设为生效版本吗?") != MessageBoxResult.OK)
                    return;
                _model.UpdateVersionStatus(selected.VersionId, VersionStatus.Effective.ToString());
                _changed = true;
                LoadVersions();
                ToastHelper.Success($"版本 [{selected.VersionId}] 已设为生效版本");
            }
            catch (Exception ex)
            {
                ToastHelper.Error($"应用版本失败: {ex.Message}");
            }
        }
        private void DetailBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                var selected = versionListView.SelectedItem as VersionDisplayItem;
                if (selected == null)
                {
                    ToastHelper.Warning("请先选中一个版本");
                    return;
                }
                tbDetailHash.Text = selected.Hash;
                tbDetailDiff.Text = selected.DiffDescription;
                tbDetailAuthor.Text = $"{selected.Author}  @  {selected.Timestamp:yyyy-MM-dd HH:mm:ss}";
                detailPanel.Visibility = Visibility.Visible;
            }
            catch (Exception ex)
            {
                ToastHelper.Error($"加载详情失败: {ex.Message}");
            }
        }
        private void RfhBtn_Click(object sender, RoutedEventArgs e)
        {
            LoadVersions();
            ToastHelper.Success("已刷新");
        }
        private void CloseBtn_Click(object sender, RoutedEventArgs e)
        {
            DialogResult = _changed;
            Close();
        }
        #endregion
    }
    /// <summary>
    /// 版本显示项
    /// </summary>
    public class VersionDisplayItem
    {
        public string VersionId { get; set; } = string.Empty;
        public VersionStatus Status { get; set; }
        public string Hash { get; set; } = string.Empty;
        public string HashShort { get; set; } = string.Empty;
        public string Author { get; set; } = string.Empty;
        public DateTime Timestamp { get; set; }
        public string DiffDescription { get; set; } = string.Empty;
    }
}
Plugin.DeviceDebug/DeviceDebug.xaml
New file
@@ -0,0 +1,177 @@
<hb:SimpleMaskControl x:Class="Plugin.DeviceDebug.DeviceDebug"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:hb="clr-namespace:HMI.Base;assembly=HMI.Base"
             xmlns:ui="clr-namespace:HMI.UI;assembly=HMI.UI"
             xmlns:uic="clr-namespace:HMI.UI.Converter;assembly=HMI.UI"
             xmlns:pw="https://opensource.panuon.com/wpf"
             xmlns:pu="https://opensource.panuon.com/wpf-ui"
             xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
             xmlns:local="clr-namespace:Plugin.DeviceDebug"
             xmlns:panels="clr-namespace:Plugin.DeviceDebug.Panels"
             mc:Ignorable="d"
             Loaded="SimpleMaskControl_Loaded"
             d:DesignHeight="600" d:DesignWidth="1000">
    <hb:SimpleMaskControl.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/HMI.UI;component/Style/PanuonListView.xaml"/>
            </ResourceDictionary.MergedDictionaries>
            <Style x:Key="TitleBox" TargetType="TextBlock">
                <Setter Property="FontSize" Value="14"/>
                <Setter Property="FontWeight" Value="SemiBold"/>
                <Setter Property="Padding" Value="8,4"/>
                <Setter Property="VerticalAlignment" Value="Center"/>
            </Style>
            <BooleanToVisibilityConverter x:Key="BoolToVis"/>
        </ResourceDictionary>
    </hb:SimpleMaskControl.Resources>
    <Grid dp:GridHelper.Space="8">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <!-- 顶部标题栏 -->
        <DockPanel Grid.Row="0">
            <StackPanel dp:StackPanelHelper.ChildSpace="10" DockPanel.Dock="Right" Orientation="Horizontal">
                <ComboBox x:Name="deviceSelector" Width="240"
                          ItemsSource="{Binding DeviceList}"
                          SelectedItem="{Binding SelectedDevice}"
                          SelectionChanged="DeviceSelector_SelectionChanged"
                          VerticalContentAlignment="Center" Padding="6,4"/>
                <Button Style="{StaticResource RfhBtn}" x:Name="rfhBtn" Click="RfhBtn_Click"/>
            </StackPanel>
            <TextBlock Style="{StaticResource TitleBox}" Text="硬件调试"/>
        </DockPanel>
        <!-- 设备信息栏 -->
        <Border Grid.Row="1" BorderBrush="#CCCCCC" BorderThickness="1" Padding="10,6" Background="#F9F9F9"
                Visibility="{Binding HasDevice, Converter={StaticResource BoolToVis}}">
            <WrapPanel dp:StackPanelHelper.ChildSpace="20">
                <StackPanel Orientation="Horizontal" dp:StackPanelHelper.ChildSpace="4">
                    <TextBlock Text="设备ID:" FontWeight="Bold" Foreground="#555"/>
                    <TextBlock Text="{Binding CurrentDeviceId}" Style="{StaticResource ListTableTextBlock}"/>
                </StackPanel>
                <StackPanel Orientation="Horizontal" dp:StackPanelHelper.ChildSpace="4">
                    <TextBlock Text="型号:" FontWeight="Bold" Foreground="#555"/>
                    <TextBlock Text="{Binding CurrentDeviceKind}" Style="{StaticResource ListTableTextBlock}"/>
                </StackPanel>
                <StackPanel Orientation="Horizontal" dp:StackPanelHelper.ChildSpace="4">
                    <TextBlock Text="连接状态:" FontWeight="Bold" Foreground="#555"/>
                    <TextBlock Text="{Binding ConnectionStateText}" Foreground="{Binding ConnectionBrush}"/>
                </StackPanel>
                <StackPanel Orientation="Horizontal" dp:StackPanelHelper.ChildSpace="4">
                    <TextBlock Text="版本:" FontWeight="Bold" Foreground="#555"/>
                    <TextBlock Text="{Binding CurrentEffectiveVersion}" Style="{StaticResource ListTableTextBlock}"/>
                </StackPanel>
                <StackPanel Orientation="Horizontal" dp:StackPanelHelper.ChildSpace="4">
                    <TextBlock Text="预约状态:" FontWeight="Bold" Foreground="#555"/>
                    <TextBlock Text="{Binding ReservationStatusText}" Style="{StaticResource ListTableTextBlock}"/>
                </StackPanel>
                <StackPanel Orientation="Horizontal" dp:StackPanelHelper.ChildSpace="4">
                    <TextBlock Text="会话状态:" FontWeight="Bold" Foreground="#555"/>
                    <TextBlock Text="{Binding SessionStateText}" Foreground="{Binding SessionStateBrush}" FontWeight="Bold"/>
                </StackPanel>
            </WrapPanel>
        </Border>
        <!-- 操作按钮栏 -->
        <StackPanel Grid.Row="2" Orientation="Horizontal" dp:StackPanelHelper.ChildSpace="10"
                    Visibility="{Binding HasDevice, Converter={StaticResource BoolToVis}}">
            <Button x:Name="enterMaintenanceBtn" Content="进入维护控制" Padding="14,6"
                    IsEnabled="{Binding CanEnterMaintenance}"
                    Click="EnterMaintenanceBtn_Click"
                    Background="#2196F3" Foreground="White" BorderThickness="0" Cursor="Hand"/>
            <Button x:Name="exitMaintenanceBtn" Content="退出维护" Padding="14,6"
                    IsEnabled="{Binding CanExitMaintenance}"
                    Click="ExitMaintenanceBtn_Click"
                    Background="#FF5722" Foreground="White" BorderThickness="0" Cursor="Hand"/>
        </StackPanel>
        <!-- 主体区域 -->
        <Grid Grid.Row="3">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="*"/>
                <ColumnDefinition Width="350"/>
            </Grid.ColumnDefinitions>
            <!-- 左侧面板容器 -->
            <Border Grid.Column="0" BorderBrush="#DDDDDD" BorderThickness="1" Margin="0,0,8,0">
                <ContentControl x:Name="panelContainer">
                    <TextBlock Text="请选择设备" HorizontalAlignment="Center" VerticalAlignment="Center"
                               FontSize="16" Foreground="#999"/>
                </ContentControl>
            </Border>
            <!-- 右侧操作日志 -->
            <Border Grid.Column="1" BorderBrush="#DDDDDD" BorderThickness="1">
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinition Height="Auto"/>
                        <RowDefinition Height="*"/>
                    </Grid.RowDefinitions>
                    <Border Grid.Row="0" Background="#F0F0F0" Padding="8,6">
                        <TextBlock Text="操作日志" FontWeight="Bold" FontSize="14"/>
                    </Border>
                    <ListView Grid.Row="1" x:Name="operationLogList"
                              ItemsSource="{Binding OperationLogs}"
                              Style="{StaticResource DefaultListTable}"
                              VirtualizingPanel.IsVirtualizing="True"
                              VirtualizingPanel.VirtualizationMode="Standard">
                        <ListView.View>
                            <GridView AllowsColumnReorder="False">
                                <GridViewColumn Width="60" Header="类型">
                                    <GridViewColumn.CellTemplate>
                                        <DataTemplate>
                                            <Border Style="{StaticResource CellBorder}">
                                                <TextBlock Style="{StaticResource ListTableTextBlock}"
                                                           Text="{Binding CommandType}"/>
                                            </Border>
                                        </DataTemplate>
                                    </GridViewColumn.CellTemplate>
                                </GridViewColumn>
                                <GridViewColumn Width="120" Header="参数">
                                    <GridViewColumn.CellTemplate>
                                        <DataTemplate>
                                            <Border Style="{StaticResource CellBorder}">
                                                <TextBlock Style="{StaticResource ListTableTextBlock}"
                                                           Text="{Binding Parameters}" TextTrimming="CharacterEllipsis"
                                                           ToolTip="{Binding Parameters}"/>
                                            </Border>
                                        </DataTemplate>
                                    </GridViewColumn.CellTemplate>
                                </GridViewColumn>
                                <GridViewColumn Width="60" Header="结果">
                                    <GridViewColumn.CellTemplate>
                                        <DataTemplate>
                                            <Border Style="{StaticResource CellBorder}">
                                                <TextBlock Style="{StaticResource ListTableTextBlock}"
                                                           Text="{Binding Outcome}"/>
                                            </Border>
                                        </DataTemplate>
                                    </GridViewColumn.CellTemplate>
                                </GridViewColumn>
                                <GridViewColumn Width="100" Header="时间">
                                    <GridViewColumn.CellTemplate>
                                        <DataTemplate>
                                            <Border Style="{StaticResource CellBorder}">
                                                <TextBlock Style="{StaticResource ListTableTextBlock}"
                                                           Text="{Binding Timestamp, StringFormat=HH:mm:ss}"/>
                                            </Border>
                                        </DataTemplate>
                                    </GridViewColumn.CellTemplate>
                                </GridViewColumn>
                            </GridView>
                        </ListView.View>
                    </ListView>
                </Grid>
            </Border>
        </Grid>
    </Grid>
</hb:SimpleMaskControl>
Plugin.DeviceDebug/DeviceDebug.xaml.cs
New file
@@ -0,0 +1,668 @@
using Aging.Infrastructure.Contracts;
using CommunityToolkit.Mvvm.ComponentModel;
using HMI.Base;
using HMI.Core.Util;
using HMI.UI.Util;
using log4net;
using Plugin.DeviceDebug.Entity;
using Plugin.DeviceDebug.Model;
using Plugin.DeviceDebug.Panels;
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace Plugin.DeviceDebug
{
    /// <summary>
    /// DeviceDebug.xaml 的交互逻辑
    /// </summary>
    [ObservableObject]
    public partial class DeviceDebug : SimpleMaskControl
    {
        private static readonly ILog _log = LogManager.GetLogger(typeof(DeviceDebug));
        private DeviceDebugModel _debugModel;
        private DebugSessionManager _sessionManager;
        // 基础设施层服务
        private IDeviceManager? _deviceManager;
        private IDeviceService? _deviceService;
        private IResourceManager? _resourceManager;
        // 设备配置数据库路径(与 DeviceConfig 插件共享)
        private string _deviceConfigDbPath;
        [ObservableProperty] private ObservableCollection<string> _deviceList = new();
        [ObservableProperty] private string _selectedDevice;
        [ObservableProperty] private DebugSession _currentSession;
        [ObservableProperty] private ObservableCollection<DebugOperation> _operationLogs = new();
        [ObservableProperty] private string _connectionStateText = "未连接";
        [ObservableProperty] private string _sessionStateText = "";
        [ObservableProperty] private string _reservationStatusText = "";
        [ObservableProperty] private string _currentDeviceId = "";
        [ObservableProperty] private string _currentDeviceKind = "";
        [ObservableProperty] private string _currentEffectiveVersion = "";
        [ObservableProperty] private string _currentEndpoint = "";
        [ObservableProperty] private string _currentDriverType = "";
        [ObservableProperty] private Brush _connectionBrush = new SolidColorBrush(Colors.Gray);
        [ObservableProperty] private Brush _sessionStateBrush = new SolidColorBrush(Colors.Gray);
        [ObservableProperty] private bool _canEnterMaintenance;
        [ObservableProperty] private bool _canExitMaintenance;
        [ObservableProperty] private bool _hasDevice;
        // 缓存当前选中的设备类型
        private string _currentDeviceKindStr;
        public DeviceDebug()
        {
            DataContext = this;
            InitializeComponent();
        }
        /// <summary>
        /// 强制刷新设备列表(供集成测试预置设备后调用)
        /// </summary>
        public void ForceRefreshForTest()
        {
            try { LoadDeviceList(); }
            catch (Exception ex) { _log.Error("ForceRefreshForTest Error.", ex); }
        }
        public override void InitFinish()
        {
            base.InitFinish();
            try
            {
                // 初始化调试数据库
                _debugModel = new DeviceDebugModel();
                string dbPath = Path.Combine(BaseFolder, "debug.db");
                _debugModel.InitDatabase(dbPath);
                // 从 UtilContainer 获取基础设施服务
                var container = new UtilContainer();
                _deviceManager = container.GetUtil<IDeviceManager>("DeviceManager");
                _deviceService = container.GetUtil<IDeviceService>("DeviceService");
                _resourceManager = container.GetUtil<IResourceManager>("ResourceManager");
                // 创建会话管理器(传入 ResourceManager)
                _sessionManager = new DebugSessionManager(_debugModel, _resourceManager);
                // 设备配置数据库路径(与 DeviceConfig 共享)
                _deviceConfigDbPath = Path.Combine(BaseFolder, "data.db");
                log.Info("硬件调试模块初始化完成");
            }
            catch (Exception ex)
            {
                log.Error("硬件调试模块初始化失败", ex);
                ToastHelper.Error($"硬件调试初始化失败: {ex.Message}");
            }
        }
        /// <summary>
        /// 加载设备列表
        /// </summary>
        private void LoadDeviceList()
        {
            try
            {
                if (!File.Exists(_deviceConfigDbPath))
                {
                    log.Warn($"设备配置数据库不存在: {_deviceConfigDbPath}");
                    ToastHelper.Warning("设备配置数据库不存在,请先在设备配置模块中添加设备");
                    return;
                }
                var sqliteHelper = new SqliteHelper(_deviceConfigDbPath);
                var dt = sqliteHelper.GetDataTable("SELECT DeviceId, Kind FROM DeviceDefinition ORDER BY DeviceId");
                var list = new ObservableCollection<string>();
                foreach (System.Data.DataRow row in dt.Rows)
                {
                    string deviceId = row["DeviceId"].ToString();
                    string kind = row["Kind"].ToString();
                    list.Add($"{deviceId} [{kind}]");
                }
                DeviceList = list;
                log.Info($"加载设备列表完成,共 {list.Count} 个设备");
            }
            catch (Exception ex)
            {
                log.Error("加载设备列表失败", ex);
                ToastHelper.Error($"加载设备列表失败: {ex.Message}");
            }
        }
        /// <summary>
        /// 解析设备选择字符串,获取 DeviceId
        /// </summary>
        private string ParseDeviceId(string selection)
        {
            if (string.IsNullOrEmpty(selection)) return string.Empty;
            int idx = selection.IndexOf(" [");
            return idx > 0 ? selection.Substring(0, idx) : selection;
        }
        /// <summary>
        /// 解析设备选择字符串,获取 Kind
        /// </summary>
        private string ParseDeviceKind(string selection)
        {
            if (string.IsNullOrEmpty(selection)) return string.Empty;
            int start = selection.IndexOf(" [");
            int end = selection.IndexOf("]");
            if (start >= 0 && end > start)
                return selection.Substring(start + 2, end - start - 2);
            return string.Empty;
        }
        /// <summary>
        /// 加载设备详细信息
        /// </summary>
        private void LoadDeviceInfo(string deviceId)
        {
            try
            {
                if (!File.Exists(_deviceConfigDbPath)) return;
                var sqliteHelper = new SqliteHelper(_deviceConfigDbPath);
                var dt = sqliteHelper.GetDataTable(
                    $"SELECT DeviceId, Kind, EffectiveVersion, Endpoint, DriverType FROM DeviceDefinition WHERE DeviceId = '{deviceId}'");
                if (dt.Rows.Count > 0)
                {
                    var row = dt.Rows[0];
                    CurrentDeviceId = row["DeviceId"].ToString();
                    CurrentDeviceKind = row["Kind"].ToString();
                    CurrentEffectiveVersion = row["EffectiveVersion"]?.ToString() ?? "";
                    CurrentEndpoint = row["Endpoint"]?.ToString() ?? "";
                    CurrentDriverType = row["DriverType"]?.ToString() ?? "";
                    // 缓存设备类型
                    _currentDeviceKindStr = CurrentDeviceKind;
                }
            }
            catch (Exception ex)
            {
                log.Error($"加载设备信息失败: {deviceId}", ex);
            }
        }
        /// <summary>
        /// 选择设备时:打开会话(ReadOnly),加载对应面板
        /// </summary>
        private void OnDeviceSelected()
        {
            try
            {
                if (string.IsNullOrEmpty(SelectedDevice))
                {
                    HasDevice = false;
                    panelContainer.Content = new TextBlock
                    {
                        Text = "请选择设备",
                        HorizontalAlignment = HorizontalAlignment.Center,
                        VerticalAlignment = VerticalAlignment.Center,
                        FontSize = 16,
                        Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#999"))
                    };
                    return;
                }
                string deviceId = ParseDeviceId(SelectedDevice);
                string kind = ParseDeviceKind(SelectedDevice);
                // 加载设备详细信息
                LoadDeviceInfo(deviceId);
                HasDevice = true;
                // 打开会话(ReadOnly)
                CurrentSession = _sessionManager.OpenSession(deviceId, "", "operator", CurrentEffectiveVersion);
                // 通过 DeviceManager 连接设备
                if (_deviceManager != null)
                {
                    try
                    {
                        _deviceManager.ConnectAsync(deviceId, CancellationToken.None)
                            .GetAwaiter().GetResult();
                        ConnectionStateText = "已连接";
                        ConnectionBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4CAF50"));
                    }
                    catch (Exception ex)
                    {
                        log.Warn($"通过 DeviceManager 连接设备失败: {ex.Message}");
                        ConnectionStateText = "连接失败";
                        ConnectionBrush = new SolidColorBrush(Colors.OrangeRed);
                    }
                }
                // 加载操作日志
                LoadOperationLogs();
                // 更新按钮状态
                UpdateButtonStates();
                // 加载对应面板
                LoadPanel(kind);
                log.Info($"已选择设备: {deviceId}, 类型: {kind}");
            }
            catch (Exception ex)
            {
                log.Error("选择设备失败", ex);
                ToastHelper.Error($"选择设备失败: {ex.Message}");
            }
        }
        /// <summary>
        /// 根据设备类型加载对应的调试面板
        /// </summary>
        private void LoadPanel(string deviceKind)
        {
            try
            {
                UIElement panel = null;
                switch (deviceKind)
                {
                    case "PowerSupply":
                        var powerPanel = new PowerSupplyDebugPanel();
                        powerPanel.IsMaintenanceMode = CurrentSession?.State == DebugSessionState.MaintenanceActive;
                        powerPanel.OnCommandExecute += Panel_OnCommandExecute;
                        panel = powerPanel;
                        break;
                    case "CanDevice":
                        var canPanel = new CanDebugPanel
                        {
                            DeviceId = CurrentDeviceId,
                            InitialBusName = ParseCanBusName(CurrentEndpoint),
                            InitialNodeIndex = ParseCanNodeIndex(CurrentEndpoint),
                            AutoConnectOnLoad = true
                        };
                        canPanel.IsMaintenanceMode = CurrentSession?.State == DebugSessionState.MaintenanceActive;
                        canPanel.OnCommandExecute += Panel_OnCommandExecute;
                        panel = canPanel;
                        break;
                    case "PlcDevice":
                        var plcPanel = new PlcDebugPanel();
                        plcPanel.IsMaintenanceMode = CurrentSession?.State == DebugSessionState.MaintenanceActive;
                        plcPanel.OnCommandExecute += Panel_OnCommandExecute;
                        panel = plcPanel;
                        break;
                    default:
                        panel = new TextBlock
                        {
                            Text = $"不支持的设备类型: {deviceKind}",
                            HorizontalAlignment = HorizontalAlignment.Center,
                            VerticalAlignment = VerticalAlignment.Center,
                            FontSize = 14,
                            Foreground = new SolidColorBrush(Colors.OrangeRed)
                        };
                        break;
                }
                panelContainer.Content = panel;
            }
            catch (Exception ex)
            {
                log.Error($"加载调试面板失败: {deviceKind}", ex);
                ToastHelper.Error($"加载面板失败: {ex.Message}");
            }
        }
        /// <summary>
        /// 面板命令执行事件处理
        /// </summary>
        private void Panel_OnCommandExecute(object sender, DebugCommand command)
        {
            try
            {
                if (CurrentSession == null)
                {
                    ToastHelper.Warning("请先选择设备");
                    return;
                }
                // 验证命令权限
                if (!_sessionManager.ValidateCommand(CurrentSession.SessionId, command))
                {
                    ToastHelper.Warning("当前会话状态不允许执行此操作,请先进入维护模式");
                    return;
                }
                // 通过 DeviceService 执行实际命令
                if (_deviceService != null)
                {
                    ExecuteViaDeviceService(command);
                }
                else
                {
                    // 降级模式:基础设施未初始化时保留原有行为
                    log.Warn("DeviceService 未初始化,使用降级模式记录命令");
                    ExecuteFallback(command);
                }
            }
            catch (Exception ex)
            {
                log.Error($"执行命令失败: {command.CommandType}", ex);
                ToastHelper.Error($"命令执行失败: {ex.Message}");
                // 记录失败操作
                var operation = new DebugOperation
                {
                    SessionId = CurrentSession?.SessionId ?? "",
                    CommandType = command.CommandType,
                    Parameters = SerializeParameters(command),
                    Outcome = "Failed",
                    ErrorMessage = ex.Message,
                    Timestamp = DateTime.Now
                };
                _debugModel.RecordOperation(operation);
                OperationLogs.Insert(0, operation);
            }
        }
        /// <summary>
        /// 通过 DeviceService 执行设备命令
        /// </summary>
        private void ExecuteViaDeviceService(DebugCommand command)
        {
            try
            {
                var ownerToken = new ResourceOwnerToken(
                    OwnerType.Maintenance,
                    CurrentSession.SessionId);
                var deviceCommand = BuildDeviceCommand(command);
                var result = _deviceService.ExecuteCommandAsync(
                    command.DeviceId,
                    deviceCommand,
                    ownerToken,
                    CancellationToken.None).GetAwaiter().GetResult();
                var operation = new DebugOperation
                {
                    SessionId = CurrentSession.SessionId,
                    CommandType = command.CommandType,
                    Parameters = SerializeParameters(command),
                    Outcome = result.Success ? "Success" : "Failed",
                    DeviceReadback = result.Readback ?? "",
                    ErrorCode = result.ErrorCode ?? "",
                    ErrorMessage = result.ErrorMessage ?? "",
                    Timestamp = DateTime.Now
                };
                _debugModel.RecordOperation(operation);
                OperationLogs.Insert(0, operation);
                if (result.Success)
                    ToastHelper.Success($"命令 {command.CommandType} 执行成功");
                else
                    ToastHelper.Warning($"命令执行失败: {result.ErrorMessage}");
                log.Info($"执行命令: {command.CommandType}, 设备: {command.DeviceId}, 结果: {(result.Success ? "成功" : "失败")}");
            }
            catch (Exception ex)
            {
                log.Error($"DeviceService 执行命令失败: {ex.Message}", ex);
                ToastHelper.Error($"设备通信失败: {ex.Message}");
                var operation = new DebugOperation
                {
                    SessionId = CurrentSession.SessionId,
                    CommandType = command.CommandType,
                    Parameters = SerializeParameters(command),
                    Outcome = "Failed",
                    ErrorMessage = ex.Message,
                    Timestamp = DateTime.Now
                };
                _debugModel.RecordOperation(operation);
                OperationLogs.Insert(0, operation);
            }
        }
        /// <summary>
        /// 降级模式:无 DeviceService 时的后备处理
        /// </summary>
        private void ExecuteFallback(DebugCommand command)
        {
            var operation = new DebugOperation
            {
                SessionId = CurrentSession.SessionId,
                CommandType = command.CommandType,
                Parameters = SerializeParameters(command),
                Outcome = "Success",
                Timestamp = DateTime.Now
            };
            _debugModel.RecordOperation(operation);
            OperationLogs.Insert(0, operation);
            ToastHelper.Success($"命令 {command.CommandType} 已发送(降级模式)");
            log.Info($"[降级模式] 执行命令: {command.CommandType}, 设备: {command.DeviceId}");
        }
        /// <summary>
        /// 根据命令类型构建实际设备命令字符串
        /// </summary>
        private string BuildDeviceCommand(DebugCommand command)
        {
            if (command is PowerSetCommand psc)
            {
                return $"VOLT {psc.Voltage};CURR {psc.CurrentLimit};OUTP {(psc.Output ? "ON" : "OFF")}";
            }
            else if (command is PowerMeasureCommand)
            {
                return "MEAS:ALL?";
            }
            else if (command is CanSendCommand csc)
            {
                return $"CAN:SEND {csc.Frame}";
            }
            else if (command is PlcWriteCommand pwc)
            {
                return $"WRITE:{pwc.IoAddress}:{(pwc.Value ? "TRUE" : "FALSE")}";
            }
            return command.CommandType;
        }
        /// <summary>
        /// 序列化命令参数为JSON字符串
        /// </summary>
        private string SerializeParameters(DebugCommand command)
        {
            try
            {
                switch (command)
                {
                    case PowerSetCommand psc:
                        return $"{{Voltage:{psc.Voltage},CurrentLimit:{psc.CurrentLimit},Output:{psc.Output}}}";
                    case PowerMeasureCommand _:
                        return "{}";
                    case CanSendCommand csc:
                        return $"{{Frame:{csc.Frame},Data:{BitConverter.ToString(csc.Data)}}}";
                    case PlcWriteCommand pwc:
                        return $"{{IoAddress:{pwc.IoAddress},Value:{pwc.Value}}}";
                    default:
                        return "{}";
                }
            }
            catch
            {
                return "{}";
            }
        }
        /// <summary>
        /// 加载操作日志
        /// </summary>
        private void LoadOperationLogs()
        {
            try
            {
                if (CurrentSession == null) return;
                var ops = _debugModel.GetOperationsBySession(CurrentSession.SessionId);
                OperationLogs = new ObservableCollection<DebugOperation>(ops.Take(100));
            }
            catch (Exception ex)
            {
                log.Error("加载操作日志失败", ex);
            }
        }
        /// <summary>
        /// 更新按钮启用状态
        /// </summary>
        private void UpdateButtonStates()
        {
            if (CurrentSession == null)
            {
                CanEnterMaintenance = false;
                CanExitMaintenance = false;
                SessionStateText = "";
                return;
            }
            var state = CurrentSession.State;
            CanEnterMaintenance = state == DebugSessionState.ReadOnly;
            CanExitMaintenance = state == DebugSessionState.MaintenanceActive;
            // 更新会话状态文本和颜色
            SessionStateText = state.ToString();
            SessionStateBrush = state switch
            {
                DebugSessionState.ReadOnly => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#2196F3")),
                DebugSessionState.MaintenanceActive => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4CAF50")),
                DebugSessionState.Faulted => new SolidColorBrush(Colors.Red),
                DebugSessionState.RecoveryRequired => new SolidColorBrush(Colors.Orange),
                _ => new SolidColorBrush(Colors.Gray)
            };
            // 更新面板的维护模式
            if (panelContainer.Content is PowerSupplyDebugPanel powerPanel)
                powerPanel.IsMaintenanceMode = state == DebugSessionState.MaintenanceActive;
            else if (panelContainer.Content is CanDebugPanel canPanel)
                canPanel.IsMaintenanceMode = state == DebugSessionState.MaintenanceActive;
            else if (panelContainer.Content is PlcDebugPanel plcPanel)
                plcPanel.IsMaintenanceMode = state == DebugSessionState.MaintenanceActive;
        }
        #region 事件处理
        private void SimpleMaskControl_Loaded(object sender, RoutedEventArgs e)
        {
            LoadDeviceList();
            Loaded -= SimpleMaskControl_Loaded;
        }
        private void DeviceSelector_SelectionChanged(object sender, SelectionChangedEventArgs e)
        {
            OnDeviceSelected();
        }
        private void RfhBtn_Click(object sender, RoutedEventArgs e)
        {
            LoadDeviceList();
        }
        private void EnterMaintenanceBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                if (CurrentSession == null) return;
                // 请求维护模式
                if (_sessionManager.RequestMaintenance(CurrentSession.SessionId))
                {
                    // 激活维护模式
                    _sessionManager.ActivateMaintenance(CurrentSession.SessionId);
                    UpdateButtonStates();
                    // 重新加载面板以更新IsMaintenanceMode
                    LoadPanel(CurrentDeviceKind);
                    ToastHelper.Success("已进入维护控制模式");
                    log.Info($"会话 {CurrentSession.SessionId} 进入维护模式");
                }
                else
                {
                    ToastHelper.Warning("无法进入维护模式,请检查设备状态");
                }
            }
            catch (Exception ex)
            {
                log.Error("进入维护模式失败", ex);
                ToastHelper.Error($"进入维护模式失败: {ex.Message}");
            }
        }
        private void ExitMaintenanceBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                if (CurrentSession == null) return;
                if (MsgBoxHelper.WarnMessage(Window.GetWindow(this), "确定要退出维护模式吗?") == MessageBoxResult.OK)
                {
                    _sessionManager.CloseSession(CurrentSession.SessionId);
                    CurrentSession = null;
                    UpdateButtonStates();
                    // 清空面板
                    panelContainer.Content = new TextBlock
                    {
                        Text = "请选择设备",
                        HorizontalAlignment = HorizontalAlignment.Center,
                        VerticalAlignment = VerticalAlignment.Center,
                        FontSize = 16,
                        Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#999"))
                    };
                    HasDevice = false;
                    SelectedDevice = null;
                    ToastHelper.Success("已退出维护模式");
                    log.Info("已退出维护模式");
                }
            }
            catch (Exception ex)
            {
                log.Error("退出维护模式失败", ex);
                ToastHelper.Error($"退出维护模式失败: {ex.Message}");
            }
        }
        /// <summary>
        /// 从 CAN Endpoint 解析总线名称(格式: 总线名:节点编号)
        /// </summary>
        private string ParseCanBusName(string endpoint)
        {
            if (string.IsNullOrEmpty(endpoint)) return "CAN_BUS_1";
            int idx = endpoint.IndexOf(':');
            return idx > 0 ? endpoint.Substring(0, idx) : endpoint;
        }
        /// <summary>
        /// 从 CAN Endpoint 解析节点编号
        /// </summary>
        private int? ParseCanNodeIndex(string endpoint)
        {
            if (string.IsNullOrEmpty(endpoint)) return 0;
            int idx = endpoint.IndexOf(':');
            if (idx > 0 && idx < endpoint.Length - 1 && int.TryParse(endpoint.Substring(idx + 1), out int n))
                return n;
            return 0;
        }
        #endregion
    }
}
Plugin.DeviceDebug/Entity/DebugCommand.cs
New file
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Plugin.DeviceDebug.Entity
{
    /// <summary>
    /// 调试命令基类
    /// </summary>
    public partial class DebugCommand : ObservableObject
    {
        public DebugCommand()
        {
            CommandId = Guid.NewGuid().ToString();
            CreatedAt = DateTime.Now;
            Parameters = new Dictionary<string, object>();
        }
        [ObservableProperty] private string _commandId = string.Empty;
        [ObservableProperty] private string _commandType = string.Empty;
        [ObservableProperty] private string _deviceId = string.Empty;
        [ObservableProperty] private int _channel;
        [ObservableProperty] private Dictionary<string, object> _parameters = new();
        [ObservableProperty] private DateTime _createdAt = DateTime.Now;
    }
    /// <summary>
    /// 电源设置命令
    /// </summary>
    public class PowerSetCommand : DebugCommand
    {
        public PowerSetCommand() { CommandType = "PowerSet"; }
        public double Voltage { get; set; }
        public double CurrentLimit { get; set; }
        public bool Output { get; set; }
    }
    /// <summary>
    /// 电源测量命令
    /// </summary>
    public class PowerMeasureCommand : DebugCommand
    {
        public PowerMeasureCommand() { CommandType = "PowerMeasure"; }
    }
    /// <summary>
    /// CAN发送命令
    /// </summary>
    public class CanSendCommand : DebugCommand
    {
        public CanSendCommand() { CommandType = "CanSend"; }
        public string Frame { get; set; } = string.Empty;
        public byte[] Data { get; set; } = Array.Empty<byte>();
    }
    /// <summary>
    /// PLC写入命令
    /// </summary>
    public class PlcWriteCommand : DebugCommand
    {
        public PlcWriteCommand() { CommandType = "PlcWrite"; }
        public string IoAddress { get; set; } = string.Empty;
        public bool Value { get; set; }
    }
}
Plugin.DeviceDebug/Entity/DebugOperation.cs
New file
@@ -0,0 +1,27 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Plugin.DeviceDebug.Entity
{
    /// <summary>
    /// 操作审计实体 - 记录每次调试操作
    /// </summary>
    public partial class DebugOperation : ObservableObject
    {
        public DebugOperation()
        {
            OperationId = Guid.NewGuid().ToString();
            Timestamp = DateTime.Now;
        }
        [ObservableProperty] private string _operationId = string.Empty;
        [ObservableProperty] private string _sessionId = string.Empty;   // FK → DebugSession
        [ObservableProperty] private string _commandType = string.Empty; // PowerSet/PowerGet/OutputOn/OutputOff/CanSend/PlcWrite等
        [ObservableProperty] private string _parameters = string.Empty;  // JSON字符串
        [ObservableProperty] private string _outcome = string.Empty;     // Success/Failed/Rejected/Timeout
        [ObservableProperty] private string _deviceReadback = string.Empty;
        [ObservableProperty] private string _errorCode = string.Empty;
        [ObservableProperty] private string _errorMessage = string.Empty;
        [ObservableProperty] private DateTime _timestamp = DateTime.Now;
    }
}
Plugin.DeviceDebug/Entity/DebugSession.cs
New file
@@ -0,0 +1,75 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Plugin.DeviceDebug.Entity
{
    /// <summary>
    /// 调试会话状态枚举
    /// </summary>
    public enum DebugSessionState
    {
        /// <summary>只读模式(默认)</summary>
        ReadOnly = 0,
        /// <summary>正在申请维护权限</summary>
        AcquiringMaintenance = 1,
        /// <summary>维护模式激活</summary>
        MaintenanceActive = 2,
        /// <summary>正在关闭</summary>
        Closing = 3,
        /// <summary>已关闭</summary>
        Closed = 4,
        /// <summary>故障</summary>
        Faulted = 5,
        /// <summary>需要恢复</summary>
        RecoveryRequired = 6
    }
    /// <summary>
    /// 调试会话实体
    /// </summary>
    public partial class DebugSession : ObservableObject
    {
        public DebugSession()
        {
            SessionId = Guid.NewGuid().ToString();
            CreatedAt = DateTime.Now;
            State = DebugSessionState.ReadOnly;
        }
        /// <summary>会话ID(主键)</summary>
        [ObservableProperty]
        private string _sessionId = string.Empty;
        /// <summary>设备ID</summary>
        [ObservableProperty]
        private string _deviceId = string.Empty;
        /// <summary>操作员ID</summary>
        [ObservableProperty]
        private string _operatorId = string.Empty;
        /// <summary>操作员名称</summary>
        [ObservableProperty]
        private string _operatorName = string.Empty;
        /// <summary>生效版本</summary>
        [ObservableProperty]
        private string _effectiveVersion = string.Empty;
        /// <summary>会话状态</summary>
        [ObservableProperty]
        private DebugSessionState _state = DebugSessionState.ReadOnly;
        /// <summary>预约引用</summary>
        [ObservableProperty]
        private string _reservationRef = string.Empty;
        /// <summary>创建时间</summary>
        [ObservableProperty]
        private DateTime _createdAt = DateTime.Now;
        /// <summary>关闭时间</summary>
        [ObservableProperty]
        private DateTime? _closedAt;
    }
}
Plugin.DeviceDebug/Entity/DeviceRuntimeStatus.cs
New file
@@ -0,0 +1,44 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Plugin.DeviceDebug.Entity
{
    public enum DeviceConnectionState
    {
        Connected,
        Disconnected,
        Connecting,
        Error
    }
    public enum DeviceHealth
    {
        Healthy,
        Degraded,
        Fault
    }
    public enum ReservationStatus
    {
        Available,
        ProductionReserved,
        MaintenanceReserved,
        RecoveryRequired
    }
    /// <summary>
    /// 设备运行状态(内存对象,不持久化)
    /// </summary>
    public partial class DeviceRuntimeStatus : ObservableObject
    {
        [ObservableProperty] private string _deviceId = string.Empty;
        [ObservableProperty] private DeviceConnectionState _connectionState = DeviceConnectionState.Disconnected;
        [ObservableProperty] private DeviceHealth _health = DeviceHealth.Healthy;
        [ObservableProperty] private string _lastError = string.Empty;
        [ObservableProperty] private DateTime? _lastErrorTime;
        [ObservableProperty] private ReservationStatus _reservationStatus = ReservationStatus.Available;
        [ObservableProperty] private string _effectiveVersion = string.Empty;
        [ObservableProperty] private int _reconnectCount;
        [ObservableProperty] private double _communicationLatencyMs;
    }
}
Plugin.DeviceDebug/Model/DebugSessionManager.cs
New file
@@ -0,0 +1,380 @@
using System;
using System.Collections.Generic;
using System.Linq;
using log4net;
using Plugin.DeviceDebug.Entity;
using Aging.Infrastructure.Contracts;
namespace Plugin.DeviceDebug.Model
{
    /// <summary>
    /// 调试会话管理器 - 负责状态机转换和权限校验
    /// </summary>
    public class DebugSessionManager
    {
        private static readonly ILog log = LogManager.GetLogger(typeof(DebugSessionManager));
        private readonly DeviceDebugModel _model;
        private readonly IResourceManager? _resourceManager;
        // 内存中的活跃会话缓存
        private readonly Dictionary<string, DebugSession> _activeSessions = new();
        public DebugSessionManager(DeviceDebugModel model, IResourceManager? resourceManager = null)
        {
            _model = model;
            _resourceManager = resourceManager;
        }
        /// <summary>
        /// 打开会话(ReadOnly模式)
        /// </summary>
        public DebugSession OpenSession(string deviceId, string operatorId, string operatorName, string effectiveVersion)
        {
            try
            {
                // 检查是否已有活跃会话
                var existingSession = GetActiveSessionByDevice(deviceId);
                if (existingSession != null)
                {
                    log.Warn($"设备 {deviceId} 已有活跃会话: {existingSession.SessionId}");
                    return existingSession;
                }
                var session = new DebugSession
                {
                    DeviceId = deviceId,
                    OperatorId = operatorId,
                    OperatorName = operatorName,
                    EffectiveVersion = effectiveVersion,
                    State = DebugSessionState.ReadOnly
                };
                // 持久化到数据库
                _model.CreateSession(session);
                // 更新内存缓存
                _activeSessions[session.SessionId] = session;
                log.Info($"打开会话: {session.SessionId}, 设备: {deviceId}, 操作员: {operatorName}");
                return session;
            }
            catch (Exception ex)
            {
                log.Error($"打开会话失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <summary>
        /// 请求进入维护模式(ReadOnly → AcquiringMaintenance)
        /// 检查:设备是否有生产Reserved
        /// </summary>
        public bool RequestMaintenance(string sessionId)
        {
            try
            {
                if (!_activeSessions.TryGetValue(sessionId, out var session))
                {
                    log.Warn($"会话不存在: {sessionId}");
                    return false;
                }
                // 验证状态转换
                if (session.State != DebugSessionState.ReadOnly)
                {
                    log.Warn($"会话 {sessionId} 当前状态为 {session.State},无法请求维护模式");
                    return false;
                }
                // 检查设备是否有生产Reserved(与资源预约系统集成)
                if (_resourceManager != null)
                {
                    var resourceKey = new ResourceKey(session.DeviceId, "Generic");
                    var ownerToken = new ResourceOwnerToken(OwnerType.Maintenance, session.SessionId);
                    try
                    {
                        var reservation = _resourceManager.ReserveAsync(
                            ownerToken, resourceKey, CancellationToken.None).GetAwaiter().GetResult();
                        session.ReservationRef = reservation.ReservationId;
                    }
                    catch (ResourceConflictException)
                    {
                        log.Warn($"设备 {session.DeviceId} 已被生产预约占用,拒绝维护请求");
                        return false;
                    }
                }
                else
                {
                    // 降级模式:无 ResourceManager 时允许所有请求(向后兼容)
                    log.Warn("ResourceManager 未初始化,跳过资源预约检查");
                }
                // 状态转换
                session.State = DebugSessionState.AcquiringMaintenance;
                // 持久化
                _model.UpdateSession(session);
                log.Info($"会话 {sessionId} 请求维护模式");
                return true;
            }
            catch (Exception ex)
            {
                log.Error($"请求维护模式失败: {ex.Message}", ex);
                return false;
            }
        }
        /// <summary>
        /// 激活维护模式(AcquiringMaintenance → MaintenanceActive)
        /// </summary>
        public void ActivateMaintenance(string sessionId)
        {
            try
            {
                if (!_activeSessions.TryGetValue(sessionId, out var session))
                {
                    log.Warn($"会话不存在: {sessionId}");
                    throw new InvalidOperationException($"会话不存在: {sessionId}");
                }
                // 验证状态转换
                if (session.State != DebugSessionState.AcquiringMaintenance)
                {
                    log.Warn($"会话 {sessionId} 当前状态为 {session.State},无法激活维护模式");
                    throw new InvalidOperationException($"会话 {sessionId} 当前状态为 {session.State},无法激活维护模式");
                }
                // 状态转换
                session.State = DebugSessionState.MaintenanceActive;
                // 持久化
                _model.UpdateSession(session);
                log.Info($"会话 {sessionId} 激活维护模式");
            }
            catch (Exception ex)
            {
                log.Error($"激活维护模式失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <summary>
        /// 关闭会话(MaintenanceActive → Closing → Closed)
        /// 执行安全状态检查
        /// </summary>
        public void CloseSession(string sessionId)
        {
            try
            {
                if (!_activeSessions.TryGetValue(sessionId, out var session))
                {
                    log.Warn($"会话不存在: {sessionId}");
                    throw new InvalidOperationException($"会话不存在: {sessionId}");
                }
                // 验证状态转换(只有 MaintenanceActive 可以关闭)
                if (session.State != DebugSessionState.MaintenanceActive)
                {
                    log.Warn($"会话 {sessionId} 当前状态为 {session.State},无法关闭");
                    throw new InvalidOperationException($"会话 {sessionId} 当前状态为 {session.State},无法关闭");
                }
                // 状态转换: MaintenanceActive → Closing
                session.State = DebugSessionState.Closing;
                _model.UpdateSession(session);
                log.Debug($"会话 {sessionId} 进入关闭中状态");
                // 执行安全状态检查:释放资源预约
                if (_resourceManager != null && !string.IsNullOrEmpty(session.ReservationRef))
                {
                    try
                    {
                        var resourceKey = new ResourceKey(session.DeviceId, "Generic");
                        var ownerToken = new ResourceOwnerToken(OwnerType.Maintenance, session.SessionId);
                        _resourceManager.ReleaseReservationAsync(
                            ownerToken, resourceKey, CancellationToken.None).GetAwaiter().GetResult();
                        log.Info($"已释放设备 {session.DeviceId} 的资源预约");
                    }
                    catch (Exception ex)
                    {
                        log.Error($"释放资源预约失败: {ex.Message}", ex);
                        // 不阻止关闭流程,但记录警告
                    }
                }
                // 状态转换: Closing → Closed
                session.State = DebugSessionState.Closed;
                session.ClosedAt = DateTime.Now;
                _model.UpdateSession(session);
                // 从缓存中移除
                _activeSessions.Remove(sessionId);
                log.Info($"会话 {sessionId} 已关闭");
            }
            catch (Exception ex)
            {
                log.Error($"关闭会话失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <summary>
        /// 故障处理(任意 → Faulted)
        /// </summary>
        public void FaultSession(string sessionId, string errorMessage)
        {
            try
            {
                if (!_activeSessions.TryGetValue(sessionId, out var session))
                {
                    log.Warn($"会话不存在: {sessionId}");
                    return;
                }
                // 记录当前状态
                var previousState = session.State;
                // 状态转换
                session.State = DebugSessionState.Faulted;
                // 持久化
                _model.UpdateSession(session);
                log.Error($"会话 {sessionId} 发生故障,从 {previousState} 转为 Faulted: {errorMessage}");
            }
            catch (Exception ex)
            {
                log.Error($"标记故障状态失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 标记需要恢复(任意 → RecoveryRequired)
        /// </summary>
        public void MarkRecoveryRequired(string sessionId)
        {
            try
            {
                if (!_activeSessions.TryGetValue(sessionId, out var session))
                {
                    log.Warn($"会话不存在: {sessionId}");
                    return;
                }
                // 记录当前状态
                var previousState = session.State;
                // 状态转换
                session.State = DebugSessionState.RecoveryRequired;
                // 持久化
                _model.UpdateSession(session);
                log.Warn($"会话 {sessionId} 标记为需要恢复,从 {previousState} 转为 RecoveryRequired");
            }
            catch (Exception ex)
            {
                log.Error($"标记恢复状态失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 验证命令是否允许执行(只有 MaintenanceActive 状态允许写操作)
        /// </summary>
        public bool ValidateCommand(string sessionId, DebugCommand command)
        {
            try
            {
                if (!_activeSessions.TryGetValue(sessionId, out var session))
                {
                    log.Warn($"会话不存在: {sessionId}");
                    return false;
                }
                // 只有 MaintenanceActive 状态允许写操作
                bool allowed = session.State == DebugSessionState.MaintenanceActive;
                if (!allowed)
                {
                    log.Warn($"会话 {sessionId} 状态为 {session.State},拒绝执行命令: {command.CommandType}");
                }
                // 增加资源预约验证
                if (allowed && _resourceManager != null)
                {
                    var resourceKey = new ResourceKey(session.DeviceId, "Generic");
                    allowed = _resourceManager.ValidateOwner(
                        new ResourceOwnerToken(OwnerType.Maintenance, session.SessionId),
                        resourceKey);
                    if (!allowed)
                    {
                        log.Warn($"会话 {sessionId} 资源预约验证失败,拒绝执行命令: {command.CommandType}");
                    }
                }
                return allowed;
            }
            catch (Exception ex)
            {
                log.Error($"验证命令失败: {ex.Message}", ex);
                return false;
            }
        }
        /// <summary>
        /// 获取会话状态
        /// </summary>
        public DebugSessionState? GetSessionState(string sessionId)
        {
            try
            {
                if (_activeSessions.TryGetValue(sessionId, out var session))
                {
                    return session.State;
                }
                // 从数据库查询
                var dbSession = _model.GetSession(sessionId);
                return dbSession?.State;
            }
            catch (Exception ex)
            {
                log.Error($"获取会话状态失败: {ex.Message}", ex);
                return null;
            }
        }
        /// <summary>
        /// 获取设备当前会话(如果有)
        /// </summary>
        public DebugSession? GetActiveSessionByDevice(string deviceId)
        {
            try
            {
                // 先从内存缓存查找
                var cachedSession = _activeSessions.Values
                    .FirstOrDefault(s => s.DeviceId == deviceId && s.State != DebugSessionState.Closed);
                if (cachedSession != null)
                {
                    return cachedSession;
                }
                // 从数据库查询
                var activeSessions = _model.GetActiveSessionsByDevice(deviceId);
                return activeSessions.FirstOrDefault();
            }
            catch (Exception ex)
            {
                log.Error($"获取设备活跃会话失败: {ex.Message}", ex);
                return null;
            }
        }
    }
}
Plugin.DeviceDebug/Model/DeviceDebugModel.cs
New file
@@ -0,0 +1,374 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.IO;
using System.Linq;
using HMI.Core.Util;
using log4net;
using Plugin.DeviceDebug.Entity;
namespace Plugin.DeviceDebug.Model
{
    /// <summary>
    /// 数据访问层 - 管理调试会话和操作审计的持久化
    /// </summary>
    public class DeviceDebugModel
    {
        private static readonly ILog log = LogManager.GetLogger(typeof(DeviceDebugModel));
        private SqliteHelper? _sqliteHelper;
        private string _dbPath = string.Empty;
        /// <summary>
        /// 初始化数据库,创建必要的表结构
        /// </summary>
        public void InitDatabase(string dbPath)
        {
            try
            {
                _dbPath = dbPath;
                // 确保目录存在
                string? dir = Path.GetDirectoryName(dbPath);
                if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
                {
                    Directory.CreateDirectory(dir);
                }
                // 如果数据库文件不存在,创建它
                if (!File.Exists(dbPath))
                {
                    SqliteHelper.CreateDatabase(dbPath);
                    log.Info($"创建调试数据库: {dbPath}");
                }
                _sqliteHelper = new SqliteHelper(dbPath);
                // 创建 debug_session 表
                string createSessionTable = @"
                    CREATE TABLE IF NOT EXISTS debug_session (
                        session_id TEXT PRIMARY KEY,
                        device_id TEXT NOT NULL,
                        operator_id TEXT NOT NULL,
                        operator_name TEXT,
                        effective_version TEXT,
                        state INTEGER NOT NULL DEFAULT 0,
                        reservation_ref TEXT,
                        created_at TEXT NOT NULL,
                        closed_at TEXT
                    )";
                // 创建 debug_operation 表
                string createOperationTable = @"
                    CREATE TABLE IF NOT EXISTS debug_operation (
                        operation_id TEXT PRIMARY KEY,
                        session_id TEXT NOT NULL,
                        command_type TEXT NOT NULL,
                        parameters TEXT,
                        outcome TEXT NOT NULL,
                        device_readback TEXT,
                        error_code TEXT,
                        error_message TEXT,
                        timestamp TEXT NOT NULL,
                        FOREIGN KEY (session_id) REFERENCES debug_session(session_id)
                    )";
                _sqliteHelper.ExecuteSql(createSessionTable);
                _sqliteHelper.ExecuteSql(createOperationTable);
                log.Info("调试数据库表初始化完成");
            }
            catch (Exception ex)
            {
                log.Error($"初始化调试数据库失败: {ex.Message}", ex);
                throw;
            }
        }
        #region 会话 CRUD
        /// <summary>
        /// 创建会话
        /// </summary>
        public void CreateSession(DebugSession session)
        {
            try
            {
                if (_sqliteHelper == null)
                    throw new InvalidOperationException("数据库未初始化,请先调用 InitDatabase");
                string sql = $@"
                    INSERT INTO debug_session (
                        session_id, device_id, operator_id, operator_name,
                        effective_version, state, reservation_ref, created_at, closed_at
                    ) VALUES (
                        '{EscapeSql(session.SessionId)}',
                        '{EscapeSql(session.DeviceId)}',
                        '{EscapeSql(session.OperatorId)}',
                        '{EscapeSql(session.OperatorName)}',
                        '{EscapeSql(session.EffectiveVersion)}',
                        {(int)session.State},
                        '{EscapeSql(session.ReservationRef)}',
                        '{session.CreatedAt:yyyy-MM-dd HH:mm:ss}',
                        {(session.ClosedAt.HasValue ? $"'{session.ClosedAt.Value:yyyy-MM-dd HH:mm:ss}'" : "NULL")}
                    )";
                _sqliteHelper.ExecuteSql(sql);
                log.Debug($"创建会话: {session.SessionId}, 设备: {session.DeviceId}");
            }
            catch (Exception ex)
            {
                log.Error($"创建会话失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <summary>
        /// 获取会话
        /// </summary>
        public DebugSession? GetSession(string sessionId)
        {
            try
            {
                if (_sqliteHelper == null)
                    throw new InvalidOperationException("数据库未初始化,请先调用 InitDatabase");
                string sql = $"SELECT * FROM debug_session WHERE session_id = '{EscapeSql(sessionId)}'";
                DataTable dt = _sqliteHelper.GetDataTable(sql);
                if (dt.Rows.Count == 0)
                    return null;
                return MapToDebugSession(dt.Rows[0]);
            }
            catch (Exception ex)
            {
                log.Error($"获取会话失败: {ex.Message}", ex);
                return null;
            }
        }
        /// <summary>
        /// 更新会话
        /// </summary>
        public void UpdateSession(DebugSession session)
        {
            try
            {
                if (_sqliteHelper == null)
                    throw new InvalidOperationException("数据库未初始化,请先调用 InitDatabase");
                string sql = $@"
                    UPDATE debug_session SET
                        device_id = '{EscapeSql(session.DeviceId)}',
                        operator_id = '{EscapeSql(session.OperatorId)}',
                        operator_name = '{EscapeSql(session.OperatorName)}',
                        effective_version = '{EscapeSql(session.EffectiveVersion)}',
                        state = {(int)session.State},
                        reservation_ref = '{EscapeSql(session.ReservationRef)}',
                        closed_at = {(session.ClosedAt.HasValue ? $"'{session.ClosedAt.Value:yyyy-MM-dd HH:mm:ss}'" : "NULL")}
                    WHERE session_id = '{EscapeSql(session.SessionId)}'";
                _sqliteHelper.ExecuteSql(sql);
                log.Debug($"更新会话: {session.SessionId}, 状态: {session.State}");
            }
            catch (Exception ex)
            {
                log.Error($"更新会话失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <summary>
        /// 获取设备的所有活跃会话(未关闭的)
        /// </summary>
        public List<DebugSession> GetActiveSessionsByDevice(string deviceId)
        {
            try
            {
                if (_sqliteHelper == null)
                    throw new InvalidOperationException("数据库未初始化,请先调用 InitDatabase");
                string sql = $@"
                    SELECT * FROM debug_session
                    WHERE device_id = '{EscapeSql(deviceId)}'
                    AND state != {(int)DebugSessionState.Closed}
                    ORDER BY created_at DESC";
                DataTable dt = _sqliteHelper.GetDataTable(sql);
                return dt.Rows.Cast<DataRow>().Select(MapToDebugSession).ToList();
            }
            catch (Exception ex)
            {
                log.Error($"获取设备活跃会话失败: {ex.Message}", ex);
                return new List<DebugSession>();
            }
        }
        /// <summary>
        /// 获取所有会话
        /// </summary>
        public List<DebugSession> GetAllSessions()
        {
            try
            {
                if (_sqliteHelper == null)
                    throw new InvalidOperationException("数据库未初始化,请先调用 InitDatabase");
                string sql = "SELECT * FROM debug_session ORDER BY created_at DESC";
                DataTable dt = _sqliteHelper.GetDataTable(sql);
                return dt.Rows.Cast<DataRow>().Select(MapToDebugSession).ToList();
            }
            catch (Exception ex)
            {
                log.Error($"获取所有会话失败: {ex.Message}", ex);
                return new List<DebugSession>();
            }
        }
        #endregion
        #region 审计 CRUD
        /// <summary>
        /// 记录操作
        /// </summary>
        public void RecordOperation(DebugOperation operation)
        {
            try
            {
                if (_sqliteHelper == null)
                    throw new InvalidOperationException("数据库未初始化,请先调用 InitDatabase");
                string sql = $@"
                    INSERT INTO debug_operation (
                        operation_id, session_id, command_type, parameters,
                        outcome, device_readback, error_code, error_message, timestamp
                    ) VALUES (
                        '{EscapeSql(operation.OperationId)}',
                        '{EscapeSql(operation.SessionId)}',
                        '{EscapeSql(operation.CommandType)}',
                        '{EscapeSql(operation.Parameters)}',
                        '{EscapeSql(operation.Outcome)}',
                        '{EscapeSql(operation.DeviceReadback)}',
                        '{EscapeSql(operation.ErrorCode)}',
                        '{EscapeSql(operation.ErrorMessage)}',
                        '{operation.Timestamp:yyyy-MM-dd HH:mm:ss}'
                    )";
                _sqliteHelper.ExecuteSql(sql);
                log.Debug($"记录操作: {operation.OperationId}, 类型: {operation.CommandType}, 结果: {operation.Outcome}");
            }
            catch (Exception ex)
            {
                log.Error($"记录操作失败: {ex.Message}", ex);
                throw;
            }
        }
        /// <summary>
        /// 获取会话的所有操作
        /// </summary>
        public List<DebugOperation> GetOperationsBySession(string sessionId)
        {
            try
            {
                if (_sqliteHelper == null)
                    throw new InvalidOperationException("数据库未初始化,请先调用 InitDatabase");
                string sql = $@"
                    SELECT * FROM debug_operation
                    WHERE session_id = '{EscapeSql(sessionId)}'
                    ORDER BY timestamp DESC";
                DataTable dt = _sqliteHelper.GetDataTable(sql);
                return dt.Rows.Cast<DataRow>().Select(MapToDebugOperation).ToList();
            }
            catch (Exception ex)
            {
                log.Error($"获取会话操作失败: {ex.Message}", ex);
                return new List<DebugOperation>();
            }
        }
        /// <summary>
        /// 获取设备最近的操作
        /// </summary>
        public List<DebugOperation> GetRecentOperations(string deviceId, int count)
        {
            try
            {
                if (_sqliteHelper == null)
                    throw new InvalidOperationException("数据库未初始化,请先调用 InitDatabase");
                string sql = $@"
                    SELECT o.* FROM debug_operation o
                    INNER JOIN debug_session s ON o.session_id = s.session_id
                    WHERE s.device_id = '{EscapeSql(deviceId)}'
                    ORDER BY o.timestamp DESC
                    LIMIT {count}";
                DataTable dt = _sqliteHelper.GetDataTable(sql);
                return dt.Rows.Cast<DataRow>().Select(MapToDebugOperation).ToList();
            }
            catch (Exception ex)
            {
                log.Error($"获取最近操作失败: {ex.Message}", ex);
                return new List<DebugOperation>();
            }
        }
        #endregion
        #region 辅助方法
        /// <summary>
        /// 将 DataRow 映射为 DebugSession
        /// </summary>
        private DebugSession MapToDebugSession(DataRow row)
        {
            return new DebugSession
            {
                SessionId = row["session_id"].ToString() ?? string.Empty,
                DeviceId = row["device_id"].ToString() ?? string.Empty,
                OperatorId = row["operator_id"].ToString() ?? string.Empty,
                OperatorName = row["operator_name"]?.ToString() ?? string.Empty,
                EffectiveVersion = row["effective_version"]?.ToString() ?? string.Empty,
                State = (DebugSessionState)Convert.ToInt32(row["state"]),
                ReservationRef = row["reservation_ref"]?.ToString() ?? string.Empty,
                CreatedAt = DateTime.Parse(row["created_at"].ToString() ?? DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")),
                ClosedAt = row["closed_at"] != DBNull.Value && !string.IsNullOrEmpty(row["closed_at"].ToString())
                    ? DateTime.Parse(row["closed_at"].ToString()!)
                    : null
            };
        }
        /// <summary>
        /// 将 DataRow 映射为 DebugOperation
        /// </summary>
        private DebugOperation MapToDebugOperation(DataRow row)
        {
            return new DebugOperation
            {
                OperationId = row["operation_id"].ToString() ?? string.Empty,
                SessionId = row["session_id"].ToString() ?? string.Empty,
                CommandType = row["command_type"].ToString() ?? string.Empty,
                Parameters = row["parameters"]?.ToString() ?? string.Empty,
                Outcome = row["outcome"].ToString() ?? string.Empty,
                DeviceReadback = row["device_readback"]?.ToString() ?? string.Empty,
                ErrorCode = row["error_code"]?.ToString() ?? string.Empty,
                ErrorMessage = row["error_message"]?.ToString() ?? string.Empty,
                Timestamp = DateTime.Parse(row["timestamp"].ToString() ?? DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))
            };
        }
        /// <summary>
        /// SQL 字符串转义(防止注入)
        /// </summary>
        private string EscapeSql(string value)
        {
            return value.Replace("'", "''");
        }
        #endregion
    }
}
Plugin.DeviceDebug/Panels/CanDebugPanel.xaml
New file
@@ -0,0 +1,220 @@
<UserControl x:Class="Plugin.DeviceDebug.Panels.CanDebugPanel"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:ui="clr-namespace:HMI.UI;assembly=HMI.UI"
             xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
             mc:Ignorable="d"
             d:DesignHeight="700" d:DesignWidth="700">
    <UserControl.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/HMI.UI;component/Style/PanuonListView.xaml"/>
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </UserControl.Resources>
    <Grid dp:GridHelper.Space="10" Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <!-- 标题 -->
        <Border Grid.Row="0" Background="#FF9800" CornerRadius="4" Padding="12,8">
            <TextBlock Text="CAN 通讯调试面板" FontSize="16" FontWeight="Bold" Foreground="White"/>
        </Border>
        <!-- 模拟器连接控制区 -->
        <Border Grid.Row="1" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4" Padding="12" Background="#FAFAFA">
            <Grid dp:GridHelper.Space="8">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="80"/>
                    <ColumnDefinition Width="150"/>
                    <ColumnDefinition Width="80"/>
                    <ColumnDefinition Width="100"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="Auto"/>
                </Grid.ColumnDefinitions>
                <!-- 第一行:总线名称和节点编号 -->
                <TextBlock Grid.Row="0" Grid.Column="0" Text="总线名称:" VerticalAlignment="Center" FontWeight="SemiBold"/>
                <TextBox Grid.Row="0" Grid.Column="1" x:Name="busNameInput"
                         VerticalContentAlignment="Center" Padding="6,4" Text="CAN_BUS_1"/>
                <TextBlock Grid.Row="0" Grid.Column="2" Text="节点编号:" VerticalAlignment="Center" FontWeight="SemiBold" Margin="8,0,0,0"/>
                <TextBox Grid.Row="0" Grid.Column="3" x:Name="nodeIndexInput"
                         VerticalContentAlignment="Center" Padding="6,4" Text="0"/>
                <Button Grid.Row="0" Grid.Column="5" x:Name="connectBtn" Content="连接" Width="80" Height="30"
                        Click="ConnectBtn_Click"
                        Background="#4CAF50" Foreground="White" BorderThickness="0" Cursor="Hand" FontWeight="SemiBold"/>
                <!-- 第二行:状态显示和控制按钮 -->
                <TextBlock Grid.Row="1" Grid.Column="0" Text="状态:" VerticalAlignment="Center" FontWeight="SemiBold"/>
                <StackPanel Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Orientation="Horizontal" VerticalAlignment="Center">
                    <Ellipse x:Name="statusIndicator" Width="12" Height="12" Fill="#9E9E9E" Margin="0,0,6,0"/>
                    <TextBlock x:Name="statusText" Text="未连接" VerticalAlignment="Center"/>
                </StackPanel>
                <TextBlock Grid.Row="1" Grid.Column="3" Text="统计:" VerticalAlignment="Center" FontWeight="SemiBold" Margin="8,0,0,0"/>
                <TextBlock Grid.Row="1" Grid.Column="4" x:Name="statsText" Text="TX: 0  RX: 0"
                           VerticalAlignment="Center" Foreground="#666"/>
                <StackPanel Grid.Row="1" Grid.Column="5" Orientation="Horizontal">
                    <ToggleButton x:Name="autoResponseToggleBtn" Content="自动响应: 关" Width="100" Height="28" Margin="0,0,4,0"
                                  Click="AutoResponseToggleBtn_Click" IsEnabled="False" IsChecked="False"
                                  Background="#9E9E9E" Foreground="White" BorderThickness="0" Cursor="Hand" FontWeight="SemiBold"/>
                    <ToggleButton x:Name="maintenanceToggleBtn" Content="维护模式: 开" Width="100" Height="28" Margin="0,0,4,0"
                                  Click="MaintenanceToggleBtn_Click" IsEnabled="False" IsChecked="True"
                                  Background="#4CAF50" Foreground="White" BorderThickness="0" Cursor="Hand" FontWeight="SemiBold"/>
                    <Button x:Name="disconnectBtn" Content="断开" Width="60" Height="28" Margin="0,0,4,0"
                            Click="DisconnectBtn_Click" IsEnabled="False"
                            Background="#F44336" Foreground="White" BorderThickness="0" Cursor="Hand"/>
                    <Button x:Name="promiscuousBtn" Content="混杂模式" Width="80" Height="28"
                            Click="PromiscuousBtn_Click" IsEnabled="False"
                            Background="#2196F3" Foreground="White" BorderThickness="0" Cursor="Hand"/>
                </StackPanel>
            </Grid>
        </Border>
        <!-- 发送区 -->
        <Border Grid.Row="2" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4" Padding="12">
            <Grid dp:GridHelper.Space="8">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="80"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="Auto"/>
                </Grid.ColumnDefinitions>
                <!-- 帧ID -->
                <TextBlock Grid.Row="0" Grid.Column="0" Text="帧 ID:" VerticalAlignment="Center" FontWeight="Bold"/>
                <TextBox Grid.Row="0" Grid.Column="1" x:Name="frameIdInput"
                         IsEnabled="{Binding IsMaintenanceMode, RelativeSource={RelativeSource AncestorType=UserControl}}"
                         VerticalContentAlignment="Center" Padding="6,4" Text="0x100"/>
                <!-- 数据 -->
                <TextBlock Grid.Row="1" Grid.Column="0" Text="数据(Hex):" VerticalAlignment="Center" FontWeight="Bold"/>
                <TextBox Grid.Row="1" Grid.Column="1" x:Name="dataInput"
                         IsEnabled="{Binding IsMaintenanceMode, RelativeSource={RelativeSource AncestorType=UserControl}}"
                         VerticalContentAlignment="Center" Padding="6,4" Text="00 00 00 00 00 00 00 00"/>
                <!-- 发送按钮 -->
                <Button Grid.Row="1" Grid.Column="2" x:Name="sendBtn" Content="发送" Width="80" Height="30"
                        IsEnabled="{Binding IsMaintenanceMode, RelativeSource={RelativeSource AncestorType=UserControl}}"
                        Click="SendBtn_Click"
                        Background="#FF9800" Foreground="White" BorderThickness="0" Cursor="Hand" FontWeight="SemiBold"/>
            </Grid>
        </Border>
        <!-- 接收区 -->
        <Border Grid.Row="3" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4">
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="*"/>
                </Grid.RowDefinitions>
                <DockPanel Grid.Row="0" Margin="8,6">
                    <Button x:Name="clearBtn" Content="清空" Padding="12,4" DockPanel.Dock="Right"
                            Click="ClearBtn_Click"
                            Background="#9E9E9E" Foreground="White" BorderThickness="0" Cursor="Hand"/>
                    <TextBlock Text="接收记录" FontWeight="Bold" FontSize="14" VerticalAlignment="Center"/>
                </DockPanel>
                <ListView Grid.Row="1" x:Name="receiveList"
                          Style="{StaticResource DefaultListTable}"
                          VirtualizingPanel.IsVirtualizing="True"
                          VirtualizingPanel.VirtualizationMode="Standard">
                    <ListView.View>
                        <GridView AllowsColumnReorder="False">
                            <GridViewColumn Width="90" Header="时间">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}"
                                                       Text="{Binding Timestamp, StringFormat=HH:mm:ss.fff}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="80" Header="帧ID">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}"
                                                       Text="{Binding FrameId}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="50" Header="DLC">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}"
                                                       Text="{Binding Dlc}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="200" Header="数据">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}"
                                                       Text="{Binding Data}" FontFamily="Consolas"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="50" Header="方向">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Text="{Binding Direction}">
                                                <TextBlock.Style>
                                                    <Style TargetType="TextBlock" BasedOn="{StaticResource ListTableTextBlock}">
                                                        <Style.Triggers>
                                                            <DataTrigger Binding="{Binding Direction}" Value="TX">
                                                                <Setter Property="Foreground" Value="#2196F3"/>
                                                            </DataTrigger>
                                                            <DataTrigger Binding="{Binding Direction}" Value="RX">
                                                                <Setter Property="Foreground" Value="#4CAF50"/>
                                                            </DataTrigger>
                                                        </Style.Triggers>
                                                    </Style>
                                                </TextBlock.Style>
                                            </TextBlock>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="100" Header="来源">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}"
                                                       Text="{Binding Source}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                        </GridView>
                    </ListView.View>
                </ListView>
            </Grid>
        </Border>
    </Grid>
</UserControl>
Plugin.DeviceDebug/Panels/CanDebugPanel.xaml.cs
New file
@@ -0,0 +1,543 @@
using Aging.Infrastructure.Drivers;
using log4net;
using Plugin.DeviceDebug.Entity;
using System;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Threading;
namespace Plugin.DeviceDebug.Panels
{
    /// <summary>
    /// CAN 帧显示模型
    /// </summary>
    public class CanFrameDisplay
    {
        public DateTime Timestamp { get; set; }
        public string FrameId { get; set; } = string.Empty;
        public int Dlc { get; set; }
        public string Data { get; set; } = string.Empty;
        public string Direction { get; set; } = string.Empty; // TX / RX
        public string Source { get; set; } = string.Empty;
    }
    /// <summary>
    /// CanDebugPanel.xaml 的交互逻辑
    /// </summary>
    public partial class CanDebugPanel : UserControl
    {
        private static readonly ILog log = LogManager.GetLogger(typeof(CanDebugPanel));
        /// <summary>
        /// 是否为维护模式(控制输入可用性)
        /// </summary>
        public static readonly DependencyProperty IsMaintenanceModeProperty =
            DependencyProperty.Register(nameof(IsMaintenanceMode), typeof(bool), typeof(CanDebugPanel),
                new PropertyMetadata(false));
        public bool IsMaintenanceMode
        {
            get => (bool)GetValue(IsMaintenanceModeProperty);
            set => SetValue(IsMaintenanceModeProperty, value);
        }
        /// <summary>
        /// 命令执行事件
        /// </summary>
        public event EventHandler<DebugCommand>? OnCommandExecute;
        /// <summary>
        /// 接收帧列表
        /// </summary>
        public ObservableCollection<CanFrameDisplay> ReceivedFrames { get; } = new();
        /// <summary>
        /// CAN 驱动适配器
        /// </summary>
        private CanDriverAdapter? _driver;
        /// <summary>
        /// 轮询定时器(用于检查接收邮箱)
        /// </summary>
        private DispatcherTimer? _pollTimer;
        /// <summary>
        /// 是否已连接
        /// </summary>
        private bool _isConnected;
        /// <summary>
        /// 设备ID(从 DeviceDebug 传入,面板自动拼接 deviceId)
        /// </summary>
        public string DeviceId
        {
            get => _deviceId;
            set
            {
                _deviceId = value;
                if (!string.IsNullOrEmpty(value))
                {
                    // 将设备ID前缀拼接到默认设备标识中,避免与模拟器面板重名
                    UpdateDeviceIdDisplay();
                }
            }
        }
        private string _deviceId = string.Empty;
        /// <summary>
        /// 从设备 Endpoint 解析出的总线名称
        /// </summary>
        public string InitialBusName { get; set; }
        /// <summary>
        /// 从设备 Endpoint 解析出的节点编号
        /// </summary>
        public int? InitialNodeIndex { get; set; }
        /// <summary>
        /// 是否自动连接(从 DeviceDebug 打开时=true)
        /// </summary>
        public bool AutoConnectOnLoad { get; set; }
        public CanDebugPanel()
        {
            InitializeComponent();
            receiveList.ItemsSource = ReceivedFrames;
            InitializePollTimer();
            Loaded += CanDebugPanel_Loaded;
        }
        private void CanDebugPanel_Loaded(object sender, RoutedEventArgs e)
        {
            Loaded -= CanDebugPanel_Loaded;
            // 从设备信息初始化总线/节点输入
            if (!string.IsNullOrEmpty(InitialBusName))
            {
                busNameInput.Text = InitialBusName;
            }
            if (InitialNodeIndex.HasValue)
            {
                nodeIndexInput.Text = InitialNodeIndex.Value.ToString();
            }
            // 自动连接
            if (AutoConnectOnLoad)
            {
                Dispatcher.BeginInvoke(new Action(() =>
                {
                    try { DoConnect(); }
                    catch (Exception ex) { log.Warn($"自动连接失败: {ex.Message}"); }
                }), DispatcherPriority.Background);
            }
        }
        private void UpdateDeviceIdDisplay()
        {
            if (!string.IsNullOrEmpty(_deviceId) && busNameInput != null)
            {
                // 保留默认总线名,仅记录 deviceId 以供日志使用
                log.Info($"[CanDebugPanel] 关联设备: {_deviceId}");
            }
        }
        /// <summary>
        /// 初始化轮询定时器
        /// </summary>
        private void InitializePollTimer()
        {
            _pollTimer = new DispatcherTimer
            {
                Interval = TimeSpan.FromMilliseconds(100) // 100ms 轮询一次
            };
            _pollTimer.Tick += PollTimer_Tick;
        }
        /// <summary>
        /// 轮询定时器回调 - 仅更新统计信息(帧显示由事件驱动)
        /// </summary>
        private void PollTimer_Tick(object? sender, EventArgs e)
        {
            if (!_isConnected || _driver == null) return;
            try
            {
                // 更新统计
                UpdateStats();
            }
            catch (Exception ex)
            {
                log.Error($"轮询统计更新失败: {ex.Message}");
            }
        }
        /// <summary>
        /// 连接按钮点击
        /// </summary>
        private void ConnectBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                DoConnect();
            }
            catch (Exception ex)
            {
                log.Error($"CAN 连接失败: {ex.Message}", ex);
                MessageBox.Show($"连接失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
                UpdateConnectionStatus(false);
            }
        }
        /// <summary>
        /// 实际连接逻辑(从 UI 或自动连接调用)
        /// </summary>
        private void DoConnect()
        {
            if (_isConnected) return;
            string busName = busNameInput.Text.Trim();
            if (!int.TryParse(nodeIndexInput.Text.Trim(), out int nodeIndex))
            {
                throw new ArgumentException("节点编号格式错误");
            }
            // 优先使用 DeviceDebug 传入的设备 ID,否则自动生成一个
            string deviceId = string.IsNullOrEmpty(_deviceId)
                ? $"CAN_Test_{nodeIndex}_{DateTime.Now:HHmmss}"
                : _deviceId;
            _driver = new CanDriverAdapter(deviceId, busName, nodeIndex);
            // 订阅驱动的帧接收事件(即时显示接收到的帧)
            _driver.DriverFrameReceived += OnDriverFrameReceived;
            // 连接
            _driver.ConnectAsync(CancellationToken.None).GetAwaiter().GetResult();
            _isConnected = true;
            // 模拟器模式下,连接后自动启用维护模式(允许发送)
            IsMaintenanceMode = true;
            if (maintenanceToggleBtn != null)
            {
                maintenanceToggleBtn.IsChecked = true;
                maintenanceToggleBtn.IsEnabled = true;
            }
            // 更新 UI
            UpdateConnectionStatus(true);
            _pollTimer?.Start();
            log.Info($"CAN 驱动已连接: Bus={busName}, Node={nodeIndex}, DeviceId={deviceId}");
        }
        /// <summary>
        /// 驱动帧接收事件处理 - 即时显示接收到的帧
        /// </summary>
        private void OnDriverFrameReceived(object? sender, CanSimulator.CanFrame frame)
        {
            try
            {
                // 判断方向:如果帧的来源是当前节点,则为自动响应(显示为 RX)
                string direction = "RX";
                string source = frame.SourceNodeId;
                AddReceivedFrame(
                    frame.FrameIdHex,
                    frame.DataHex,
                    direction,
                    source
                );
                log.Debug($"[即时显示] 接收帧: {frame}");
            }
            catch (Exception ex)
            {
                log.Error($"即时显示帧失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 断开按钮点击
        /// </summary>
        private void DisconnectBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                _pollTimer?.Stop();
                if (_driver != null)
                {
                    _driver.DriverFrameReceived -= OnDriverFrameReceived;
                    _driver.DisconnectAsync().GetAwaiter().GetResult();
                    _driver.Dispose();
                }
                _driver = null;
                _isConnected = false;
                IsMaintenanceMode = false;
                UpdateConnectionStatus(false);
                log.Info("CAN 驱动已断开");
            }
            catch (Exception ex)
            {
                log.Error($"CAN 断开失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 混杂模式按钮点击
        /// </summary>
        private void PromiscuousBtn_Click(object sender, RoutedEventArgs e)
        {
            if (!_isConnected || _driver == null) return;
            try
            {
                _driver.QueryAsync("PROMISCUOUS", CancellationToken.None).GetAwaiter().GetResult();
                log.Info("已设置混杂模式(接收所有帧)");
                MessageBox.Show("已设置混杂模式,将接收所有 CAN 帧", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
            }
            catch (Exception ex)
            {
                log.Error($"设置混杂模式失败: {ex.Message}");
            }
        }
        /// <summary>
        /// 维护模式切换按钮点击
        /// </summary>
        private void MaintenanceToggleBtn_Click(object sender, RoutedEventArgs e)
        {
            bool isOn = maintenanceToggleBtn.IsChecked == true;
            IsMaintenanceMode = isOn;
            if (isOn)
            {
                maintenanceToggleBtn.Content = "维护模式: 开";
                maintenanceToggleBtn.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4CAF50"));
                log.Info("维护模式已开启(可发送 CAN 帧)");
            }
            else
            {
                maintenanceToggleBtn.Content = "维护模式: 关";
                maintenanceToggleBtn.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9E9E9E"));
                log.Info("维护模式已关闭(只读监控)");
            }
        }
        /// <summary>
        /// 自动响应模式切换按钮点击
        /// </summary>
        private void AutoResponseToggleBtn_Click(object sender, RoutedEventArgs e)
        {
            if (!_isConnected || _driver == null) return;
            try
            {
                bool isOn = autoResponseToggleBtn.IsChecked == true;
                // 通过驱动发送命令启用/禁用自动响应
                string cmd = isOn ? "AUTO_RESPONSE:ON" : "AUTO_RESPONSE:OFF";
                _driver.QueryAsync(cmd, CancellationToken.None).GetAwaiter().GetResult();
                if (isOn)
                {
                    autoResponseToggleBtn.Content = "自动响应: 开";
                    autoResponseToggleBtn.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FF9800"));
                    log.Info("自动响应模式已开启(收到帧将自动回复)");
                }
                else
                {
                    autoResponseToggleBtn.Content = "自动响应: 关";
                    autoResponseToggleBtn.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9E9E9E"));
                    log.Info("自动响应模式已关闭");
                }
            }
            catch (Exception ex)
            {
                log.Error($"切换自动响应模式失败: {ex.Message}");
                MessageBox.Show($"切换失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
            }
        }
        /// <summary>
        /// 更新连接状态显示
        /// </summary>
        private void UpdateConnectionStatus(bool connected)
        {
            _isConnected = connected;
            if (connected)
            {
                statusIndicator.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4CAF50"));
                statusText.Text = "已连接";
                connectBtn.IsEnabled = false;
                disconnectBtn.IsEnabled = true;
                promiscuousBtn.IsEnabled = true;
                maintenanceToggleBtn.IsEnabled = true;
                autoResponseToggleBtn.IsEnabled = true;
                busNameInput.IsEnabled = false;
                nodeIndexInput.IsEnabled = false;
            }
            else
            {
                statusIndicator.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9E9E9E"));
                statusText.Text = "未连接";
                connectBtn.IsEnabled = true;
                disconnectBtn.IsEnabled = false;
                promiscuousBtn.IsEnabled = false;
                maintenanceToggleBtn.IsEnabled = false;
                autoResponseToggleBtn.IsEnabled = false;
                busNameInput.IsEnabled = true;
                nodeIndexInput.IsEnabled = true;
            }
        }
        /// <summary>
        /// 更新统计信息
        /// </summary>
        private void UpdateStats()
        {
            if (_driver == null) return;
            try
            {
                var status = _driver.GetStatus();
                var response = _driver.QueryAsync("STATUS", CancellationToken.None).GetAwaiter().GetResult();
                // 解析状态: "STATUS:Node=..., Active=True, Rx=0, Tx=0, Err=0, Mailbox=0|Bus=..."
                statsText.Text = $"TX: {GetStatValue(response, "Tx")}  RX: {GetStatValue(response, "Rx")}  Err: {GetStatValue(response, "Err")}";
            }
            catch
            {
                // 忽略统计更新失败
            }
        }
        /// <summary>
        /// 从状态字符串中提取数值值
        /// </summary>
        private string GetStatValue(string statusStr, string key)
        {
            try
            {
                var parts = statusStr.Split(',');
                foreach (var part in parts)
                {
                    var kv = part.Trim().Split('=');
                    if (kv.Length == 2 && kv[0].Trim() == key)
                    {
                        return kv[1].Trim();
                    }
                }
            }
            catch { }
            return "0";
        }
        /// <summary>
        /// 添加接收帧
        /// </summary>
        public void AddReceivedFrame(string frameId, string data, string direction = "RX", string source = "")
        {
            try
            {
                var frame = new CanFrameDisplay
                {
                    Timestamp = DateTime.Now,
                    FrameId = frameId,
                    Dlc = data.Split(new[] { ' ', '-' }, StringSplitOptions.RemoveEmptyEntries).Length,
                    Data = data,
                    Direction = direction,
                    Source = source
                };
                Application.Current.Dispatcher.Invoke(() =>
                {
                    ReceivedFrames.Insert(0, frame);
                    // 限制显示数量
                    if (ReceivedFrames.Count > 500)
                    {
                        var toRemove = ReceivedFrames.Skip(500).ToList();
                        foreach (var item in toRemove)
                        {
                            ReceivedFrames.Remove(item);
                        }
                    }
                });
            }
            catch (Exception ex)
            {
                log.Error($"添加接收帧失败: {ex.Message}", ex);
            }
        }
        /// <summary>
        /// 发送按钮点击
        /// </summary>
        private void SendBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                if (!IsMaintenanceMode)
                {
                    MessageBox.Show("当前为只读模式,无法发送数据", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
                    return;
                }
                if (!_isConnected || _driver == null)
                {
                    MessageBox.Show("请先连接 CAN 总线", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
                    return;
                }
                string frameIdStr = frameIdInput.Text.Trim();
                string dataStr = dataInput.Text.Trim();
                // 验证帧 ID
                uint frameId = CanSimulator.CanFrame.ParseFrameId(frameIdStr);
                // 验证数据
                byte[] dataBytes = CanSimulator.CanFrame.ParseData(dataStr);
                if (dataBytes.Length > 8)
                {
                    MessageBox.Show("数据长度不能超过 8 字节", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
                    return;
                }
                // 通过驱动发送
                string command = $"SEND:{frameIdStr}:{dataStr}";
                var response = _driver.SendCommandAsync(command, CancellationToken.None).GetAwaiter().GetResult();
                // 添加到显示列表(TX)
                AddReceivedFrame($"0x{frameId:X3}", BitConverter.ToString(dataBytes).Replace("-", " "), "TX", "LOCAL");
                log.Info($"发送 CAN 帧: ID=0x{frameId:X3}, Data={BitConverter.ToString(dataBytes)}, Response={response}");
            }
            catch (Exception ex)
            {
                log.Error($"发送 CAN 帧失败: {ex.Message}", ex);
                MessageBox.Show($"发送失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
            }
        }
        /// <summary>
        /// 清空按钮点击
        /// </summary>
        private void ClearBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                ReceivedFrames.Clear();
                log.Info("清空 CAN 接收记录");
            }
            catch (Exception ex)
            {
                log.Error($"清空接收记录失败: {ex.Message}", ex);
            }
        }
    }
}
Plugin.DeviceDebug/Panels/PlcDebugPanel.xaml
New file
@@ -0,0 +1,176 @@
<UserControl x:Class="Plugin.DeviceDebug.Panels.PlcDebugPanel"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:ui="clr-namespace:HMI.UI;assembly=HMI.UI"
             xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
             mc:Ignorable="d"
             d:DesignHeight="500" d:DesignWidth="600">
    <UserControl.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/HMI.UI;component/Style/PanuonListView.xaml"/>
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </UserControl.Resources>
    <Grid dp:GridHelper.Space="12" Margin="12">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <!-- 标题 -->
        <Border Grid.Row="0" Background="#9C27B0" CornerRadius="4" Padding="12,8">
            <TextBlock Text="PLC 调试面板" FontSize="16" FontWeight="Bold" Foreground="White"/>
        </Border>
        <!-- IO 控制区 -->
        <Border Grid.Row="1" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4" Padding="12">
            <Grid dp:GridHelper.Space="10">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="80"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                <!-- 地址 -->
                <TextBlock Grid.Row="0" Grid.Column="0" Text="地址:" VerticalAlignment="Center" FontWeight="Bold"/>
                <TextBox Grid.Row="0" Grid.Column="1" x:Name="addressInput"
                         IsEnabled="{Binding IsMaintenanceMode, RelativeSource={RelativeSource AncestorType=UserControl}}"
                         VerticalContentAlignment="Center" Padding="6,4" Text="M0.0"/>
                <!-- 值 -->
                <TextBlock Grid.Row="1" Grid.Column="0" Text="值:" VerticalAlignment="Center" FontWeight="Bold"/>
                <ComboBox Grid.Row="1" Grid.Column="1" x:Name="valueSelect"
                          IsEnabled="{Binding IsMaintenanceMode, RelativeSource={RelativeSource AncestorType=UserControl}}"
                          VerticalContentAlignment="Center" Padding="6,4">
                    <ComboBoxItem Content="True" IsSelected="True"/>
                    <ComboBoxItem Content="False"/>
                </ComboBox>
                <!-- 写入按钮 -->
                <Button Grid.Row="2" Grid.ColumnSpan="2" x:Name="writeBtn" Content="写入" Padding="20,6"
                        IsEnabled="{Binding IsMaintenanceMode, RelativeSource={RelativeSource AncestorType=UserControl}}"
                        Click="WriteBtn_Click" HorizontalAlignment="Left"
                        Background="#9C27B0" Foreground="White" BorderThickness="0" Cursor="Hand"/>
            </Grid>
        </Border>
        <!-- IO 状态显示区 -->
        <Border Grid.Row="2" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4">
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>
                <Border Grid.Row="0" Background="#F5F5F5" Padding="8,6">
                    <TextBlock Text="IO 点位状态" FontWeight="Bold" FontSize="14"/>
                </Border>
                <ListView Grid.Row="1" x:Name="ioStatusList" Height="180"
                          Style="{StaticResource DefaultListTable}"
                          VirtualizingPanel.IsVirtualizing="True"
                          VirtualizingPanel.VirtualizationMode="Standard">
                    <ListView.View>
                        <GridView AllowsColumnReorder="False">
                            <GridViewColumn Width="120" Header="地址">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}"
                                                       Text="{Binding Address}" FontFamily="Consolas"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="80" Header="值">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Text="{Binding Value}">
                                                <TextBlock.Style>
                                                    <Style TargetType="TextBlock" BasedOn="{StaticResource ListTableTextBlock}">
                                                        <Style.Triggers>
                                                            <DataTrigger Binding="{Binding Value}" Value="True">
                                                                <Setter Property="Foreground" Value="#4CAF50"/>
                                                                <Setter Property="FontWeight" Value="Bold"/>
                                                            </DataTrigger>
                                                            <DataTrigger Binding="{Binding Value}" Value="False">
                                                                <Setter Property="Foreground" Value="#999"/>
                                                            </DataTrigger>
                                                        </Style.Triggers>
                                                    </Style>
                                                </TextBlock.Style>
                                            </TextBlock>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                            <GridViewColumn Width="150" Header="更新时间">
                                <GridViewColumn.CellTemplate>
                                    <DataTemplate>
                                        <Border Style="{StaticResource CellBorder}">
                                            <TextBlock Style="{StaticResource ListTableTextBlock}"
                                                       Text="{Binding LastUpdate, StringFormat=HH:mm:ss}"/>
                                        </Border>
                                    </DataTemplate>
                                </GridViewColumn.CellTemplate>
                            </GridViewColumn>
                        </GridView>
                    </ListView.View>
                </ListView>
            </Grid>
        </Border>
        <!-- 互锁状态区 -->
        <Border Grid.Row="3" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4">
            <Grid>
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="*"/>
                </Grid.RowDefinitions>
                <Border Grid.Row="0" Background="#FFF3E0" Padding="8,6">
                    <TextBlock Text="互锁状态" FontWeight="Bold" FontSize="14" Foreground="#E65100"/>
                </Border>
                <ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
                    <ItemsControl x:Name="interlockList" Margin="8">
                        <ItemsControl.ItemTemplate>
                            <DataTemplate>
                                <Border BorderBrush="#EEE" BorderThickness="0,0,0,1" Padding="4,6">
                                    <StackPanel Orientation="Horizontal">
                                        <TextBlock Text="{Binding Name}" FontWeight="Bold" Width="150"/>
                                        <TextBlock Text="{Binding Status}" Margin="10,0,0,0">
                                            <TextBlock.Style>
                                                <Style TargetType="TextBlock">
                                                    <Style.Triggers>
                                                        <DataTrigger Binding="{Binding Status}" Value="满足">
                                                            <Setter Property="Foreground" Value="#4CAF50"/>
                                                        </DataTrigger>
                                                        <DataTrigger Binding="{Binding Status}" Value="不满足">
                                                            <Setter Property="Foreground" Value="#F44336"/>
                                                        </DataTrigger>
                                                    </Style.Triggers>
                                                </Style>
                                            </TextBlock.Style>
                                        </TextBlock>
                                    </StackPanel>
                                </Border>
                            </DataTemplate>
                        </ItemsControl.ItemTemplate>
                    </ItemsControl>
                </ScrollViewer>
            </Grid>
        </Border>
    </Grid>
</UserControl>
Plugin.DeviceDebug/Panels/PlcDebugPanel.xaml.cs
New file
@@ -0,0 +1,197 @@
using log4net;
using Plugin.DeviceDebug.Entity;
using System;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
namespace Plugin.DeviceDebug.Panels
{
    /// <summary>
    /// IO 点位状态显示模型
    /// </summary>
    public class IoPointStatus
    {
        public string Address { get; set; }
        public bool Value { get; set; }
        public DateTime LastUpdate { get; set; }
    }
    /// <summary>
    /// 互锁状态显示模型
    /// </summary>
    public class InterlockStatus
    {
        public string Name { get; set; }
        public string Status { get; set; } // "满足" / "不满足"
    }
    /// <summary>
    /// PlcDebugPanel.xaml 的交互逻辑
    /// </summary>
    public partial class PlcDebugPanel : UserControl
    {
        private static readonly ILog log = LogManager.GetLogger(typeof(PlcDebugPanel));
        /// <summary>
        /// 是否为维护模式(控制输入可用性)
        /// </summary>
        public static readonly DependencyProperty IsMaintenanceModeProperty =
            DependencyProperty.Register(nameof(IsMaintenanceMode), typeof(bool), typeof(PlcDebugPanel),
                new PropertyMetadata(false));
        public bool IsMaintenanceMode
        {
            get => (bool)GetValue(IsMaintenanceModeProperty);
            set => SetValue(IsMaintenanceModeProperty, value);
        }
        /// <summary>
        /// 命令执行事件
        /// </summary>
        public event EventHandler<DebugCommand> OnCommandExecute;
        /// <summary>
        /// IO 点位状态列表
        /// </summary>
        public ObservableCollection<IoPointStatus> IoStatuses { get; } = new();
        /// <summary>
        /// 互锁状态列表
        /// </summary>
        public ObservableCollection<InterlockStatus> Interlocks { get; } = new();
        public PlcDebugPanel()
        {
            InitializeComponent();
            ioStatusList.ItemsSource = IoStatuses;
            interlockList.ItemsSource = Interlocks;
            // 初始化示例数据
            InitSampleData();
        }
        /// <summary>
        /// 初始化示例数据(实际应从 PLC 读取)
        /// </summary>
        private void InitSampleData()
        {
            try
            {
                // 添加示例 IO 点位
                IoStatuses.Add(new IoPointStatus { Address = "M0.0", Value = false, LastUpdate = DateTime.Now });
                IoStatuses.Add(new IoPointStatus { Address = "M0.1", Value = true, LastUpdate = DateTime.Now });
                IoStatuses.Add(new IoPointStatus { Address = "M0.2", Value = false, LastUpdate = DateTime.Now });
                IoStatuses.Add(new IoPointStatus { Address = "I0.0", Value = true, LastUpdate = DateTime.Now });
                IoStatuses.Add(new IoPointStatus { Address = "Q0.0", Value = false, LastUpdate = DateTime.Now });
                // 添加示例互锁
                Interlocks.Add(new InterlockStatus { Name = "安全门关闭", Status = "满足" });
                Interlocks.Add(new InterlockStatus { Name = "急停未触发", Status = "满足" });
                Interlocks.Add(new InterlockStatus { Name = "气压正常", Status = "不满足" });
                Interlocks.Add(new InterlockStatus { Name = "温度正常", Status = "满足" });
            }
            catch (Exception ex)
            {
                log.Error("初始化示例数据失败", ex);
            }
        }
        /// <summary>
        /// 更新 IO 点位状态
        /// </summary>
        public void UpdateIoStatus(string address, bool value)
        {
            try
            {
                var existing = IoStatuses.FirstOrDefault(s => s.Address == address);
                if (existing != null)
                {
                    existing.Value = value;
                    existing.LastUpdate = DateTime.Now;
                }
                else
                {
                    IoStatuses.Add(new IoPointStatus
                    {
                        Address = address,
                        Value = value,
                        LastUpdate = DateTime.Now
                    });
                }
            }
            catch (Exception ex)
            {
                log.Error($"更新 IO 状态失败: {address}", ex);
            }
        }
        /// <summary>
        /// 更新互锁状态
        /// </summary>
        public void UpdateInterlock(string name, bool satisfied)
        {
            try
            {
                var existing = Interlocks.FirstOrDefault(i => i.Name == name);
                if (existing != null)
                {
                    existing.Status = satisfied ? "满足" : "不满足";
                }
                else
                {
                    Interlocks.Add(new InterlockStatus
                    {
                        Name = name,
                        Status = satisfied ? "满足" : "不满足"
                    });
                }
            }
            catch (Exception ex)
            {
                log.Error($"更新互锁状态失败: {name}", ex);
            }
        }
        private void WriteBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                if (!IsMaintenanceMode)
                {
                    MessageBox.Show("当前为只读模式,无法执行写入操作", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
                    return;
                }
                string address = addressInput.Text.Trim();
                if (string.IsNullOrEmpty(address))
                {
                    MessageBox.Show("请输入 IO 地址", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
                    return;
                }
                bool value = (valueSelect.SelectedItem as ComboBoxItem)?.Content?.ToString() == "True";
                // 创建命令
                var command = new PlcWriteCommand
                {
                    IoAddress = address,
                    Value = value
                };
                // 触发命令执行事件
                OnCommandExecute?.Invoke(this, command);
                // 更新本地显示
                UpdateIoStatus(address, value);
                log.Info($"写入 PLC IO: {address} = {value}");
            }
            catch (Exception ex)
            {
                log.Error("写入 PLC IO 失败", ex);
                MessageBox.Show($"写入失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
            }
        }
    }
}
Plugin.DeviceDebug/Panels/PowerSupplyDebugPanel.xaml
New file
@@ -0,0 +1,117 @@
<UserControl x:Class="Plugin.DeviceDebug.Panels.PowerSupplyDebugPanel"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
             mc:Ignorable="d"
             d:DesignHeight="500" d:DesignWidth="600">
    <Grid dp:GridHelper.Space="12" Margin="12">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        <!-- 标题 -->
        <Border Grid.Row="0" Background="#2196F3" CornerRadius="4" Padding="12,8">
            <TextBlock Text="电源控制面板" FontSize="16" FontWeight="Bold" Foreground="White"/>
        </Border>
        <!-- 设置区 -->
        <Border Grid.Row="1" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4" Padding="12">
            <Grid dp:GridHelper.Space="10">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="100"/>
                    <ColumnDefinition Width="*"/>
                    <ColumnDefinition Width="50"/>
                </Grid.ColumnDefinitions>
                <!-- 电压设定 -->
                <TextBlock Grid.Row="0" Grid.Column="0" Text="电压设定:" VerticalAlignment="Center" FontWeight="Bold"/>
                <TextBox Grid.Row="0" Grid.Column="1" x:Name="voltageInput"
                         IsEnabled="{Binding IsMaintenanceMode, RelativeSource={RelativeSource AncestorType=UserControl}}"
                         VerticalContentAlignment="Center" Padding="6,4" Text="0.00"/>
                <TextBlock Grid.Row="0" Grid.Column="2" Text="V" VerticalAlignment="Center" HorizontalAlignment="Center" Foreground="#666"/>
                <!-- 限流设定 -->
                <TextBlock Grid.Row="1" Grid.Column="0" Text="限流设定:" VerticalAlignment="Center" FontWeight="Bold"/>
                <TextBox Grid.Row="1" Grid.Column="1" x:Name="currentInput"
                         IsEnabled="{Binding IsMaintenanceMode, RelativeSource={RelativeSource AncestorType=UserControl}}"
                         VerticalContentAlignment="Center" Padding="6,4" Text="0.000"/>
                <TextBlock Grid.Row="1" Grid.Column="2" Text="A" VerticalAlignment="Center" HorizontalAlignment="Center" Foreground="#666"/>
                <!-- 输出控制 -->
                <TextBlock Grid.Row="2" Grid.Column="0" Text="输出控制:" VerticalAlignment="Center" FontWeight="Bold"/>
                <ToggleButton Grid.Row="2" Grid.Column="1" x:Name="outputToggle"
                              IsEnabled="{Binding IsMaintenanceMode, RelativeSource={RelativeSource AncestorType=UserControl}}"
                              Checked="OutputToggle_Changed" Unchecked="OutputToggle_Changed"
                              Height="30" Cursor="Hand">
                    <ToggleButton.Style>
                        <Style TargetType="ToggleButton">
                            <Setter Property="Content" Value="OFF"/>
                            <Setter Property="Background" Value="#F44336"/>
                            <Setter Property="Foreground" Value="White"/>
                            <Setter Property="FontWeight" Value="Bold"/>
                            <Style.Triggers>
                                <Trigger Property="IsChecked" Value="True">
                                    <Setter Property="Content" Value="ON"/>
                                    <Setter Property="Background" Value="#4CAF50"/>
                                </Trigger>
                            </Style.Triggers>
                        </Style>
                    </ToggleButton.Style>
                </ToggleButton>
                <!-- 操作按钮 -->
                <StackPanel Grid.Row="3" Grid.ColumnSpan="3" Orientation="Horizontal"
                            dp:StackPanelHelper.ChildSpace="10" HorizontalAlignment="Left" Margin="0,6,0,0">
                    <Button x:Name="applyBtn" Content="设置" Padding="16,6"
                            IsEnabled="{Binding IsMaintenanceMode, RelativeSource={RelativeSource AncestorType=UserControl}}"
                            Click="ApplyBtn_Click"
                            Background="#2196F3" Foreground="White" BorderThickness="0" Cursor="Hand"/>
                    <Button x:Name="measureBtn" Content="测量" Padding="16,6"
                            Click="MeasureBtn_Click"
                            Background="#4CAF50" Foreground="White" BorderThickness="0" Cursor="Hand"/>
                </StackPanel>
            </Grid>
        </Border>
        <!-- 实测值显示区 -->
        <Border Grid.Row="2" BorderBrush="#DDD" BorderThickness="1" CornerRadius="4" Padding="12">
            <Grid dp:GridHelper.Space="8">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="100"/>
                    <ColumnDefinition Width="*"/>
                </Grid.ColumnDefinitions>
                <TextBlock Grid.Row="0" Grid.Column="0" Text="实测电压:" FontWeight="Bold" VerticalAlignment="Center"/>
                <TextBlock Grid.Row="0" Grid.Column="1" x:Name="measuredVoltage"
                           Text="-- V" FontSize="18" Foreground="#2196F3" VerticalAlignment="Center"/>
                <TextBlock Grid.Row="1" Grid.Column="0" Text="实测电流:" FontWeight="Bold" VerticalAlignment="Center"/>
                <TextBlock Grid.Row="1" Grid.Column="1" x:Name="measuredCurrent"
                           Text="-- A" FontSize="18" Foreground="#FF9800" VerticalAlignment="Center"/>
                <TextBlock Grid.Row="2" Grid.Column="0" Text="更新时间:" FontWeight="Bold" VerticalAlignment="Center"/>
                <TextBlock Grid.Row="2" Grid.Column="1" x:Name="lastUpdateTime"
                           Text="--" Foreground="#999" VerticalAlignment="Center"/>
            </Grid>
        </Border>
    </Grid>
</UserControl>
Plugin.DeviceDebug/Panels/PowerSupplyDebugPanel.xaml.cs
New file
@@ -0,0 +1,125 @@
using log4net;
using Plugin.DeviceDebug.Entity;
using System;
using System.Windows;
using System.Windows.Controls;
namespace Plugin.DeviceDebug.Panels
{
    /// <summary>
    /// PowerSupplyDebugPanel.xaml 的交互逻辑
    /// </summary>
    public partial class PowerSupplyDebugPanel : UserControl
    {
        private static readonly ILog log = LogManager.GetLogger(typeof(PowerSupplyDebugPanel));
        /// <summary>
        /// 是否为维护模式(控制输入可用性)
        /// </summary>
        public static readonly DependencyProperty IsMaintenanceModeProperty =
            DependencyProperty.Register(nameof(IsMaintenanceMode), typeof(bool), typeof(PowerSupplyDebugPanel),
                new PropertyMetadata(false));
        public bool IsMaintenanceMode
        {
            get => (bool)GetValue(IsMaintenanceModeProperty);
            set => SetValue(IsMaintenanceModeProperty, value);
        }
        /// <summary>
        /// 命令执行事件
        /// </summary>
        public event EventHandler<DebugCommand> OnCommandExecute;
        public PowerSupplyDebugPanel()
        {
            InitializeComponent();
        }
        /// <summary>
        /// 更新实测值
        /// </summary>
        public void UpdateReadback(string voltage, string current, DateTime time)
        {
            try
            {
                measuredVoltage.Text = $"{voltage} V";
                measuredCurrent.Text = $"{current} A";
                lastUpdateTime.Text = time.ToString("yyyy-MM-dd HH:mm:ss");
            }
            catch (Exception ex)
            {
                log.Error("更新实测值失败", ex);
            }
        }
        private void ApplyBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                if (!IsMaintenanceMode)
                {
                    MessageBox.Show("当前为只读模式,无法执行设置操作", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
                    return;
                }
                // 解析输入值
                if (!double.TryParse(voltageInput.Text, out double voltage))
                {
                    MessageBox.Show("电压值格式不正确", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
                    return;
                }
                if (!double.TryParse(currentInput.Text, out double currentLimit))
                {
                    MessageBox.Show("电流值格式不正确", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
                    return;
                }
                bool output = outputToggle.IsChecked == true;
                // 创建命令
                var command = new PowerSetCommand
                {
                    Voltage = voltage,
                    CurrentLimit = currentLimit,
                    Output = output
                };
                // 触发命令执行事件
                OnCommandExecute?.Invoke(this, command);
                log.Info($"发送电源设置命令: V={voltage}, I={currentLimit}, Output={output}");
            }
            catch (Exception ex)
            {
                log.Error("发送电源设置命令失败", ex);
                MessageBox.Show($"发送命令失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
            }
        }
        private void MeasureBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                // 创建测量命令
                var command = new PowerMeasureCommand();
                // 触发命令执行事件
                OnCommandExecute?.Invoke(this, command);
                log.Info("发送电源测量命令");
            }
            catch (Exception ex)
            {
                log.Error("发送电源测量命令失败", ex);
                MessageBox.Show($"发送命令失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
            }
        }
        private void OutputToggle_Changed(object sender, RoutedEventArgs e)
        {
            // ToggleButton 状态变化时的视觉反馈已在 XAML Style 中处理
        }
    }
}
Plugin.DeviceDebug/Plugin.DeviceDebug.csproj
New file
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net472</TargetFramework>
    <Nullable>enable</Nullable>
    <LangVersion>10.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
    <AutoGenerateBindingRedirects>True</AutoGenerateBindingRedirects>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
    <PackageReference Include="log4net" Version="3.0.4" />
    <PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.135" />
    <PackageReference Include="Panuon.WPF.UI" Version="1.3.0.2" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\HMI.Base\HMI.Base.csproj" />
    <ProjectReference Include="..\HMI.Core\HMI.Core.csproj" />
    <ProjectReference Include="..\HMI.UI\HMI.UI.csproj" />
    <ProjectReference Include="..\HMI.Util\HMI.Util.csproj" />
    <ProjectReference Include="..\Aging.Infrastructure\Aging.Infrastructure.csproj" />
    <ProjectReference Include="..\CanSimulator\CanSimulator.csproj" />
  </ItemGroup>
</Project>
Plugin.IntegrationTest/App.xaml
New file
@@ -0,0 +1,36 @@
<Application x:Class="Plugin.IntegrationTest.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:pw="https://opensource.panuon.com/wpf"
             xmlns:pu="https://opensource.panuon.com/wpf-ui"
             xmlns:uic="clr-namespace:HMI.UI.Converter;assembly=HMI.UI"
             xmlns:hc="clr-namespace:HMI.Core;assembly=HMI.Core"
             StartupUri="MainWindow.xaml"
             xmlns:sys="clr-namespace:System;assembly=mscorlib">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/HMI.UI;component/Style/BaseWindow.xaml"/>
                <ResourceDictionary Source="/HMI.UI;component/Style/FormStyle.xaml"/>
                <ResourceDictionary Source="/HMI.UI;component/Resources/Font/Font.xaml"/>
                <ResourceDictionary Source="/HMI.UI;component/Style/Colors.xaml"/>
                <ResourceDictionary Source="/HMI.UI;component/Style/PanuonResource.xaml"/>
                <ResourceDictionary Source="/HMI.UI;component/Style/MsgBoxStyle.xaml"/>
                <ResourceDictionary Source="/HMI.Base;component/MaskControl.xaml"/>
            </ResourceDictionary.MergedDictionaries>
            <FontFamily x:Key="iconfont">
                /HMI.UI;component/Resources/Font/#iconfont
            </FontFamily>
            <sys:Double x:Key="nanD">NaN</sys:Double>
            <Style TargetType="{x:Type pu:FormGroup}" BasedOn="{StaticResource InputFormGroupStyle}"></Style>
            <uic:PathImageConverter x:Key="path2Image"/>
            <uic:DataGridRowNumberConverter x:Key="rowNumber"/>
            <uic:VisibilityBoolConverter x:Key="vbConverter"/>
            <uic:DataGridRowFlagConverter x:Key="rowFlag"/>
            <uic:ReverseBoolConverter x:Key="reverseBool"/>
            <hc:LangConverter x:Key="lc"/>
            <hc:WidthSizeConverter x:Key="fc"/>
            <hc:HeightSizeConverter x:Key="hfc"/>
        </ResourceDictionary>
    </Application.Resources>
</Application>
Plugin.IntegrationTest/App.xaml.cs
New file
@@ -0,0 +1,8 @@
using System.Windows;
namespace Plugin.IntegrationTest
{
    public partial class App : Application
    {
    }
}
Plugin.IntegrationTest/MainWindow.xaml
New file
@@ -0,0 +1,136 @@
<Window x:Class="Plugin.IntegrationTest.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="仪器仪表注册 &amp; 硬件调试 - 集成测试环境"
        Height="800" Width="1300"
        WindowStartupLocation="CenterScreen"
        WindowState="Maximized"
        Background="#F5F5F5"
        Loaded="Window_Loaded" Closing="Window_Closing">
    <Grid Margin="8">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <!-- 顶部状态栏 -->
        <Border Grid.Row="0" Background="#E3F2FD" CornerRadius="4" Padding="12,6" Margin="0,0,0,6">
            <DockPanel>
                <TextBlock Text="集成测试环境" FontSize="16" FontWeight="Bold"
                           VerticalAlignment="Center" Foreground="#1565C0"/>
                <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" DockPanel.Dock="Right">
                    <TextBlock x:Name="txtStatus" Text="就绪" FontSize="13"
                               VerticalAlignment="Center" Foreground="#666" Margin="0,0,12,0"/>
                    <Button x:Name="btnRun" Content="▶ 运行自动化测试" Width="150" Height="32"
                            FontSize="13" FontWeight="SemiBold"
                            Background="#4CAF50" Foreground="White" BorderThickness="0"
                            Cursor="Hand" Click="BtnRun_Click" Margin="0,0,6,0"/>
                    <Button x:Name="btnReset" Content="重置数据" Width="90" Height="32"
                            FontSize="13" Click="BtnReset_Click" Margin="0,0,6,0"
                            Background="#FF9800" Foreground="White" BorderThickness="0" Cursor="Hand"/>
                    <Button x:Name="btnClear" Content="清空日志" Width="80" Height="32"
                            FontSize="13" Click="BtnClear_Click"/>
                </StackPanel>
            </DockPanel>
        </Border>
        <!-- 主 TabControl -->
        <TabControl x:Name="mainTabs" Grid.Row="1" Margin="0,4">
            <!-- Tab 1: 自动化测试 -->
            <TabItem Header="  自动化测试  " FontSize="13">
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinition Height="Auto"/>
                        <RowDefinition Height="*"/>
                    </Grid.RowDefinitions>
                    <ProgressBar x:Name="progressBar" Height="6" Minimum="0" Maximum="100" Value="0" Margin="4"/>
                    <TextBox x:Name="txtLog" Grid.Row="1"
                             IsReadOnly="True" VerticalScrollBarVisibility="Auto"
                             HorizontalScrollBarVisibility="Auto"
                             FontFamily="Consolas" FontSize="12.5"
                             Background="#1E1E1E" Foreground="#D4D4D4"
                             BorderThickness="1" BorderBrush="#CCC"
                             Padding="8" TextWrapping="NoWrap"
                             AcceptsReturn="True" VerticalContentAlignment="Top"/>
                </Grid>
            </TabItem>
            <!-- Tab 2: 仪器仪表注册 -->
            <TabItem Header="  仪器仪表注册  " FontSize="13">
                <Border x:Name="deviceConfigHost" Background="White"/>
            </TabItem>
            <!-- Tab 3: 硬件调试 -->
            <TabItem Header="  硬件调试  " FontSize="13">
                <Border x:Name="deviceDebugHost" Background="White"/>
            </TabItem>
            <!-- Tab 4: CAN 模拟器 -->
            <TabItem Header="  CAN 模拟器  " FontSize="13">
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinition Height="Auto"/>
                        <RowDefinition Height="*"/>
                    </Grid.RowDefinitions>
                    <!-- 模拟器控制面板 -->
                    <Border Grid.Row="0" Background="#FFF3E0" CornerRadius="4" Padding="12,8" Margin="4">
                        <Grid>
                            <Grid.ColumnDefinitions>
                                <ColumnDefinition Width="Auto"/>
                                <ColumnDefinition Width="*"/>
                            </Grid.ColumnDefinitions>
                            <Grid.RowDefinitions>
                                <RowDefinition Height="Auto"/>
                                <RowDefinition Height="Auto"/>
                                <RowDefinition Height="Auto"/>
                            </Grid.RowDefinitions>
                            <TextBlock Grid.Row="0" Grid.Column="0" Text="总线状态:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,0,8,4"/>
                            <StackPanel Grid.Row="0" Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
                                <Ellipse x:Name="canBusIndicator" Width="14" Height="14" Fill="#9E9E9E" Margin="0,0,8,0"/>
                                <TextBlock x:Name="canBusStatusText" Text="未启动" FontSize="13"/>
                            </StackPanel>
                            <TextBlock Grid.Row="1" Grid.Column="0" Text="总线名称:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,0,8,4"/>
                            <TextBox Grid.Row="1" Grid.Column="1" x:Name="canBusNameInput" Text="CAN_BUS_1"
                                     VerticalContentAlignment="Center" Padding="6,4" Margin="0,0,80,4" Width="200" HorizontalAlignment="Left"/>
                            <TextBlock Grid.Row="2" Grid.Column="0" Text="控制:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,0,8,0"/>
                            <StackPanel Grid.Row="2" Grid.Column="1" Orientation="Horizontal">
                                <Button x:Name="canStartBtn" Content="启动总线" Width="100" Height="30" Margin="0,0,8,0"
                                        Click="CanStartBtn_Click" Background="#4CAF50" Foreground="White"
                                        BorderThickness="0" Cursor="Hand" FontWeight="SemiBold"/>
                                <Button x:Name="canStopBtn" Content="停止总线" Width="100" Height="30" Margin="0,0,8,0"
                                        Click="CanStopBtn_Click" Background="#F44336" Foreground="White"
                                        BorderThickness="0" Cursor="Hand" FontWeight="SemiBold" IsEnabled="False"/>
                                <Button x:Name="canResetBtn" Content="重置所有" Width="100" Height="30"
                                        Click="CanResetBtn_Click" Background="#9E9E9E" Foreground="White"
                                        BorderThickness="0" Cursor="Hand" FontWeight="SemiBold"/>
                            </StackPanel>
                        </Grid>
                    </Border>
                    <!-- CAN 调试面板 -->
                    <Border x:Name="canSimulatorHost" Grid.Row="1" Background="White" Margin="4"/>
                </Grid>
            </TabItem>
        </TabControl>
        <!-- 底部结果栏 -->
        <Border Grid.Row="2" Padding="12,6" Background="#FAFAFA"
                BorderBrush="#DDD" BorderThickness="1" CornerRadius="4">
            <DockPanel>
                <TextBlock x:Name="txtSummary" FontSize="13" FontWeight="SemiBold"
                           VerticalAlignment="Center" Foreground="#333" Text="就绪 - 请切换到对应 Tab 进行手动测试"/>
                <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" DockPanel.Dock="Right">
                    <TextBlock x:Name="txtPassed" FontSize="13" Foreground="#4CAF50"
                               Margin="0,0,16,0" VerticalAlignment="Center"/>
                    <TextBlock x:Name="txtFailed" FontSize="13" Foreground="#F44336"
                               VerticalAlignment="Center"/>
                </StackPanel>
            </DockPanel>
        </Border>
    </Grid>
</Window>
Plugin.IntegrationTest/MainWindow.xaml.cs
New file
@@ -0,0 +1,840 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Threading;
using Aging.Infrastructure.Drivers;
using CanSimulator;
using log4net;
using Plugin.DeviceConfig.Entity;
using Plugin.DeviceConfig.Model;
using Plugin.DeviceDebug.Entity;
using Plugin.DeviceDebug.Model;
using Plugin.DeviceDebug.Panels;
namespace Plugin.IntegrationTest
{
    public partial class MainWindow : Window
    {
        private int _passed;
        private int _failed;
        private int _totalSteps;
        private int _currentStep;
        private string _testDir = string.Empty;
        private string _logFilePath = string.Empty;
        private readonly StringBuilder _logBuffer = new();
        // 嵌入的插件实例
        private Plugin.DeviceConfig.DeviceConfig? _deviceConfigPlugin;
        private Plugin.DeviceDebug.DeviceDebug? _deviceDebugPlugin;
        private static readonly ILog _testLog = LogManager.GetLogger("IntegrationTest");
        // CAN 模拟器
        private CanBusSimulator? _canBus;
        private CanDebugPanel? _canDebugPanel;
        public MainWindow()
        {
            InitializeComponent();
        }
        private void Window_Loaded(object sender, RoutedEventArgs e)
        {
            InitializePlugins();
            InitializeCanSimulator();
        }
        private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
        {
            // 清理 CAN 模拟器
            _canBus?.Dispose();
            CanDriverAdapter.ResetAllBuses();
            // 不删除测试目录,保留数据以便重启后恢复
            // (如需手动重置,使用工具栏中的 "重置数据" 按钮)
        }
        /// <summary>
        /// 初始化两个插件并嵌入到对应 Tab 中
        /// </summary>
        private void InitializePlugins()
        {
            // 使用固定目录保存数据(重启后不丢失)
            string appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
            _testDir = Path.Combine(appData, "PluginIntegrationTest");
            Directory.CreateDirectory(_testDir);
            _logFilePath = Path.Combine(_testDir, "test_result.log");
            txtStatus.Text = $"数据目录: {_testDir}";
            try
            {
                // 初始化仪器仪表注册插件
                _deviceConfigPlugin = new Plugin.DeviceConfig.DeviceConfig();
                _deviceConfigPlugin.SetBaseFolder(_testDir);
                _deviceConfigPlugin.SetLogger(_testLog);
                _deviceConfigPlugin.InitFinish();
                deviceConfigHost.Child = _deviceConfigPlugin;
                // 初始化硬件调试插件
                _deviceDebugPlugin = new Plugin.DeviceDebug.DeviceDebug();
                _deviceDebugPlugin.SetBaseFolder(_testDir);
                _deviceDebugPlugin.SetLogger(_testLog);
                _deviceDebugPlugin.InitFinish();
                deviceDebugHost.Child = _deviceDebugPlugin;
                // 预置 CAN 测试设备(方便直接验证驱动与面板)
                SeedPresetCanDevices();
                // 刷新插件内部列表(需手动调用插件方法)
                _deviceConfigPlugin.ForceRefreshForTest();
                _deviceDebugPlugin.ForceRefreshForTest();
                txtStatus.Text = "插件初始化完成 - 可切换 Tab 手动测试";
                Log("[INFO] 插件初始化成功");
                Log($"[INFO] 测试目录: {_testDir}");
                Log("[INFO] DeviceConfig 已嵌入到 '仪器仪表注册' Tab");
                Log("[INFO] DeviceDebug 已嵌入到 '硬件调试' Tab");
                Log("[INFO] 预置测试设备: CAN-001, CAN-002 (Transport=Can)");
                Log("");
                Log("[TIP] 使用流程:");
                Log("  1. 先切换到 '仪器仪表注册' Tab,添加设备、通道、Slot映射等");
                Log("  2. 已预置 CAN-001 (CAN_BUS_1:0) 和 CAN-002 (CAN_BUS_1:1) 供测试");
                Log("  3. 切换到 '硬件调试' Tab,选择 CAN-001 会自动打开 CAN 面板并连接总线");
                Log("  4. 切换到 'CAN 模拟器' Tab 可手动控制总线或另一个节点发送数据");
                Log("  5. 可随时点击 '运行自动化测试' 验证核心逻辑");
            }
            catch (Exception ex)
            {
                txtStatus.Text = $"插件初始化失败: {ex.Message}";
                Log($"[ERROR] 插件初始化失败: {ex.Message}");
                Log(ex.StackTrace ?? "");
            }
        }
        /// <summary>
        /// 预置 CAN 测试设备(仅当不存在时插入,避免覆盖用户修改)
        /// </summary>
        private void SeedPresetCanDevices()
        {
            try
            {
                var model = new DeviceConfigModel();
                string dbPath = Path.Combine(_testDir, "data.db");
                model.InitDatabase(dbPath);
                int inserted = 0;
                // CAN-001
                if (model.GetDeviceById("CAN-001") == null)
                {
                    model.SaveDevice(new DeviceDefinition
                    {
                        DeviceId = "CAN-001",
                        Kind = DeviceKind.CanDevice,
                        DriverType = "CAN",
                        Transport = TransportType.Can,
                        Endpoint = "CAN_BUS_1:0",
                        EffectiveVersion = "V1.0"
                    });
                    inserted++;
                }
                // CAN-002
                if (model.GetDeviceById("CAN-002") == null)
                {
                    model.SaveDevice(new DeviceDefinition
                    {
                        DeviceId = "CAN-002",
                        Kind = DeviceKind.CanDevice,
                        DriverType = "CAN",
                        Transport = TransportType.Can,
                        Endpoint = "CAN_BUS_1:1",
                        EffectiveVersion = "V1.0"
                    });
                    inserted++;
                }
                Log(inserted > 0
                    ? $"[INFO] 预置 CAN 设备: 新增 {inserted} 个"
                    : "[INFO] 预置 CAN 设备已存在,未覆盖");
            }
            catch (Exception ex)
            {
                Log($"[WARN] 预置 CAN 设备失败: {ex.Message}");
            }
        }
        /// <summary>
        /// 重置测试数据(删除本地目录中的 db 文件)
        /// </summary>
        private void ResetTestData()
        {
            try
            {
                if (MessageBox.Show("确定要删除本地保存的设备数据吗?\n此操作不可恢复!",
                    "确认重置", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes)
                {
                    return;
                }
                // 释放插件后删除数据
                _deviceConfigPlugin = null;
                _deviceDebugPlugin = null;
                foreach (var file in Directory.GetFiles(_testDir, "*.db", SearchOption.AllDirectories))
                {
                    try { File.Delete(file); } catch { }
                }
                GC.Collect();
                GC.WaitForPendingFinalizers();
                MessageBox.Show("数据已重置,请重新启动程序。", "提示", MessageBoxButton.OK, MessageBoxImage.Information);
            }
            catch (Exception ex)
            {
                MessageBox.Show($"重置失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
            }
        }
        private void BtnClear_Click(object sender, RoutedEventArgs e)
        {
            txtLog.Clear();
            txtSummary.Text = "就绪 - 请切换到对应 Tab 进行手动测试";
            txtPassed.Text = "";
            txtFailed.Text = "";
            progressBar.Value = 0;
            _passed = 0;
            _failed = 0;
            _currentStep = 0;
            btnRun.IsEnabled = true;
        }
        private void BtnReset_Click(object sender, RoutedEventArgs e)
        {
            ResetTestData();
        }
        private async void BtnRun_Click(object sender, RoutedEventArgs e)
        {
            btnRun.IsEnabled = false;
            txtLog.Clear();
            _passed = 0;
            _failed = 0;
            _currentStep = 0;
            _totalSteps = CountTestSteps();
            UpdateProgress();
            Log("========================================");
            Log("  仪器仪表注册 & 硬件调试 集成测试");
            Log($"  时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
            Log("========================================");
            Log("");
            // 为自动化测试创建独立的数据库目录
            string autoTestDir = Path.Combine(_testDir, "auto_test_" + Guid.NewGuid().ToString("N").Substring(0, 8));
            Directory.CreateDirectory(autoTestDir);
            await Task.Run(() => RunAllTests(autoTestDir));
            Log("");
            Log("========================================");
            string result = _failed == 0 ? "全部通过 ✓" : $"有 {_failed} 项失败 ✗";
            Log($"  测试结果: {result}");
            Log($"  通过: {_passed}  失败: {_failed}  总计: {_passed + _failed}");
            Log("========================================");
            Dispatcher.Invoke(() =>
            {
                txtSummary.Text = _failed == 0 ? "✓ 自动化测试全部通过" : $"✗ 有 {_failed} 项测试失败";
                txtSummary.Foreground = _failed == 0
                    ? System.Windows.Media.Brushes.Green
                    : System.Windows.Media.Brushes.Red;
                txtPassed.Text = $"通过: {_passed}";
                txtFailed.Text = $"失败: {_failed}";
                btnRun.IsEnabled = true;
                // 复制日志到输出目录
                try
                {
                    string appDir = AppDomain.CurrentDomain.BaseDirectory;
                    string copyPath = Path.Combine(appDir, "test_result.log");
                    File.Copy(_logFilePath, copyPath, overwrite: true);
                }
                catch { }
            });
        }
        private int CountTestSteps() => 22;
        private void RunAllTests(string testDir)
        {
            string configDbPath = Path.Combine(testDir, "device_config.db");
            string debugDbPath = Path.Combine(testDir, "device_debug.db");
            // ===== 第一部分:仪器仪表注册 =====
            RunSection("第一部分:仪器仪表注册 (DeviceConfig)");
            var configModel = TestInit("初始化设备配置数据库", () =>
            {
                var model = new DeviceConfigModel();
                model.InitDatabase(configDbPath);
                return model;
            });
            if (configModel == null)
            {
                SkipRemainingSection(9, "DeviceConfig 数据库初始化失败");
            }
            else
            {
                var device = TestInit("创建设备定义(电源设备)", () =>
                {
                    var dev = new DeviceDefinition
                    {
                        DeviceId = "PS-001",
                        Kind = DeviceKind.PowerSupply,
                        DriverType = "SCPI",
                        Transport = TransportType.Tcp,
                        Endpoint = "192.168.1.100:5025",
                        EffectiveVersion = "V1.0",
                        CreatedAt = DateTime.Now,
                        UpdatedAt = DateTime.Now
                    };
                    configModel.SaveDevice(dev);
                    return dev;
                });
                TestStep("读取设备定义并验证", () =>
                {
                    var read = configModel.GetDeviceById("PS-001");
                    Assert(read != null, "设备应存在");
                    Assert(read!.Kind == DeviceKind.PowerSupply, "种类应为PowerSupply");
                    Assert(read.Transport == TransportType.Tcp, "传输方式应为Tcp");
                    Assert(read.Endpoint == "192.168.1.100:5025", "端点地址应匹配");
                });
                TestStep("更新设备定义", () =>
                {
                    Assert(device != null, "设备对象不应为空");
                    device!.Endpoint = "192.168.1.200:5025";
                    device.EffectiveVersion = "V2.0";
                    configModel.SaveDevice(device);
                    var updated = configModel.GetDeviceById("PS-001");
                    Assert(updated!.Endpoint == "192.168.1.200:5025", "端点应已更新");
                    Assert(updated.EffectiveVersion == "V2.0", "版本应已更新");
                });
                TestStep("添加设备通道", () =>
                {
                    for (int i = 1; i <= 4; i++)
                    {
                        var ch = new DeviceChannel
                        {
                            DeviceId = "PS-001",
                            ChannelId = i,
                            Capability = i <= 2 ? "Voltage" : "Current",
                            MaxVoltage = 30.0,
                            MaxCurrent = 5.0,
                            Limits = $"{{\"min_v\":0,\"max_v\":30,\"min_i\":0,\"max_i\":5}}",
                            SessionKey = ""
                        };
                        configModel.SaveChannel(ch);
                    }
                    var channels = configModel.GetChannelsByDevice("PS-001");
                    Assert(channels.Count == 4, $"应有4个通道,实际{channels.Count}个");
                });
                TestStep("添加Slot映射", () =>
                {
                    var mapping = new SlotDeviceMapping
                    {
                        SlotId = "SLOT-01",
                        Capability = "Power",
                        Version = "V2.0",
                        DeviceId = "PS-001",
                        ChannelId = 1,
                        MappingVersion = "MV-001"
                    };
                    configModel.SaveSlotMapping(mapping);
                    var mappings = configModel.GetSlotMappings();
                    Assert(mappings.Count == 1, "应有1条Slot映射");
                    Assert(mappings[0].DeviceId == "PS-001", "映射设备ID应匹配");
                });
                TestStep("创建配置版本", () =>
                {
                    var ver = new DeviceConfigVersion
                    {
                        VersionId = "VER-001",
                        Status = VersionStatus.Draft,
                        Hash = configModel.ComputeConfigHash(),
                        Author = "TestUser",
                        Timestamp = DateTime.Now,
                        DiffDescription = "初始配置"
                    };
                    configModel.SaveConfigVersion(ver);
                    var versions = configModel.GetConfigVersions();
                    Assert(versions.Count == 1, "应有1个版本");
                    Assert(versions[0].Status == VersionStatus.Draft, "版本状态应为Draft");
                    Assert(!string.IsNullOrEmpty(versions[0].Hash), "哈希不应为空");
                });
                TestStep("冲突检测(应无冲突)", () =>
                {
                    var conflicts = configModel.DetectConflicts();
                    Assert(conflicts.Count == 0, $"不应有冲突,实际: {string.Join("; ", conflicts)}");
                });
                TestStep("添加第二个设备并检测IP冲突", () =>
                {
                    var dev2 = new DeviceDefinition
                    {
                        DeviceId = "PS-002",
                        Kind = DeviceKind.PowerSupply,
                        DriverType = "SCPI",
                        Transport = TransportType.Tcp,
                        Endpoint = "192.168.1.200:5025",
                        EffectiveVersion = "V1.0",
                        CreatedAt = DateTime.Now,
                        UpdatedAt = DateTime.Now
                    };
                    configModel.SaveDevice(dev2);
                    var dev3 = new DeviceDefinition
                    {
                        DeviceId = "PS-003",
                        Kind = DeviceKind.PowerSupply,
                        DriverType = "ModbusTCP",
                        Transport = TransportType.Tcp,
                        Endpoint = "192.168.1.200:5025",
                        EffectiveVersion = "V1.0",
                        CreatedAt = DateTime.Now,
                        UpdatedAt = DateTime.Now
                    };
                    configModel.SaveDevice(dev3);
                    var conflicts = configModel.DetectConflicts();
                    Assert(conflicts.Count > 0, "应检测到IP冲突");
                    Log($"  [冲突] {conflicts[0]}");
                });
                TestStep("删除设备和清理", () =>
                {
                    configModel.DeleteDevice("PS-003");
                    configModel.DeleteChannel("PS-001", 4);
                    var allDevices = configModel.GetAllDevices();
                    Assert(allDevices.Count == 2, $"删除后应有2个设备,实际{allDevices.Count}");
                    var channels = configModel.GetChannelsByDevice("PS-001");
                    Assert(channels.Count == 3, $"删除通道后应有3个,实际{channels.Count}");
                });
            }
            Log("");
            // ===== 第二部分:硬件调试 =====
            RunSection("第二部分:硬件调试 (DeviceDebug)");
            var debugModel = TestInit("初始化调试数据库", () =>
            {
                var model = new DeviceDebugModel();
                model.InitDatabase(debugDbPath);
                return model;
            });
            DebugSessionManager? sessionMgr = null;
            if (debugModel == null)
            {
                SkipRemainingSection(8, "DeviceDebug 数据库初始化失败");
            }
            else
            {
                sessionMgr = TestInit("创建会话管理器", () =>
                {
                    return new DebugSessionManager(debugModel, resourceManager: null);
                });
                if (sessionMgr == null)
                {
                    SkipRemainingSection(7, "会话管理器创建失败");
                }
                else
                {
                    string? sessionId = null;
                    TestStep("打开调试会话(ReadOnly)", () =>
                    {
                        var session = sessionMgr.OpenSession("PS-001", "OP001", "张工", "V2.0");
                        sessionId = session.SessionId;
                        Assert(!string.IsNullOrEmpty(sessionId), "会话ID不应为空");
                        Assert(session.State == DebugSessionState.ReadOnly, "初始状态应为ReadOnly");
                        Assert(session.DeviceId == "PS-001", "设备ID应匹配");
                        Log($"  会话ID: {sessionId}");
                    });
                    if (sessionId == null)
                    {
                        SkipRemainingSection(6, "打开会话失败");
                    }
                    else
                    {
                        TestStep("请求维护模式(ReadOnly → AcquiringMaintenance)", () =>
                        {
                            bool result = sessionMgr.RequestMaintenance(sessionId!);
                            Assert(result, "请求维护应成功");
                            var state = sessionMgr.GetSessionState(sessionId!);
                            Assert(state == DebugSessionState.AcquiringMaintenance, $"状态应为AcquiringMaintenance,实际{state}");
                        });
                        TestStep("激活维护模式(AcquiringMaintenance → MaintenanceActive)", () =>
                        {
                            sessionMgr.ActivateMaintenance(sessionId!);
                            var state = sessionMgr.GetSessionState(sessionId!);
                            Assert(state == DebugSessionState.MaintenanceActive, $"状态应为MaintenanceActive,实际{state}");
                        });
                        TestStep("验证命令执行权限", () =>
                        {
                            var cmd = new PowerSetCommand { Voltage = 12.5, CurrentLimit = 3.0, Output = true };
                            bool allowed = sessionMgr.ValidateCommand(sessionId!, cmd);
                            Assert(allowed, "MaintenanceActive状态下应允许命令执行");
                            var readOnlySession = sessionMgr.OpenSession("PS-002", "OP002", "李工", "V1.0");
                            bool readOnlyAllowed = sessionMgr.ValidateCommand(readOnlySession.SessionId, cmd);
                            Assert(!readOnlyAllowed, "ReadOnly状态下应拒绝命令执行");
                        });
                        TestStep("记录调试操作审计", () =>
                        {
                            var op = new DebugOperation
                            {
                                SessionId = sessionId!,
                                CommandType = "PowerSet",
                                Parameters = "{\"voltage\":12.5,\"current\":3.0,\"output\":true}",
                                Outcome = "Success",
                                DeviceReadback = "V=12.48,I=2.99",
                                ErrorCode = "",
                                ErrorMessage = ""
                            };
                            debugModel.RecordOperation(op);
                            var ops = debugModel.GetOperationsBySession(sessionId!);
                            Assert(ops.Count == 1, $"应有1条操作记录,实际{ops.Count}");
                            Assert(ops[0].CommandType == "PowerSet", "命令类型应匹配");
                            Assert(ops[0].Outcome == "Success", "结果应为Success");
                        });
                        TestStep("关闭会话(MaintenanceActive → Closing → Closed)", () =>
                        {
                            sessionMgr.CloseSession(sessionId!);
                            var state = sessionMgr.GetSessionState(sessionId!);
                            Assert(state == DebugSessionState.Closed, $"状态应为Closed,实际{state}");
                            var session = debugModel.GetSession(sessionId!);
                            Assert(session != null, "会话记录应仍存在");
                            Assert(session!.ClosedAt.HasValue, "关闭时间应已设置");
                        });
                        TestStep("故障和恢复测试", () =>
                        {
                            var faultSession = sessionMgr.OpenSession("PS-001", "OP001", "张工", "V2.0");
                            string faultId = faultSession.SessionId;
                            sessionMgr.RequestMaintenance(faultId);
                            sessionMgr.ActivateMaintenance(faultId);
                            sessionMgr.FaultSession(faultId, "通信超时");
                            var state = sessionMgr.GetSessionState(faultId);
                            Assert(state == DebugSessionState.Faulted, $"状态应为Faulted,实际{state}");
                            sessionMgr.MarkRecoveryRequired(faultId);
                            state = sessionMgr.GetSessionState(faultId);
                            Assert(state == DebugSessionState.RecoveryRequired, $"状态应为RecoveryRequired,实际{state}");
                        });
                    }
                }
            }
            Log("");
            // ===== 第三部分:跨模块联动验证 =====
            RunSection("第三部分:跨模块联动验证");
            if (configModel == null || debugModel == null)
            {
                SkipRemainingSection(4, "前置模块初始化失败,跳过联动测试");
            }
            else
            {
                TestStep("设备配置 → 调试会话数据一致性", () =>
                {
                    var configDevice = configModel.GetDeviceById("PS-001");
                    Assert(configDevice != null, "配置中应存在PS-001");
                    var activeSessions = debugModel.GetActiveSessionsByDevice("PS-001");
                    Assert(activeSessions.Count > 0, "PS-001应有活跃会话记录");
                    Assert(activeSessions.All(s => s.DeviceId == "PS-001"), "所有会话应引用PS-001");
                });
                TestStep("配置版本哈希一致性", () =>
                {
                    var currentHash = configModel.ComputeConfigHash();
                    var versions = configModel.GetConfigVersions();
                    Assert(versions.Count > 0, "应有版本记录");
                    string originalHash = versions[0].Hash;
                    Log($"  原始哈希: {originalHash.Substring(0, 16)}...");
                    Log($"  当前哈希: {currentHash.Substring(0, 16)}...");
                    Log($"  哈希变化: {(originalHash != currentHash ? "是(配置已更新)" : "否")}");
                    Assert(true, "哈希计算流程正常");
                });
                TestStep("操作审计完整性验证", () =>
                {
                    var allSessions = debugModel.GetAllSessions();
                    int totalOps = 0;
                    foreach (var s in allSessions)
                    {
                        var ops = debugModel.GetOperationsBySession(s.SessionId);
                        totalOps += ops.Count;
                    }
                    Assert(totalOps > 0, $"应有操作审计记录,实际{totalOps}条");
                    Log($"  总会话数: {allSessions.Count}, 总操作记录: {totalOps}");
                });
                TestStep("设备组模板功能验证", () =>
                {
                    var template = new DeviceGroupTemplate
                    {
                        TemplateId = "TPL-001",
                        DeviceType = "PowerSupply",
                        DriverType = "SCPI",
                        Transport = "Tcp",
                        DeviceCount = 4,
                        ChannelsPerDevice = 4,
                        AddressRule = "192.168.1.{0}",
                        SlotStart = 1
                    };
                    configModel.SaveGroupTemplate(template);
                    var templates = configModel.GetGroupTemplates();
                    Assert(templates.Count == 1, "应有1个模板");
                    Assert(templates[0].DeviceCount == 4, "设备数量应为4");
                });
            }
        }
        #region CAN 模拟器
        /// <summary>
        /// 初始化 CAN 模拟器
        /// </summary>
        private void InitializeCanSimulator()
        {
            try
            {
                // 创建 CAN 调试面板并嵌入到 Tab 中
                _canDebugPanel = new CanDebugPanel();
                canSimulatorHost.Child = _canDebugPanel;
                Log("[INFO] CAN 模拟器已嵌入到 'CAN 模拟器' Tab");
            }
            catch (Exception ex)
            {
                Log($"[ERROR] CAN 模拟器初始化失败: {ex.Message}");
            }
        }
        /// <summary>
        /// 启动总线按钮点击
        /// </summary>
        private void CanStartBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                string busName = canBusNameInput.Text.Trim();
                if (string.IsNullOrEmpty(busName))
                {
                    MessageBox.Show("请输入总线名称", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
                    return;
                }
                // 通过 DriverFactory 创建 CAN 驱动来启动总线
                var driver = DriverFactory.CreateDriver("CAN_SIM_0", "CAN", $"{busName}:0");
                driver.ConnectAsync(CancellationToken.None).GetAwaiter().GetResult();
                // 获取总线实例
                var buses = CanDriverAdapter.GetAllBuses();
                if (buses.TryGetValue(busName, out var bus))
                {
                    _canBus = bus;
                    bus.Start();
                    // 更新 UI
                    canBusIndicator.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4CAF50"));
                    canBusStatusText.Text = $"运行中 ({busName})";
                    canStartBtn.IsEnabled = false;
                    canStopBtn.IsEnabled = true;
                    Log($"[CAN] 总线 {busName} 已启动");
                }
            }
            catch (Exception ex)
            {
                Log($"[CAN] 启动总线失败: {ex.Message}");
                MessageBox.Show($"启动失败: {ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
            }
        }
        /// <summary>
        /// 停止总线按钮点击
        /// </summary>
        private void CanStopBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                _canBus?.Stop();
                // 更新 UI
                canBusIndicator.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9E9E9E"));
                canBusStatusText.Text = "已停止";
                canStartBtn.IsEnabled = true;
                canStopBtn.IsEnabled = false;
                Log("[CAN] 总线已停止");
            }
            catch (Exception ex)
            {
                Log($"[CAN] 停止总线失败: {ex.Message}");
            }
        }
        /// <summary>
        /// 重置所有按钮点击
        /// </summary>
        private void CanResetBtn_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                CanDriverAdapter.ResetAllBuses();
                _canBus = null;
                // 更新 UI
                canBusIndicator.Fill = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9E9E9E"));
                canBusStatusText.Text = "未启动";
                canStartBtn.IsEnabled = true;
                canStopBtn.IsEnabled = false;
                Log("[CAN] 所有总线已重置");
            }
            catch (Exception ex)
            {
                Log($"[CAN] 重置失败: {ex.Message}");
            }
        }
        #endregion
        #region 测试辅助方法
        private T? TestInit<T>(string name, Func<T> action) where T : class
        {
            _currentStep++;
            UpdateProgress();
            try
            {
                var result = action();
                _passed++;
                LogStep("PASS", name);
                return result;
            }
            catch (Exception ex)
            {
                _failed++;
                LogStep("FAIL", name, ex.Message);
                return default;
            }
        }
        private void TestStep(string name, Action action)
        {
            _currentStep++;
            UpdateProgress();
            try
            {
                action();
                _passed++;
                LogStep("PASS", name);
            }
            catch (Exception ex)
            {
                _failed++;
                LogStep("FAIL", name, ex.Message);
            }
        }
        private void Assert(bool condition, string message)
        {
            if (!condition)
                throw new Exception($"断言失败: {message}");
        }
        private void RunSection(string title)
        {
            Log($"--- {title} ---");
            Log("");
        }
        private void SkipRemainingSection(int count, string reason)
        {
            for (int i = 0; i < count; i++)
            {
                _currentStep++;
                _failed++;
                UpdateProgress();
                LogStep("FAIL", $"[SKIP] 依赖失败: {reason}");
            }
        }
        private void LogStep(string status, string name, string? error = null)
        {
            string line = status == "PASS"
                ? $"  [PASS] {name}"
                : $"  [FAIL] {name} → {error}";
            Log(line);
        }
        private void Log(string message)
        {
            _logBuffer.AppendLine(message);
            Dispatcher.Invoke(() =>
            {
                txtLog.AppendText(message + Environment.NewLine);
                txtLog.ScrollToEnd();
            });
            if (!string.IsNullOrEmpty(_logFilePath))
            {
                try { File.AppendAllText(_logFilePath, message + Environment.NewLine); }
                catch { }
            }
        }
        private void UpdateProgress()
        {
            Dispatcher.Invoke(() =>
            {
                if (_totalSteps > 0)
                {
                    double pct = (_currentStep * 100.0) / _totalSteps;
                    progressBar.Value = Math.Min(pct, 100);
                }
            });
        }
        #endregion
    }
}
Plugin.IntegrationTest/Plugin.IntegrationTest.csproj
New file
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net472</TargetFramework>
    <Nullable>enable</Nullable>
    <LangVersion>10.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
    <AutoGenerateBindingRedirects>True</AutoGenerateBindingRedirects>
    <PlatformTarget>x64</PlatformTarget>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="log4net" Version="3.0.4" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\Plugin.DeviceConfig\Plugin.DeviceConfig.csproj" />
    <ProjectReference Include="..\Plugin.DeviceDebug\Plugin.DeviceDebug.csproj" />
    <ProjectReference Include="..\CanSimulator\CanSimulator.csproj" />
    <ProjectReference Include="..\Aging.Infrastructure\Aging.Infrastructure.csproj" />
  </ItemGroup>
  <ItemGroup>
    <Content Include="..\Libs\SQLite.Interop.dll" Link="SQLite.Interop.dll">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </Content>
    <Content Include="..\Libs\SQLite.Interop.dll" Link="x64\SQLite.Interop.dll">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </Content>
  </ItemGroup>
</Project>
Plugin.StandardTest/AssemblyInfo.cs
New file
@@ -0,0 +1,12 @@
using HMI.Core.Annotation;
using System.Windows;
[assembly:ThemeInfo(
    ResourceDictionaryLocation.None,            //where theme specific resource dictionaries are located
                                                //(used if a resource is not found in the page,
                                                // or application resource dictionaries)
    ResourceDictionaryLocation.SourceAssembly   //where the generic resource dictionary is located
                                                //(used if a resource is not found in the page,
                                                // app, or any theme specific resource dictionaries)
)]
[assembly: PluginClassName("Plugin.StandardTest.StandardTestUI")]
Plugin.StandardTest/Entity/TestStep.cs
New file
@@ -0,0 +1,14 @@
namespace Plugin.StandardTest.Entity
{
    public class TestStep
    {
        public string Name { get; set; }
        public string Step { get; set; }
        public string Value { get; set; }
        public string LimitUp { get; set; }
        public string LimitDown { get; set; }
        public string Unit { get; set; }
        public string ConsumeTime { get; set; }
        public string Result { get; set; }
    }
}
Plugin.StandardTest/Plugin.StandardTest.csproj
New file
@@ -0,0 +1,63 @@
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net472</TargetFramework>
    <Nullable>enable</Nullable>
    <LangVersion>10.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
    <Platforms>AnyCPU;x64</Platforms>
    <AssemblyVersion>1.0.0.0</AssemblyVersion>
    <FileVersion>1.0.0.0</FileVersion>
    <IncludeSymbols>False</IncludeSymbols>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
    <PackageReference Include="LiveChartsCore" Version="2.0.0-rc5.4" />
    <PackageReference Include="LiveChartsCore.SkiaSharpView" Version="2.0.0-rc5.4" />
    <PackageReference Include="LiveChartsCore.SkiaSharpView.WPF" Version="2.0.0-rc5.4" />
    <PackageReference Include="log4net" Version="3.0.4" />
    <PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.135" />
    <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
    <PackageReference Include="Panuon.WPF.UI" Version="1.3.0.2" />
    <PackageReference Include="SuperSocket" Version="1.6.6.1" />
    <PackageReference Include="SuperSocket.Engine" Version="1.6.6.1" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\HMI.Base\HMI.Base.csproj" />
    <ProjectReference Include="..\HMI.Core\HMI.Core.csproj" />
    <ProjectReference Include="..\HMI.StdControl\HMI.StdControl.csproj" />
    <ProjectReference Include="..\PLCHelper\PLCHelper.csproj" />
    <ProjectReference Include="..\Plugin.TsSupport\Plugin.TsSupport.csproj" />
    <ProjectReference Include="..\SimpleUtil\HMI.LogUtil\HMI.LogUtil.csproj" />
    <ProjectReference Include="..\Util.ManualDebug\Util.ManualDebug.csproj" />
  </ItemGroup>
  <ItemGroup>
    <Reference Include="HslCommunication">
      <HintPath>..\Libs\HslCommunication.dll</HintPath>
    </Reference>
    <Reference Include="NationalInstruments.TestStand.Interop.API">
      <HintPath>..\Libs\NationalInstruments.TestStand.Interop.API.dll</HintPath>
    </Reference>
    <Reference Include="NationalInstruments.TestStand.Interop.UI.AxControls">
      <HintPath>..\Libs\NationalInstruments.TestStand.Interop.UI.AxControls.dll</HintPath>
    </Reference>
    <Reference Include="NPOI">
      <HintPath>..\HMI.ParameterManage\bin\x64\Debug\NPOI.dll</HintPath>
    </Reference>
    <Reference Include="System.Net.Http" />
    <Reference Include="System.Windows.Forms" />
    <Reference Include="WindowsFormsIntegration" />
  </ItemGroup>
  <ItemGroup>
    <Compile Update="StandardTestUI.xaml.cs">
      <SubType>Code</SubType>
    </Compile>
  </ItemGroup>
</Project>
Plugin.StandardTest/Scanner.xaml
New file
@@ -0,0 +1,77 @@
<pu:WindowX x:Class="Plugin.StandardTest.Scanner"
        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:local="clr-namespace:Plugin.StandardTest"
        WindowStartupLocation="CenterScreen"
        Closing="Window_Closing"
        FontSize="20"
        mc:Ignorable="d"
        Style="{StaticResource ModalWindow}"
        xmlns:hb="clr-namespace:HMI.Base;assembly=HMI.Base"
        xmlns:uic="clr-namespace:HMI.UI.Converter;assembly=HMI.UI"
        xmlns:pw="https://opensource.panuon.com/wpf"
        xmlns:pu="https://opensource.panuon.com/wpf-ui"
        xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
        xmlns:cc="clr-namespace:HMI.Core;assembly=HMI.Core"
        InteropOwnersMask="False"
        SizeToContent="Height"
        pu:WindowXModalDialog.Buttons="{x:Null}"
        Title="{Binding title}" Width="600">
    <Window.Resources>
        <Style TargetType="Label">
            <Setter Property="HorizontalAlignment" Value="Right"/>
            <Setter Property="FontSize" Value="{Binding ActualWidth,RelativeSource={RelativeSource AncestorType=Window},
Converter={StaticResource fc}, ConverterParameter='80,1920'}" />
        </Style>
        <Style TargetType="TextBox">
            <Setter Property="FontSize" Value="{Binding ActualWidth,RelativeSource={RelativeSource AncestorType=Window},
Converter={StaticResource fc}, ConverterParameter='100,1920'}" />
        </Style>
        <Style TargetType="Button" BasedOn="{StaticResource PrimaryButton}">
            <Setter Property="FontSize" Value="{Binding ActualWidth,RelativeSource={RelativeSource AncestorType=Window},
Converter={StaticResource fc}, ConverterParameter='20,1920'}" />
        </Style>
    </Window.Resources>
    <Grid Margin="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition/>
            <ColumnDefinition/>
        </Grid.ColumnDefinitions>
        <!--<Label Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" HorizontalAlignment="Stretch" Padding="0"
               Content="{DynamicResource Tr.PleaseScanSnr}"
               Margin="0,0,0,20"
               />-->
        <GroupBox Grid.Row="1" Header="{DynamicResource Tr.PleaseScanSnr}" Padding="0,0,0,10" Grid.ColumnSpan="2" >
            <StackPanel>
                <pu:FormGroup
         Header="{DynamicResource Tr.PositionSnr}"
         dp:FormGroupHelper.IsRequired="True"
         Style="{StaticResource InputFormGroupStyle}">
                    <TextBox Style="{StaticResource EditorTextBoxStyle}" x:Name="positionText" TextWrapping="NoWrap"/>
                </pu:FormGroup>
                <pu:FormGroup
         Header="{DynamicResource Tr.ProductSnr}"
         dp:FormGroupHelper.IsRequired="True" Style="{StaticResource InputFormGroupStyle}">
                    <TextBox Style="{StaticResource EditorTextBoxStyle}" x:Name="snrText" TextWrapping="NoWrap"/>
                </pu:FormGroup>
            </StackPanel>
        </GroupBox>
        <Button Grid.Row="2" Click="Con_Click" Style="{StaticResource PrimaryButton}"
        Content="{DynamicResource Tr.ContinueScan}"/>
        <Button Grid.Row="2" Grid.Column="1" Click="Ok_Click" Style="{StaticResource SuccessButton}"
        Content="{DynamicResource Tr.FinishScan}"/>
    </Grid>
</pu:WindowX>
Plugin.StandardTest/Scanner.xaml.cs
New file
@@ -0,0 +1,209 @@
using HMI.Core.Util;
using HMI.UI.Entity;
using HMI.UI.Util;
using HMI.Util;
using Panuon.WPF.UI;
using System;
using System.ComponentModel;
using System.IO.Ports;
using System.Windows;
namespace Plugin.StandardTest
{
    public class BarcodeEventArg
    {
        public string name { get; private set; }
        public string positionCode { get; private set; }
        public string snrCode { get; private set; }
        public BarcodeEventArg(string name, string positionCode, string snrCode)
        {
            this.name = name;
            this.positionCode = positionCode;
            this.snrCode = snrCode;
        }
    }
    /// <summary>
    /// Scanner.xaml 的交互逻辑
    /// </summary>
    public partial class Scanner : WindowX
    {
        private string _title;
        public string title
        {
            get { return _title; }
            set => Set(ref _title, value);
        }
        public event EventHandler<BarcodeEventArg> FinishBarcode;
        SerialPort serialPort;
        public Scanner(string title)
        {
            this.title = title;
            this.DataContext = this;
            InitializeComponent();
            Loaded += Scanner_Loaded;
        }
        private void Scanner_Loaded(object sender, RoutedEventArgs e)
        {
            InitScanner();
            Loaded -= Scanner_Loaded;
        }
        /// <summary>
        /// 设置扫码枪
        /// </summary>
        private void InitScanner()
        {
            string comStr = "";
            if (serialPort != null) return;
            string section = this.Tag.ToString();
            ScannerConfig common = ScannerConfig.ReadFromIni("", "Config");
            string portName = IniFileHelper.ReadIniData(section, "PortName", "","");
            if (string.IsNullOrEmpty(portName))
            {
                if (section.StartsWith("A"))
                {
                    portName = "COM1";
                }
                else
                {
                    portName = "COM2";
                }
            }
            common.PortName = portName;
            // 配置串口
            serialPort = new SerialPort
            {
                PortName = common.PortName,
                BaudRate = common.BaudRate,
                Parity = Parity.None,
                DataBits = common.DataBits,
                StopBits = common.StopBits,
                Handshake = Handshake.None,
                DtrEnable = common.DtrEnable,
                RtsEnable = common.RtsEnable
                //ReadTimeout = 500,
                //WriteTimeout = 500,
                //ReciveBufferSize=4096
            };
            try
            {
                // 打开串口
                serialPort.Open();
                // 注册接收数据事件
                serialPort.DataReceived += SerialPort_DataReceived;
            }
            catch (Exception ex)
            {
                MsgBoxHelper.Error(this, $"{this.lang("Tr.InitGunFailed")}: {ex.StackTrace}");
            }
        }
        private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
        {
            try
            {
                SerialPort sp = (SerialPort)sender;
                string barcode = ReadDataFrom(sp);
                //string barcode =
                Dispatcher.Invoke(() =>
                {
                    if (snrText.IsFocused)
                    {
                        snrText.Text = barcode;
                        Con_Click(null, null);
                    }
                    else
                    {
                        positionText.Text = barcode;
                        //positionText.Focus();
                        //snrText.Text = "";
                        snrText.Focus();
                    }
                });
            }
            catch (Exception ex)
            {
                MessageBox.Show($"{this.lang("Tr.GetGunData")}: {ex.StackTrace}");
            }
        }
        private void Con_Click(object sender, RoutedEventArgs e)
        {
            Dispatcher.Invoke(() =>
            {
                string position = positionText.Text, snr = snrText.Text;
                if (string.IsNullOrEmpty(position) || string.IsNullOrEmpty(snr))
                {
                    ToastHelper.Warning(this, this.lang("Tr.PleaseInputSnrs"));
                    return;
                }
                try
                {
                    FinishBarcode.Invoke(this, new BarcodeEventArg(Tag.ToString(), position, snr));
                    //positionText.Text = "";
                    //snrText.Text = "";
                    positionText.Focus();
                }
                catch (Exception ex)
                {
                    ToastHelper.Warning(this, ex.Message);
                }
            });
        }
        private void Ok_Click(object sender, RoutedEventArgs e)
        {
            string position = positionText.Text, snr = snrText.Text;
            if (string.IsNullOrEmpty(position) || string.IsNullOrEmpty(snr))
            {
                ToastHelper.Warning(this, this.lang("Tr.PleaseInputSnrs"));
                return;
            }
            try
            {
                FinishBarcode.Invoke(this, new BarcodeEventArg(Tag.ToString(), position, snr));
                this.Close();
            }
            catch (Exception ex)
            {
                ToastHelper.Warning(this, ex.Message);
            }
        }
        private string ReadDataFrom(SerialPort sp)
        {
            string barcode = "";
            while (sp.BytesToRead > 0)
            {
                byte[] buffer = new byte[sp.BytesToRead];
                sp.Read(buffer, 0, buffer.Length);
                barcode += System.Text.Encoding.UTF8.GetString(buffer);
                System.Threading.Thread.Sleep(50);
            }
            return barcode;
        }
        private void Window_Closing(object sender, CancelEventArgs e)
        {
            try
            {
                serialPort.Close();
                serialPort = null;
            }
            catch (Exception ex)
            {
            }
        }
    }
}
Plugin.StandardTest/ScannerManager.cs
New file
@@ -0,0 +1,99 @@
using HMI.UI.Entity;
using System.IO.Ports;
using System.Windows.Controls;
namespace Plugin.StandardTest
{
    public class ScannerManager
    {
        public static Dictionary<string, SerialPort> map = new Dictionary<string, SerialPort>();
        public static Dictionary<SerialPort, Queue<TextBox>> controlMap = new Dictionary<SerialPort, Queue<TextBox>>();
        public static Dictionary<SerialPort, TextBox> focusMap = new Dictionary<SerialPort, TextBox>();
        public static Dictionary<TextBox, SerialPort> reverseMap = new Dictionary<TextBox, SerialPort>();
        public static void RegisterCom(ScannerConfig setting, TextBox tb)
        {
            if (!map.ContainsKey(setting.PortName))
            {
                // 配置串口
                var serialPort = new SerialPort
                {
                    PortName = setting.PortName,
                    BaudRate = setting.BaudRate,
                    Parity = Parity.None,
                    DataBits = setting.DataBits,
                    StopBits = setting.StopBits,
                    Handshake = Handshake.None,
                    DtrEnable = setting.DtrEnable,
                    RtsEnable = setting.RtsEnable
                    //ReadTimeout = 500,
                    //WriteTimeout = 500,
                    //ReciveBufferSize=4096
                };
                try
                {
                    // 打开串口
                    serialPort.Open();
                    map[setting.PortName] = serialPort;
                    controlMap[serialPort] = new Queue<TextBox>();
                    reverseMap[tb] = serialPort;
                    tb.GotFocus += Tb_GotFocus;
                    controlMap[serialPort].Enqueue(tb);
                    // 注册接收数据事件
                    serialPort.DataReceived += SerialPort_DataReceived; ;
                }
                catch (Exception ex)
                {
                }
            }
            else {
            }
        }
        private static string ReadDataFrom(SerialPort sp)
        {
            string barcode = "";
            while (sp.BytesToRead > 0)
            {
                byte[] buffer = new byte[sp.BytesToRead];
                sp.Read(buffer, 0, buffer.Length);
                barcode += System.Text.Encoding.UTF8.GetString(buffer);
                System.Threading.Thread.Sleep(50);
            }
            return barcode;
        }
        private static void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
        {
            var sp = sender as SerialPort;
            string barcode = ReadDataFrom(sp);
            TextBox tb;
            if (focusMap.ContainsKey(sp))
            {
                tb = focusMap[sp];
            }
            else
            {
                tb = controlMap[sp].Dequeue();
            }
            //string barcode =
            tb.Dispatcher.Invoke(() =>
                {
                    tb.Text = barcode;
                });
        }
        private static void Tb_GotFocus(object sender, System.Windows.RoutedEventArgs e)
        {
            var tb = sender as TextBox;
            SerialPort sp = reverseMap[tb];
            focusMap[sp] = tb;
        }
    }
}
Plugin.StandardTest/ScreenGrid.xaml
New file
@@ -0,0 +1,15 @@
<hb:NotifyControl x:Class="Plugin.StandardTest.ScreenGrid"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:local="clr-namespace:Plugin.StandardTest"
             mc:Ignorable="d"
             xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
             xmlns:hb="clr-namespace:HMI.Base;assembly=HMI.Base"
             Background="LightGray" Padding="0" Margin="0"
             d:DesignHeight="450" d:DesignWidth="800">
    <Grid x:Name="grid" dp:GridHelper.Space="2" Margin="0">
    </Grid>
</hb:NotifyControl>
Plugin.StandardTest/ScreenGrid.xaml.cs
New file
@@ -0,0 +1,45 @@
using HMI.Base;
using HMI.UI.DpUtil;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
namespace Plugin.StandardTest
{
    public class ScreenGridConfig
    {
        public int rows { get; set; }
        public int columns { get; set; }
        public ObservableCollection<bool> values { get; set; }
    }
    /// <summary>
    /// ScreenTouch.xaml 的交互逻辑
    /// </summary>
    public partial class ScreenGrid : NotifyControl
    {
        public ScreenGrid(int rows, int columns)
        {
            InitializeComponent();
            for (int i = 0; i < rows; i++)
            {
                grid.RowDefinitions.Add(new RowDefinition());
            }
            for (int i = 0; i < columns; i++)
            {
                grid.ColumnDefinitions.Add(new ColumnDefinition() { Width = GridLength.Auto});
            }
        }
        public void AddScreen(ScreenTouch screen, int row, int col)
        {
            grid.Children.Add(screen);
            Grid.SetRow(screen, row);
            Grid.SetColumn(screen, col);
            GridHelper.RefreshGridSpace(grid);
        }
    }
}
Plugin.StandardTest/ScreenTouch.xaml
New file
@@ -0,0 +1,14 @@
<hb:NotifyControl x:Class="Plugin.StandardTest.ScreenTouch"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:local="clr-namespace:Plugin.StandardTest"
             mc:Ignorable="d"
             xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
             xmlns:hb="clr-namespace:HMI.Base;assembly=HMI.Base"
             d:DesignHeight="450" d:DesignWidth="800">
    <UniformGrid x:Name="uniform" dp:UniformGridHelper.InnerBorder="0.5"
                 dp:UniformGridHelper.InnerBorderBrush="#37375f">
    </UniformGrid>
</hb:NotifyControl>
Plugin.StandardTest/ScreenTouch.xaml.cs
New file
@@ -0,0 +1,179 @@
using HMI.Base;
using HMI.UI.DpUtil;
using System;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Media;
namespace Plugin.StandardTest
{
    public class ColorConverter : IMultiValueConverter
    {
        public static ColorConverter Instance = new ColorConverter();
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            if (values[0] is ObservableCollection<string> list && values[1] is string tag)
            {
                if (list?.Contains(tag) == true) return Brushes.Green;
                return Brushes.White;
            }
            return values;
        }
        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            return null;
        }
    }
    /// <summary>
    /// ScreenTouch.xaml 的交互逻辑
    /// </summary>
    public partial class ScreenTouch : NotifyControl
    {
        public static readonly DependencyProperty ValuesProperty = DependencyProperty.Register(
            nameof(Values),
            typeof(ObservableCollection<bool>),
            typeof(ScreenTouch),
            new PropertyMetadata(null, OnValuesChanged));
        private static void OnValuesChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (d is ScreenTouch control)
            {
                // 如果有旧的集合,取消订阅
                if (e.OldValue is ObservableCollection<bool> oldCollection)
                {
                    oldCollection.CollectionChanged -= control.Value_CollectionChanged;
                }
                // 如果有新的集合,订阅变更事件
                if (e.NewValue is ObservableCollection<bool> newCollection)
                {
                    newCollection.CollectionChanged += control.Value_CollectionChanged;
                }
                // 刷新所有单元格
                control.PaintPoints();
            }
        }
        public ScreenTouch(int rows, int columns)
        {
            this.rows = rows;
            this.columns = columns;
            InitializeComponent();
            Refresh();
            Values = new ObservableCollection<bool>(new bool[rows * columns]);
        }
        public ObservableCollection<bool> Values
        {
            get => (ObservableCollection<bool>)GetValue(ValuesProperty);
            set
            {
                SetValue(ValuesProperty, value);
            }
        }
        private void Value_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
        {
            PaintPoints();
        }
        public int rows { get; private set; }
        public int columns { get; private set; }
        private Border[] children;
        public void Refresh()
        {
            uniform.Children.Clear();
            uniform.Rows = rows;
            uniform.Columns = columns;
            children = new Border[rows * columns];
            for (int i = 0; i < children.Length; i++)
            {
                var border = new Border
                {
                    BorderThickness = new Thickness(0),
                    Background = Brushes.White
                };
                children[i] = border;
                uniform.Children.Add(border);
            }
            UniformGridHelper.RefreshGridInnerBorder(uniform);
        }
        public void Refresh(int rows, int columns)
        {
            this.rows = rows;
            this.columns = columns;
            Refresh();
        }
        public void Clear()
        {
            Values = new ObservableCollection<bool>(new bool[rows * columns]);
        }
        public void DrawPoint(int x, int y)
        {
            Values[y * columns + x] = true;
        }
        public void DrawPoints(int[][] points)
        {
            var temp = new ObservableCollection<bool>(new bool[rows * columns]);
            for (int i = 0; i < points.Length; i++)
            {
                int[] point = points[i];
                int index = point[1] * columns + point[0];
                //children[point[0]][point[1]].Background = Brushes.Green;
                temp[index] = true;
            }
            Values = temp;
        }
        public void DrawNewPoints(int[][] points)
        {
            var temp = Values == null ?
                new ObservableCollection<bool>(new bool[rows * columns]) :
                new ObservableCollection<bool>(Values);
            for (int i = 0; i < points.Length; i++)
            {
                int[] point = points[i];
                if (point == null)
                {
                    continue;
                }
                int index = point[1] * columns + point[0];
                //children[point[0]][point[1]].Background = Brushes.Green;
                temp[index] = true;
            }
            Values = temp;
        }
        private void PaintPoints()
        {
            for (int i = 0; i < Values.Count; i++)
            {
                if (Values[i])
                {
                    children[i].Background = Brushes.Green;
                }
                else
                {
                    children[i].Background = Brushes.White;
                }
            }
        }
    }
}
Plugin.StandardTest/ScreenTouchWin.xaml
New file
@@ -0,0 +1,20 @@
<pu:WindowX x:Class="Plugin.StandardTest.ScreenTouchWin"
        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:local="clr-namespace:Plugin.StandardTest"
        mc:Ignorable="d"
        xmlns:pw="https://opensource.panuon.com/wpf"
        xmlns:pu="https://opensource.panuon.com/wpf-ui"
        SizeChanged="Window_SizeChanged"
        Style="{StaticResource ModalWindow}"
        pu:WindowXModalDialog.Buttons="{x:Null}"
        WindowStartupLocation="CenterScreen"
        WindowState="Maximized"
        Background="LightGray"
        Title="Screens" Height="450" Width="800">
    <Grid x:Name="grid" HorizontalAlignment="Center">
    </Grid>
</pu:WindowX>
Plugin.StandardTest/ScreenTouchWin.xaml.cs
New file
@@ -0,0 +1,67 @@
using Panuon.WPF.UI;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
namespace Plugin.StandardTest
{
    /// <summary>
    /// ScreenTouchWin.xaml 的交互逻辑
    /// </summary>
    public partial class ScreenTouchWin : WindowX
    {
        private ScreenGrid screenGrid;
        private ScreenTouch[] screens;
        public ScreenTouchWin(ScreenTouch[] configs)
        {
            screenGrid = new ScreenGrid(1, configs.Length);
            screens = new ScreenTouch[configs.Length];
            InitializeComponent();
            int space = 2 * (screens.Length + 1);
            for (int i = 0; i < screens.Length; i++)
            {
                var screen = new ScreenTouch(configs[i].rows, configs[i].columns)
                {
                    Width = Math.Max(Math.Floor((this.ActualHeight / 9 * 16 - space) / screens.Length), 0),
                    Height = Math.Max(Math.Floor((this.ActualHeight / 9 * 16 - space) / screens.Length / 16 * 9), 0)
                };
                screen.SetBinding(ScreenTouch.ValuesProperty, new Binding(nameof(ScreenTouch.Values)) { Source = configs[i], Mode = BindingMode.OneWay });
                screens[i] = screen;
                screenGrid.AddScreen(screen, 0, i);
            }
            grid.Children.Add(screenGrid);
        }
        private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
        {
            if (screenGrid == null)
            {
                return;
            }
            int space = 2 * (screens.Length + 1);
            for (int i = 0; i < screens.Length; i++)
            {
                var screen = screens[i];
                screen.Width = Math.Max(Math.Floor((this.ActualHeight / 9 * 16 - space) / screens.Length), 0);
                screen.Height = Math.Max(Math.Floor((this.ActualHeight / 9 * 16 - space) / screens.Length / 16 * 9), 0);
            }
        }
    }
}
Plugin.StandardTest/Setting/TcpServer.cs
New file
@@ -0,0 +1,49 @@
using SuperSocket.SocketBase;
using SuperSocket.SocketBase.Protocol;
using System.Text;
namespace Plugin.StandardTest.Setting
{
    public class DTReceiveFilter : ReceiveFilterBase<StringRequestInfo>
    {
        public override StringRequestInfo Filter(byte[] readBuffer, int offset, int length, bool toBeCopied, out int rest)
        {
            rest = 0;
            int i = offset;
            List<byte> bytes = new List<byte>();
            while (i < readBuffer.Length)
            {
                byte b = readBuffer[i++];
                if (b == '\n')
                {
                    break;
                }
                bytes.Add(b);
            }
            byte[] arr = bytes.ToArray();
            return new StringRequestInfo(DateTime.Now.ToString("yyyyMMddHHmmssfff"), Encoding.UTF8.GetString(arr), null);//sb.ToString()
        }
    }
    public class StringParser : IRequestInfoParser<StringRequestInfo>
    {
        public StringRequestInfo ParseRequestInfo(string source)
        {
            return new StringRequestInfo(DateTime.Now.ToString("yyyyMMddHHmmssfff"), source, null);
        }
    }
    public class TcpServer : AppServer<TcpSession>
    {
        public TcpServer() : base(new TerminatorReceiveFilterFactory("\n", Encoding.UTF8, new StringParser()))
        {
        }
    }
    public class TcpSession : AppSession<TcpSession, StringRequestInfo>
    {
    }
}
Plugin.StandardTest/Setting/TsSetting.cs
New file
@@ -0,0 +1,6 @@
namespace Plugin.StandardTest.Setting
{
    public class TsSetting
    {
    }
}
Plugin.StandardTest/SingleControl.xaml
New file
@@ -0,0 +1,419 @@
<UserControl x:Class="Plugin.StandardTest.SingleControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
             xmlns:pw="https://opensource.panuon.com/wpf"
             xmlns:pu="https://opensource.panuon.com/wpf-ui"
             xmlns:local="clr-namespace:Plugin.StandardTest"
             xmlns:cdp="clr-namespace:HMI.Core.DpUtil;assembly=HMI.Core"
             mc:Ignorable="d"
             xmlns:sys="clr-namespace:System;assembly=mscorlib"
             Loaded="UserControl_Loaded"
             Background="#ccc6c7"
             PreviewMouseRightButtonDown="control_PreviewMouseRightButtonDown"
             FontSize="{Binding ActualHeight,RelativeSource={RelativeSource AncestorType=UserControl},
Converter={StaticResource hfc}, ConverterParameter='12,816'}"
             x:Name="control"
             SizeChanged="control_SizeChanged"
             d:DesignHeight="450" d:DesignWidth="800">
    <UserControl.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/HMI.UI;component/Style/PanuonListView.xaml"/>
                <ResourceDictionary Source="/HMI.UI;component/Style/PanuonDataGrid.xaml"/>
            </ResourceDictionary.MergedDictionaries>
            <sys:Double x:Key="nanD">NaN</sys:Double>
            <Style TargetType="Label" x:Key="baseLabel">
                <Setter Property="HorizontalContentAlignment" Value="Left"/>
                <Setter Property="VerticalContentAlignment" Value="Center"/>
                <Setter Property="Padding" Value="5"/>
                <Setter Property="FontSize" Value="{Binding ActualWidth,ElementName=control,
Converter={StaticResource fc}, ConverterParameter='25,1093'}"/>
            </Style>
            <Style TargetType="TextBlock" x:Key="baseText">
                <Setter Property="HorizontalAlignment" Value="Left"/>
                <Setter Property="VerticalAlignment" Value="Center"/>
                <Setter Property="Padding" Value="0,0,2,0"/>
                <Setter Property="FontSize" Value="{Binding ActualWidth,ElementName=control,
Converter={StaticResource fc}, ConverterParameter='25,1093'}"/>
            </Style>
            <Brush x:Key="passedColor">#beed00</Brush>
        </ResourceDictionary>
    </UserControl.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <Grid dp:GridHelper.InnerBorder="1" dp:GridHelper.Space="2" x:Name="tableHeader"
              dp:GridHelper.InnerBorderBrush="{DynamicResource CommonBoarder}">
            <Grid.RowDefinitions>
                <RowDefinition/>
                <RowDefinition/>
            </Grid.RowDefinitions>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition />
                <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
            <StackPanel Grid.RowSpan="3">
                <StackPanel.Style>
                    <Style TargetType="StackPanel">
                        <Style.Triggers>
                            <DataTrigger Binding="{Binding Status}" Value="Passed">
                                <Setter Property="Background" Value="{StaticResource passedColor}"/>
                            </DataTrigger>
                            <DataTrigger Binding="{Binding Status}" Value="Testing">
                                <Setter Property="Background" Value="Gold"/>
                            </DataTrigger>
                            <DataTrigger Binding="{Binding Status}" Value="Failed">
                                <Setter Property="Background" Value="{DynamicResource DangerColor}"/>
                            </DataTrigger>
                            <DataTrigger Binding="{Binding Status}" Value="Failed">
                                <Setter Property="Background" Value="{DynamicResource DangerColor}"/>
                            </DataTrigger>
                            <DataTrigger Binding="{Binding Status}" Value="Ready">
                                <Setter Property="Background" Value="DeepSkyBlue"/>
                            </DataTrigger>
                        </Style.Triggers>
                    </Style>
                </StackPanel.Style>
                <Label>
                    <Label.Style>
                        <Style TargetType="Label">
                            <Setter Property="VerticalAlignment" Value="Center"/>
                            <Setter Property="Padding" Value="10,10,10,0"/>
                            <Setter Property="Margin" Value="0"/>
                            <Setter Property="FontFamily" Value="{StaticResource iconfont}"/>
                            <Setter Property="Foreground" Value="White"/>
                            <Setter Property="HorizontalContentAlignment" Value="Center"/>
                            <Setter Property="VerticalContentAlignment" Value="Center"/>
                            <Setter Property="FontSize" Value="{Binding ActualWidth,ElementName=control,
Converter={StaticResource fc}, ConverterParameter='50,816'}"/>
                            <Style.Triggers>
                                <DataTrigger Binding="{Binding Status}" Value="Passed">
                                    <Setter Property="Content" Value="&#xe65a;"/>
                                    <Setter Property="Background" Value="{StaticResource passedColor}"/>
                                </DataTrigger>
                                <DataTrigger Binding="{Binding Status}" Value="Testing">
                                    <Setter Property="Content" Value="&#xeb61;"/>
                                    <Setter Property="Background" Value="Gold"/>
                                </DataTrigger>
                                <DataTrigger Binding="{Binding Status}" Value="Failed">
                                    <Setter Property="Content" Value="&#xe604;"/>
                                    <Setter Property="Background" Value="{DynamicResource DangerColor}"/>
                                </DataTrigger>
                                <DataTrigger Binding="{Binding Status}" Value="Failed">
                                    <Setter Property="Content" Value="&#xe604;"/>
                                    <Setter Property="Background" Value="{DynamicResource DangerColor}"/>
                                </DataTrigger>
                                <DataTrigger Binding="{Binding Status}" Value="Ready">
                                    <Setter Property="Content" Value="&#xe6ae;"/>
                                    <Setter Property="Background" Value="DeepSkyBlue"/>
                                </DataTrigger>
                            </Style.Triggers>
                        </Style>
                    </Label.Style>
                </Label>
                <Label Content="{Binding Status}" VerticalAlignment="Center" VerticalContentAlignment="Center"
                       Padding="0" Margin="0"
                       HorizontalContentAlignment="Center"
                       Foreground="#FFF"
                       FontWeight="Bold"
                       FontSize="{Binding ActualWidth,ElementName=control,
Converter={StaticResource fc}, ConverterParameter='15,816'}"/>
            </StackPanel>
            <!--<Label HorizontalContentAlignment="Left" Grid.Column="1" Content="{Binding Status}"/>-->
            <Label Style="{StaticResource baseLabel}" Background="#FFF" Grid.Row="0" Grid.Column="1">
                <StackPanel Orientation="Horizontal">
                    <TextBlock Style="{StaticResource baseText}" Text="{DynamicResource Ts.Total}"/>
                    <TextBlock Style="{StaticResource baseText}" Margin="2,0,18,0" Text="{Binding TotalNum}"/>
                    <TextBlock Style="{StaticResource baseText}" Text="{DynamicResource Ts.Pass}"/>
                    <TextBlock Style="{StaticResource baseText}" Margin="2,0,18,0" Text="{Binding PassNum}"/>
                    <TextBlock Style="{StaticResource baseText}" Text="{DynamicResource Ts.Fail}"/>
                    <TextBlock Style="{StaticResource baseText}" Margin="2,0,18,0" Text="{Binding FailNum}"/>
                    <TextBlock Style="{StaticResource baseText}" Text="{DynamicResource Ts.PassRate}"/>
                    <TextBlock Style="{StaticResource baseText}" Margin="2,0,18,0" Text="{Binding PassRate,StringFormat={}{0:P2}}"/>
                    <TextBlock Style="{StaticResource baseText}" Text="{DynamicResource Ts.ProbeCount}"/>
                    <TextBlock Style="{StaticResource baseText}" Margin="2,0,18,0" Text="{Binding ProbeCount}"/>
                </StackPanel>
            </Label>
            <Label Style="{StaticResource baseLabel}" Background="#FFF" Grid.Row="1" Grid.Column="1">
                <StackPanel Orientation="Horizontal">
                    <Label Style="{StaticResource baseLabel}" Content="{DynamicResource Ts.CurrentSnr}"/>
                    <Label Style="{StaticResource baseLabel}" Margin="2,0,18,0" Content="{Binding Barcode}"/>
                    <Label Style="{StaticResource baseLabel}" Content="{DynamicResource Ts.TimeSpan}"/>
                    <Label Style="{StaticResource baseLabel}" Margin="2,0,18,0" Content="{Binding ElapseTime}"/>
                </StackPanel>
            </Label>
            <Border x:Name="screenContainer" BorderThickness="0" Grid.Column="2" Grid.RowSpan="2" MouseLeftButtonDown="screenContainer_MouseLeftButtonDown">
            </Border>
        </Grid>
        <Grid Grid.Row="2" x:Name="simplePanel" dp:GridHelper.Space="2" dp:GridHelper.SpaceIgnore="TOP">
            <Grid.RowDefinitions>
                <RowDefinition/>
                <RowDefinition Height="Auto"/>
            </Grid.RowDefinitions>
            <Label VerticalContentAlignment="Center"
                       HorizontalContentAlignment="Center"
                       FontSize="{Binding ActualWidth,ElementName=control,
Converter={StaticResource fc}, ConverterParameter='50,816'}">
                <Label.Style>
                    <Style TargetType="Label">
                        <Setter Property="Foreground" Value="#848286"/>
                        <Setter Property="Content" Value="{Binding CurrentStep}"/>
                        <Style.Triggers>
                            <DataTrigger Binding="{Binding Status}" Value="Passed">
                                <Setter Property="Background" Value="{StaticResource passedColor}"/>
                                <Setter Property="Content" Value="--PASS--"/>
                                <Setter Property="Foreground" Value="#000"/>
                            </DataTrigger>
                            <DataTrigger Binding="{Binding Status}" Value="Failed">
                                <Setter Property="Background" Value="{DynamicResource DangerColor}"/>
                                <Setter Property="Content" Value="--FAIL--"/>
                                <Setter Property="Foreground" Value="#000"/>
                            </DataTrigger>
                        </Style.Triggers>
                    </Style>
                </Label.Style>
            </Label>
            <ProgressBar Grid.Row="1" MinHeight="20" Value="{Binding ProcessValue}"
                         Height="{Binding ActualHeight,ElementName=control,
Converter={StaticResource hfc}, ConverterParameter='30,816'}"/>
            <Label Grid.Row="1"
                   VerticalContentAlignment="Center"
                   HorizontalContentAlignment="Center"
                   Content="{Binding ProcessValueLabel}" Background="Transparent" Foreground="#000"/>
        </Grid>
        <Grid Grid.Row="2" x:Name="detailPanel">
            <Grid.RowDefinitions>
                <RowDefinition/>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="Auto"/>
            </Grid.RowDefinitions>
            <Canvas Grid.Row="0" x:Name="canvas" Background="Transparent">
                <DataGrid Canvas.Left="0" Canvas.Top="0" Height="{Binding ActualHeight,ElementName=canvas}"
                          Width="{Binding ActualWidth,ElementName=canvas}"
                          Style="{StaticResource DefaultDataGrid}" Margin="2,0,2,2"
                  FontSize="{DynamicResource tableFontSize}"
                  dp:DataGridHelper.ShowRowNumber="True"
                  VirtualizingPanel.IsContainerVirtualizable="True"
                  VirtualizingPanel.IsVirtualizing="True"
                  VirtualizingPanel.VirtualizationMode="Recycling"
                  RowHeight="{StaticResource nanD}"
                  ColumnHeaderHeight="{Binding ActualWidth,ElementName=control,
Converter={StaticResource fc}, ConverterParameter='32,1093,30,45'}"
                  HorizontalScrollBarVisibility="Disabled"
                  ItemsSource="{Binding Datasource}" x:Name="table">
                    <DataGrid.Columns>
                        <DataGridTextColumn Header="#"
                            ElementStyle="{StaticResource TableTextBlock}"
            Binding="{Binding Header, RelativeSource={RelativeSource AncestorType={x:Type DataGridRow}, Mode=FindAncestor}}"
                        Width="0.4*"/>
                        <DataGridTextColumn Width="2.5*" Binding="{Binding Name}"
                            ElementStyle="{StaticResource TableTextBlock}">
                            <DataGridTextColumn.Header>
                                <TextBlock Text="{DynamicResource Test.Name}"/>
                            </DataGridTextColumn.Header>
                        </DataGridTextColumn>
                        <DataGridTextColumn Width="0.8*" Binding="{Binding Unit}"
                            ElementStyle="{StaticResource TableTextBlock}">
                            <DataGridTextColumn.Header>
                                <TextBlock Text="{DynamicResource Test.Unit}"/>
                            </DataGridTextColumn.Header>
                        </DataGridTextColumn>
                        <DataGridTextColumn Width="2*" Binding="{Binding Value}"
                            ElementStyle="{StaticResource TableTextBlock}">
                            <DataGridTextColumn.Header>
                                <TextBlock Text="{DynamicResource Test.Value}"/>
                            </DataGridTextColumn.Header>
                        </DataGridTextColumn>
                        <DataGridTextColumn Width="1.5*" Binding="{Binding LimitDown}"
                            ElementStyle="{StaticResource TableTextBlock}">
                            <DataGridTextColumn.Header>
                                <TextBlock Text="{DynamicResource Test.LimitDown}"/>
                            </DataGridTextColumn.Header>
                        </DataGridTextColumn>
                        <DataGridTextColumn Width="1.5*" Binding="{Binding LimitUp}"
                            ElementStyle="{StaticResource TableTextBlock}">
                            <DataGridTextColumn.Header>
                                <TextBlock Text="{DynamicResource Test.LimitUp}"/>
                            </DataGridTextColumn.Header>
                        </DataGridTextColumn>
                        <DataGridTextColumn Width="0.6*" Binding="{Binding ConsumeTime}"
        ElementStyle="{StaticResource TableTextBlock}">
                            <DataGridTextColumn.Header>
                                <TextBlock Text="{DynamicResource Test.ConsumeTime}"/>
                            </DataGridTextColumn.Header>
                        </DataGridTextColumn>
                        <DataGridTemplateColumn Width="1*">
                            <DataGridTemplateColumn.Header>
                                <TextBlock Text="{DynamicResource Test.Result}"/>
                            </DataGridTemplateColumn.Header>
                            <DataGridTemplateColumn.CellTemplate>
                                <DataTemplate>
                                    <Grid x:Name="border" Margin="0">
                                        <TextBlock x:Name="text" Margin="-1" Padding="0" VerticalAlignment="Center" Text="{Binding Result}"/>
                                    </Grid>
                                    <DataTemplate.Triggers>
                                        <DataTrigger Binding="{Binding Result}" Value="Passed">
                                            <Setter TargetName="border" Property="Background" Value="{StaticResource passedColor}"/>
                                            <Setter TargetName="text" Property="Foreground" Value="#000"/>
                                        </DataTrigger>
                                        <DataTrigger Binding="{Binding Result}" Value="Failed">
                                            <Setter TargetName="border" Property="Background" Value="{DynamicResource DangerColor}"/>
                                            <Setter TargetName="text" Property="Foreground" Value="#000"/>
                                        </DataTrigger>
                                    </DataTemplate.Triggers>
                                </DataTemplate>
                            </DataGridTemplateColumn.CellTemplate>
                        </DataGridTemplateColumn>
                    </DataGrid.Columns>
                </DataGrid>
            </Canvas>
            <Grid Grid.Row="1" Height="20" Margin="0">
                <ProgressBar Grid.Row="3" x:Name="testProcess" Value="{Binding ProcessValue}"/>
                <Label Width="45" DockPanel.Dock="Right" x:Name="testProcessLabel" Padding="0"
                       Content="{Binding ProcessValueLabel}"
                   VerticalContentAlignment="Center"/>
            </Grid>
            <Grid Background="#f1eded" Grid.Row="2" Height="25">
                <DockPanel>
                    <StackPanel Margin="10,0,0,0" DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center" dp:StackPanelHelper.ChildSpace="8">
                        <StackPanel Orientation="Horizontal">
                            <Ellipse Width="10" Height="10">
                                <Ellipse.Style>
                                    <Style TargetType="Ellipse">
                                        <Setter Property="Fill" Value="LightGray"/>
                                        <Style.Triggers>
                                            <DataTrigger Binding="{Binding TcpServerStatus,ElementName=control}" Value="ERROR">
                                                <Setter Property="Fill" Value="Red"/>
                                            </DataTrigger>
                                            <DataTrigger Binding="{Binding TcpServerStatus,ElementName=control}" Value="RUNNING">
                                                <Setter Property="Fill" Value="Green"/>
                                            </DataTrigger>
                                        </Style.Triggers>
                                    </Style>
                                </Ellipse.Style>
                            </Ellipse>
                            <TextBlock Text="{Binding TcpServerInfo}"/>
                            <Button Padding="0" Margin="0" BorderThickness="0" Click="Button_Click">🔄</Button>
                        </StackPanel>
                    </StackPanel>
                    <Label Margin="10,0,0,0" Padding="0" FontSize="16" VerticalContentAlignment="Center" Content="{Binding SocketName}"/>
                    <Label Margin="20,0,0,0" Padding="0" FontSize="14" VerticalContentAlignment="Center" Content="{Binding CurrentStep}"/>
                    <StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="0,0,5,0">
                        <!--<Button FontSize="12" x:Name="scannerSetting"
                                Margin="0,0,10,0" Click="Button_Click" Content="{DynamicResource App.ScannerSetting}">
                            <Button.Style>
                                <Style TargetType="Button" BasedOn="{StaticResource LinkButton}">
                                    <Style.Triggers>
                                        <DataTrigger Binding="{Binding IsAutoMode}" Value="True">
                                            <Setter Property="Visibility" Value="Collapsed"/>
                                        </DataTrigger>
                                    </Style.Triggers>
                                </Style>
                            </Button.Style>
                        </Button>-->
                        <Label Padding="0" FontSize="12"
VerticalAlignment="Center"
VerticalContentAlignment="Center" Content="{DynamicResource Ts.ProbeCount}"/>
                        <Label Padding="0" FontSize="12"  Margin="0,0,15,0"
VerticalAlignment="Center"
VerticalContentAlignment="Center">
                            <TextBlock>
                                <Run Text="{Binding ProbeCount}"/>
                                <Run Text="/"/>
                                <Run Text="{Binding ProbeWarn,Mode=OneWay}"/>
                            </TextBlock>
                        </Label>
                        <StackPanel x:Name="probeWarnPanel" Orientation="Horizontal" Visibility="Collapsed">
                            <Label x:Name="alarmLabel" Padding="0" FontSize="12"
VerticalAlignment="Center"
VerticalContentAlignment="Center" Content="{DynamicResource Ts.ProbeAlarmCount}"/>
                            <Label x:Name="alarmValue" Padding="0" FontSize="12"
VerticalAlignment="Center"
VerticalContentAlignment="Center" Content="{Binding ProbeWarn,Mode=OneWay}"/>
                        </StackPanel>
                        <Button FontSize="12" Style="{StaticResource LinkButton}"
                                cdp:PermissionHelper.RequiredPermission="Permission:ClearProbeCount"
                                x:Name="clear" Margin="10,0,0,0" Click="Button_Click"
                            Content="{DynamicResource App.Clear}" Cursor=""/>
                    </StackPanel>
                    <Label x:Name="probeCountAlarm" Visibility="Collapsed" DockPanel.Dock="Left" Padding="30,0" FontSize="12" Foreground="Red"
VerticalAlignment="Center"
VerticalContentAlignment="Center" Content="{DynamicResource Ts.ProbeAlarm}"/>
                </DockPanel>
            </Grid>
        </Grid>
        <Canvas Grid.Row="0" Grid.RowSpan="5" Visibility="Collapsed"
                VirtualizingPanel.IsVirtualizing="False"
                VirtualizingPanel.VirtualizationMode="Standard"
                Background="#AAFFFFFF" x:Name="pausePanel" SizeChanged="pausePanel_Loaded">
            <Border BorderBrush="#000" Background="#FFF" BorderThickness="0.5"
                    CornerRadius="5"
                    x:Name="visualModal">
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinition Height="Auto"/>
                        <RowDefinition Height="Auto"/>
                        <RowDefinition Height="Auto"/>
                    </Grid.RowDefinitions>
                    <Grid.ColumnDefinitions>
                        <ColumnDefinition Width="Auto"/>
                        <ColumnDefinition />
                    </Grid.ColumnDefinitions>
                    <Border Padding="10,5,0,5" Grid.ColumnSpan="2" Background="{DynamicResource WarningColor}"
                           CornerRadius="5,5,0,0">
                        <Label Padding="0" Margin="0" Foreground="#FFF" Content="{DynamicResource App.Warn}"/>
                    </Border>
                    <TextBlock Margin="20,20,20,0" Grid.Row="1"
                               FontSize="40"
                               FontFamily="/Panuon.WPF.UI;component/Resources/Fonts/#PanuonIcon"
                               Foreground="#F9D01A" Text="&#xe931;"/>
                    <Label Margin="0,20,20,0" Grid.Row="1" Grid.Column="1" Content="{DynamicResource Ts.PauseMsg}"/>
                    <StackPanel Margin="10" Grid.Row="2" Grid.ColumnSpan="2" Orientation="Horizontal" HorizontalAlignment="Right">
                        <Button Style="{StaticResource ModalButton}" Tag="Cancel"
                                x:Name="cancelPauseBtn"
                                Click="Button_Click" Content="{DynamicResource App.Cancel}"/>
                        <Button Style="{StaticResource ModalButton}"
                                x:Name="continuePauseBtn"
                                Click="Button_Click"
                                Background="{DynamicResource WarningColor}"
                                Tag="OK" Content="{DynamicResource App.Continue}"/>
                    </StackPanel>
                </Grid>
            </Border>
        </Canvas>
    </Grid>
</UserControl>
Plugin.StandardTest/SingleControl.xaml.cs
New file
@@ -0,0 +1,916 @@
using HMI.Core.Entity;
using HMI.Core.Manager;
using HMI.Core.Setting;
using HMI.Core.Util;
using HMI.UI.Util;
using HMI.Util;
using NationalInstruments.TestStand.Interop.API;
using Plugin.StandardTest.Entity;
using Plugin.StandardTest.Setting;
using SuperSocket.SocketBase.Config;
using SuperSocket.SocketBase.Protocol;
using System.Collections;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Configuration;
using System.IO;
using System.Runtime.CompilerServices;
using System.Timers;
using System.Windows;
using System.Windows.Controls;
using Util.ManualDebug;
namespace Plugin.StandardTest
{
    /// <summary>
    /// SingleControl.xaml 的交互逻辑
    /// </summary>
    public partial class SingleControl : UserControl, INotifyPropertyChanged
    {
        #region ...
        public event PropertyChangedEventHandler PropertyChanged;
        protected void Set([CallerMemberName] string name = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
        }
        protected void Set<T>(ref T old, T @new, [CallerMemberName] string propertyName = null)
        {
            old = @new;
            if (propertyName != null)
            {
                Set(propertyName);
            }
        }
        #endregion
        private int index;
        private string CountIniPath;
        private string status = "Ready";
        private ScreenTouch[] screens;
        private ScreenGrid screenGrid;
        public string Status
        {
            get { return status; }
            set { Set(ref status, value); }
        }
        private double totalNum;
        public double TotalNum
        {
            get => totalNum;
            private set
            {
                Set(ref totalNum, value);
            }
        }
        public StandardTestUI parent { get; set; }
        private int passNum;
        public int PassNum
        {
            get => passNum;
            private set { Set(ref passNum, value); }
        }
        private int failNum;
        public int FailNum
        {
            get => failNum;
            private set { Set(ref failNum, value); }
        }
        private double passRate;
        public double PassRate
        {
            get => passRate;
            private set
            {
                Set(ref passRate, value);
            }
        }
        public string NumInfo
        {
            get => this.lang("Ts.NumInfo", TotalNum, PassNum, FailNum, string.Format("{0:0.00}", PassRate));
        }
        public string TestInfo
        {
            get => this.lang("Ts.TestInfo", Barcode, ElapseTime);
        }
        private string barcode = string.Empty;
        public string Barcode
        {
            get { return barcode; }
            set { Set(ref barcode, value); }
        }
        private string model = string.Empty;
        public string Model
        {
            get { return model; }
            set { Set(ref model, value); }
        }
        private string elapseTime = "00:00:00";
        public string ElapseTime
        {
            get { return elapseTime; }
            set
            {
                if (value == "Start Timing")
                {
                    this.refreshTimer.Enabled = true;
                    TestStartTime = DateTime.Now;
                    StepStartTime = DateTime.Now;
                }
                else
                {
                    this.refreshTimer.Enabled = false;
                    if (value != "Stop Timing")
                    {
                        Set(ref elapseTime, value + "s");
                        Set("TestInfo");
                    }
                }
            }
        }
        private string socketName = string.Empty;
        public string SocketName
        {
            get { return socketName; }
            set
            {
                Set(ref socketName, value);
            }
        }
        private ObservableCollection<TestStep> _datasource = new ObservableCollection<TestStep>();
        public ObservableCollection<TestStep> Datasource
        {
            get => _datasource;
            set
            {
                Set(ref _datasource, value);
            }
        }
        private DateTime TestStartTime = new DateTime();
        private DateTime StepStartTime = new DateTime();
        private TimeSpan TimeCount = new TimeSpan();
        private System.Timers.Timer refreshTimer;
        private string currentStep = "";
        public string CurrentStep
        {
            get => currentStep;
            set
            {
                Set(ref currentStep, value);
            }
        }
        private int _probeCount;
        public int ProbeCount
        {
            get => _probeCount;
            set
            {
                Set(ref _probeCount, value);
                try
                {
                    if (ProbeWarn <= 0) probeCountAlarm.Visibility = Visibility.Collapsed;
                    else if (value >= ProbeWarn) probeCountAlarm.Visibility = Visibility.Visible;
                    else probeCountAlarm.Visibility = Visibility.Collapsed;
                }
                catch
                {
                }
            }
        }
        /// <summary>
        /// 进度值
        /// </summary>
        private int _processValue;
        public int ProcessValue
        {
            get => _processValue;
            set
            {
                Set(ref _processValue, value);
            }
        }
        public string ProcessValueLabel
        {
            get
            {
                if (ProcessValue > 0) return $"{ProcessValue}%";
                return "";
            }
            set
            {
            }
        }
        public void SetSimpleUI(bool flag)
        {
            if (flag)
            {
                simplePanel.Visibility = Visibility.Visible;
                detailPanel.Visibility = Visibility.Collapsed;
            }
            else
            {
                simplePanel.Visibility = Visibility.Collapsed;
                detailPanel.Visibility = Visibility.Visible;
            }
        }
        private int _probeWarn;
        public int ProbeWarn
        {
            get => _probeWarn;
            private set
            {
                Set(ref _probeWarn, value);
            }
        }
        private bool isAutoMode;
        public bool IsAutoMode
        {
            get => isAutoMode;
            set
            {
                Set(ref isAutoMode, value);
            }
        }
        public SingleControl(int Index, string CountIniPath, int probeWarn)
        {
            DataContext = this;
            this.CountIniPath = CountIniPath;
            this.index = Index;
            ProbeWarn = probeWarn;
            refreshTimer = new System.Timers.Timer()
            {
                Interval = 100
            };
            refreshTimer.Elapsed += RefreshTimer_Elapsed;
            ReadCounter();
            InitializeComponent();
            RefreshProbeCount(null);
            try
            {
                var value = ParameterManager.GetCommonParameterByName("AutoTestMode");
                IsAutoMode = bool.Parse(value.Value);
            }
            catch
            {
                IsAutoMode = true;
            }
        }
        private void RefreshTimer_Elapsed(object sender, ElapsedEventArgs e)
        {
            TimeCount = DateTime.Now - TestStartTime;
            elapseTime = string.Format("{0:00}:{1:00}:{2:00}", TimeCount.Hours, TimeCount.Minutes, TimeCount.Seconds);
            Set("ElapseTime");
            Set("TestInfo");
        }
        /// <summary>
        /// 获取TS的每步测试的数据添加到表格
        /// 项目名称,测试参数,测试值,上限,下限,单位,结果
        /// </summary>
        /// <param name="TS_Data"></param>
        public void AddTestData(string name, string step_name, string value, string HightLimit, string LowLimit, string unit, string Status)
        {
            try
            {
                var data = new TestStep
                {
                    Name = name,
                    Step = step_name,
                    Value = value,
                    LimitUp = HightLimit,
                    LimitDown = LowLimit,
                    Unit = unit,
                    Result = Status
                };
                table.Dispatcher.Invoke(new Action(() =>
                {
                    var now = DateTime.Now;
                    var span = now - StepStartTime;
                    StepStartTime = now;
                    data.ConsumeTime = $"{(span.TotalMilliseconds / 1000d).ToString("F2")}s";
                    Datasource.Add(data);
                    table.ScrollIntoView(data);
                }));
            }
            catch (Exception ex)
            {
                //屏蔽错误
            }
        }
        public void UpdateStatus(string status)
        {
            if (status == "Passed")
            {
                UpdateTestCounter(true);
            }
            else if (status == "Error" || status == "Failed")
            {
                UpdateTestCounter(false);
            }
            else if (Status != "Testing" && status == "Testing")
            {
                UpdateProbeCount(ProbeCount + 1);
            }
            Status = status;
        }
        public void IniTestData()
        {
            Datasource.Clear();
        }
        /// <summary>
        /// 设置测试进度条
        /// </summary>
        /// <param name="ProcessVal"></param>
        public void SetTestProcess(int ProcessVal)
        {
            ProcessValue = ProcessVal;
            Set("ProcessValueLabel");
        }
        private void UserControl_Loaded(object sender, RoutedEventArgs e)
        {
            StartTcpServer();
            this.UpdateTestCounter(null);
            Loaded -= UserControl_Loaded;
        }
        private void UpdateTestCounter(bool? result)
        {
            if (result == true)
            {
                PassNum += 1;
                //Interlocked.Increment(ref passNum);
                //Set("PassNum");
            }
            else if (result == false)
            {
                FailNum += 1;
                //Interlocked.Increment(ref failNum);
                //Set("FailNum");
            }
            TotalNum = PassNum + FailNum;
            if (TotalNum != 0)
            {
                PassRate = ((double)PassNum / TotalNum);
            }
            else
            {
                PassRate = 0.0;
            }
            Set("NumInfo");
            this.WriteCounter();
        }
        private void WriteCounter()
        {
            IniFileHelper.WriteIniData("Socket" + this.index.ToString(), "Pass",
                this.passNum.ToString(), this.CountIniPath);
            IniFileHelper.WriteIniData("Socket" + this.index.ToString(), "Fail", this.failNum.ToString(),
                this.CountIniPath);
        }
        private void ReadCounter()
        {
            try
            {
                this.PassNum = Convert.ToInt32(IniFileHelper.ReadIniData("Socket" + this.index.ToString(), "Pass", "", CountIniPath));
                this.FailNum = Convert.ToInt32(IniFileHelper.ReadIniData("Socket" + this.index.ToString(), "Fail", "", CountIniPath));
            }
            catch (Exception ex)
            {
                //do nothing
            }
        }
        public void ResetCounter()
        {
            this.PassNum = 0;
            this.FailNum = 0;
            this.UpdateTestCounter(null);
        }
        public void WriteToExcel(string path)
        {
            Dispatcher.Invoke(() =>
            {
                try
                {
                    string suffix = Path.GetExtension(path);
                    if (!string.Equals(".xlsx", suffix, StringComparison.OrdinalIgnoreCase) &&
                    !string.Equals(".xls", suffix, StringComparison.OrdinalIgnoreCase))
                    {
                        path = path.Replace(suffix, ".xls");
                    }
                    FileInfo fi = new FileInfo(path);
                    if (!fi.Directory.Exists)
                    {
                        fi.Directory.Create();  //文件不存在,创建文件
                    }
                    List<string[]> rows = new List<string[]>();
                    rows.Add(new string[] { "测试时间", "测试人", "测试设备", "条形码", "测试结果" });
                    rows.Add(new string[] { DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), UserInfo.User.Name, ParamSetting.StationName, Barcode, Status });
                    rows.Add(new string[] { "项目名称", "测试参数", "测试值", "上限", "下限", "单位", "结果" });
                    if (Datasource != null && Datasource.Count > 0)
                    {
                        foreach (var data in Datasource)
                        {
                            rows.Add(new string[] { data.Name, data.Step, data.Value, data.LimitUp, data.LimitDown, data.Unit, data.Result });
                        }
                    }
                    ExcelHelper.ExportExcel(path, rows);
                }
                catch (Exception ex)
                {
                    StandardTestUI.logger.Error("Write CSV File Failed.", ex);
                }
            });
        }
        public void WriteToCSV(string path)
        {
            Dispatcher.Invoke(() =>
            {
                try
                {
                    FileInfo fi = new FileInfo(path);
                    if (!fi.Directory.Exists)
                    {
                        fi.Directory.Create();  //文件不存在,创建文件
                    }
                    using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write))
                    using (StreamWriter sw = new StreamWriter(fs, System.Text.Encoding.UTF8))
                    {
                        string title = "项目名称,测试参数,测试值,上限,下限,单位,结果";
                        sw.WriteLine(title);
                        if (Datasource != null && Datasource.Count > 0)
                        {
                            foreach (var data in Datasource)
                            {
                                sw.WriteLine($"{data.Name},{data.Step},{data.Value},{data.LimitUp},{data.LimitDown},{data.Unit},{data.Result}");
                            }
                        }
                    }
                }
                catch (Exception ex)
                {
                    StandardTestUI.logger.Error("Write CSV File Failed.", ex);
                }
            });
        }
        #region TCP相关
        private string tcpServerStatus;
        public string TcpServerStatus
        {
            get => tcpServerStatus;
            set
            {
                Set(ref tcpServerStatus, value);
            }
        }
        private string tcpServerInfo;
        public string TcpServerInfo
        {
            get => tcpServerInfo;
            set
            {
                Set(ref tcpServerInfo, value);
            }
        }
        private TcpServer tcpServer;
        private int GetConfigPort()
        {
            try
            {
                var customConfig = (Hashtable)ConfigurationManager.GetSection("TestResultConfig");
                if (customConfig.ContainsKey("PortStart"))
                {
                    int port = int.Parse(customConfig["PortStart"].ToString());
                    return port;
                }
                else
                {
                    return 6660;
                }
            }
            catch (Exception ex)
            {
                return 6660;
            }
        }
        private void StartTcpServer()
        {
            int port = GetConfigPort();
            var serverConfig = new ServerConfig()
            {
                MaxRequestLength = 1024 * 1024,
                Port = port + index
            };
            try
            {
                if (tcpServer == null)
                {
                    tcpServer = new TcpServer();
                    tcpServer.NewRequestReceived += TcpServer_NewRequestReceived;
                    if (!tcpServer.Setup(serverConfig))
                    {
                        ChangeTcpServerStatus("ERROR", this.lang("Tr.TcpServer.SettingFailed"));
                        return;
                    }
                    if (!tcpServer.Start())
                    {
                        ChangeTcpServerStatus("ERROR", this.lang("Tr.TcpServer.StartFailed"));
                        return;
                    }
                    ChangeTcpServerStatus("RUNNING", this.lang("Tr.TcpServer.Running"));
                }
                else if (tcpServer.State != SuperSocket.SocketBase.ServerState.Running)
                {
                    if (!tcpServer.Setup(serverConfig))
                    {
                        ChangeTcpServerStatus("ERROR", this.lang("Tr.TcpServer.SettingFailed"));
                        return;
                    }
                    if (!tcpServer.Start())
                    {
                        ChangeTcpServerStatus("ERROR", this.lang("Tr.TcpServer.StartFailed"));
                        return;
                    }
                    ChangeTcpServerStatus("RUNNING", this.lang("Tr.TcpServer.Running"));
                }
            }
            catch (Exception ex)
            {
                ChangeTcpServerStatus("ERROR", $"{ex.Message}");
                tcpServer?.Stop();
                tcpServer = null;
            }
        }
        /// <summary>
        /// 更新Tcp服务器状态
        /// </summary>
        /// <param name="status"></param>
        /// <param name="info"></param>
        private void ChangeTcpServerStatus(string status, string info)
        {
            Dispatcher.Invoke(() =>
            {
                TcpServerStatus = status;
                TcpServerInfo = info;
            });
        }
        /// <summary>
        /// 停止Tcp服务器
        /// </summary>
        private void StopTcpServer()
        {
            try
            {
                tcpServer?.Stop();
                tcpServer = null;
            }
            catch (Exception ex)
            {
            }
        }
        private void TcpServer_NewRequestReceived(TcpSession session, StringRequestInfo requestInfo)
        {
            string msg = requestInfo.Body;
            StandardTestUI.logger.Debug($"TCP Data From {session.RemoteEndPoint}: {msg}.");
            string[] datas_10015 = msg.Split(';');
            int cellIndex = int.Parse(datas_10015[1]);
            string strs = datas_10015[2];
            string[] values = strs.Split(',');
            Dispatcher.Invoke(() =>
            {
                AddTestData("", values[0], values[2], values[4], values[5], values[3], values[6]);
                //new Info
                //{
                //    step = values[0],
                //    value = values[2],
                //    unit = values[3],
                //    limitUp = values[4],
                //    limitDown = values[5],
                //    result = values[6],
                //    updateTime = values[7]
                //}
            });
            //session.Send("1");
        }
        #endregion
        public void ShowPausePanel()
        {
            pausePanel.Visibility = Visibility.Visible;
        }
        private void Button_Click(object sender, RoutedEventArgs e)
        {
            Button btn = sender as Button;
            switch (btn.Name)
            {
                case "clear":
                    UpdateProbeCount(0);
                    break;
                case "cancelPauseBtn":
                    parent.testExecutionWrapper.SetStationGlobalValue($"StationState.IsContinue_{index}", 1, false);
                    pausePanel.Visibility = Visibility.Collapsed;
                    break;
                case "continuePauseBtn":
                    parent.testExecutionWrapper.SetStationGlobalValue($"StationState.IsContinue_{index}", 1, true);
                    pausePanel.Visibility = Visibility.Collapsed;
                    break;
                case "scannerSetting":
                    //ScannerSetting setting = new ScannerSetting()
                    //{
                    //    Owner = Window.GetWindow(this)
                    //};
                    //setting.Open(true, $"Socket{index}", CountIniPath);
                    ShowScannerPanel();
                    break;
                case "scannerOkBtn":
                    SendSnr2Ts();
                    break;
                default:
                    break;
            }
        }
        public void ShowScannerPanel()
        {
            //scannerPanel.Visibility = Visibility.Visible;
        }
        private void SendSnr2Ts()
        {
        }
        private void pausePanel_Loaded(object sender, RoutedEventArgs e)
        {
            if (sender == pausePanel)
            {
                var can = pausePanel;
                var modal = visualModal;
                Canvas.SetLeft(modal, (pausePanel.ActualWidth - visualModal.ActualWidth) / 2);
                Canvas.SetTop(modal, (pausePanel.ActualHeight - visualModal.ActualHeight) / 2);
            }
            else
            {
                //var can = scannerPanel;
                //var modal = scannerVisualModal;
                //Canvas.SetLeft(modal, (scannerPanel.ActualWidth - scannerVisualModal.ActualWidth) / 2);
                //Canvas.SetTop(modal, (scannerPanel.ActualHeight - scannerVisualModal.ActualHeight) / 2);
            }
        }
        public void RefreshScreen(int[][] configs)
        {
            //foreach (UIElement item in canvas.Children)
            //{
            //    if (item is ScreenTouch)
            //    {
            //        canvas.Children.Remove(item);
            //    }
            //}
            screenContainer.Child = null;
            screenGrid = new ScreenGrid(1, configs.Length);
            screens = new ScreenTouch[configs.Length];
            int space = 2 * (screens.Length + 1);
            for (int i = 0; i < configs.Length; i++)
            {
                var screen = new ScreenTouch(configs[i][0], configs[i][1])
                {
                    Width = Math.Max((tableHeader.ActualHeight / 9 * 16 - space) / screens.Length, 0),
                    Height = Math.Max((tableHeader.ActualHeight / 9 * 16 - space) / screens.Length / 16 * 9, 0)
                };
                screens[i] = screen;
                screenGrid.AddScreen(screen, 0, i);
            }
            screenContainer.Child = screenGrid;
            //canvas.Children.Add(screens[0]);
            //Canvas.SetBottom(screens[0], 0);
            //Canvas.SetRight(screens[0], 0);
            //Panel.SetZIndex(screens[0], 999);
        }
        public void DrawScreenPoint(int index, int x, int y)
        {
            screens[index].DrawPoint(x, y);
        }
        /// <summary>
        /// 绘图之前清空已有的
        /// </summary>
        /// <param name="index"></param>
        /// <param name="points"></param>
        public void DrawScreenPoints(int index, int[][] points)
        {
            screens[index].DrawPoints(points);
        }
        /// <summary>
        /// 绘图之前不清空已有的
        /// </summary>
        /// <param name="index"></param>
        /// <param name="points"></param>
        public void DrawNewScreenPoints(int index, int[][] points)
        {
            screens[index].DrawNewPoints(points);
        }
        public void ClearScreenPoint(int index)
        {
            screens[index].Clear();
        }
        public void RefreshProbeCount(ProductData product)
        {
            try
            {
                var num = IniFileHelper.ReadIniData($"Socket{this.index.ToString()}", "ProbeCount", "0", this.CountIniPath);
                this.ProbeCount = int.Parse(num);
            }
            catch (Exception ex)
            {
                this.ProbeCount = 0;
            }
        }
        /// <summary>
        /// 更新探针使用次数
        /// </summary>
        /// <param name="count"></param>
        private void UpdateProbeCount(int count)
        {
            /// 写到配置文件
            IniFileHelper.WriteIniData("Socket" + this.index.ToString(), "ProbeCount", count.ToString(), this.CountIniPath);
            ProbeCount = count;
        }
        public void RefreshProduct(ProductData product)
        {
            if (product == null)
            {
                screenContainer.Child = null;
                return;
            }
            try
            {
                string screenSize = ProductManager.GetParameterByProductAndName(product.Name, "ScreenTouch")?.Value;
                if (string.IsNullOrEmpty(screenSize))
                {
                    screenContainer.Child = null;
                    return;
                }
                string[] arr = screenSize.Split(';');
                int[][] configs = new int[arr.Length][];
                for (int i = 0; i < arr.Length; i++)
                {
                    string[] size = arr[i].Split(',');
                    configs[i] = new int[2] { int.Parse(size[0]), int.Parse(size[1]) };
                }
                RefreshScreen(configs);
            }
            catch (Exception ex)
            {
                StandardTestUI.logger.Error("RefreshProduct Failed.", ex);
                screenContainer.Child = null;
            }
        }
        private void control_SizeChanged(object sender, SizeChangedEventArgs e)
        {
            if (screenGrid == null)
            {
                return;
            }
            //screenGrid.Height = tableHeader.ActualHeight - 2;
            //screenGrid.Width = tableHeader.ActualHeight / 9 * 16 - 2;
            int space = 2 * (screens.Length + 1);
            for (int i = 0; i < screens.Length; i++)
            {
                var screen = screens[i];
                screen.Width = Math.Max(Math.Floor((tableHeader.ActualHeight / 9 * 16 - space) / screens.Length), 0);
                screen.Height = Math.Max(Math.Floor(screen.Width / 16 * 9), 0);
            }
        }
        private void screenContainer_MouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
        {
            if (e.ClickCount == 2)
            {
                new ScreenTouchWin(screens)
                {
                    Owner = Window.GetWindow(this)
                }.ShowDialog();
            }
        }
        private void control_PreviewMouseRightButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
        {
            if (parent.EnableManualDebug)
            {
                ContextMenu contextMenu = new ContextMenu();
                contextMenu.Closed += ContextMenu_Closed;
                var item = new MenuItem
                {
                    IsCheckable = false,
                    Name = "manualDebugItem",
                    Header = this.lang("手动调试"),
                };
                item.Click += Item_Click; ;
                contextMenu.Items.Add(item);
                this.ContextMenu = contextMenu;
            }
            else
            {
                this.ContextMenu = null;
            }
        }
        private void Item_Click(object sender, RoutedEventArgs e)
        {
            string manualDebugSeq = ParameterManager.GetCommonParameterByName("ManualDebugSeqPath")?.Value;
            if (string.IsNullOrEmpty(manualDebugSeq))
            {
                MsgBoxHelper2.Error("请先设置手动调试序列路径[ManualDebugSeqPath]!");
                return;
            }
            string modelPath = parent.tsModelPath;
            var win = new ManualDebugWindow(manualDebugSeq, modelPath, false)
            {
                Title = $"工位{this.index}手动调试",
                Owner = Window.GetWindow(this),
                OvenIndex = index,
                OvenName = "",
                CellCount = parent.tsSockets.Count,
                CellIndex = 0,
                CellName = "",
            };
            win.ShowDialog();
        }
        private void ContextMenu_Closed(object sender, RoutedEventArgs e)
        {
            this.ContextMenu = null;
        }
    }
}
Plugin.StandardTest/StandardTestUI.xaml
New file
@@ -0,0 +1,315 @@
<hb:PluginMaskControl x:Class="Plugin.StandardTest.StandardTestUI"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:local="clr-namespace:Plugin.StandardTest"
         mc:Ignorable="d"
         xmlns:hb="clr-namespace:HMI.Base;assembly=HMI.Base"
        xmlns:uic="clr-namespace:HMI.UI.Converter;assembly=HMI.UI"
        xmlns:pw="https://opensource.panuon.com/wpf"
        xmlns:pu="https://opensource.panuon.com/wpf-ui"
        xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
         xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF"
         xmlns:sys="clr-namespace:System;assembly=mscorlib"
         xmlns:ui="clr-namespace:HMI.UI;assembly=HMI.UI"
         Padding="2,5,2,5"
         Loaded="PluginControl_Loaded"
         SizeChanged="PluginControl_SizeChanged"
         Background="{DynamicResource WorkspaceBackground}"
         d:DesignHeight="450" d:DesignWidth="800">
    <UserControl.Resources>
        <ResourceDictionary>
            <sys:Double x:Key="tableFontSize">20</sys:Double>
            <Style x:Key="FormComboxStyle" TargetType="ComboBox" BasedOn="{StaticResource FormComboxStyle}">
                <Setter Property="Height" Value="28"/>
            </Style>
        </ResourceDictionary>
    </UserControl.Resources>
    <Grid>
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition />
                <ColumnDefinition Width="Auto"/>
            </Grid.ColumnDefinitions>
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition/>
            </Grid.RowDefinitions>
            <WindowsFormsHost x:Name="host" Visibility="Collapsed"></WindowsFormsHost>
            <DockPanel Grid.Row="0" Grid.ColumnSpan="2" Margin="5,0,5,0" LastChildFill="False">
                <DockPanel.Resources>
                    <Style TargetType="CheckBox">
                        <Setter Property="FontSize" Value="20"/>
                        <Setter Property="VerticalContentAlignment" Value="Center"/>
                    </Style>
                </DockPanel.Resources>
                <StackPanel Orientation="Horizontal" VerticalAlignment="Center" dp:StackPanelHelper.ChildSpace="10">
                    <pu:FormGroup HeaderHeight="Auto" HeaderPadding="0" x:Name="selectModelPanel" Visibility="Collapsed">
                        <pu:FormGroup.Header>
                            <TextBlock FontSize="20" Foreground="#000" Text="{DynamicResource Ts.SelectProductModel}"/>
                        </pu:FormGroup.Header>
                        <ComboBox Style="{StaticResource FormComboxStyle}" MinWidth="180" ItemsSource="{Binding Products}"
DisplayMemberPath="Name" SelectedValuePath="Name"
SelectedItem="{Binding SelectedProduct}" x:Name="CbProduct"/>
                    </pu:FormGroup>
                    <pu:FormGroup HeaderHeight="Auto" HeaderPadding="0" x:Name="autoModelPanel" Visibility="Collapsed">
                        <pu:FormGroup.Header>
                            <TextBlock FontSize="20" Foreground="#000" Text="当前产品"/>
                        </pu:FormGroup.Header>
                        <TextBox Style="{StaticResource EditorTextBoxStyle}" IsReadOnly="True" MinWidth="180" Text="{Binding CurrentProductModel}"/>
                    </pu:FormGroup>
                    <CheckBox x:Name="CbEnableMes"
        IsChecked="{Binding EnableMes,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"
        Content="{DynamicResource Ts.EnableMes}"
        />
                    <CheckBox x:Name="CbEnableNGPause"
              IsChecked="{Binding EnableNGPause,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"
              Content="{DynamicResource Ts.EnableNGPause}"
              />
                    <CheckBox x:Name="CbEnableDebugMode"
              IsChecked="{Binding EnableDebugMode,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"
              Content="{DynamicResource Ts.DebugMode}"/>
                    <CheckBox x:Name="CbDisableScanner"
IsChecked="{Binding DisableScanner,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"
Content="{DynamicResource Ts.BlockScanner}"/>
                    <CheckBox x:Name="CbEnableSimpleMode"
IsChecked="{Binding EnableSimpleMode,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"
Content="{DynamicResource Ts.EnableSimpleMode}"/>
                    <CheckBox x:Name="CbUI_ShowInfoDock"
IsChecked="{Binding UI_ShowInfoDock,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"
Content="{DynamicResource Ts.UI_ShowInfoDock}"/>
                    <CheckBox FontSize="20" x:Name="Cb_ManualDebug"  Visibility="Collapsed"
                              IsChecked="{Binding EnableManualDebug,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"
                              VerticalContentAlignment="Center" Content="手动调试" />
                    <StackPanel Orientation="Horizontal" dp:StackPanelHelper.ChildSpace="5">
                        <Label FontSize="20" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" Padding="0"
                       Content="{DynamicResource HMI.User.Code}" />
                        <Label FontSize="20" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" Padding="0"
                       Content="{Binding CurrentUserCode}" />
                    </StackPanel>
                    <Label x:Name="workorderPanel" VerticalContentAlignment="Center" Visibility="Collapsed" Padding="0" Margin="0" MouseDoubleClick="Label_MouseDoubleClick">
                        <StackPanel Orientation="Horizontal"
                                dp:StackPanelHelper.ChildSpace="5">
                            <Label FontSize="20" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" Padding="0"
                                   Content="{DynamicResource Ts.WorkOrder}"/>
                            <TextBlock x:Name="TbWorkOrder" FontSize="20" MinWidth="50" Text="{Binding CurrentWorkorder}"/>
                            <!--<ui:EditBox FontSize="20" x:Name="workOrderEditor" MinWidth="50"
                                CanEdit="True"
                                ShowEmpty="True"
                                PreEditing="RequirePassword"
                                Value="{Binding CurrentWorkorder,Mode=TwoWay}"/>-->
                        </StackPanel>
                    </Label>
                    <!--<StackPanel Orientation="Horizontal" dp:StackPanelHelper.ChildSpace="5">
                        <Label FontSize="20" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" Padding="0" Content="字体大小"/>
                        <ComboBox Name="CbFontSize" Width="60" ItemsSource="{Binding FontSizeSource}"
                                  SelectionChanged="CbFontSize_SelectionChanged"/>
                    </StackPanel>-->
                </StackPanel>
                <StackPanel DockPanel.Dock="Right" Orientation="Horizontal" dp:StackPanelHelper.ChildSpace="10">
                    <Button Style="{StaticResource SuccessButton}"
x:Name="BtnStartSeq" Click="Button_Click"
Content="{DynamicResource Ts.BtnStartSeq}"/>
                    <Button Grid.Row="1" Style="{StaticResource DangerButton}" IsEnabled="False"
x:Name="BtnStopSeq" Click="Button_Click"
Content="{DynamicResource Ts.BtnStopSeq}"/>
                    <Button Grid.Row="2" Style="{StaticResource PrimaryButton}"
x:Name="BtnClearNum" Click="Button_Click"
Content="{DynamicResource Ts.BtnClearNum}"/>
                    <Button Grid.Row="3"
x:Name="BtnSample" Click="Button_Click">
                        <Button.Style>
                            <Style TargetType="Button" BasedOn="{StaticResource PrimaryButton}">
                                <Style.Triggers>
                                    <DataTrigger Binding="{Binding SampleStatus}" Value="True">
                                        <Setter Property="Content" Value="{DynamicResource Ts.BtnIsSample}"/>
                                    </DataTrigger>
                                    <DataTrigger Binding="{Binding SampleStatus}" Value="False">
                                        <Setter Property="Content" Value="{DynamicResource Ts.BtnNotSample}"/>
                                    </DataTrigger>
                                </Style.Triggers>
                            </Style>
                        </Button.Style>
                    </Button>
                </StackPanel>
            </DockPanel>
            <Grid dp:GridHelper.Space="5" Grid.Row="1">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition/>
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>
                <UniformGrid x:Name="panel" Grid.Row="1" Columns="2" dp:UniformGridHelper.InnerBorder="1"
                         dp:UniformGridHelper.InnerBorderBrush="#3a3a3a">
                </UniformGrid>
                <!--<Border x:Name="alarmPanel" Height="80" VerticalAlignment="Bottom" Visibility="Visible" Background="Red"/>
                <Border Grid.Row="1" x:Name="alarmPanel" Visibility="{Binding AlarmVisiable,Converter={StaticResource vbConverter}}"
                     Height="50" Background="Red"/>-->
            </Grid>
            <Grid Grid.Row="1" Grid.Column="1" x:Name="detailPanel">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition />
                    <RowDefinition Height="Auto"/>
                </Grid.RowDefinitions>
                <!--<GroupBox>
                <pu:FormGroup Orientation="Vertical"
                              HeaderHeight="Auto"
                              HeaderPadding="0" Header="{DynamicResource Ts.SelectProductModel}">
                    <ComboBox Style="{StaticResource FormComboxStyle}"
          ItemsSource="{Binding Products}"
          DisplayMemberPath="Name" SelectedValuePath="Name"
          SelectedItem="{Binding SelectedProduct}"
          x:Name="CbProduct"/>
                </pu:FormGroup>
            </GroupBox>-->
                <GroupBox Grid.Row="2">
                    <StackPanel>
                        <pu:FormGroup Header="{DynamicResource Ts.MesStatus}" GroupName="Number">
                            <Label Grid.Row="1">
                                <Label.Style>
                                    <Style TargetType="Label">
                                        <Setter Property="Foreground" Value="#FFF"/>
                                        <Setter Property="HorizontalContentAlignment" Value="Center"/>
                                        <Setter Property="FontSize" Value="16"/>
                                        <Setter Property="FontWeight" Value="Bold"/>
                                        <Style.Triggers>
                                            <DataTrigger Binding="{Binding EnableMes}" Value="True">
                                                <Setter Property="Background" Value="Green"/>
                                                <Setter Property="Content" Value="{DynamicResource Ts.MesStatus.Enable}"/>
                                            </DataTrigger>
                                            <DataTrigger Binding="{Binding EnableMes}" Value="False">
                                                <Setter Property="Background" Value="Red"/>
                                                <Setter Property="Content" Value="{DynamicResource Ts.MesStatus.Disable}"/>
                                            </DataTrigger>
                                        </Style.Triggers>
                                    </Style>
                                </Label.Style>
                            </Label>
                        </pu:FormGroup>
                        <pu:FormGroup Header="{DynamicResource Ts.Total}" GroupName="Number">
                            <TextBox Style="{StaticResource EditorTextBoxStyle}"  IsReadOnly="True"
                 x:Name="TbTotal"/>
                        </pu:FormGroup>
                        <pu:FormGroup Header="{DynamicResource Ts.Pass}" GroupName="Number">
                            <TextBox Style="{StaticResource EditorTextBoxStyle}" IsReadOnly="True"
                 x:Name="TbPass"/>
                        </pu:FormGroup>
                        <pu:FormGroup Header="{DynamicResource Ts.Fail}" GroupName="Number">
                            <TextBox Style="{StaticResource EditorTextBoxStyle}" IsReadOnly="True"
                 x:Name="TbFail"/>
                        </pu:FormGroup>
                        <pu:FormGroup Header="{DynamicResource Ts.PassRate}" GroupName="Number">
                            <TextBox Style="{StaticResource EditorTextBoxStyle}" IsReadOnly="True"
                 x:Name="TbPassRate"/>
                        </pu:FormGroup>
                    </StackPanel>
                </GroupBox>
                <GroupBox Grid.Row="3">
                    <lvc:PieChart Grid.Row="3" x:Name="pie" Grid.Column="1" Background="#FFF" BorderThickness="1" BorderBrush="{DynamicResource CommonBoarder}"
Title="{Binding Chart1Title}"
TooltipTextPaint="{Binding TooltipTextPaint}"
Padding="0"
Margin="2"
Series="{Binding Chart1_series}"
LegendPosition="Top">
                    </lvc:PieChart>
                </GroupBox>
                <!-- <GroupBox Grid.Row="4">
                <Grid dp:GridHelper.Space="20">
                    <Grid.RowDefinitions>
                        <RowDefinition/>
                        <RowDefinition/>
                        <RowDefinition/>
                        <RowDefinition/>
                        <RowDefinition/>
                    </Grid.RowDefinitions>
                    <Button Style="{StaticResource SuccessButton}"
                            x:Name="BtnStartSeq" Click="Button_Click"
                            Content="{DynamicResource Ts.BtnStartSeq}"/>
                    <Button Grid.Row="1" Style="{StaticResource DangerButton}" IsEnabled="False"
                            x:Name="BtnStopSeq" Click="Button_Click"
                            Content="{DynamicResource Ts.BtnStopSeq}"/>
                    <Button Grid.Row="2" Style="{StaticResource PrimaryButton}"
                            x:Name="BtnClearNum" Click="Button_Click"
                            Content="{DynamicResource Ts.BtnClearNum}"/>
                    <Button Grid.Row="3"
                            x:Name="BtnSample" Click="Button_Click">
                        <Button.Style>
                            <Style TargetType="Button" BasedOn="{StaticResource PrimaryButton}">
                                <Style.Triggers>
                                    <DataTrigger Binding="{Binding SampleStatus}" Value="True">
                                        <Setter Property="Content" Value="{DynamicResource Ts.BtnIsSample}"/>
                                    </DataTrigger>
                                    <DataTrigger Binding="{Binding SampleStatus}" Value="False">
                                        <Setter Property="Content" Value="{DynamicResource Ts.BtnNotSample}"/>
                                    </DataTrigger>
                                </Style.Triggers>
                            </Style>
                        </Button.Style>
                    </Button>
                    <pu:Switch Grid.Row="3" BoxWidth="120"
    CheckedBackground="{DynamicResource PrimaryColor}"
    CheckedBorderBrush="#36abff"
    ToggleBrush="#030303"
    CheckedForeground="White"
    CheckedToggleBrush="#FFFFFF"
    IsChecked="{Binding SampleStatus,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"
    ToggleSize="25" Cursor="Hand" >
                        <pu:Switch.BoxTemplate>
                            <DataTemplate>
                                <TextBlock x:Name="text"
                                           Text="{Binding Converter={StaticResource lc},ConverterParameter='Ts.BtnNotSample'}"
                                           Padding="30,0"
                                           VerticalAlignment="Center" HorizontalAlignment="Center"/>
                                <DataTemplate.Triggers>
                                    <DataTrigger Binding="{Binding IsChecked,
                                        RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=pu:Switch,AncestorLevel=1}}"
                                                 Value="True">
                                        <Setter TargetName="text" Property="Text"
                                                Value="{Binding Converter={StaticResource lc},ConverterParameter='Ts.BtnIsSample'}"/>
                                        <Setter TargetName="text" Property="Foreground" Value="{DynamicResource PrimaryColorContract}"/>
                                    </DataTrigger>
                                </DataTemplate.Triggers>
                            </DataTemplate>
                        </pu:Switch.BoxTemplate>
                    </pu:Switch>
                </Grid>
        </GroupBox> -->
            </Grid>
        </Grid>
    </Grid>
</hb:PluginMaskControl>
Plugin.StandardTest/StandardTestUI.xaml.cs
New file
@@ -0,0 +1,1906 @@
using HMI.Base;
using HMI.Core.Api;
using HMI.Core.Entity;
using HMI.Core.Manager;
using HMI.Core.Setting;
using HMI.Core.Util;
using HMI.Simple.Util;
using HMI.UI.Util;
using HMI.Util;
using LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using log4net;
using NationalInstruments.TestStand.Interop.API;
using NPOI.HSSF.UserModel;
using NPOI.SS.UserModel;
using Plugin.TsSupport;
using SkiaSharp;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.IO;
using System.Windows;
using System.Windows.Controls;
namespace Plugin.StandardTest
{
    /// <summary>
    /// UserControl1.xaml 的交互逻辑
    /// </summary>
    public partial class StandardTestUI : PluginMaskControl
    {
        #region TestStand事件
        /// <summary>
        /// TS 引擎
        /// </summary>
        //private Engine engine = null;
        ///// <summary>
        ///// 测试序列
        ///// </summary>
        //private SequenceFile testSeq = null;
        ///// <summary>
        ///// 测试模型序列
        ///// </summary>
        //private SequenceFile modeSeq = null;
        //private Execution execution = null;
        private string testSeqFile = string.Empty;
        public ITestExecutionWrapper testExecutionWrapper { get; private set; }
        #endregion
        public static ILog logger { get; private set; }
        public List<SingleControl> tsSockets { get; private set; } = new List<SingleControl>();
        /// <summary>
        /// 样件模式状态
        /// </summary>
        private bool sampleStatus;
        public bool SampleStatus
        {
            get { return sampleStatus; }
            set
            {
                Set(ref sampleStatus, value);
                SampleTest(value);
            }
        }
        private string _currentUserCode;
        public string CurrentUserCode
        {
            get { return _currentUserCode; }
            set { Set(ref _currentUserCode, value); }
        }
        private bool isLoadOK = false;
        private int PassAmount = 0;
        private int FailAmount = 0;
        /// <summary>
        /// Socket数量
        /// </summary>
        private int socketNum = 1;
        private ProductData selectedProduct;
        private MSMQHelper mSMQHelper = new MSMQHelper("HMI_Alarm");
        public ProductData SelectedProduct
        {
            get { return selectedProduct; }
            set
            {
                Set(ref selectedProduct, value);
                CurrentProductModel = value?.Name;
                if (tsSockets != null)
                {
                    foreach (SingleControl socket in tsSockets)
                    {
                        socket.RefreshProduct(value);
                        // socket.RefreshProbeCount(value);
                    }
                }
                SetTestSeqFile(value);
            }
        }
        private string currentProductModel;
        public string CurrentProductModel
        {
            get => currentProductModel;
            set
            {
                Set(ref currentProductModel, value);
            }
        }
        public override void SetLogger(ILog logger)
        {
            base.SetLogger(logger);
            StandardTestUI.logger = logger;
        }
        private ObservableCollection<ProductData> products;
        public ObservableCollection<ProductData> Products
        {
            get { return products; }
            set
            {
                Set(ref products, value);
            }
        }
        public List<double> FontSizeSource { get; set; } = new List<double> {
            10,11,12,13,14,15,16,17,18,19,20
        };
        private string CountIniPath;
        private bool IsOptRequirePassword;
        private bool alarmVisiable;
        public bool AlarmVisiable
        {
            get => alarmVisiable;
            set
            {
                Set(ref alarmVisiable, value);
            }
        }
        private bool _enableMes;
        public bool EnableMes
        {
            get => _enableMes;
            set
            {
                if (RequirePassword() == true)
                {
                    if (WriteConfig("EnableMes", value))
                    {
                        Set(ref _enableMes, value);
                    }
                }
            }
        }
        private bool _enableCanLog;
        public bool EnableCanLog
        {
            get => _enableCanLog;
            set
            {
                if (RequirePassword() == true)
                {
                    if (WriteConfig("EnableCanLog", value))
                    {
                        Set(ref _enableCanLog, value);
                    }
                }
            }
        }
        private bool _enableDebugMode;
        public bool EnableDebugMode
        {
            get => _enableDebugMode;
            set
            {
                if (RequirePassword() == true)
                {
                    if (WriteConfig("EnableDebugMode", value))
                    {
                        Set(ref _enableDebugMode, value);
                    }
                }
            }
        }
        private bool _enableNGPause;
        public bool EnableNGPause
        {
            get => _enableNGPause;
            set
            {
                if (RequirePassword() == true)
                {
                    if (WriteConfig("EnableNGPause", value))
                    {
                        Set(ref _enableNGPause, value);
                    }
                }
            }
        }
        private bool _enableSimpleMode;
        public bool EnableSimpleMode
        {
            get => _enableSimpleMode;
            set
            {
                if (RequirePassword() == true)
                {
                    if (WriteConfig("EnableSimpleMode", value))
                    {
                        Set(ref _enableSimpleMode, value);
                        foreach (var cell in tsSockets) cell.SetSimpleUI(value);
                    }
                }
            }
        }
        private bool _ui_showInfoDock;
        public bool UI_ShowInfoDock
        {
            get => _ui_showInfoDock;
            set
            {
                if (RequirePassword() == true)
                {
                    if (WriteConfig("UI_ShowInfoDock", value))
                    {
                        Set(ref _ui_showInfoDock, value);
                        detailPanel.Visibility = value ? Visibility.Visible : Visibility.Collapsed;
                    }
                }
            }
        }
        private bool autoExecuteSeq;
        public bool AutoExecuteSeq
        {
            get => autoExecuteSeq;
            set
            {
                Set(ref autoExecuteSeq, value);
            }
        }
        private string currentWorkorder;
        public string CurrentWorkorder
        {
            get => currentWorkorder;
            set
            {
                if (WriteIni("UI_CurrentWorkorder", value))
                {
                    Set(ref currentWorkorder, value);
                    testExecutionWrapper?.SetStationGlobalValue("StationState.WorkOrder", 1, value);
                    testExecutionWrapper?.CommitToDisk(false);
                }
            }
        }
        private bool _DisableScanner;
        public bool DisableScanner
        {
            get => _DisableScanner;
            set
            {
                if (RequirePassword() == true)
                {
                    if (WriteConfig("DisableScanner", value))
                    {
                        Set(ref _DisableScanner, value);
                    }
                }
            }
        }
        private bool enableManualDebug;
        public bool EnableManualDebug
        {
            get => enableManualDebug;
            set
            {
                if (IsTesting)
                {
                    MsgBoxHelper2.Warn("请先结束自动测试");
                    return;
                }
                Set(ref enableManualDebug, value);
            }
        }
        public string tsModelPath { get; private set; }
        private string dataLogPath;
        private SqliteHelper sqliteHelper;
        private int probeWarn;
        #region 图表
        private SolidColorPaint tooltipTextPaint = new SolidColorPaint(SKColors.Black)
        {
            SKTypeface = SKFontManager.Default.MatchCharacter('汉')
        };
        public SolidColorPaint TooltipTextPaint
        {
            get => tooltipTextPaint;
            set
            {
                Set(ref tooltipTextPaint, value);
            }
        }
        public ISeries[] Chart1_series { get; set; } = new PieSeries<double>[]
       {
            new PieSeries<double> {
                Name="Passed",
                Values = new double[]{ 0},
            DataLabelsPosition = LiveChartsCore.Measure.PolarLabelsPosition.Outer,
             DataLabelsSize = 15,
             DataLabelsPaint = new SolidColorPaint(new SKColor(30, 30, 30)){
                 SKTypeface = SKFontManager.Default.MatchCharacter('汉')
             },
             Fill = new SolidColorPaint(new SKColor(0, 128, 0)),
             DataLabelsFormatter =
                point =>point.StackedValue.Total>0?
                    $"Passed\r\n计数:{point.Coordinate.PrimaryValue}\r\n占比:{point.StackedValue.Share:P2}":"",
             ToolTipLabelFormatter = point => $"{point.StackedValue.Share:P2}"
            },
            new PieSeries<double> { Values = new double[]{ 0} ,
                Name="Failed",
            DataLabelsPosition = LiveChartsCore.Measure.PolarLabelsPosition.Outer,
             DataLabelsSize = 15,
             DataLabelsPaint = new SolidColorPaint(new SKColor(30, 30, 30)){
                 SKTypeface = SKFontManager.Default.MatchCharacter('汉')
             },
             Fill = new SolidColorPaint(new SKColor(255, 0, 0)),
             DataLabelsFormatter =
                point =>point.StackedValue.Total>0?
                    $"Failed\r\n计数:{point.Coordinate.PrimaryValue}\r\n占比:{point.StackedValue.Share:P2}":"",
             ToolTipLabelFormatter = point => $"{point.StackedValue.Share:P2}"},
       };
        #endregion
        public StandardTestUI()
        {
            DataContext = this;
            InitializeComponent();
            InitChart();
            InitTsEngine();
        }
        public override void SetBaseFolder(string folder)
        {
            CountIniPath = Path.Combine(folder, "Count.ini");
            base.SetBaseFolder(folder);
        }
        private void CheckSqlHelper()
        {
            string today = DateTime.Now.ToString("yyyyMMdd");
            string dbPath = Path.Combine(dataLogPath, $"Data_{today}.db");
            if (!File.Exists(dbPath))
            {
                SqliteHelper.CreateDatabase(dbPath);
            }
            if (sqliteHelper == null || sqliteHelper.DataBaseFile != dbPath)
            {
                sqliteHelper?.Dispose();
                sqliteHelper = new SqliteHelper(dbPath);
                sqliteHelper.ExecuteSql(@"CREATE TABLE IF NOT EXISTS serialData (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    sn TEXT NOT NULL,
                    model TEXT,
                    station TEXT NOT NULL,
                    step TEXT NOT NULL,
                    position TEXT,
                    limitDown TEXT,
                    limitUp TEXT,
                    unit TEXT,
                    offset TEXT,
                    value TEXT,
                    status TEXT,
                    finalStatus TEXT,
                    consumeTime TEXT,
                    totalConsumeTime TEXT,
                    time DATETIME NOT NULL);");
                sqliteHelper.ExecuteSql(@"CREATE TABLE IF NOT EXISTS testResult (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    sn TEXT NOT NULL,
                    model TEXT,
                    station TEXT NOT NULL,
                    position TEXT,
                    status TEXT,
                    result TEXT NOT NULL,
                    time DATETIME NOT NULL);");
            }
        }
        public override void InitFinish()
        {
            this.ReadCount(out int pass, out int fail);
            this.DisplayCount(pass, fail);
            ReadConfig();
            InitProducts();
            CheckSqlHelper();
            base.InitFinish();
            if (ParamSetting.EnableAutoModel)
            {
                selectModelPanel.Visibility = Visibility.Collapsed;
                autoModelPanel.Visibility = Visibility.Visible;
                CurrentProductModel = string.Empty;
            }
            try
            {
                UserInfo user = UserInfo.User;
                CurrentUserCode = user?.Code;
                testExecutionWrapper?.SetStationGlobalValue("StationState.OPID", 1, CurrentUserCode);
                testExecutionWrapper?.CommitToDisk(false);
            }
            catch (Exception ex)
            {
                log.Warn("Get Login User Failed.", ex);
            }
            RefreshUIConfig();
        }
        /// <summary>
        /// 刷新UI配置
        /// </summary>
        private void RefreshUIConfig()
        {
            try
            {
                bool enableManualDebug = ParameterManager.GetCommonParameterByName("EnableManualDebug", false);
                if (enableManualDebug)
                {
                    Cb_ManualDebug.Visibility = Visibility.Visible;
                    EnableManualDebug = false;
                }
                else
                {
                    Cb_ManualDebug.Visibility = Visibility.Collapsed;
                    EnableManualDebug = false;
                }
            }
            catch (Exception ex)
            {
                log.Error("RefreshUIConfig Error.", ex);
            }
        }
        //private MQConnector mqConnector;
        //private void InitMQ()
        //{
        //    mqConnector = new MQConnector("UI");
        //    mqConnector.BeginReceive(HandleMQUI);
        //}
        //void HandleMQUI(string msg)
        //{
        //    string[] arr = msg.Split('|');
        //    int eventCode = int.Parse(arr[0]);
        //    int index = int.Parse(arr[1]);
        //    string strData = arr[2];
        //    Dispatcher.BeginInvoke(new Action(() =>
        //    {
        //        HandleUIMessage(eventCode, index, strData);
        //    }));
        //}
        /// <summary>
        /// 初始化Winform图表
        /// </summary>
        private void InitChart()
        {
            //        pieChart.Plot.Axes.Frameless();
            //        pieChart.Plot.HideGrid();
            //        pieChart.Plot.DataBackground.Color = ScottPlot.Colors.Transparent;
            //        var legend = pieChart.Plot.ShowLegend(Alignment.UpperRight);
            //        legend.Alignment = Alignment.UpperRight;
            //        legend.Padding = new PixelPadding(0);
            //        legend.FontSize = (float)WidthSizeConverter.CountFontSize(20, 300, 300);
            //        pieChart.UserInputProcessor.Disable();
            //        pieChart.Plot.Font.Automatic();
            //        List<PieSlice> slices = new List<PieSlice> {
            //new PieSlice() { Value = 5, FillColor = ScottPlot.Colors.Green,
            //    LabelFontSize = (float)WidthSizeConverter.CountFontSize(20,300,300),
            //    Label = "Pass",LegendText="Pass" },
            //new PieSlice() { Value = 2, FillColor = ScottPlot.Colors.Red,
            //    LabelFontSize = (float)WidthSizeConverter.CountFontSize(20,300,300),
            //    Label = "Fail",LegendText="Fail" } };
            //        pie = pieChart.Plot.Add.Pie(slices);
        }
        private bool? RequirePassword()
        {
            if (!IsOptRequirePassword) return true;
            var win = new RequirePasswordWindow
            {
                Owner = Window.GetWindow(this)
            };
            return win.ShowDialog();
        }
        private void InitProducts()
        {
            RefershUI();
            //Products = ProductManager.GetProducts<ObservableCollection<ProductData>>();
            //if (Products != null && Products.Count > 0)
            //{
            //    string lastModel = testExecutionWrapper?.GetStationGlobalValue<string>("StationState.ProductModel", 1);
            //    var tempProduct = Products[0];
            //    if (!string.IsNullOrEmpty(lastModel))
            //    {
            //        var product = Products.FirstOrDefault(p => p.Name == lastModel);
            //        if (product != null)
            //        {
            //            tempProduct = product;
            //        }
            //    }
            //    SelectedProduct = tempProduct;
            //}
            //RefershUI();
            //if (tsSockets != null)
            //{
            //    foreach (SingleControl socket in tsSockets)
            //    {
            //        socket.RefreshProduct(SelectedProduct);
            //    }
            //}
            //SampleStatus = false;
        }
        private bool _isTesting = false;
        public bool IsTesting
        {
            get { return _isTesting; }
            set
            {
                Set(ref _isTesting, value);
                Dispatcher.Invoke(() =>
                {
                    BtnStartSeq.IsEnabled = !value;
                    BtnStopSeq.IsEnabled = value;
                    CbProduct.IsEnabled = !value;
                });
            }
        }
        /// <summary>
        /// 初始化TestStand引擎
        /// </summary>
        private void InitTsEngine()
        {
            if (ParamSetting.EngineType == "Tap")
            {
                testExecutionWrapper = new TapWrapper();
            }
            else
            {
                testExecutionWrapper = new TeststandWrapper();
            }
            testExecutionWrapper.EndExecutionEvent += TestExecutionWrapper_EndExecutionEvent;
            testExecutionWrapper.UIMessageEvent += TestExecutionWrapper_UIMessageEvent;
            if (testExecutionWrapper is TeststandWrapper tsw)
            {
                var AxApplicationMgr = tsw.AxApplicationMgr;
                ((System.ComponentModel.ISupportInitialize)(AxApplicationMgr)).BeginInit();
                host.Child = AxApplicationMgr;
                ((System.ComponentModel.ISupportInitialize)(AxApplicationMgr)).EndInit();
            }
            try
            {
                testSeqFile = ParameterManager.GetCommonParameterByName<string>("BurnInMainSeq", string.Empty);
            }
            catch
            {
            }
            testExecutionWrapper.Init();
        }
        private void TestExecutionWrapper_UIMessageEvent(object sender, UIMessageEvent e)
        {
            try
            {
                int eventCode = Convert.ToInt32(e.EventCode);
                int MethodCode = Convert.ToInt16(e.NumberData);//SocketIndex
                var data = e.Data;
                string strData = e.StrData;
                try
                {
                    log.Debug($"Event: {eventCode}, MethodCode: {MethodCode}, StringData: {strData}");
                }
                catch
                {
                }
                HandleUIMessage(eventCode, MethodCode, strData, data);
            }
            catch (Exception ex)
            {
                log.Warn("Ts Event Error.", ex);
            }
        }
        private void TestExecutionWrapper_EndExecutionEvent(object sender, ExecutionEndEvent e)
        {
            log.Info("AxApplicationMgr_EndExecution");
            if (ReleaseSequenceFile())
            {
                IsTesting = false;
                log.Info("Release SeqFile Success!");
                if (canSwitchModel) switchAction?.Invoke();
            }
            else
            {
                log.Warn("Release SeqFile Fail!");
            }
        }
        private volatile bool canSwitchModel = false;
        private volatile Action switchAction = null;
        private void PluginControl_Loaded(object sender, RoutedEventArgs e)
        {
            ShowMask(() =>
            {
                try
                {
                    //Dispatcher.Invoke(() =>
                    //{
                    //});
                    isLoadOK = true;
                    if (AutoExecuteSeq) StartSequence();
                }
                catch (Exception ex)
                {
                    log.Error("TsExecution Loaded Failed.", ex);
                    MsgBoxHelper.Error(this, $"{LangUtil.Get("Ts.UserControlLoadFailed")}: {ex.Message}");
                }
                Dispatcher.Invoke(() =>
                {
                    if (isLoadOK) Loaded -= PluginControl_Loaded;
                });
            }, (ex) =>
            {
                ToastHelper.Error("Loaded Failed.");
                log.Error("Loaded Error.", ex);
            }, null, LangUtil.Get("App.UsercontrolIsLoading"));
        }
        private void RefershUI()
        {
            panel.Children.Clear();
            string[] names = ParamSetting.SocketNames.Split(',');
            //重新生成子面板
            for (int i = 0; i < this.socketNum; i++)
            {
                string name = string.Empty;
                if (i < names.Length)
                {
                    name = names[i];
                }
                SingleControl control = new SingleControl(i, CountIniPath, this.probeWarn)
                {
                    SocketName = name
                };
                control.parent = this;
                panel.Children.Add(control);
                tsSockets.Add(control);
                //createChildInNewThread(i, CountIniPath, ProbeWarn);
            }
            if (tsSockets.Count == 1)
            {
                panel.Columns = 1;
            }
            else if (tsSockets.Count > 0 && (tsSockets.Count % 3) == 0)
            {
                panel.Columns = 3;
            }
            else
            {
                panel.Columns = 2;
            }
        }
        /// <summary>
        /// 读取Count配置文件的pass和fail数
        /// </summary>
        /// <param name="pass">合格数</param>
        /// <param name="fail">不合格数</param>
        private void ReadCount(out int pass, out int fail)
        {
            pass = Convert.ToInt32(IniFileHelper.ReadIniData("Count", "Pass", "0", CountIniPath));
            fail = Convert.ToInt32(IniFileHelper.ReadIniData("Count", "Fail", "0", CountIniPath));
            var fontSize = Convert.ToDouble(IniFileHelper.ReadIniData("Config", "TableFontSize", "12", CountIniPath));
            this.Resources["tableFontSize"] = 20d;
            //CbFontSize.SelectedItem = fontSize;
        }
        private string csvDir;
        /// <summary>
        /// 读取配置文件里的几个属性
        /// </summary>
        /// <param name="pass"></param>
        /// <param name="fail"></param>
        private void ReadConfig()
        {
            HMI.Core.Entity.ParameterData value;
            bool flag = false;
            try
            {
                value = ParameterManager.GetCommonParameterByName("DefaultEnableMes");
                if (value != null)
                {
                    flag = bool.Parse(value.Value);
                }
            }
            catch
            {
                flag = false;
            }
            try
            {
                value = ParameterManager.GetCommonParameterByName("SocketNum");
                if (value != null)
                {
                    this.socketNum = int.Parse(value.Value);
                }
            }
            catch
            {
                this.socketNum = 2;
            }
            if (flag)
            {
                EnableMes = true;
            }
            else
            {
                try
                {
                    value = ParameterManager.GetCommonParameterByName("EnableMes");
                    if (value == null)
                    {
                        CbEnableMes.Visibility = Visibility.Collapsed;
                    }
                    else
                    {
                        _enableMes = bool.Parse(value.Value);
                        Set("EnableMes");
                    }
                }
                catch
                {
                    _enableMes = false;
                    Set("EnableMes");
                }
            }
            try
            {
                value = ParameterManager.GetCommonParameterByName("EnableNGPause");
                if (value == null)
                {
                    CbEnableNGPause.Visibility = Visibility.Collapsed;
                }
                else
                {
                    _enableNGPause = bool.Parse(value.Value);
                    Set("EnableNGPause");
                }
            }
            catch
            {
                _enableNGPause = false;
                Set("EnableNGPause");
            }
            try
            {
                value = ParameterManager.GetCommonParameterByName("EnableDebugMode");
                if (value == null)
                {
                    CbEnableDebugMode.Visibility = Visibility.Collapsed;
                }
                else
                {
                    _enableDebugMode = bool.Parse(value.Value);
                    Set("EnableDebugMode");
                }
            }
            catch
            {
                _enableDebugMode = false;
                Set("EnableDebugMode");
            }
            try
            {
                value = ParameterManager.GetCommonParameterByName("DisableScanner");
                if (value == null)
                {
                    CbDisableScanner.Visibility = Visibility.Collapsed;
                }
                else
                {
                    _DisableScanner = bool.Parse(value.Value);
                    Set("DisableScanner");
                }
            }
            catch
            {
                _DisableScanner = false;
                Set("DisableScanner");
            }
            try
            {
                value = ParameterManager.GetCommonParameterByName("EnableCanLog");
                _enableCanLog = bool.Parse(value.Value);
                Set("EnableCanLog");
            }
            catch
            {
                _enableCanLog = false;
                Set("EnableCanLog");
            }
            try
            {
                value = ParameterManager.GetCommonParameterByName("EnableSimpleMode");
                if (value == null)
                {
                    CbEnableSimpleMode.Visibility = Visibility.Collapsed;
                }
                else
                {
                    EnableSimpleMode = bool.Parse(value.Value);
                }
            }
            catch
            {
                EnableSimpleMode = false;
            }
            try
            {
                value = ParameterManager.GetCommonParameterByName("UI_ShowInfoDock");
                if (value == null)
                {
                    CbUI_ShowInfoDock.Visibility = Visibility.Collapsed;
                }
                else
                {
                    UI_ShowInfoDock = bool.Parse(value.Value);
                }
            }
            catch
            {
                UI_ShowInfoDock = false;
            }
            try
            {
                value = ParameterManager.GetCommonParameterByName("AutoExecuteSeq");
                AutoExecuteSeq = bool.Parse(value.Value);
            }
            catch
            {
                AutoExecuteSeq = true;
            }
            try
            {
                value = ParameterManager.GetCommonParameterByName("TestReportFolder");
                csvDir = value.Value;
                if (!Directory.Exists(csvDir))
                {
                    Directory.CreateDirectory(csvDir);
                }
            }
            catch
            {
                csvDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TestReport");
            }
            try
            {
                value = ParameterManager.GetCommonParameterByName("IsOptRequirePassword");
                IsOptRequirePassword = bool.Parse(value.Value);
            }
            catch
            {
                IsOptRequirePassword = false;
            }
            try
            {
                value = ParameterManager.GetCommonParameterByName("UI_CurrentWorkorder");
                CurrentWorkorder = value.Value;
            }
            catch
            {
                CurrentWorkorder = string.Empty;
            }
            try
            {
                value = ParameterManager.GetCommonParameterByName("TS_ShowTitleWorkorder");
                var boo = bool.Parse(value.Value);
                if (boo)
                {
                    workorderPanel.Visibility = Visibility.Visible;
                }
            }
            catch
            {
            }
            dataLogPath = ParamSetting.DataLogFolder;
            try
            {
                value = ParameterManager.GetCommonParameterByName("TsModelPath");
                tsModelPath = value?.Value ?? @"C:\Users\Public\Documents\National Instruments\TestStand 2020 (64-bit)\Components\Models\ParallelModel.seq";
            }
            catch
            {
                //@"C:\Program Files\National Instruments\TestStand 2020\Components\Models\TestStandModels\ParallelModel.seq"
                tsModelPath = @"C:\Users\Public\Documents\National Instruments\TestStand 2020 (64-bit)\Components\Models\ParallelModel.seq";
            }
            try
            {
                value = ParameterManager.GetCommonParameterByName("ProbeWarn");
                probeWarn = int.Parse(value?.Value);
            }
            catch
            {
                probeWarn = 0;
            }
            //Convert.ToBoolean(IniFileHelper.ReadIniData("Config", "EnableMes", "true", CountIniPath));
            //EnableCanLog = Convert.ToBoolean(IniFileHelper.ReadIniData("Config", "EnableCanLog", "true", CountIniPath));
            //EnableDebugMode = Convert.ToBoolean(IniFileHelper.ReadIniData("Config", "EnableDebugMode", "true", CountIniPath));
        }
        private bool WriteConfig(string name, bool value)
        {
            try
            {
                if (ParameterManager.UpdateCommonParameter(name, value, out string errMsg))
                {
                    foreach (var ts in tsSockets)
                    {
                        testExecutionWrapper.SetStationGlobalValue($"SystemSetting.{name}", 1, value);
                        if (testExecutionWrapper is TeststandWrapper tsw)
                        {
                            if (name == "EnableDebugMode" && tsw.context?.Sequence != null)
                            {
                                tsw.context.Sequence.FailureAction =
                                    value ? FailureActions.FailureAction_None : FailureActions.FailureAction_GotoCleanup;
                            }
                        }
                    }
                    return true;
                }
                else
                {
                    throw new Exception(errMsg);
                }
            }
            catch (Exception ex)
            {
                MsgBoxHelper2.Error(ex.Message);
                return false;
            }
        }
        private bool WriteIni(string name, object value)
        {
            //IniFileHelper.WriteIniData("Config", name, value.ToString(), CountIniPath);
            try
            {
                if (ParameterManager.UpdateCommonParameter(name, value, out string errMsg))
                {
                    return true;
                }
                else
                {
                    throw new Exception(errMsg);
                }
            }
            catch (Exception ex)
            {
                MsgBoxHelper2.Error(ex.Message);
                return false;
            }
        }
        private void WriteCount(double pass, double fail)
        {
            IniFileHelper.WriteIniData("Count", "Pass", pass.ToString(), CountIniPath);
            IniFileHelper.WriteIniData("Count", "Fail", fail.ToString(), CountIniPath);
        }
        /// <summary>
        /// 刷新计数
        /// </summary>
        /// <param name="pass">合格数</param>
        /// <param name="fail">不合格数</param>
        private void DisplayCount(double pass, double fail)
        {
            double total = pass + fail;
            double passRate;
            if (total == 0)
            {
                passRate = 0.00;
            }
            else
            {
                passRate = (double)pass / (double)total * 100.00;
            }
            this.TbPass.Text = pass.ToString();
            this.TbFail.Text = fail.ToString();
            this.TbTotal.Text = total.ToString();
            this.TbPassRate.Text = string.Format("{0:0.00}", passRate) + "%";
            Dispatcher.BeginInvoke(new Action(() =>
            {
                Chart1_series[0].Values = new double[] { pass };
                Chart1_series[1].Values = new double[] { fail };
                // webview2.CoreWebView2?.ExecuteScriptAsync($"drawChart1({res})");
            }));
            WriteCount(pass, fail);
        }
        /// <summary>
        /// 启动样件测试
        /// </summary>
        /// <param name="flag"></param>
        /// <exception cref="Exception"></exception>
        private void SampleTest(bool flag)
        {
            try
            {
                testExecutionWrapper.SetStationGlobalValue("StationState.Sample", 1, flag);
            }
            catch (Exception ex)
            {
                throw new Exception(LangUtil.Get("Ts.SampleTestFail"), ex);
            }
        }
        /// <summary>
        /// 启动测试序列
        /// </summary>
        private void StartSequence()
        {
            log.Info($"Start ATE Seq isLoadOK:{isLoadOK}");
            if (!isLoadOK) return;
            if (ParamSetting.EnableAutoModel)
            {
                try
                {
                    var path = ParameterManager.GetCommonParameterByName<string>("CommonMainSeq", "");
                    if (string.IsNullOrEmpty(path))
                    {
                        Dispatcher.Invoke(() =>
                        {
                            MsgBoxHelper.Error(this, $"请先设置CommonMainSeq参数");
                        });
                        return;
                    }
                    this.testSeqFile = path;
                    testExecutionWrapper?.StartSequence(this.testSeqFile, this.tsModelPath);
                    //this.modeSeq = this.engine.GetSequenceFileEx(tsModelPath);
                    //this.testSeq = this.engine.GetSequenceFileEx(this.testSeqFile, 2, TypeConflictHandlerTypes.ConflictHandler_UseGlobalType);
                    //this.execution = this.engine.NewExecution(this.testSeq, "Test UUTs", this.modeSeq, false, 0, null, null, null);
                    IsTesting = true;
                }
                catch (Exception ex)
                {
                    Dispatcher.Invoke(() =>
                    {
                        log.Error("Start Sequence Failed.", ex);
                        MsgBoxHelper.Error(this, $"{LangUtil.Get("Ts.StartSequenceFail")}: {ex.Message}");
                    });
                }
            }
            else
            {
                //MaskWindow mask = new MaskWindow();
                try
                {
                    //for (int i = 0; i < tsSockets.Count; i++)
                    //{
                    //    testExecutionWrapper?.SetStationGlobalValue($"StationHMI.UUTHMI[{i}].Model", 1, CurrentProductModel);
                    //}
                    //testExecutionWrapper?.CommitToDisk(false);
                    testExecutionWrapper?.StartSequence(this.testSeqFile, this.tsModelPath);
                    IsTesting = true;
                }
                catch (Exception ex)
                {
                    Dispatcher.Invoke(() =>
                    {
                        log.Error("Start Sequence Failed.", ex);
                        MsgBoxHelper.Error(this, $"{LangUtil.Get("Ts.StartSequenceFail")}: {ex.Message}");
                    });
                }
                finally
                {
                }
            }
        }
        public void StopSequenceClick()
        {
            canSwitchModel = false;
            switchAction = null;
            ShowMask(() =>
            {
                bool stoped = StopSequence();
            }, null, null, LangUtil.Get("App.SequenceIsStopping"));
        }
        /// <summary>
        /// 停止测试序列
        /// </summary>
        private bool StopSequence()
        {
            try
            {
                testExecutionWrapper.SetStationGlobalValue("StationHMI.AllStop", 1, true);
                //bool res = this.execution.WaitForEndEx(-1, true, null, null);
                bool res = this.testExecutionWrapper.WaitForExit();
                //if (this.execution != null)
                //    Marshal.ReleaseComObject(this.execution);
                //this.execution = null;
                return res;
            }
            catch (Exception ex)
            {
                log.Error("Stop Sequence Failed.", ex);
                MsgBoxHelper.Error(this, $"{LangUtil.Get("Ts.StopSequenceFail")}: {ex.Message}");
                return false;
            }
        }
        private void Button_Click(object sender, RoutedEventArgs e)
        {
            Button btn = sender as Button;
            switch (btn.Name)
            {
                case "BtnStartSeq":
                    StartClick();
                    break;
                case "BtnStopSeq":
                    StopSequenceClick();
                    break;
                case "BtnClearNum":
                    ClearNum();
                    break;
                case "BtnSample":
                    SampleStatus = !SampleStatus;
                    break;
                default:
                    break;
            }
        }
        private void StartClick()
        {
            canSwitchModel = true;
            ShowMask(() =>
            {
                StartSequence();
            }, null, null, LangUtil.Get("Ts.SequenceIsStarting"));
        }
        public void UpdatePositionStatus(int index, string status)
        {
            this.tsSockets[index].UpdateStatus(status);
            OnStatusPublished(index, status);
            this.UpdateTestCounter();
        }
        private void OnStatusPublished(int index, string status)
        {
            if (ParamSetting.CustomerCode != "SOUND")
            {
                return;
            }
            if (status == "Passed" || status == "Failed")
            {
                SaveSOUNDExcel(index, status);
            }
        }
        private string ConvertStatus(string status)
        {
            if (string.Equals(status, "passed", StringComparison.OrdinalIgnoreCase))
            {
                return "Pass";
            }
            else
            {
                return "Fail";
            }
        }
        #region 桑德定制
        private ICell CreateCell(ISheet sheet, int r, int c, ICellStyle style)
        {
            var row = sheet.GetRow(r);
            if (row == null)
            {
                row = sheet.CreateRow(r);
            }
            var cell = row.GetCell(c);
            if (cell == null)
            {
                cell = row.CreateCell(c);
            }
            if (style != null)
            {
                cell.CellStyle = style;
            }
            return cell;
        }
        private string GetOutputFolder()
        {
            var value = ParameterManager.GetCommonParameterByName("SOUND_Output");
            if (value != null)
            {
                return value.Value;
            }
            return ParamSetting.DataLogFolder;
        }
        private void SaveSOUNDExcel(int index, string status)
        {
            lock (this)
            {
                try
                {
                    string outputFolder = GetOutputFolder();
                    var socket = this.tsSockets[index];
                    DateTime testTime = DateTime.Now;
                    string filePath = Path.Combine(outputFolder, $"{testTime.ToString("yy-MM-dd")}_{CurrentProductModel}.xls");
                    if (!File.Exists(filePath))
                    {
                        Dictionary<string, int> columnMap = new Dictionary<string, int>();
                        HSSFWorkbook workbook = new HSSFWorkbook();
                        ISheet sheet = workbook.CreateSheet("Sheet1");
                        ICellStyle border = workbook.CreateCellStyle();
                        border.BorderBottom = NPOI.SS.UserModel.BorderStyle.Thin;
                        border.BorderTop = NPOI.SS.UserModel.BorderStyle.Thin;
                        border.BorderLeft = NPOI.SS.UserModel.BorderStyle.Thin;
                        border.BorderRight = NPOI.SS.UserModel.BorderStyle.Thin;
                        border.BottomBorderColor = NPOI.HSSF.Util.HSSFColor.Black.Index;
                        border.TopBorderColor = NPOI.HSSF.Util.HSSFColor.Black.Index;
                        border.LeftBorderColor = NPOI.HSSF.Util.HSSFColor.Black.Index;
                        border.RightBorderColor = NPOI.HSSF.Util.HSSFColor.Black.Index;
                        ICellStyle red = workbook.CreateCellStyle();
                        red.CloneStyleFrom(border);
                        red.FillForegroundColor = NPOI.HSSF.Util.HSSFColor.Red.Index;
                        red.FillPattern = FillPattern.SolidForeground;
                        ICellStyle green = workbook.CreateCellStyle();
                        green.CloneStyleFrom(border);
                        green.FillForegroundColor = NPOI.HSSF.Util.HSSFColor.BrightGreen.Index;
                        green.FillPattern = FillPattern.SolidForeground;
                        CreateCell(sheet, 1, 0, border);
                        CreateCell(sheet, 1, 1, border);
                        CreateCell(sheet, 1, 2, border);
                        CreateCell(sheet, 1, 3, border);
                        CreateCell(sheet, 1, 4, border);
                        CreateCell(sheet, 2, 0, border);
                        CreateCell(sheet, 2, 1, border);
                        CreateCell(sheet, 2, 2, border);
                        CreateCell(sheet, 2, 3, border);
                        CreateCell(sheet, 2, 4, border).SetCellValue("上限");
                        CreateCell(sheet, 3, 0, border).SetCellValue("测试时间");
                        CreateCell(sheet, 3, 1, border).SetCellValue("测试人");
                        CreateCell(sheet, 3, 2, border).SetCellValue("测试设备");
                        CreateCell(sheet, 3, 3, border).SetCellValue("条形码");
                        CreateCell(sheet, 3, 4, border).SetCellValue("下限");
                        int column = 5;
                        int row = 4;
                        CreateCell(sheet, row, 0, border).SetCellValue(testTime.ToString("*HH:mm:ss*"));
                        CreateCell(sheet, row, 1, border).SetCellValue(UserInfo.User.Name);
                        CreateCell(sheet, row, 2, border).SetCellValue(ParamSetting.StationName);
                        CreateCell(sheet, row, 3, border).SetCellValue(socket.Barcode);
                        CreateCell(sheet, row, 4, status == "Passed" ? green : red).SetCellValue(ConvertStatus(status));
                        foreach (var data in socket.Datasource)
                        {
                            int col = column;
                            if (columnMap.ContainsKey(data.Name))
                            {
                                col = columnMap[data.Name];
                            }
                            else
                            {
                                columnMap[data.Name] = column++;
                            }
                            CreateCell(sheet, 1, col, border).SetCellValue(data.Name);
                            CreateCell(sheet, 2, col, border).SetCellValue(data.LimitUp);
                            CreateCell(sheet, 3, col, border).SetCellValue(data.LimitDown);
                            CreateCell(sheet, row, col, data.Result == "Failed" ? red : border).SetCellValue(data.Value);
                        }
                        using (FileStream fs = new FileStream(filePath, FileMode.OpenOrCreate))
                        {
                            workbook.Write(fs);
                        }
                    }
                    else
                    {
                        using (FileStream fs = File.OpenRead(filePath))
                        {
                            IWorkbook workbook = new HSSFWorkbook(fs);
                            ICellStyle border = workbook.CreateCellStyle();
                            border.BorderBottom = NPOI.SS.UserModel.BorderStyle.Thin;
                            border.BorderTop = NPOI.SS.UserModel.BorderStyle.Thin;
                            border.BorderLeft = NPOI.SS.UserModel.BorderStyle.Thin;
                            border.BorderRight = NPOI.SS.UserModel.BorderStyle.Thin;
                            border.BottomBorderColor = NPOI.HSSF.Util.HSSFColor.Black.Index;
                            border.TopBorderColor = NPOI.HSSF.Util.HSSFColor.Black.Index;
                            border.LeftBorderColor = NPOI.HSSF.Util.HSSFColor.Black.Index;
                            border.RightBorderColor = NPOI.HSSF.Util.HSSFColor.Black.Index;
                            ICellStyle red = workbook.CreateCellStyle();
                            red.CloneStyleFrom(border);
                            red.FillForegroundColor = NPOI.HSSF.Util.HSSFColor.Red.Index;
                            red.FillPattern = FillPattern.SolidForeground;
                            ICellStyle green = workbook.CreateCellStyle();
                            green.CloneStyleFrom(border);
                            green.FillForegroundColor = NPOI.HSSF.Util.HSSFColor.BrightGreen.Index;
                            green.FillPattern = FillPattern.SolidForeground;
                            ISheet sheet = workbook.GetSheet("Sheet1");
                            Dictionary<string, int> columnMap = new Dictionary<string, int>();
                            var titleRow = sheet.GetRow(1);
                            for (int i = 0; i < titleRow.LastCellNum; i++)
                            {
                                if (!string.IsNullOrEmpty(titleRow.GetCell(i).StringCellValue))
                                {
                                    columnMap[titleRow.GetCell(i).StringCellValue] = i;
                                }
                            }
                            int row = sheet.LastRowNum + 1;
                            CreateCell(sheet, row, 0, border).SetCellValue(testTime.ToString("*HH:mm:ss*"));
                            CreateCell(sheet, row, 1, border).SetCellValue(UserInfo.User.Name);
                            CreateCell(sheet, row, 2, border).SetCellValue(ParamSetting.StationName);
                            CreateCell(sheet, row, 3, border).SetCellValue(socket.Barcode);
                            CreateCell(sheet, row, 4, status == "Passed" ? green : red).SetCellValue(ConvertStatus(status));
                            foreach (var data in socket.Datasource)
                            {
                                int col = titleRow.LastCellNum;
                                if (columnMap.ContainsKey(data.Name))
                                {
                                    col = columnMap[data.Name];
                                }
                                else
                                {
                                    columnMap[data.Name] = col;
                                }
                                CreateCell(sheet, 1, col, border).SetCellValue(data.Name);
                                CreateCell(sheet, 2, col, border).SetCellValue(data.LimitUp);
                                CreateCell(sheet, 3, col, border).SetCellValue(data.LimitDown);
                                CreateCell(sheet, row, col, data.Result == "Failed" ? red : border).SetCellValue(data.Value);
                            }
                            using (FileStream nfs = new FileStream(filePath, FileMode.OpenOrCreate))
                            {
                                workbook.Write(nfs);
                            }
                        }
                    }
                }
                catch (Exception ex)
                {
                    log.Error("SOUND Export Excel Data Error.", ex);
                }
            }
        }
        #endregion
        public void UpdatePositionProcessValue(int index, int value)
        {
            this.tsSockets[index].SetTestProcess(value);
        }
        public void PausePosition(int index)
        {
            this.tsSockets[index].ShowPausePanel();
        }
        public void AddTestData(int index, string data)
        {
            string[] TestItems = data.Split(',');
            tsSockets[index].AddTestData(TestItems[0], TestItems[1], TestItems[2], TestItems[3],
                TestItems[4], TestItems[5], TestItems[6]);
        }
        public void SetPositionBarcode(int index, string strData)
        {
            if (strData.Contains(";"))
            {
                var strs = strData.Split(';');
                this.tsSockets[index].Barcode = strs[0];
                this.tsSockets[index].Model = strs[1];
            }
            else
            {
                this.tsSockets[index].Barcode = strData;
            }
        }
        public void SavePositionCSV(int index, string csvPath)
        {
            //var time = DateTime.Now;
            //string status = this.tsSockets[index].Status == "Passed" ? "PASS" : "FAIL";
            //string csvPath = Path.Combine(csvDir, SelectedProduct?.Name ?? "未知产品", time.ToString("yyyyMMdd"), status,
            //    $"{this.tsSockets[index].Barcode}-{time.ToString("HHmmss")}.csv");
            this.tsSockets[index].WriteToCSV(csvPath);
        }
        public void SaveTemplateCSV(int index)
        {
            var time = DateTime.Now;
            string status = this.tsSockets[index].Status == "Passed" ? "PASS" : "FAIL";
            string csvPath = Path.Combine(csvDir, CurrentProductModel ?? "未知产品", time.ToString("yyyyMMdd"), status, $"{this.tsSockets[index].Barcode}-{time.ToString("HHmmss")}.csv");
            this.tsSockets[index].WriteToCSV(csvPath);
        }
        public void SavePositionDB(int index, string status)
        {
            CheckSqlHelper();
            var socket = this.tsSockets[index];
            var time = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
            string snr = socket.Barcode;
            string model = CurrentProductModel;
            List<string> sqls = new List<string>();
            foreach (var data in socket.Datasource)
            {
                sqls.Add($@"INSERT INTO serialData (sn,model,station,step,position,limitDown,limitUp,unit,value,status,finalStatus,consumeTime,totalConsumeTime,time) VALUES
('{snr}','{model}','','{data.Name}','{index}','{data.LimitDown}','{data.LimitUp}','{data.Unit}','{data.Value}','{data.Result}','{socket.Status}','{data.ConsumeTime}','{socket.ElapseTime}','{time}')");
            }
            sqliteHelper.ExecuteBatchSql(sqls);
            var ssql = $@"INSERT INTO testResult (sn,model,station,result,time,position,status) VALUES
('{snr}','{model}','','{socket.Status}','{time}','{index}','{status}')";
            sqliteHelper.ExecuteSql(ssql);
        }
        private void HandleUIMessage(int eventCode, int MethodCode, string strData, object xData)
        {
            if (eventCode == 10035)
            {
                string[] arr_10035 = strData.Split(';');
                int[][] points = new int[arr_10035.Length - 1][];
                for (int i = 1; i < arr_10035.Length; i++)
                {
                    string[] strs = arr_10035[i].Split(',');
                    points[i - 1] = new int[2] { int.Parse(strs[0]), int.Parse(strs[1]) };
                }
                this.tsSockets[MethodCode].DrawScreenPoints(int.Parse(arr_10035[0]), points);
                return;
            }
            else if (eventCode == 100350)
            {
                string[] arr_100350 = strData.Split(';');
                int[][] points_0 = new int[arr_100350.Length - 1][];
                for (int i = 1; i < arr_100350.Length; i++)
                {
                    if (string.IsNullOrEmpty(arr_100350[i]))
                    {
                        continue;
                    }
                    string[] strs = arr_100350[i].Split(',');
                    points_0[i - 1] = new int[2] { int.Parse(strs[0]), int.Parse(strs[1]) };
                }
                this.tsSockets[MethodCode].DrawNewScreenPoints(int.Parse(arr_100350[0]), points_0);
                return;
            }
            else if (eventCode == 10036)
            {
                this.tsSockets[MethodCode].ClearScreenPoint(int.Parse(strData));
                return;
            }
            else if (eventCode == 10036)
            {
                this.tsSockets[MethodCode].ClearScreenPoint(int.Parse(strData));
                return;
            }
            if (MethodCode == 0)
            {
                /// 设置句柄
                testExecutionWrapper?.SetContext((SequenceContext)xData);
                return;
            }
            if (MethodCode == 3)
            {
                /// 更新炉子时间信息
                string[] datas_10014 = strData.Split(';');
                int ovenIndex = int.Parse(datas_10014[0]);
                string startTime = datas_10014[1];
                string consumeTime = datas_10014[2];
                string planEndTime = datas_10014[3];
                tsSockets[ovenIndex].ElapseTime = consumeTime;
                return;
            }
            else if (MethodCode == 4)
            {
                /// 修改工位底部信息栏
                string[] datas_10014 = strData.Split(';');
                int ovenIndex = int.Parse(datas_10014[0]);
                string statusBar = datas_10014[1];
                this.tsSockets[ovenIndex].CurrentStep = statusBar;
                return;
            }
            else if (MethodCode == 6)
            {
                Dispatcher.Invoke(() =>
                {/// 更新穴位状态
                    string[] datas_10015 = strData.Split(';');
                    int ovenIndex = int.Parse(datas_10015[0]);
                    int cellIndex = int.Parse(datas_10015[1]);
                    string status_10015 = datas_10015[2];
                    UpdatePositionStatus(ovenIndex, status_10015);
                });
                return;
            }
            else if (MethodCode == 9)
            {
                if (string.Equals(strData, "stop", StringComparison.OrdinalIgnoreCase))
                {
                    IsTesting = false;
                }
                return;
            }
            else if (MethodCode == 11)
            {
                // 清空测试数据
                string[] datas_10015 = strData.Split(';');
                int ovenIndex = int.Parse(datas_10015[0]);
                this.tsSockets[ovenIndex].IniTestData();
                return;
            }
            else if (MethodCode == 18)
            {
                string[] datas = strData.Split(';');
                int ovenIndex = int.Parse(datas[0]);
                int cellIndex = int.Parse(datas[1]);
                string filePath = datas[2];
                if (ParamSetting.CustomerCode == "SOUND")
                {
                    this.tsSockets[ovenIndex].WriteToExcel(filePath);
                    Dispatcher.Invoke(() =>
                    {
                        SavePositionDB(ovenIndex, tsSockets[ovenIndex].Status);
                    });
                    return;
                }
                try
                {
                    SavePositionCSV(ovenIndex, filePath);
                    Dispatcher.Invoke(() =>
                    {
                        var sss = this.tsSockets[ovenIndex].Status;
                        SavePositionDB(ovenIndex, sss);
                    });
                }
                catch (Exception ex)
                {
                    log.Warn("写CSV/DB数据失败.", ex);
                    SaveTemplateCSV(ovenIndex);
                }
                return;
            }
            else if (MethodCode == 9000)
            {
                //更新型号
                string[] datas = strData.Split(';');
                int ovenIndex = int.Parse(datas[0]);
                int cellIndex = int.Parse(datas[1]);
                CurrentProductModel = datas[2];
                return;
            }
            else if (MethodCode == 9001)
            {
                //更新条码
                string[] datas = strData.Split(';');
                int ovenIndex = int.Parse(datas[0]);
                int cellIndex = int.Parse(datas[1]);
                SetPositionBarcode(ovenIndex, datas[2]);
                return;
            }
            else if (MethodCode == 9002)
            {
                //显示测试进度
                string[] datas = strData.Split(';');
                int ovenIndex = int.Parse(datas[0]);
                int cellIndex = int.Parse(datas[1]);
                int ProcessVal = Convert.ToInt32(datas[2]);
                this.tsSockets[ovenIndex].SetTestProcess(ProcessVal);
                return;
            }
            else if (MethodCode == 9003)
            {
                //开始计时
                string[] datas = strData.Split(';');
                int ovenIndex = int.Parse(datas[0]);
                int cellIndex = int.Parse(datas[1]);
                tsSockets[ovenIndex].ElapseTime = "Start Timing";
                return;
            }
            else if (MethodCode == 9004)
            {
                //结束计时
                string[] datas = strData.Split(';');
                int ovenIndex = int.Parse(datas[0]);
                int cellIndex = int.Parse(datas[1]);
                tsSockets[ovenIndex].ElapseTime = "Stop Timing";
                return;
            }
            else if (MethodCode == 9005)
            {
                //手动设置耗时
                string[] datas = strData.Split(';');
                int ovenIndex = int.Parse(datas[0]);
                int cellIndex = int.Parse(datas[1]);
                tsSockets[ovenIndex].ElapseTime = datas[2];
                return;
            }
        }
        private void SwitchSequenceByModel(int index, string model, string snr)
        {
            Dispatcher.Invoke(() =>
            {
                var target = Products.FirstOrDefault(e => e.Name == model);
                ShowMask(() =>
                {
                    bool stoped = StopSequence();
                    //if (stoped)
                    //{
                    //    IsTesting = false;
                    //}
                }, null, null, "换型中,请稍后...");
                if (target == null)
                {
                    MsgBoxHelper2.Warn($"请先在产品管理页面配置型号 [{model}] 的产品信息!");
                    return;
                }
                switchAction = () =>
                {
                    switchAction = null;
                    log?.Info($"尝试自动换型: {switchAction}");
                    if (canSwitchModel)
                    {
                        Dispatcher.Invoke(() =>
                        {
                            try
                            {
                                log?.Info("开始自动换型");
                                SelectedProduct = target;
                                testExecutionWrapper?.SetStationGlobalValue($"CacheSNR[{index}]", 1, snr);
                                testExecutionWrapper?.CommitToDisk(false);
                                StartClick();
                            }
                            catch (Exception ex)
                            {
                                log?.Error("自动换型失败", ex);
                            }
                        });
                    }
                };
            });
        }
        private void ShowAlarm(AlarmText alarm)
        {
            AlarmShowHelper.ShowAlarm(alarm);
            mSMQHelper.Send("Alarm", "PC", $"ATE测试 {alarm.AlarmInfo}", 2);
        }
        private void UpdateTestCounter()
        {
            double PassNum = 0;
            double FailNum = 0;
            foreach (SingleControl socket in this.tsSockets)
            {
                PassNum += socket.PassNum;
                FailNum += socket.FailNum;
            }
            this.DisplayCount(PassNum, FailNum);
        }
        private void SetTestSeqFile(ProductData product)
        {
            if (product == null)
            {
                testSeqFile = null;
                return;
            }
            string name = product.Name;
            List<HMI.Core.Entity.ParameterData> parameters = ProductManager.GetParametersByProductName(name);
            testSeqFile = parameters.FirstOrDefault(p => p.Name == "TestSequence")?.Value?.ToString();
            if (AutoExecuteSeq)
            {
                StartSequence();
            }
        }
        private void ClearNum()
        {
            if (MsgBoxHelper.WarnMessage(Window.GetWindow(this), LangUtil.Get("Ts.MakeSureClearNum")) == MessageBoxResult.OK)
            {
                PassAmount = 0;
                FailAmount = 0;
                //this.OpcHandle.DoFctWriteSingle("Count_Clear", "1", out string ErrMsg);
                foreach (SingleControl socket in tsSockets)
                {
                    socket.ResetCounter();
                }
                DisplayCount(PassAmount, FailAmount);
            }
        }
        public override void OnClose(object sender, CancelEventArgs e)
        {
            try
            {
                StopSequence();
                base.OnClose(sender, e);
            }
            catch (Exception ex)
            {
                log.Error("OnClose Error.", ex);
            }
        }
        public override void DoClose(object sender, EventArgs e)
        {
            testExecutionWrapper.Dispose();
            base.DoClose(sender, e);
        }
        /// <summary>
        /// 释放所有的序列文件
        /// </summary>
        private bool ReleaseSequenceFile()
        {
            try
            {
                lock (this)
                {
                    //execution.Abort();
                    int count = 10;
                    while (count > 0)
                    {
                        try
                        {
                            return testExecutionWrapper.ReleaseSeqFile();
                        }
                        catch (Exception ex)
                        {
                            log.Warn($"ReleaseSequenceFile In {count} Times Fail.", ex);
                            count--;
                            System.Threading.Thread.Sleep(1000);
                        }
                    }
                    log.Warn($"ReleaseSequenceFile Failed.");
                    return false;
                }
            }
            catch (Exception ex)
            {
                log.Warn("ReleaseSequenceFile Error.", ex);
                return false;
            }
        }
        private void PluginControl_SizeChanged(object sender, SizeChangedEventArgs e)
        {
        }
        private void CbFontSize_SelectionChanged(object sender, SelectionChangedEventArgs e)
        {
            try
            {
                if (e.AddedItems != null && e.AddedItems.Count > 0)
                {
                    var fontSize = (double)e.AddedItems[0];
                    this.Resources["tableFontSize"] = fontSize;
                    IniFileHelper.WriteIniData("Config", "TableFontSize", fontSize.ToString(), CountIniPath);
                }
            }
            catch
            {
            }
        }
        private void Label_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
        {
            //workOrderEditor.Focus();
            //workOrderEditor.IsEditing = true;
            if (RequirePassword() == true)
            {
                var win = new WorkOrderWin
                {
                    WorkOrder = CurrentWorkorder,
                    Owner = Window.GetWindow(this)
                };
                if (win.ShowDialog() == true)
                {
                    CurrentWorkorder = win.WorkOrder;
                }
            }
        }
    }
}
Plugin.StandardTest/WorkOrderWin.xaml
New file
@@ -0,0 +1,24 @@
<pu:WindowX x:Class="Plugin.StandardTest.WorkOrderWin"
        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:local="clr-namespace:Plugin.StandardTest"
        mc:Ignorable="d"
        Style="{StaticResource ModalWindow}"
        xmlns:pu="https://opensource.panuon.com/wpf-ui"
        xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
        SizeToContent="Height"
        FontSize="14"
        WindowStartupLocation="CenterOwner"
        pu:WindowXModalDialog.OKButtonContent="确定"
        Title="请输入工单号" Width="500">
    <Grid dp:GridHelper.Space="10">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <pu:FormGroup Header="工单号" Orientation="Horizontal" Style="{StaticResource RequiredInputFormGroupStyle}">
            <TextBox Text="{Binding WorkOrder}" Style="{StaticResource EditorTextBoxStyle}"/>
        </pu:FormGroup>
    </Grid>
</pu:WindowX>
Plugin.StandardTest/WorkOrderWin.xaml.cs
New file
@@ -0,0 +1,36 @@
using Panuon.WPF.UI;
using System.ComponentModel;
namespace Plugin.StandardTest
{
    /// <summary>
    /// RequirePasswordWindow.xaml 的交互逻辑
    /// </summary>
    public partial class WorkOrderWin : WindowX
    {
        public WorkOrderWin()
        {
            this.DataContext = this;
            InitializeComponent();
        }
        private string workOrder;
        public string WorkOrder
        {
            get => workOrder;
            set
            {
                Set(ref workOrder, value);
            }
        }
        protected override void OnClosing(CancelEventArgs e)
        {
            //Press Continue Button
            if (DialogResult == true)
            {
            }
            base.OnClosing(e);
        }
    }
}
Plugin.TestPlanEditor/AssemblyInfo.cs
New file
@@ -0,0 +1,12 @@
using HMI.Core.Annotation;
using System.Windows;
[assembly:ThemeInfo(
    ResourceDictionaryLocation.None,            //where theme specific resource dictionaries are located
                                                //(used if a resource is not found in the page,
                                                // or application resource dictionaries)
    ResourceDictionaryLocation.SourceAssembly   //where the generic resource dictionary is located
                                                //(used if a resource is not found in the page,
                                                // app, or any theme specific resource dictionaries)
)]
[assembly:PluginClassName("Plugin.TestPlanEditor.EditorUI")]
Plugin.TestPlanEditor/EditorUI.xaml
New file
@@ -0,0 +1,81 @@
<hb:SimpleMaskControl x:Class="Plugin.TestPlanEditor.EditorUI"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:local="clr-namespace:Plugin.TestPlanEditor"
             mc:Ignorable="d"
             xmlns:hb="clr-namespace:HMI.Base;assembly=HMI.Base"
             xmlns:ote="clr-namespace:OpenTapEditor;assembly=SeqEditor"
             xmlns:ui="clr-namespace:UILib;assembly=UILib"
             xmlns:op="clr-namespace:OpenTap;assembly=OpenTap"
             xmlns:sys="clr-namespace:System;assembly=mscorlib"
             xmlns:pu="https://opensource.panuon.com/wpf-ui"
             d:DesignHeight="450" d:DesignWidth="800">
    <UserControl.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="/UILib;component/Styles.xaml"/>
                <ResourceDictionary Source="/SeqEditor;component/UIStyle.xaml"/>
                <pu:StyleDictionary Excludes="Menu,ListView,ToggleButton,ContextMenu,TabControl,CheckBox,TextBox,Button" />
            </ResourceDictionary.MergedDictionaries>
            <sys:Double x:Key="GlobalFontSize">14</sys:Double>
            <ui:ReverseBoolConverter x:Key="ReverseBool"/>
            <ObjectDataProvider x:Key="BreakConditionValues"
        MethodName="GetValues"
        ObjectType="{x:Type sys:Enum}">
                <ObjectDataProvider.MethodParameters>
                    <x:Type TypeName="op:BreakCondition"/>
                </ObjectDataProvider.MethodParameters>
            </ObjectDataProvider>
            <sys:Double x:Key="{x:Static SystemParameters.VerticalScrollBarWidthKey}">3</sys:Double>
            <sys:Double x:Key="{x:Static SystemParameters.HorizontalScrollBarHeightKey}">3</sys:Double>
            <Style TargetType="ScrollViewer">
                <Style.Resources>
                    <Style TargetType="ScrollBar" BasedOn="{StaticResource ScrollStyle_Mobile}"/>
                </Style.Resources>
            </Style>
            <Style TargetType="TabItem">
                <Setter Property="Padding" Value="5"/>
                <Setter Property="Background" Value="#f2f3f5"/>
                <Setter Property="BorderThickness" Value="0"/>
                <Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
                <Setter Property="FontSize" Value="10"/>
                <Setter Property="Cursor" Value="Hand"/>
                <Setter Property="Template">
                    <Setter.Value>
                        <ControlTemplate TargetType="TabItem">
                            <Border x:Name="border" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}">
                                <ContentPresenter x:Name="contentPresenter"
              ContentSource="Header"
              HorizontalAlignment="Center"
              VerticalAlignment="Center"/>
                            </Border>
                            <ControlTemplate.Triggers>
                                <Trigger Property="IsMouseOver" Value="True">
                                    <Setter TargetName="border" Property="Background" Value="#e0e9f2"/>
                                </Trigger>
                                <Trigger Property="IsSelected" Value="True">
                                    <Setter TargetName="border" Property="Background" Value="#bcddee"/>
                                    <Setter TargetName="border" Property="BorderThickness" Value="0,0,0,2"/>
                                    <Setter TargetName="border" Property="BorderBrush" Value="#3498DB"/>
                                </Trigger>
                            </ControlTemplate.Triggers>
                        </ControlTemplate>
                    </Setter.Value>
                </Setter>
            </Style>
        </ResourceDictionary>
    </UserControl.Resources>
    <Grid>
        <ote:SeqEditorUI x:Name="editor"/>
    </Grid>
</hb:SimpleMaskControl>
Plugin.TestPlanEditor/EditorUI.xaml.cs
New file
@@ -0,0 +1,29 @@
using AddInPlugin;
using HMI.Base;
namespace Plugin.TestPlanEditor
{
    /// <summary>
    /// EditorUI.xaml 的交互逻辑
    /// </summary>
    public partial class EditorUI : SimpleMaskControl
    {
        public EditorUI()
        {
            InitializeComponent();
        }
        public override void InitFinish()
        {
            try
            {
                LvlibpLoader.PreLoad();
                log.Debug("PreLoad completed.");
            }
            catch (Exception ex)
            {
                log.Error("Labview PreLoad error.", ex);
            }
        }
    }
}
Plugin.TestPlanEditor/Plugin.TestPlanEditor.csproj
New file
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net472</TargetFramework>
    <Nullable>enable</Nullable>
    <LangVersion>10.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
    <PackageReference Include="log4net" Version="3.0.4" />
    <PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.135" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\..\VisualStudio\UILib\UILib.csproj" />
    <ProjectReference Include="..\..\..\VisualStudio\UtilLib\UtilLib.csproj" />
    <ProjectReference Include="..\..\OpenTapEditor\Editor\SeqEditor.csproj" />
    <ProjectReference Include="..\..\OpenTapEditor\OpenTap\BasicSteps\Tap.Plugins.BasicSteps.csproj" />
    <ProjectReference Include="..\..\OpenTapEditor\OpenTap\Engine\Tap.Engine.csproj" />
    <ProjectReference Include="..\..\OpenTapEditor\Plugin\AddInPlugin\AddInPlugin.csproj" />
    <ProjectReference Include="..\HMI.Base\HMI.Base.csproj" />
    <ProjectReference Include="..\HMI.Core\HMI.Core.csproj" />
    <ProjectReference Include="..\HMI.UI\HMI.UI.csproj" />
    <ProjectReference Include="..\HMI.Util\HMI.Util.csproj" />
    <ProjectReference Include="..\SimpleUtil\HMI.LogUtil\HMI.LogUtil.csproj" />
    <ProjectReference Include="..\SimpleUtil\HMI.Util.Base\HMI.Util.Base.csproj" />
  </ItemGroup>
</Project>
Plugin.TestResult/ManualDebugWindow.xaml
@@ -4,7 +4,7 @@
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d"
        xmlns:sb="clr-namespace:Util.SB;assembly=Util.ManualDebug"
        xmlns:sb="clr-namespace:Util.ManualDebug;assembly=Util.ManualDebug"
        Title="ManualDebugWindow" 
        xmlns:pu="https://opensource.panuon.com/wpf-ui"
        Style="{StaticResource ModalWindow}"
@@ -13,7 +13,7 @@
        pu:WindowXModalDialog.Buttons="{x:Null}"
        Height="1000" Width="900">
    <Grid x:Name="sb">
        <sb:ManualDebug OvenName="{Binding OvenName,Mode=OneWay}"
        <sb:OldManualDebug OvenName="{Binding OvenName,Mode=OneWay}"
    CellName="{Binding CellName,Mode=OneWay}" 
    OvenIndex="{Binding OvenIndex,Mode=OneWay}" 
    CellIndex="{Binding CellIndex,Mode=OneWay}"/>
Plugin.TestResult/ManualDebugWindow.xaml.cs
@@ -5,7 +5,7 @@
using System.ComponentModel;
using System.IO;
using System.Windows;
using Util.SB;
using Util.ManualDebug;
namespace Plugin.TestResult
{
@@ -15,13 +15,13 @@
    public partial class ManualDebugWindow : WindowX
    {
        private ManualDebug debug;
        private OldManualDebug debug;
        public ManualDebugWindow(string seqPath, string modelPath)
        {
            DataContext = this;
            InitializeComponent();
            debug = (ManualDebug)sb.Children[0];
            debug = (OldManualDebug)sb.Children[0];
            ReadBtns();
            //List<BtnConfig> settings = new List<BtnConfig> { new BtnConfig { Title = "上电激活", Name = "Active" } };
            debug.seqPath = seqPath;
Plugin.TestResult/Plugin.TestResult.csproj
@@ -360,6 +360,10 @@
    <Resource Include="Resource\barcode.png" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\OpenTapEditor\OpenTap\Engine\Tap.Engine.csproj">
      <Project>{efbd516d-3dc9-9d75-f082-48fbc76715f7}</Project>
      <Name>Tap.Engine</Name>
    </ProjectReference>
    <ProjectReference Include="..\HMI.Base\HMI.Base.csproj">
      <Project>{2a6f9720-a890-4036-9d73-41a0dae28341}</Project>
      <Name>HMI.Base</Name>
Plugin.TestResult/TestResultView.xaml.cs
@@ -218,11 +218,11 @@
                }
                else
                {
                    executionWrapper = new TsExecutionWrapper();
                    executionWrapper = new TeststandWrapper();
                }
                executionWrapper.UIMessageEvent += ExecutionWrapper_UIMessageEvent;
                if (executionWrapper is TsExecutionWrapper tsWrapper)
                if (executionWrapper is TeststandWrapper tsWrapper)
                {
                    ((System.ComponentModel.ISupportInitialize)(tsWrapper.AxApplicationMgr)).BeginInit();
                    host.Child = tsWrapper.AxApplicationMgr;
@@ -615,7 +615,7 @@
        public void ReleaseResource()
        {
            executionWrapper.UIMessageEvent -= ExecutionWrapper_UIMessageEvent;
            executionWrapper.Dispose();
            foreach (var table in tables)
            {
                table.RelaseResource();
Plugin.TsExecution/Plugin.TsExecution.csproj
@@ -240,6 +240,10 @@
    </None>
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\..\Project\OpenTapEditor\OpenTap\Engine\Tap.Engine.csproj">
      <Project>{efbd516d-3dc9-9d75-f082-48fbc76715f7}</Project>
      <Name>Tap.Engine</Name>
    </ProjectReference>
    <ProjectReference Include="..\HMI.Base\HMI.Base.csproj">
      <Project>{2a6f9720-a890-4036-9d73-41a0dae28341}</Project>
      <Name>HMI.Base</Name>
@@ -272,6 +276,10 @@
      <Project>{CF5BEBE9-2803-49C3-B773-73D082E0984A}</Project>
      <Name>HMI.Simple.Util</Name>
    </ProjectReference>
    <ProjectReference Include="..\Util.ManualDebug\Util.ManualDebug.csproj">
      <Project>{d0944e62-ee52-4abe-832a-a09d98814db1}</Project>
      <Name>Util.ManualDebug</Name>
    </ProjectReference>
  </ItemGroup>
  <ItemGroup>
    <WCFMetadata Include="Connected Services\" />
Plugin.TsExecution/Properties/AssemblyInfo.cs
@@ -49,6 +49,6 @@
//      生成号
//      修订号
//
[assembly: AssemblyVersion("1.0.0.7")]
[assembly: AssemblyFileVersion("1.0.0.7")]
[assembly: AssemblyVersion("1.0.0.8")]
[assembly: AssemblyFileVersion("1.0.0.8")]
[assembly: PluginClassName("Plugin.TsExecution.TsExecution")]
Plugin.TsExecution/SingleControl.xaml
@@ -12,6 +12,7 @@
             xmlns:sys="clr-namespace:System;assembly=mscorlib"
             Loaded="UserControl_Loaded"
             Background="#ccc6c7"
             PreviewMouseRightButtonDown="control_PreviewMouseRightButtonDown"
             FontSize="{Binding ActualHeight,RelativeSource={RelativeSource AncestorType=UserControl}, 
Converter={StaticResource hfc}, ConverterParameter='12,816'}"
             x:Name="control"
Plugin.TsExecution/SingleControl.xaml.cs
@@ -2,11 +2,9 @@
using HMI.Core.Manager;
using HMI.Core.Setting;
using HMI.Core.Util;
using HMI.UI.Util;
using HMI.Util;
using NationalInstruments.TestStand.Interop.API;
using NPOI.HSSF.UserModel;
using NPOI.SS.Formula.Functions;
using NPOI.SS.UserModel;
using Plugin.TsExecution.Entity;
using System;
using System.Collections.Generic;
@@ -17,6 +15,7 @@
using System.Timers;
using System.Windows;
using System.Windows.Controls;
using Util.ManualDebug;
namespace Plugin.TsExecution
{
@@ -65,6 +64,8 @@
                Set(ref totalNum, value);
            }
        }
        public TsExecution parent { get; set; }
        private int passNum;
        public int PassNum
@@ -271,7 +272,7 @@
            try
            {
                var value = ParameterManager.GetCommonParameterByName("AutoTestMode");
                IsAutoMode = bool.Parse(value.Value);
                IsAutoMode = value == null ? true : bool.Parse(value.Value);
            }
            catch
            {
@@ -373,7 +374,10 @@
        public void IniTestData()
        {
            Datasource.Clear();
            Dispatcher.Invoke(() =>
            {
                Datasource.Clear();
            });
        }
        /// <summary>
@@ -564,7 +568,7 @@
        private void SendSnr2Ts()
        {
        }
        private void pausePanel_Loaded(object sender, RoutedEventArgs e)
@@ -735,5 +739,57 @@
                }.ShowDialog();
            }
        }
        private void control_PreviewMouseRightButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
        {
            if (parent.EnableManualDebug)
            {
                ContextMenu contextMenu = new ContextMenu();
                contextMenu.Closed += ContextMenu_Closed;
                var item = new MenuItem
                {
                    IsCheckable = false,
                    Name = "manualDebugItem",
                    Header = this.lang("手动调试"),
                };
                item.Click += Item_Click; ;
                contextMenu.Items.Add(item);
                this.ContextMenu = contextMenu;
            }
            else
            {
                this.ContextMenu = null;
            }
        }
        private void Item_Click(object sender, RoutedEventArgs e)
        {
            string manualDebugSeq = ParameterManager.GetCommonParameterByName("ManualDebugSeqPath")?.Value;
            if (string.IsNullOrEmpty(manualDebugSeq))
            {
                MsgBoxHelper2.Error("请先设置手动调试序列路径[ManualDebugSeqPath]!");
                return;
            }
            string modelPath = parent.tsModelPath;
            var win = new ManualDebugWindow(manualDebugSeq, modelPath, false)
            {
                Title = $"工位{this.index}手动调试",
                Owner = Window.GetWindow(this),
                OvenIndex = 0,
                OvenName = "",
                CellCount = parent.tsSockets.Count,
                CellIndex = this.index,
                CellName = "",
            };
            win.ShowDialog();
        }
        private void ContextMenu_Closed(object sender, RoutedEventArgs e)
        {
            this.ContextMenu = null;
        }
    }
}
Plugin.TsExecution/TsExecution.xaml
@@ -89,6 +89,10 @@
IsChecked="{Binding UI_ShowInfoDock,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"
Content="{DynamicResource Ts.UI_ShowInfoDock}"/>
                    <CheckBox FontSize="20" x:Name="Cb_ManualDebug"  Visibility="Collapsed"
                              IsChecked="{Binding EnableManualDebug,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"
                              VerticalContentAlignment="Center" Content="手动调试" />
                    <StackPanel Orientation="Horizontal" dp:StackPanelHelper.ChildSpace="5">
                        <Label FontSize="20" VerticalContentAlignment="Center" HorizontalContentAlignment="Center" Padding="0"
                       Content="{DynamicResource HMI.User.Code}" />
Plugin.TsExecution/TsExecution.xaml.cs
@@ -13,6 +13,7 @@
using NationalInstruments.TestStand.Interop.API;
using NPOI.HSSF.UserModel;
using NPOI.SS.UserModel;
using OpenTap;
using Plugin.TsSupport;
using ScottPlot;
using ScottPlot.Plottables;
@@ -24,7 +25,6 @@
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Forms.DataVisualization.Charting;
namespace Plugin.TsExecution
{
@@ -57,7 +57,7 @@
        #endregion
        public static ILog logger { get; private set; }
        private List<SingleControl> tsSockets = new List<SingleControl>();
        public List<SingleControl> tsSockets { get; private set; } = new List<SingleControl>();
        /// <summary>
        /// 样件模式状态
        /// </summary>
@@ -298,7 +298,22 @@
            }
        }
        private string tsModelPath;
        private bool enableManualDebug;
        public bool EnableManualDebug
        {
            get => enableManualDebug;
            set
            {
                if (IsTesting)
                {
                    MsgBoxHelper2.Warn("请先结束自动测试");
                    return;
                }
                Set(ref enableManualDebug, value);
            }
        }
        public string tsModelPath { get; private set; }
        private string dataLogPath;
        private SqliteHelper sqliteHelper;
@@ -309,9 +324,7 @@
        {
            DataContext = this;
            InitializeComponent();
            InitChart();
            InitTsEngine();
        }
        public override void SetBaseFolder(string folder)
@@ -363,6 +376,7 @@
        public override void InitFinish()
        {
            InitTsEngine();
            this.ReadCount(out int pass, out int fail);
            this.DisplayCount(pass, fail);
            ReadConfig();
@@ -388,6 +402,33 @@
            catch (Exception ex)
            {
                log.Warn("Get Login User Failed.", ex);
            }
            RefreshUIConfig();
        }
        /// <summary>
        /// 刷新UI配置
        /// </summary>
        private void RefreshUIConfig()
        {
            try
            {
                bool enableManualDebug = ParameterManager.GetCommonParameterByName("EnableManualDebug", false);
                if (enableManualDebug)
                {
                    Cb_ManualDebug.Visibility = Visibility.Visible;
                    EnableManualDebug = false;
                }
                else
                {
                    Cb_ManualDebug.Visibility = Visibility.Collapsed;
                    EnableManualDebug = false;
                }
            }
            catch (Exception ex)
            {
                log.Error("RefreshUIConfig Error.", ex);
            }
        }
@@ -498,7 +539,10 @@
        {
            if (ParamSetting.EngineType == "Tap")
            {
                testExecutionWrapper = new TapWrapper();
                var wrapper = new TapWrapper();
                wrapper.RegisteUserInput(this);
                log.Debug($"Wrapper UI is {wrapper.ui}");
                testExecutionWrapper = wrapper;
            }
            else
            {
@@ -657,6 +701,7 @@
                {
                    SocketName = name
                };
                control.parent = this;
                panel.Children.Add(control);
                tsSockets.Add(control);
@@ -874,7 +919,7 @@
            {
                value = ParameterManager.GetCommonParameterByName("TestReportFolder");
                csvDir = value.Value;
                csvDir = value?.Value ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TestReport");
                if (!Directory.Exists(csvDir))
                {
                    Directory.CreateDirectory(csvDir);
@@ -889,7 +934,7 @@
            {
                value = ParameterManager.GetCommonParameterByName("IsOptRequirePassword");
                IsOptRequirePassword = bool.Parse(value.Value);
                IsOptRequirePassword = value == null ? false : bool.Parse(value.Value);
            }
            catch
            {
@@ -1218,9 +1263,12 @@
        public void UpdatePositionStatus(int index, string status)
        {
            this.tsSockets[index].UpdateStatus(status);
            OnStatusPublished(index, status);
            this.UpdateTestCounter();
            Dispatcher.Invoke(() =>
            {
                this.tsSockets[index].UpdateStatus(status);
                OnStatusPublished(index, status);
                this.UpdateTestCounter();
            });
        }
        private void OnStatusPublished(int index, string status)
@@ -1531,17 +1579,26 @@
                    break;
                case 10006://发送产品条码
                    SetPositionBarcode(index, strData);
                    Dispatcher.Invoke(() =>
                    {
                        SetPositionBarcode(index, strData);
                    });
                    break;
                case 10007://开始测试计时
                    tsSockets[index].ElapseTime = "Start Timing";
                    BtnStopSeq.IsEnabled = false;
                    Dispatcher.Invoke(() =>
                    {
                        tsSockets[index].ElapseTime = "Start Timing";
                        BtnStopSeq.IsEnabled = false;
                    });
                    break;
                case 10008://停止测试计时
                    tsSockets[index].ElapseTime = "Stop Timing";
                    BtnStopSeq.IsEnabled = true;
                    Dispatcher.Invoke(() =>
                    {
                        tsSockets[index].ElapseTime = "Stop Timing";
                        BtnStopSeq.IsEnabled = true;
                    });
                    break;
                case 10009:
@@ -1582,7 +1639,10 @@
                    break;
                case 10017://更新测试计时,针对部分计时有PLC完成的项目
                    this.tsSockets[index].ElapseTime = strData;
                    Dispatcher.Invoke(() =>
                    {
                        this.tsSockets[index].ElapseTime = strData;
                    });
                    break;
                case (int)UIMessageCodes.UIMsg_EndExecution://执行结束
                    //IsTesting = false;
@@ -1590,7 +1650,10 @@
                    break;
                case 10030://显示测试进度
                    int ProcessVal = Convert.ToInt32(strData);
                    this.tsSockets[index].SetTestProcess(ProcessVal);
                    Dispatcher.Invoke(() =>
                    {
                        this.tsSockets[index].SetTestProcess(ProcessVal);
                    });
                    break;
                case 10031://导出CSV
@@ -1806,6 +1869,7 @@
        public override void DoClose(object sender, EventArgs e)
        {
            testExecutionWrapper?.Dispose();
            base.DoClose(sender, e);
        }
Plugin.TsSupport/ITestExecutionWrapper.cs
@@ -42,5 +42,7 @@
        bool ReleaseSeqFile();
        void SetContext(object context);
        void Dispose();
    }
}
Plugin.TsSupport/Plugin.TsSupport.csproj
@@ -81,6 +81,7 @@
      <SpecificVersion>False</SpecificVersion>
      <HintPath>..\Libs\NationalInstruments.TestStand.Interop.UI.AxControls.dll</HintPath>
    </Reference>
    <Reference Include="PresentationFramework" />
    <Reference Include="System" />
    <Reference Include="System.Core" />
    <Reference Include="System.Windows.Forms" />
@@ -90,6 +91,7 @@
    <Reference Include="System.Data" />
    <Reference Include="System.Net.Http" />
    <Reference Include="System.Xml" />
    <Reference Include="WindowsBase" />
  </ItemGroup>
  <ItemGroup>
    <Compile Include="ITestExecutionWrapper.cs" />
@@ -97,7 +99,20 @@
    <Compile Include="TestAppManager.cs" />
    <Compile Include="Properties\AssemblyInfo.cs" />
    <Compile Include="TeststandWrapper.cs" />
    <Compile Include="TsExecutionWrapper.cs" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\..\Project\OpenTapEditor\OpenTap\BasicSteps\Tap.Plugins.BasicSteps.csproj">
      <Project>{4BCFADC0-54F9-7032-B918-E4DD407CAA76}</Project>
      <Name>Tap.Plugins.BasicSteps</Name>
    </ProjectReference>
    <ProjectReference Include="..\..\..\Project\OpenTapEditor\OpenTap\Engine\Tap.Engine.csproj">
      <Project>{EFBD516D-3DC9-9D75-F082-48FBC76715F7}</Project>
      <Name>Tap.Engine</Name>
    </ProjectReference>
    <ProjectReference Include="..\..\..\Project\OpenTapEditor\Plugin\AddInPlugin\AddInPlugin.csproj">
      <Project>{0d150ca1-d6ea-3362-7330-028939e66e1c}</Project>
      <Name>AddInPlugin</Name>
    </ProjectReference>
  </ItemGroup>
  <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
Plugin.TsSupport/TapWrapper.cs
@@ -1,39 +1,188 @@
using System;
//using AddInPlugin;
//using AddInPlugin.Util;
//using Microsoft.Win32;
//using OpenTap;
//using OpenTap.Addin;
//using OpenTap.Addin.Util;
//using System.ComponentModel;
//using System.IO;
using AddInPlugin;
using AddInPlugin.Util;
using Microsoft.Win32;
using OpenTap;
using OpenTap.Addin;
using OpenTap.Addin.Listener;
using OpenTap.Addin.Reader;
using OpenTap.Addin.Util;
using OpenTap.Plugins.BasicSteps;
using System;
using System.ComponentModel;
using System.IO;
using System.Windows;
using System.Windows.Threading;
namespace Plugin.TsSupport
{
    public class TapWrapper : ITestExecutionWrapper
    public class Net47Getter : IRegGetter
    {
        public string GetAppPath(string keyPath)
        {
            using (RegistryKey key = Registry.CurrentUser.OpenSubKey(keyPath))
            {
                if (key == null)
                {
                    throw new Exception("注册表不存在,请先运行一次编辑器");
                }
                string value = key.GetValue("Location")?.ToString();
                if (string.IsNullOrEmpty(value))
                {
                    throw new Exception("注册表不存在,请先运行一次编辑器");
                }
                return value;
            }
        }
    }
    public class TapWrapper : ITestExecutionWrapper, IResultListener, IUserInputInterface
    {
        TapThread testPlanThread;
        TestPlan Plan;
        TestPlanRun PlanRun;
        public event PropertyChangedEventHandler PropertyChanged;
        public event EventHandler<UIMessageEvent> UIMessageEvent;
        public event EventHandler<ExecutionEndEvent> EndExecutionEvent;
        public void CommitToDisk(bool option)
        {
        private PostUIMessageHandler uIMessageHandler;
        }
        public string Name { get; set; }
        public T GetFileGlobalValue<T>(string key, int option)
        {
            return default(T);
        }
        public bool IsConnected => true;
        public T GetStationGlobalValue<T>(string key, int option)
        static TapWrapper()
        {
            return default(T);
            RegUtil.Register(new Net47Getter());
        }
        public object Init()
        {
            StationGlobalsManager.Register(new DefaultStationGlobalsManager());
            VariableResolverFactory.Register(new DefaultVariableResolverFactory());
            ResultSettings.Current.Add(this);
            UserInput.SetInterface(this);
            LvlibpLoader.PreLoad();
            PropertyInfoCache.Refresh(typeof(MethodVariable));
            uIMessageHandler = PostUIMessageHandler.Get();
            uIMessageHandler.OnPostUIMsg += PostUIMsgHandler_OnPostUIMsg;
            return null;
        }
        private void PostUIMsgHandler_OnPostUIMsg(object sender, PostUIMsg e)
        {
            UIMessageEvent?.Invoke(this, new UIMessageEvent()
            {
                Origin = e,
                EventCode = e.EventCode,
                BoolData = e.BoolData,
                NumberData = e.NumberData,
                StrData = e.StringData
            });
        }
        public object StartSequence(string filePath, string modelPath)
        {
            Plan = TestPlanReader.ReadTestPlan(filePath);
            //Plan = TestPlan.Load(filePath);
            testPlanThread = TapThread.Start(() =>
            {
                var res = ResultSettings.Current;
                res.Add(uIMessageHandler);
                Plan.Execute(ResultSettings.Current);
            });
            return Plan;
        }
        public T GetFileGlobalValue<T>(string key, int option)
        {
            return PlanRun.Resolve<T>(null, $"FileGlobals.{key}");
        }
        public T GetStationGlobalValue<T>(string key, int option)
        {
            if (PlanRun == null)
            {
                return StationGlobalsManager.GetValue<T>(key);
            }
            return PlanRun.Resolve<T>(null, $"StationGlobals.{key}");
        }
        public T GetValue<T>(string key, int option)
        {
            return default(T);
        }
        public void SetValue<T>(string key, T value, int option)
        {
        }
        public void SetFileGlobalValue<T>(string key, int option, T value)
        {
            PlanRun.Update(null, $"FileGlobals.{key}", value);
        }
        public void SetStationGlobalValue<T>(string key, int option, T value, bool inContext = false)
        {
            if (PlanRun == null)
            {
                StationGlobalsManager.SetValue(key, value);
            }
            else
            {
                PlanRun.Update(null, $"StationGlobals.{key}", value);
            }
        }
        public void CommitToDisk(bool option)
        {
            StationGlobalsManager.WriteToLocal(null);
        }
        public void OnTestPlanRunStart(TestPlanRun planRun)
        {
            PlanRun = planRun;
        }
        public void OnTestPlanRunCompleted(TestPlanRun planRun, Stream logStream)
        {
            PlanRun = null;
        }
        public void OnTestStepRunStart(TestStepRun stepRun)
        {
        }
        public void OnTestStepRunCompleted(TestStepRun stepRun)
        {
        }
        public void OnResultPublished(Guid stepRunID, ResultTable result)
        {
        }
        public void Open()
        {
        }
        public void Close()
        {
            EndExecutionEvent?.Invoke(this, new ExecutionEndEvent());
            Plan = null;
            PlanRun = null;
            testPlanThread = null;
        }
        public bool WaitForExit()
        {
            return true;
        }
        public bool ReleaseSeqFile()
@@ -41,198 +190,48 @@
            return true;
        }
        public void Dispose()
        {
        }
        public void SetContext(object context)
        {
        }
        public void SetFileGlobalValue<T>(string key, int option, T value)
        {
        public DispatcherObject ui { get; private set; }
        public void RegisteUserInput(DispatcherObject ui)
        {
            this.ui = ui;
        }
        public void SetStationGlobalValue<T>(string key, int option, T value, bool inContext = false)
        public void RequestUserInput(object dataObject, TimeSpan Timeout, bool modal)
        {
        }
        public object StartSequence(string filePath, string modelPath)
        {
            return null;
        }
        public bool WaitForExit()
        {
            return true;
            if (dataObject is DialogRequest req)
            {
                this.ui?.Dispatcher.BeginInvoke(new Action(() =>
                {
                    if (req.Buttons == InputButtons.OkCancel)
                    {
                        var res = MessageBox.Show(req.Message, req.Name, MessageBoxButton.OKCancel);
                        req.Input2 = res == MessageBoxResult.OK ? WaitForInputResult2.Ok : WaitForInputResult2.Cancel;
                    }
                    else
                    {
                        var res = MessageBox.Show(req.Message, req.Name, MessageBoxButton.YesNo);
                        req.Input1 = res == MessageBoxResult.Yes ? WaitForInputResult1.Yes : WaitForInputResult1.No;
                    }
                })).Wait();
            }
            else if (dataObject is OpenTap.TestPlan.PlanLoadError err)
            {
                this.ui?.Dispatcher.BeginInvoke(new Action(() =>
                {
                    var res = MessageBox.Show(err.Message, err.Name, MessageBoxButton.OKCancel);
                    err.Response = res == MessageBoxResult.OK ? TestPlan.PlanLoadErrorResponse.Ignore : TestPlan.PlanLoadErrorResponse.Abort;
                })).Wait();
            }
        }
    }
    //public class Net47Getter : IRegGetter
    //{
    //    public string GetAppPath(string keyPath)
    //    {
    //        using (RegistryKey key = Registry.CurrentUser.OpenSubKey(keyPath))
    //        {
    //            if (key == null)
    //            {
    //                throw new Exception("注册表不存在,请先运行一次编辑器");
    //            }
    //            string value = key.GetValue("Location")?.ToString();
    //            if (string.IsNullOrEmpty(value))
    //            {
    //                throw new Exception("注册表不存在,请先运行一次编辑器");
    //            }
    //            return value;
    //        }
    //    }
    //}
    //public class TapWrapper : ITestExecutionWrapper, IResultListener
    //{
    //    TapThread testPlanThread;
    //    TestPlan Plan;
    //    TestPlanRun PlanRun;
    //    public event PropertyChangedEventHandler PropertyChanged;
    //    public event EventHandler<UIMessageEvent> UIMessageEvent;
    //    public event EventHandler<ExecutionEndEvent> EndExecutionEvent;
    //    public string Name { get; set; }
    //    public bool IsConnected => true;
    //    public object Init()
    //    {
    //        RegUtil.Register(new Net47Getter());
    //        StationGlobalsManager.Register(new DefaultStationGlobalsManager());
    //        VariableResolverFactory.Register(new DefaultVariableResolverFactory());
    //        ResultSettings.Current.Add(this);
    //        return null;
    //    }
    //    private void PostUIMsgHandler_OnPostUIMsg(object sender, PostUIMsg e)
    //    {
    //        UIMessageEvent?.Invoke(this, new UIMessageEvent()
    //        {
    //            Origin = e,
    //            EventCode = e.EventCode,
    //            BoolData = e.BoolData,
    //            NumberData = e.NumberData,
    //            StrData = e.StringData
    //        });
    //    }
    //    public object StartSequence(string filePath, string modelPath)
    //    {
    //        Plan = TestPlan.Load(filePath);
    //        testPlanThread = TapThread.Start(() =>
    //        {
    //            var res = ResultSettings.Current;
    //            Plan.Execute(ResultSettings.Current, null);
    //        });
    //        return Plan;
    //    }
    //    public T GetFileGlobalValue<T>(string key, int option)
    //    {
    //        return PlanRun.Resolve<T>(null, $"FileGlobals.{key}");
    //    }
    //    public T GetStationGlobalValue<T>(string key, int option)
    //    {
    //        if (PlanRun == null)
    //        {
    //            return StationGlobalsManager.GetValue<T>(key);
    //        }
    //        return PlanRun.Resolve<T>(null, $"StationGlobals.{key}");
    //    }
    //    public T GetValue<T>(string key, int option)
    //    {
    //        return default(T);
    //    }
    //    public void SetValue<T>(string key, T value, int option)
    //    {
    //    }
    //    public void SetFileGlobalValue<T>(string key, int option, T value)
    //    {
    //        PlanRun.Update(null, $"FileGlobals.{key}", value);
    //    }
    //    public void SetStationGlobalValue<T>(string key, int option, T value, bool inContext = false)
    //    {
    //        if (PlanRun == null)
    //        {
    //            StationGlobalsManager.SetValue(key, value);
    //        }
    //        else
    //        {
    //            PlanRun.Update(null, $"StationGlobals.{key}", value);
    //        }
    //    }
    //    public void CommitToDisk(bool option)
    //    {
    //        StationGlobalsManager.WriteToLocal(null);
    //    }
    //    public void OnTestPlanRunStart(TestPlanRun planRun)
    //    {
    //        PlanRun = planRun;
    //    }
    //    public void OnTestPlanRunCompleted(TestPlanRun planRun, Stream logStream)
    //    {
    //    }
    //    public void OnTestStepRunStart(TestStepRun stepRun)
    //    {
    //    }
    //    public void OnTestStepRunCompleted(TestStepRun stepRun)
    //    {
    //    }
    //    public void OnResultPublished(Guid stepRunID, ResultTable result)
    //    {
    //    }
    //    public void Open()
    //    {
    //        PostUIMsgHandler.OnPostUIMsg += PostUIMsgHandler_OnPostUIMsg;
    //    }
    //    public void Close()
    //    {
    //        PostUIMsgHandler.OnPostUIMsg -= PostUIMsgHandler_OnPostUIMsg;
    //        EndExecutionEvent?.Invoke(this, new ExecutionEndEvent());
    //        PlanRun = null;
    //    }
    //    public bool WaitForExit()
    //    {
    //        return true;
    //    }
    //    public bool ReleaseSeqFile()
    //    {
    //        return true;
    //    }
    //    public void SetContext(object context)
    //    {
    //    }
    //}
}
Plugin.TsSupport/TestAppManager.cs
@@ -17,5 +17,17 @@
                return instance;
            }
        }
        public static void Dispose()
        {
            lock (lockObj)
            {
                if (instance != null)
                {
                    instance.Dispose();
                    instance = null;
                }
            }
        }
    }
}
Plugin.TsSupport/TeststandWrapper.cs
@@ -82,12 +82,17 @@
            return this.AxApplicationMgr;
        }
        private SequenceFile GetFile(string path)
        {
            return null;
        }
        public object StartSequence(string filePath, string modelPath)
        {
            this.testSeqFile = filePath;
            this.modeSeq = this.engine.GetSequenceFileEx(modelPath);
            this.testSeq = this.engine.GetSequenceFileEx(this.testSeqFile, 2, TypeConflictHandlerTypes.ConflictHandler_UseGlobalType);
            this.testSeq = this.engine.GetSequenceFileEx(this.testSeqFile, 107, TypeConflictHandlerTypes.ConflictHandler_UseGlobalType);
            this.execution = this.engine.NewExecution(this.testSeq, "Test UUTs", this.modeSeq, false, 0, null, null, null);
            return execution;
        }
@@ -232,5 +237,12 @@
        {
            this.context = (SequenceContext)context;
        }
        public void Dispose()
        {
            AxApplicationMgr = null;
            engine = null;
            TestAppManager.Dispose();
        }
    }
}
Plugin.TsSupport/TsExecutionWrapper.cs
File was deleted
Plugin.UICollection/Plugin.UICollection.csproj
@@ -17,8 +17,8 @@
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\UILib\UILib.csproj" />
    <ProjectReference Include="..\..\UtilLib\UtilLib.csproj" />
    <ProjectReference Include="..\..\..\VisualStudio\UILib\UILib.csproj" />
    <ProjectReference Include="..\..\..\VisualStudio\UtilLib\UtilLib.csproj" />
    <ProjectReference Include="..\HMI.UI\HMI.UI.csproj" />
  </ItemGroup>
README.md
New file
@@ -0,0 +1,4 @@
## TestPlatfoming
平台测试程序
SimpleUtil/HMI.LogUtil/HMI.LogUtil.csproj
@@ -52,7 +52,7 @@
    <Reference Include="System" />
    <Reference Include="System.Core" />
    <Reference Include="System.Data.SQLite">
      <HintPath>..\..\..\HMI.Old\Plugin_OPCServer\Libs\System.Data.SQLite.dll</HintPath>
      <HintPath>..\..\Libs\System.Data.SQLite.dll</HintPath>
    </Reference>
    <Reference Include="System.Xml.Linq" />
    <Reference Include="System.Data.DataSetExtensions" />
Util.ManualDebug/ManualCell.xaml
New file
@@ -0,0 +1,33 @@
<UserControl x:Class="Util.ManualDebug.ManualCell"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:local="clr-namespace:Util.ManualDebug"
        xmlns:pu="https://opensource.panuon.com/wpf-ui"
             mc:Ignorable="d" Padding="5" MaxHeight="80"
             d:DesignHeight="450" d:DesignWidth="800">
    <UserControl.Style>
        <Style TargetType="UserControl">
            <Style.Triggers>
                <DataTrigger Binding="{Binding Selected}" Value="True">
                    <Setter Property="Background" Value="{DynamicResource SuccessColor}"/>
                    <Setter Property="Foreground" Value="#FFF"/>
                </DataTrigger>
            </Style.Triggers>
        </Style>
    </UserControl.Style>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition/>
        </Grid.RowDefinitions>
        <TextBlock FontSize="20" FontWeight="Bold" Text="{Binding Title}"/>
        <pu:Switch Grid.Row="1" FontSize="18" BoxHeight="20" Cursor="Hand"
        Content="关"
        CheckedBackground="Green"
        Background="Red"
        CheckedContent="开"
        IsChecked="{Binding Selected,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}"/>
    </Grid>
</UserControl>
Util.ManualDebug/ManualCell.xaml.cs
New file
@@ -0,0 +1,66 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Controls;
namespace Util.ManualDebug
{
    /// <summary>
    /// ManualCell.xaml 的交互逻辑
    /// </summary>
    public partial class ManualCell : UserControl, INotifyPropertyChanged
    {
        #region ...
        public event PropertyChangedEventHandler PropertyChanged;
        protected void Set([CallerMemberName] string name = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
        }
        protected void Set<T>(ref T old, T @new, [CallerMemberName] string propertyName = null)
        {
            old = @new;
            if (propertyName != null)
            {
                Set(propertyName);
            }
        }
        #endregion
        private string title;
        public string Title
        {
            get => title;
            set => Set(ref title, value);
        }
        private bool selected;
        public bool Selected
        {
            get => selected;
            set
            {
                if (selected != value)
                {
                    Set(ref selected, value);
                    SelectedChanged?.Invoke(this, new EventArgs());
                }
            }
        }
        private int index;
        public int Index
        {
            get => index; set => Set(ref index, value);
        }
        public event EventHandler SelectedChanged;
        public ManualCell()
        {
            this.DataContext = this;
            InitializeComponent();
        }
    }
}
Util.ManualDebug/ManualDebug.xaml
@@ -1,9 +1,10 @@
<UserControl x:Class="Util.SB.ManualDebug"
<UserControl x:Class="Util.ManualDebug.ManualDebug"
        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:local="clr-namespace:Util.SB"
        xmlns:local="clr-namespace:Util.ManualDebug"
        xmlns:dp="clr-namespace:HMI.UI.DpUtil;assembly=HMI.UI"
        mc:Ignorable="d"
        x:Name="self"
        MinHeight="400" MinWidth="500">
@@ -17,104 +18,149 @@
    </UserControl.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>
        <Grid Margin="10">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto"/>
                <RowDefinition Height="5"/>
                <RowDefinition Height="*"/>
                <RowDefinition Height="5"/>
                <RowDefinition Height="*"/>
            </Grid.RowDefinitions>
            <WindowsFormsHost x:Name="host" Visibility="Collapsed"></WindowsFormsHost>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto"/>
                <ColumnDefinition/>
            </Grid.ColumnDefinitions>
            <!-- 按钮和状态区域 -->
            <Border Grid.Row="0" Background="{StaticResource TsPanelBrush}"
            <Grid x:Name="CellSelectBorder">
                <Grid.RowDefinitions>
                    <RowDefinition/>
                    <RowDefinition/>
                </Grid.RowDefinitions>
                <Border Background="{StaticResource TsPanelBrush}" Margin="0,0,10,5"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="4" Padding="15">
                    <StackPanel>
                        <TextBlock Text="穴位选择" FontSize="14" FontWeight="SemiBold"
           Foreground="{StaticResource PrimaryBrush}" Margin="0,0,0,15"/>
                        <UniformGrid IsEnabled="False" x:Name="CellGrid" dp:UniformGridHelper.InnerBorder="1" Columns="1"
 dp:UniformGridHelper.InnerBorderBrush="{StaticResource BorderBrush}">
                        </UniformGrid>
                    </StackPanel>
                </Border>
                <Border Grid.Row="1" Background="{StaticResource TsPanelBrush}" Margin="0,5,10,0"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="4" Padding="15">
                    <StackPanel>
                        <TextBlock Text="穴位测试结果" FontSize="14" FontWeight="SemiBold"
           Foreground="{StaticResource PrimaryBrush}" Margin="0,0,0,15"/>
                        <UniformGrid IsEnabled="False" x:Name="CellResultGrid" Columns="1" dp:UniformGridHelper.InnerBorder="1"
 dp:UniformGridHelper.InnerBorderBrush="{StaticResource BorderBrush}">
                        </UniformGrid>
                    </StackPanel>
                </Border>
            </Grid>
            <Grid Grid.Column="1">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto"/>
                    <RowDefinition Height="5"/>
                    <RowDefinition Height="*"/>
                    <RowDefinition Height="5"/>
                    <RowDefinition Height="*"/>
                </Grid.RowDefinitions>
                <WindowsFormsHost x:Name="host" Visibility="Collapsed"></WindowsFormsHost>
                <!-- 按钮和状态区域 -->
                <Border Grid.Row="0" Background="{StaticResource TsPanelBrush}"
                BorderBrush="{StaticResource BorderBrush}" 
                BorderThickness="1" CornerRadius="4" Padding="15">
                <StackPanel>
                    <TextBlock Text="手动调试控制" FontSize="14" FontWeight="SemiBold"
                    <StackPanel>
                        <TextBlock Text="手动调试控制" FontSize="14" FontWeight="SemiBold"
                           Foreground="{StaticResource PrimaryBrush}" Margin="0,0,0,15"/>
                    <WrapPanel x:Name="btnPanel">
                        <WrapPanel x:Name="btnPanel">
                    </WrapPanel>
                </StackPanel>
            </Border>
                        </WrapPanel>
                    </StackPanel>
                </Border>
            <!-- 分隔条1 -->
            <GridSplitter Grid.Row="1" Height="5" HorizontalAlignment="Stretch"
                <!-- 分隔条1 -->
                <GridSplitter Grid.Row="1" Height="5" HorizontalAlignment="Stretch"
                      Background="Transparent" Cursor="SizeNS"/>
            <!-- TX/RX 数据区域 -->
            <Border Grid.Row="2" Background="{StaticResource TsPanelBrush}"
                <!-- TX/RX 数据区域 -->
                <Border Grid.Row="2" Background="{StaticResource TsPanelBrush}"
                BorderBrush="{StaticResource BorderBrush}" 
                BorderThickness="1" CornerRadius="4" Padding="10">
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinition Height="Auto"/>
                        <RowDefinition Height="*"/>
                    </Grid.RowDefinitions>
                    <Grid>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="Auto"/>
                            <RowDefinition Height="*"/>
                        </Grid.RowDefinitions>
                    <StackPanel Orientation="Horizontal" Grid.Row="0" Margin="0,0,0,10">
                        <TextBlock Text="测试数据" FontSize="14" FontWeight="SemiBold"
                        <StackPanel Orientation="Horizontal" Grid.Row="0" Margin="0,0,0,10">
                            <TextBlock Text="测试数据" FontSize="14" FontWeight="SemiBold"
                               Foreground="{StaticResource PrimaryBrush}" VerticalAlignment="Center"/>
                        <Button Content="清空" Style="{StaticResource ActionButtonStyle}"
                            <Button Content="清空" Style="{StaticResource ActionButtonStyle}"
                            Margin="15,0,0,0" Click="OnClearClick"/>
                    </StackPanel>
                        </StackPanel>
                    <DataGrid x:Name="DataGridComm" Grid.Row="1"
                        <DataGrid x:Name="DataGridComm" Grid.Row="1"
                          ItemsSource="{Binding TestData,ElementName=self}">
                        <DataGrid.Columns>
                            <DataGridTextColumn Header="测试项" Binding="{Binding Name}" Width="120" IsReadOnly="True"/>
                            <DataGridTextColumn Header="收" Binding="{Binding Receive}" Width="*" IsReadOnly="True"/>
                            <DataGridTextColumn Header="发" Binding="{Binding Send}" Width="*" IsReadOnly="True"/>
                            <DataGridTextColumn Header="单位" Binding="{Binding Unit}" Width="60" IsReadOnly="True"/>
                            <DataGridTextColumn Header="上限" Binding="{Binding UpperLimit}" Width="80" IsReadOnly="True"/>
                            <DataGridTextColumn Header="下限" Binding="{Binding LowerLimit}" Width="80" IsReadOnly="True"/>
                            <DataGridTextColumn Header="结果" Binding="{Binding Result}" Width="70" IsReadOnly="True"/>
                            <DataGridTextColumn Header="时间" Binding="{Binding Time}" Width="90" IsReadOnly="True"/>
                        </DataGrid.Columns>
                        <DataGrid.RowStyle>
                            <Style TargetType="DataGridRow">
                                <Style.Triggers>
                                    <DataTrigger Value="True">
                                        <DataTrigger.Binding>
                                            <MultiBinding Converter="{x:Static local:ResultFailedConverter.Instance}">
                                                <Binding Path="Result"/>
                                            </MultiBinding>
                                        </DataTrigger.Binding>
                                        <Setter Property="Background" Value="#FFE0E0"/>
                                        <Setter Property="Foreground" Value="#C53030"/>
                                    </DataTrigger>
                                </Style.Triggers>
                            </Style>
                        </DataGrid.RowStyle>
                    </DataGrid>
                </Grid>
            </Border>
                            <DataGrid.Columns>
                                <DataGridTextColumn Header="测试项" Binding="{Binding Name}" Width="120" IsReadOnly="True"/>
                                <DataGridTextColumn Header="收" Binding="{Binding Receive}" Width="*" IsReadOnly="True"/>
                                <DataGridTextColumn Header="发" Binding="{Binding Send}" Width="*" IsReadOnly="True"/>
                                <DataGridTextColumn Header="单位" Binding="{Binding Unit}" Width="60" IsReadOnly="True"/>
                                <DataGridTextColumn Header="上限" Binding="{Binding UpperLimit}" Width="80" IsReadOnly="True"/>
                                <DataGridTextColumn Header="下限" Binding="{Binding LowerLimit}" Width="80" IsReadOnly="True"/>
                                <DataGridTextColumn Header="结果" Binding="{Binding Result}" Width="70" IsReadOnly="True"/>
                                <DataGridTextColumn Header="时间" Binding="{Binding Time}" Width="90" IsReadOnly="True"/>
                            </DataGrid.Columns>
                            <DataGrid.RowStyle>
                                <Style TargetType="DataGridRow">
                                    <Style.Triggers>
                                        <DataTrigger Value="True">
                                            <DataTrigger.Binding>
                                                <MultiBinding Converter="{x:Static local:ResultFailedConverter.Instance}">
                                                    <Binding Path="Result"/>
                                                </MultiBinding>
                                            </DataTrigger.Binding>
                                            <Setter Property="Background" Value="#FFE0E0"/>
                                            <Setter Property="Foreground" Value="#C53030"/>
                                        </DataTrigger>
                                    </Style.Triggers>
                                </Style>
                            </DataGrid.RowStyle>
                        </DataGrid>
                    </Grid>
                </Border>
            <!-- 分隔条2 -->
            <GridSplitter Grid.Row="3" Height="5" HorizontalAlignment="Stretch"
                <!-- 分隔条2 -->
                <GridSplitter Grid.Row="3" Height="5" HorizontalAlignment="Stretch"
                      Background="Transparent" Cursor="SizeNS"/>
            <!-- 日志区域 -->
            <Border Grid.Row="4" Background="{StaticResource TsPanelBrush}"
                <!-- 日志区域 -->
                <Border Grid.Row="4" Background="{StaticResource TsPanelBrush}"
                BorderBrush="{StaticResource BorderBrush}" 
                BorderThickness="1" CornerRadius="4" Padding="10">
                <Grid>
                    <Grid.RowDefinitions>
                        <RowDefinition Height="Auto"/>
                        <RowDefinition Height="*"/>
                    </Grid.RowDefinitions>
                    <Grid>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="Auto"/>
                            <RowDefinition Height="*"/>
                        </Grid.RowDefinitions>
                    <StackPanel Orientation="Horizontal" Grid.Row="0" Margin="0,0,0,8">
                        <TextBlock Text="日志" FontSize="14" FontWeight="SemiBold"
                        <StackPanel Orientation="Horizontal" Grid.Row="0" Margin="0,0,0,8">
                            <TextBlock Text="日志" FontSize="14" FontWeight="SemiBold"
                               Foreground="{StaticResource PrimaryBrush}" VerticalAlignment="Center"/>
                        <Button Content="清空日志" Style="{StaticResource ActionButtonStyle}"
                            <Button Content="清空日志" Style="{StaticResource ActionButtonStyle}"
                            Margin="15,0,0,0" Click="OnClearLogClick"/>
                    </StackPanel>
                        </StackPanel>
                    <RichTextBox x:Name="LogRichTextBox" Grid.Row="1"
                        <RichTextBox x:Name="LogRichTextBox" Grid.Row="1"
                             IsReadOnly="True"
                             VerticalScrollBarVisibility="Auto"
                             FontFamily="Consolas"
@@ -122,12 +168,45 @@
                             BorderThickness="1"
                             BorderBrush="Black" 
                             Padding="4"/>
                </Grid>
                    </Grid>
                </Border>
            </Grid>
            <Border Background="#AAFFFFFF" Grid.ColumnSpan="2" x:Name="mask">
                <TextBlock Text="初始化中..."
                       FontSize="16" FontWeight="Bold" Foreground="{StaticResource PrimaryBrush}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
            </Border>
        </Grid>
        <Border Background="#AAFFFFFF" x:Name="mask">
            <TextBlock Text="初始化中..."
                       FontSize="16" FontWeight="Bold" Foreground="{StaticResource PrimaryBrush}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
        </Border>
        <StatusBar FontSize="14" Grid.Row="1">
            <StatusBar.ItemsPanel>
                <ItemsPanelTemplate>
                    <DockPanel LastChildFill="False"/>
                </ItemsPanelTemplate>
            </StatusBar.ItemsPanel>
            <StatusBarItem DockPanel.Dock="Left">
                <StackPanel Orientation="Horizontal" dp:StackPanelHelper.ChildSpace="8">
                    <StackPanel Orientation="Horizontal">
                        <Ellipse Width="10" Height="10">
                            <Ellipse.Style>
                                <Style TargetType="Ellipse">
                                    <Setter Property="Fill" Value="LightGray"/>
                                    <Style.Triggers>
                                        <DataTrigger Binding="{Binding TcpServerStatus,ElementName=self}" Value="ERROR">
                                            <Setter Property="Fill" Value="Red"/>
                                        </DataTrigger>
                                        <DataTrigger Binding="{Binding TcpServerStatus,ElementName=self}" Value="RUNNING">
                                            <Setter Property="Fill" Value="Green"/>
                                        </DataTrigger>
                                    </Style.Triggers>
                                </Style>
                            </Ellipse.Style>
                        </Ellipse>
                        <TextBlock Text="{Binding TcpServerInfo,ElementName=self}"/>
                        <Button Padding="0" Margin="0" BorderThickness="0" Click="Button_Click">🔄</Button>
                    </StackPanel>
                </StackPanel>
            </StatusBarItem>
        </StatusBar>
    </Grid>
</UserControl>
Util.ManualDebug/ManualDebug.xaml.cs
@@ -1,14 +1,22 @@
using HMI.Core.Util;
using HMI.UI.DpUtil;
using NationalInstruments.TestStand.Interop.API;
using Plugin.TsSupport;
using SuperSocket.SocketBase.Config;
using System.Collections;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Configuration;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Media;
using Util.ManualDebug.Model;
namespace Util.SB
namespace Util.ManualDebug
{
    public class BtnConfig
    {
@@ -45,9 +53,27 @@
        Warn = 2,
        Error = 3
    }
    public partial class ManualDebug : UserControl
    public partial class ManualDebug : UserControl, INotifyPropertyChanged
    {
        #region ...
        public event PropertyChangedEventHandler PropertyChanged;
        protected void Set([CallerMemberName] string name = null)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
        }
        protected void Set<T>(ref T old, T @new, [CallerMemberName] string propertyName = null)
        {
            old = @new;
            if (propertyName != null)
            {
                Set(propertyName);
            }
        }
        #endregion
        public static readonly DependencyProperty OvenNameProperty = DependencyProperty.Register(
                            "OvenName",
                            typeof(string),
@@ -68,6 +94,12 @@
        public static readonly DependencyProperty CellIndexProperty = DependencyProperty.Register(
                            "CellIndex",
                            typeof(int),
                            typeof(ManualDebug),
                            new PropertyMetadata(-1));
        public static readonly DependencyProperty CellCountProperty = DependencyProperty.Register(
                            "CellCount",
                            typeof(int),
                            typeof(ManualDebug),
                            new PropertyMetadata(0));
@@ -99,6 +131,12 @@
            set { SetValue(CellIndexProperty, value); }
        }
        public int CellCount
        {
            get { return (int)GetValue(CellCountProperty); }
            set { SetValue(CellCountProperty, value); }
        }
        public ObservableCollection<TestDataItem> TestData { get; private set; } = new ObservableCollection<TestDataItem>();
        private Dictionary<string, StatusBtn> btnCache = new Dictionary<string, StatusBtn>();
@@ -111,8 +149,30 @@
        private ITestExecutionWrapper executionWrapper;
        private TcpServer tcpServer;
        public string seqPath { get; set; }
        public string modelPath { get; set; } = "C:\\Program Files\\National Instruments\\TestStand 2020\\Components\\Models\\TestStandModels\\ParallelModel.seq";
        private string tcpServerStatus;
        public string TcpServerStatus
        {
            get => tcpServerStatus;
            set
            {
                Set(ref tcpServerStatus, value);
            }
        }
        private string tcpServerInfo;
        public string TcpServerInfo
        {
            get => tcpServerInfo;
            set
            {
                Set(ref tcpServerInfo, value);
            }
        }
        public ManualDebug()
        {
@@ -125,8 +185,61 @@
            RefreshButtons(null);
        }
        public void HideCells(bool flag)
        {
            CellSelectBorder.Visibility = flag ? Visibility.Collapsed : Visibility.Visible;
        }
        private List<ManualCell> Cells = new List<ManualCell>();
        private List<StatusCell> StatusCells = new List<StatusCell>();
        public void RefreshGrid(CellConfig[] cells)
        {
            if (cells != null)
            {
                int i = 0;
                foreach (var cell in cells)
                {
                    var mCell = new ManualCell
                    {
                        Title = cell.Name,
                        Index = cell.Index
                    };
                    mCell.SelectedChanged += MCell_SelectedChanged;
                    Cells.Add(mCell);
                    CellGrid.Children.Add(mCell);
                    var sCell = new StatusCell
                    {
                        Title = cell.Name,
                        Index = cell.Index
                    };
                    StatusCells.Add(sCell);
                    CellResultGrid.Children.Add(sCell);
                    BindingOperations.SetBinding(sCell, StatusCell.StatusProperty, new Binding(nameof(ManualCell.Selected))
                    {
                        Source = mCell,
                        Mode = BindingMode.OneWay,
                        //UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged,
                        Converter = CellStatusConverter.Instance
                    });
                }
                UniformGridHelper.RefreshGridInnerBorder(CellGrid);
                UniformGridHelper.RefreshGridInnerBorder(CellResultGrid);
            }
        }
        private void MCell_SelectedChanged(object sender, EventArgs e)
        {
            object[] arr = Cells.Select(c => (object)c.Selected).ToArray();
            SetArray($"Running.CellIndex", PropertyValueTypes.PropValType_Boolean, arr);
        }
        private void ManualDebug_Loaded(object sender, RoutedEventArgs e)
        {
            StartTcpServer();
            Task.Run(() =>
            {
                System.Threading.Thread.Sleep(500);
@@ -139,11 +252,117 @@
            });
        }
        private int GetConfigPort()
        {
            try
            {
                var customConfig = (Hashtable)ConfigurationManager.GetSection("TestResultConfig");
                if (customConfig.ContainsKey("PortStart"))
                {
                    int port = int.Parse(customConfig["PortStart"].ToString()) - 1;
                    return port;
                }
                else
                {
                    return 6659;
                }
            }
            catch (Exception ex)
            {
                AppendLog(LogLevel.Error, $"读取起始端口错误: {ex.Message} {ex.StackTrace} 使用默认端口6659");
                return 6659;
            }
        }
        /// <summary>
        /// 启动Tcp服务器
        /// </summary>
        private void StartTcpServer()
        {
            int port = GetConfigPort();
            var serverConfig = new ServerConfig()
            {
                MaxRequestLength = 1024 * 1024,
                Port = port
            };
            try
            {
                if (tcpServer == null)
                {
                    tcpServer = new TcpServer();
                    tcpServer.NewRequestReceived += TcpServer_NewRequestReceived;
                    if (!tcpServer.Setup(serverConfig))
                    {
                        ChangeTcpServerStatus("ERROR", this.lang("Tr.TcpServer.SettingFailed"));
                        return;
                    }
                    if (!tcpServer.Start())
                    {
                        ChangeTcpServerStatus("ERROR", this.lang("Tr.TcpServer.StartFailed"));
                        return;
                    }
                    ChangeTcpServerStatus("RUNNING", this.lang("Tr.TcpServer.Running"));
                    AppendLog(LogLevel.Debug, $"数据接收服务器启动,端口:{port}");
                }
                else if (tcpServer.State != SuperSocket.SocketBase.ServerState.Running)
                {
                    if (!tcpServer.Setup(serverConfig))
                    {
                        ChangeTcpServerStatus("ERROR", this.lang("Tr.TcpServer.SettingFailed"));
                        return;
                    }
                    if (!tcpServer.Start())
                    {
                        ChangeTcpServerStatus("ERROR", this.lang("Tr.TcpServer.StartFailed"));
                        return;
                    }
                    ChangeTcpServerStatus("RUNNING", this.lang("Tr.TcpServer.Running"));
                    AppendLog(LogLevel.Debug, $"数据接收服务器启动,端口:{port}");
                }
            }
            catch (Exception ex)
            {
                ChangeTcpServerStatus("ERROR", $"{ex.Message}");
                AppendLog(LogLevel.Error, $"数据接收服务器启动错误: {ex.Message} {ex.StackTrace}");
                tcpServer?.Stop();
                tcpServer = null;
            }
        }
        /// <summary>
        /// 更新Tcp服务器状态
        /// </summary>
        /// <param name="status"></param>
        /// <param name="info"></param>
        private void ChangeTcpServerStatus(string status, string info)
        {
            Dispatcher.Invoke(() =>
            {
                TcpServerStatus = status;
                TcpServerInfo = info;
            });
        }
        private void TcpServer_NewRequestReceived(TcpSession session, SuperSocket.SocketBase.Protocol.StringRequestInfo requestInfo)
        {
            string msg = requestInfo.Body;
            string[] datas_10015 = msg.Split(';');
            //int cellIndex = int.Parse(datas_10015[1]);
            string strs = datas_10015[2];
            string[] arr = strs.Split(',');
            Dispatcher.Invoke(() =>
            {
                AddTestData(arr[0], arr[1], arr[2], arr[3], arr[4], arr[5], arr[6], arr[7]);
            });
        }
        public void RefreshButtons(List<BtnConfig> settings)
        {
            if (settings == null) return;
            for (int i = 0; i < settings.Count; i++) {
            for (int i = 0; i < settings.Count; i++)
            {
                var setting = settings[i];
                var margin = new Thickness(0, 0, 20, 0);
@@ -196,6 +415,35 @@
            }
        }
        private void SetArray(string name, PropertyValueTypes type, object[] arr)
        {
            try
            {
                var context = ((TeststandWrapper)executionWrapper).context;
                context.FileGlobals.DeleteSubProperty(name, 2);
                context.FileGlobals.NewSubProperty(name, type, true, null, 1);
                context.FileGlobals.GetPropertyObject(name, 0).SetNumElements(arr.Length);
                for (int i = 0; i < arr.Length; i++)
                {
                    if (type == PropertyValueTypes.PropValType_Boolean)
                    {
                        context.FileGlobals.SetValBoolean($"{name}[{i}]", 0, arr[i] == null ? false : (bool)arr[i]);
                    }
                    else
                    {
                        context.FileGlobals.SetValString($"{name}[{i}]", 0, arr[i]?.ToString());
                    }
                    // (type == PropertyValueTypes.PropValType_String)
                }
            }
            catch (Exception ex)
            {
                AppendLog(LogLevel.Error, $"写数组 {name} 错误: {ex.Message} {ex.StackTrace}");
            }
        }
        private void Wrapper_UIMessageEvent(object sender, UIMessageEvent e)
        {
            try
@@ -207,15 +455,35 @@
                    string key = $"Running.OvenName";
                    executionWrapper?.SetFileGlobalValue(key, 1, OvenName);
                    key = $"Running.CellName";
                    executionWrapper?.SetFileGlobalValue(key, 1, CellName);
                    key = $"Running.OvenIndex";
                    executionWrapper?.SetFileGlobalValue(key, 1, OvenIndex);
                    key = $"Running.CellIndex";
                    executionWrapper?.SetFileGlobalValue(key, 1, CellIndex);
                    //key = $"Running.CellIndex";
                    //executionWrapper?.SetFileGlobalValue(key, 1, CellIndex);
                    if (CellIndex >= 0)
                    {
                        key = $"Running.CellIndex";
                        object[] arr = new object[CellCount];
                        arr[CellIndex] = true;
                        SetArray(key, PropertyValueTypes.PropValType_Boolean, arr);
                        key = $"Running.CellName";
                        string[] names = new string[CellCount];
                        names[CellIndex] = CellName;
                        SetArray(key, PropertyValueTypes.PropValType_String, names);
                    }
                    else
                    {
                        string[] names = Cells.Select(c => c.Title).ToArray();
                        SetArray($"Running.CellName", PropertyValueTypes.PropValType_String, names);
                    }
                    Dispatcher.Invoke(() =>
                    {
                        CellGrid.IsEnabled = true;
                        AppendLog(LogLevel.Debug, "Context初始化完成");
                    });
                    return;
                }
                if (e.EventCode == 10001)
@@ -247,14 +515,26 @@
                    });
                    return;
                }
                if (e.EventCode == 10004)
                {
                    int index = Convert.ToInt32(e.NumberData);
                    //CellStatus status = (CellStatus)Enum.Parse(typeof(CellStatus), e.StrData);
                    CellStatus status = (CellStatus)int.Parse(e.StrData);
                    Dispatcher.BeginInvoke(() =>
                    {
                        if (index < StatusCells.Count)
                        {
                            StatusCells[index].SetCurrentValue(StatusCell.StatusProperty, status);
                        }
                    });
                    return;
                }
            }
            catch (Exception ex)
            {
                AppendLog(LogLevel.Error, $"处理UI信息失败 {ex.Message} {ex.StackTrace}");
            }
        }
        private void Btn_BtnClick(object sender, RoutedEventArgs e)
        {
@@ -391,12 +671,19 @@
            {
                executionWrapper?.SetFileGlobalValue("Running.StopTest", 1, true);
                executionWrapper?.WaitForExit();
                executionWrapper?.Dispose();
                tcpServer?.Stop();
            }
            catch (Exception ex)
            {
            }
        }
        private void Button_Click(object sender, RoutedEventArgs e)
        {
            StartTcpServer();
        }
    }
    public class TestDataItem : INotifyPropertyChanged
Util.ManualDebug/ManualDebugWindow.xaml
New file
@@ -0,0 +1,23 @@
<pu:WindowX x:Class="Util.ManualDebug.ManualDebugWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        mc:Ignorable="d"
        Title="ManualDebugWindow"
        xmlns:pu="https://opensource.panuon.com/wpf-ui"
        xmlns:local="clr-namespace:Util.ManualDebug"
        Style="{StaticResource ModalWindow}"
        Closing="Window_Closing"
        WindowState="Maximized"
        WindowStartupLocation="CenterScreen"
        pu:WindowXModalDialog.Buttons="{x:Null}"
        Height="1000" Width="900">
    <Grid x:Name="sb">
        <local:ManualDebug OvenName="{Binding OvenName,Mode=OneWay}"
                           CellCount="{Binding CellCount,Mode=OneWay}"
    CellName="{Binding CellName,Mode=OneWay}"
    OvenIndex="{Binding OvenIndex,Mode=OneWay}"
    CellIndex="{Binding CellIndex,Mode=OneWay}"/>
    </Grid>
</pu:WindowX>
Util.ManualDebug/ManualDebugWindow.xaml.cs
New file
@@ -0,0 +1,114 @@
using HMI.Util;
using Panuon.WPF.UI;
using System.ComponentModel;
using System.IO;
using System.Windows;
using Util.ManualDebug.Model;
namespace Util.ManualDebug
{
    /// <summary>
    /// ManualDebugWindow.xaml 的交互逻辑
    /// </summary>
    public partial class ManualDebugWindow : WindowX
    {
        private ManualDebug debug;
        public ManualDebugWindow(string seqPath, string modelPath, bool isMuilt) : this(seqPath, modelPath, isMuilt, null, 1)
        {
        }
        public ManualDebugWindow(string seqPath, string modelPath, bool isMuilt, CellConfig[] cells, int col)
        {
            DataContext = this;
            InitializeComponent();
            debug = (ManualDebug)sb.Children[0];
            ReadBtns();
            if (!isMuilt)
            {
                debug.HideCells(true);
            }
            //List<BtnConfig> settings = new List<BtnConfig> { new BtnConfig { Title = "上电激活", Name = "Active" } };
            debug.seqPath = seqPath;
            debug.modelPath = modelPath;
            debug.CellGrid.Columns = col;
            debug.CellResultGrid.Columns = col;
            debug.RefreshGrid(cells);
        }
        private string ovenName;
        public string OvenName
        {
            get => ovenName;
            set
            {
                Set(ref ovenName, value);
            }
        }
        private string cellName;
        public string CellName
        {
            get => cellName;
            set
            {
                Set(ref cellName, value);
            }
        }
        private int ovenIndex;
        public int OvenIndex
        {
            get => ovenIndex;
            set
            {
                Set(ref ovenIndex, value);
            }
        }
        private int cellIndex = -1;
        public int CellIndex
        {
            get => cellIndex;
            set
            {
                Set(ref cellIndex, value);
            }
        }
        private int cellCount = 0;
        public int CellCount
        {
            get => cellCount;
            set
            {
                Set(ref cellCount, value);
            }
        }
        private void ReadBtns()
        {
            string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Settings.ini");
            var map = IniFileHelper.GetKeyValuesBySection(path, "Btns");
            List<BtnConfig> settings = new List<BtnConfig>();
            foreach (var kv in map)
            {
                settings.Add(new BtnConfig { Title = kv.Key, Name = kv.Value });
            }
            debug.RefreshButtons(settings);
        }
        private void Window_Closing(object sender, CancelEventArgs e)
        {
            if (MessageBox.Show("确定要关闭吗?", "提示", MessageBoxButton.OKCancel) == MessageBoxResult.OK)
            {
                debug.Stop();
            }
            else
            {
                e.Cancel = true;
            }
        }
    }
}
Diff truncated after the above file
Util.ManualDebug/Model/CellConfig.cs Util.ManualDebug/Model/TcpServer.cs Util.ManualDebug/OldManualDebug.xaml Util.ManualDebug/OldManualDebug.xaml.cs Util.ManualDebug/StatusBtn.xaml Util.ManualDebug/StatusBtn.xaml.cs Util.ManualDebug/StatusCell.xaml Util.ManualDebug/StatusCell.xaml.cs Util.ManualDebug/Util.ManualDebug.csproj WPFProject/App.config WPFProject/App.xaml WPFProject/App.xaml.cs WPFProject/BuildCmd.bat WPFProject/Config/Database/database.db WPFProject/PanuonWindow.xaml WPFProject/PanuonWindow.xaml.cs WPFProject/TapProject.csproj WPFProject/TapProject.sln WPFProject/packages.config check_parent.py debug_check.py docs/TapProject_代码结构.drawio docs/TapProject_代码结构_01.drawio.png docs/TapProject_代码结构_01_预览.png docs/TapProject_代码结构_02.drawio.png docs/TapProject_代码结构_02_预览.png docs/TapProject_代码结构分析.md fix_menu.py