| | |
| | | # Object files |
| | | *.o |
| | | *.ko |
| | | ## Ignore Visual Studio temporary files, build results, and |
| | | ## files generated by popular Visual Studio add-ons. |
| | | |
| | | # User-specific files |
| | | packages/ |
| | | |
| | | **/*.vsidx |
| | | *.suo |
| | | *.user |
| | | *.sln.docstates |
| | | *.csproj.user |
| | | |
| | | # Build results |
| | | .vs/ |
| | | [Dd]ebug/ |
| | | [Dd]ebugPublic/ |
| | | [Rr]elease/ |
| | | [Rr]eleases/ |
| | | x64/ |
| | | build/ |
| | | bld/ |
| | | [Bb]in/ |
| | | [Oo]bj/ |
| | | |
| | | # Roslyn cache directories |
| | | *.ide/ |
| | | |
| | | # MSTest test Results |
| | | [Tt]est[Rr]esult*/ |
| | | [Bb]uild[Ll]og.* |
| | | |
| | | #NUNIT |
| | | *.VisualState.xml |
| | | TestResult.xml |
| | | |
| | | # Build Results of an ATL Project |
| | | [Dd]ebugPS/ |
| | | [Rr]eleasePS/ |
| | | dlldata.c |
| | | |
| | | *_i.c |
| | | *_p.c |
| | | *_i.h |
| | | *.ilk |
| | | *.meta |
| | | *.obj |
| | | *.elf |
| | | *.pch |
| | | *.pdb |
| | | *.pgc |
| | | *.pgd |
| | | *.rsp |
| | | *.sbr |
| | | *.tlb |
| | | *.tli |
| | | *.tlh |
| | | *.tmp |
| | | *.tmp_proj |
| | | *.log |
| | | *.vspscc |
| | | *.vssscc |
| | | .builds |
| | | *.pidb |
| | | *.svclog |
| | | *.scc |
| | | |
| | | # Libraries |
| | | *.lib |
| | | *.a |
| | | # Chutzpah Test files |
| | | _Chutzpah* |
| | | |
| | | # Shared objects (inc. Windows DLLs) |
| | | *.dll |
| | | *.so |
| | | *.so.* |
| | | *.dylib |
| | | # Visual C++ cache files |
| | | ipch/ |
| | | *.aps |
| | | *.ncb |
| | | *.opensdf |
| | | *.sdf |
| | | *.cachefile |
| | | |
| | | # Executables |
| | | *.exe |
| | | *.out |
| | | *.app |
| | | *.i*86 |
| | | *.x86_64 |
| | | *.hex |
| | | # Visual Studio profiler |
| | | *.psess |
| | | *.vsp |
| | | *.vspx |
| | | |
| | | # TFS 2012 Local Workspace |
| | | $tf/ |
| | | |
| | | # Guidance Automation Toolkit |
| | | *.gpState |
| | | |
| | | # ReSharper is a .NET coding add-in |
| | | _ReSharper*/ |
| | | *.[Rr]e[Ss]harper |
| | | *.DotSettings.user |
| | | |
| | | # JustCode is a .NET coding addin-in |
| | | .JustCode |
| | | |
| | | # TeamCity is a build add-in |
| | | _TeamCity* |
| | | |
| | | # DotCover is a Code Coverage Tool |
| | | *.dotCover |
| | | |
| | | # NCrunch |
| | | _NCrunch_* |
| | | .*crunch*.local.xml |
| | | |
| | | # MightyMoose |
| | | *.mm.* |
| | | AutoTest.Net/ |
| | | |
| | | # Web workbench (sass) |
| | | .sass-cache/ |
| | | |
| | | # Installshield output folder |
| | | [Ee]xpress/ |
| | | |
| | | # DocProject is a documentation generator add-in |
| | | DocProject/buildhelp/ |
| | | DocProject/Help/*.HxT |
| | | DocProject/Help/*.HxC |
| | | DocProject/Help/*.hhc |
| | | DocProject/Help/*.hhk |
| | | DocProject/Help/*.hhp |
| | | DocProject/Help/Html2 |
| | | DocProject/Help/html |
| | | |
| | | # Click-Once directory |
| | | publish/ |
| | | |
| | | # Publish Web Output |
| | | *.[Pp]ublish.xml |
| | | *.azurePubxml |
| | | ## TODO: Comment the next line if you want to checkin your |
| | | ## web deploy settings but do note that will include unencrypted |
| | | ## passwords |
| | | *.pubxml |
| | | |
| | | # NuGet Packages |
| | | packages/* |
| | | *.nupkg |
| | | ## TODO: If the tool you use requires repositories.config |
| | | ## uncomment the next line |
| | | #!packages/repositories.config |
| | | |
| | | # Enable "build/" folder in the NuGet Packages folder since |
| | | # NuGet packages use it for MSBuild targets. |
| | | # This line needs to be after the ignore of the build folder |
| | | # (and the packages folder if the line above has been uncommented) |
| | | !packages/build/ |
| | | |
| | | # Windows Azure Build Output |
| | | csx/ |
| | | *.build.csdef |
| | | |
| | | # Windows Store app package directory |
| | | AppPackages/ |
| | | |
| | | # Others |
| | | sql/ |
| | | *.Cache |
| | | ClientBin/ |
| | | [Ss]tyle[Cc]op.* |
| | | ~$* |
| | | *~ |
| | | *.dbmdl |
| | | *.dbproj.schemaview |
| | | *.pfx |
| | | *.publishsettings |
| | | node_modules/ |
| | | |
| | | # RIA/Silverlight projects |
| | | Generated_Code/ |
| | | |
| | | # Backup & report files from converting an old project file |
| | | # to a newer Visual Studio version. Backup files are not needed, |
| | | # because we have git ;-) |
| | | _UpgradeReport_Files/ |
| | | Backup*/ |
| | | UpgradeLog*.XML |
| | | UpgradeLog*.htm |
| | | |
| | | # SQL Server files |
| | | *.mdf |
| | | *.ldf |
| | | |
| | | # Business Intelligence projects |
| | | *.rdl.data |
| | | *.bim.layout |
| | | *.bim_*.settings |
| | | |
| | | # Microsoft Fakes |
| | | FakesAssemblies/ |
| New file |
| | |
| | | namespace DeviceService.Core |
| | | { |
| | | public interface ICanDriver |
| | | { |
| | | bool Open(out string msg); |
| | | bool Close(out string msg); |
| | | |
| | | bool Send(string data, out string msg); |
| | | bool Read(out string data, out string msg); |
| | | } |
| | | } |
| New file |
| | |
| | | using System; |
| | | using System.Collections.Generic; |
| | | using System.Linq; |
| | | using System.Text; |
| | | using System.Threading.Tasks; |
| | | |
| | | namespace DeviceService.Core.Api |
| | | { |
| | | public interface IModbusDriver |
| | | { |
| | | bool Open(out string msg); |
| | | bool Close(out string msg); |
| | | |
| | | bool ReadBytes(string address, int length, out byte[] data, out string msg); |
| | | bool WriteBytes(string address, byte[] data, out string msg); |
| | | } |
| | | } |
| New file |
| | |
| | | namespace DeviceService.Core.Api |
| | | { |
| | | public interface IPLCDriver |
| | | { |
| | | bool Open(out string msg); |
| | | bool Close(out string msg); |
| | | |
| | | bool ReadBytes(string address, int length, out byte[] data, out string msg); |
| | | bool WriteBytes(string address, byte[] data, out string msg); |
| | | } |
| | | } |
| New file |
| | |
| | | using System; |
| | | |
| | | namespace DeviceService.Core.Api |
| | | { |
| | | public interface IServiceLogger |
| | | { |
| | | void Debug(string source, string message); |
| | | void Info(string source, string message); |
| | | void Warn(string source, string message, Exception ex = null); |
| | | void Error(string source, string message, Exception ex = null); |
| | | } |
| | | } |
| New file |
| | |
| | | <?xml version="1.0" encoding="utf-8"?> |
| | | <Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> |
| | | <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> |
| | | <PropertyGroup> |
| | | <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> |
| | | <Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform> |
| | | <ProjectGuid>{0ABE4877-BDEB-4C95-9C85-1ADDE3B641C4}</ProjectGuid> |
| | | <OutputType>Library</OutputType> |
| | | <AppDesignerFolder>Properties</AppDesignerFolder> |
| | | <RootNamespace>DeviceService.Core</RootNamespace> |
| | | <AssemblyName>DeviceService.Core</AssemblyName> |
| | | <TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion> |
| | | <FileAlignment>512</FileAlignment> |
| | | <Deterministic>true</Deterministic> |
| | | </PropertyGroup> |
| | | <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' "> |
| | | <DebugSymbols>true</DebugSymbols> |
| | | <DebugType>full</DebugType> |
| | | <Optimize>false</Optimize> |
| | | <OutputPath>bin\Debug\</OutputPath> |
| | | <DefineConstants>DEBUG;TRACE</DefineConstants> |
| | | <ErrorReport>prompt</ErrorReport> |
| | | <WarningLevel>4</WarningLevel> |
| | | </PropertyGroup> |
| | | <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' "> |
| | | <DebugType>pdbonly</DebugType> |
| | | <Optimize>true</Optimize> |
| | | <OutputPath>bin\Release\</OutputPath> |
| | | <DefineConstants>TRACE</DefineConstants> |
| | | <ErrorReport>prompt</ErrorReport> |
| | | <WarningLevel>4</WarningLevel> |
| | | </PropertyGroup> |
| | | <ItemGroup> |
| | | <Reference Include="System" /> |
| | | <Reference Include="System.Core" /> |
| | | <Reference Include="System.Xml.Linq" /> |
| | | <Reference Include="System.Data.DataSetExtensions" /> |
| | | <Reference Include="Microsoft.CSharp" /> |
| | | <Reference Include="System.Data" /> |
| | | <Reference Include="System.Net.Http" /> |
| | | <Reference Include="System.Xml" /> |
| | | </ItemGroup> |
| | | <ItemGroup> |
| | | <Compile Include="Api\IModbusDriver.cs" /> |
| | | <Compile Include="Api\IPLCDriver.cs" /> |
| | | <Compile Include="Api\IServiceLogger.cs" /> |
| | | <Compile Include="DriverDesc.cs" /> |
| | | <Compile Include="Api\ICanDriver.cs" /> |
| | | <Compile Include="Entity\MessageData.cs" /> |
| | | <Compile Include="IDevice.cs" /> |
| | | <Compile Include="IDeviceManager.cs" /> |
| | | <Compile Include="Properties\AssemblyInfo.cs" /> |
| | | </ItemGroup> |
| | | <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> |
| | | </Project> |
| New file |
| | |
| | | using System; |
| | | using System.Runtime.InteropServices; |
| | | |
| | | namespace DeviceService.Core |
| | | { |
| | | [AttributeUsage(AttributeTargets.Class, Inherited = false)] |
| | | [ComVisible(true)] |
| | | public class DriverDescAttribute : Attribute |
| | | { |
| | | public string Type { get; private set; } |
| | | public string Brand { get; private set; } |
| | | |
| | | public Type ClassType { get; private set; } |
| | | |
| | | public DriverDescAttribute(string Type, string Brand, Type ClassType = null) |
| | | { |
| | | this.Type = Type; |
| | | this.Brand = Brand; |
| | | this.ClassType = ClassType; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | using System; |
| | | using System.Collections.Generic; |
| | | using System.Linq; |
| | | using System.Text; |
| | | using System.Threading.Tasks; |
| | | |
| | | namespace DeviceService.Core |
| | | { |
| | | public class RequestData |
| | | { |
| | | public string DeviceId { get; set; } |
| | | |
| | | public string Opt { get; set; } |
| | | public string Parameters { get; set; } |
| | | } |
| | | |
| | | |
| | | |
| | | public class ResponseData |
| | | { |
| | | public bool Success { get; set; } |
| | | |
| | | public string Message { get; set; } |
| | | public string Result { get; set; } |
| | | } |
| | | } |
| New file |
| | |
| | | using System; |
| | | |
| | | namespace DeviceService.Core |
| | | { |
| | | /// <summary> |
| | | /// 设备状态 |
| | | /// </summary> |
| | | public enum DeviceStatus |
| | | { |
| | | Ready = 0, |
| | | Idle = 1, |
| | | Busy = 2, |
| | | Error = 3, |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 设备访问类型 |
| | | /// </summary> |
| | | public enum AccessType |
| | | { |
| | | Share = 0, |
| | | Exclusive = 1 |
| | | } |
| | | |
| | | public interface IDevice |
| | | { |
| | | /// <summary>数据库自增主键(Config\Devices.db 的 Device.Id),0 表示尚未入库。</summary> |
| | | long Id { get; } |
| | | |
| | | string DeviceId { get; } |
| | | string Type { get; } |
| | | string Brand { get; } |
| | | string ConnectStr { get; } |
| | | |
| | | DeviceStatus Status { get; } |
| | | |
| | | AccessType AccessType { get; } |
| | | |
| | | bool Init(out string msg); |
| | | |
| | | bool Open(out string msg); |
| | | bool Close(out string msg); |
| | | |
| | | ResponseData HandleRequest(RequestData req); |
| | | } |
| | | } |
| New file |
| | |
| | | using System; |
| | | using System.Collections.Generic; |
| | | using System.Linq; |
| | | using System.Text; |
| | | using System.Threading.Tasks; |
| | | |
| | | namespace DeviceService.Device |
| | | { |
| | | public interface IDeviceManager |
| | | { |
| | | bool RequestDevice(string deviceId, out string msg); |
| | | } |
| | | } |
| New file |
| | |
| | | using System.Reflection; |
| | | using System.Runtime.CompilerServices; |
| | | using System.Runtime.InteropServices; |
| | | |
| | | // 有关程序集的一般信息由以下 |
| | | // 控制。更改这些特性值可修改 |
| | | // 与程序集关联的信息。 |
| | | [assembly: AssemblyTitle("DeviceService.Core")] |
| | | [assembly: AssemblyDescription("")] |
| | | [assembly: AssemblyConfiguration("")] |
| | | [assembly: AssemblyCompany("")] |
| | | [assembly: AssemblyProduct("DeviceService.Core")] |
| | | [assembly: AssemblyCopyright("Copyright © 2026")] |
| | | [assembly: AssemblyTrademark("")] |
| | | [assembly: AssemblyCulture("")] |
| | | |
| | | // 将 ComVisible 设置为 false 会使此程序集中的类型 |
| | | //对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型 |
| | | //请将此类型的 ComVisible 特性设置为 true。 |
| | | [assembly: ComVisible(false)] |
| | | |
| | | // 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID |
| | | [assembly: Guid("0abe4877-bdeb-4c95-9c85-1adde3b641c4")] |
| | | |
| | | // 程序集的版本信息由下列四个值组成: |
| | | // |
| | | // 主版本 |
| | | // 次版本 |
| | | // 生成号 |
| | | // 修订号 |
| | | // |
| | | [assembly: AssemblyVersion("1.0.0.0")] |
| | | [assembly: AssemblyFileVersion("1.0.0.0")] |
| New file |
| | |
| | | |
| | | Microsoft Visual Studio Solution File, Format Version 12.00 |
| | | # Visual Studio Version 17 |
| | | VisualStudioVersion = 17.13.35828.75 d17.13 |
| | | MinimumVisualStudioVersion = 10.0.40219.1 |
| | | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DeviceService", "DeviceService\DeviceService.csproj", "{DB628943-4DFC-491E-BB66-EC17B95F22A7}" |
| | | EndProject |
| | | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DeviceService.Core", "DeviceService.Core\DeviceService.Core.csproj", "{0ABE4877-BDEB-4C95-9C85-1ADDE3B641C4}" |
| | | EndProject |
| | | Global |
| | | GlobalSection(SolutionConfigurationPlatforms) = preSolution |
| | | Debug|Any CPU = Debug|Any CPU |
| | | Release|Any CPU = Release|Any CPU |
| | | EndGlobalSection |
| | | GlobalSection(ProjectConfigurationPlatforms) = postSolution |
| | | {DB628943-4DFC-491E-BB66-EC17B95F22A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU |
| | | {DB628943-4DFC-491E-BB66-EC17B95F22A7}.Debug|Any CPU.Build.0 = Debug|Any CPU |
| | | {DB628943-4DFC-491E-BB66-EC17B95F22A7}.Release|Any CPU.ActiveCfg = Release|Any CPU |
| | | {DB628943-4DFC-491E-BB66-EC17B95F22A7}.Release|Any CPU.Build.0 = Release|Any CPU |
| | | {0ABE4877-BDEB-4C95-9C85-1ADDE3B641C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU |
| | | {0ABE4877-BDEB-4C95-9C85-1ADDE3B641C4}.Debug|Any CPU.Build.0 = Debug|Any CPU |
| | | {0ABE4877-BDEB-4C95-9C85-1ADDE3B641C4}.Release|Any CPU.ActiveCfg = Release|Any CPU |
| | | {0ABE4877-BDEB-4C95-9C85-1ADDE3B641C4}.Release|Any CPU.Build.0 = Release|Any CPU |
| | | {312E217E-4406-41D8-A1FF-DC7BD71C5693}.Debug|Any CPU.ActiveCfg = Debug|Any CPU |
| | | {312E217E-4406-41D8-A1FF-DC7BD71C5693}.Debug|Any CPU.Build.0 = Debug|Any CPU |
| | | {312E217E-4406-41D8-A1FF-DC7BD71C5693}.Release|Any CPU.ActiveCfg = Release|Any CPU |
| | | {312E217E-4406-41D8-A1FF-DC7BD71C5693}.Release|Any CPU.Build.0 = Release|Any CPU |
| | | EndGlobalSection |
| | | GlobalSection(SolutionProperties) = preSolution |
| | | HideSolutionNode = FALSE |
| | | EndGlobalSection |
| | | GlobalSection(ExtensibilityGlobals) = postSolution |
| | | SolutionGuid = {C72103FF-2DFE-42DF-9C64-2DD6A2ACB4D8} |
| | | EndGlobalSection |
| | | EndGlobal |
| New file |
| | |
| | | <Window x:Class="DeviceService.AddDeviceWindow" |
| | | 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="添加设备" |
| | | Width="480" Height="440" |
| | | MinWidth="420" MinHeight="360" |
| | | WindowStartupLocation="CenterOwner" |
| | | ResizeMode="CanResize" |
| | | Background="{StaticResource PageBackgroundBrush}"> |
| | | <Grid Margin="12"> |
| | | <Grid.RowDefinitions> |
| | | <RowDefinition Height="Auto" /> |
| | | <RowDefinition Height="*" /> |
| | | <RowDefinition Height="Auto" /> |
| | | </Grid.RowDefinitions> |
| | | |
| | | <!-- 设备类型选择 --> |
| | | <Border Grid.Row="0" Style="{StaticResource CardBorder}" Padding="10"> |
| | | <StackPanel> |
| | | <TextBlock Text="设备类型" |
| | | Style="{StaticResource TitleText}" |
| | | Margin="2,0,0,8" /> |
| | | <ComboBox x:Name="TypeCombo" |
| | | Padding="8,6" |
| | | SelectionChanged="TypeCombo_SelectionChanged" /> |
| | | </StackPanel> |
| | | </Border> |
| | | |
| | | <!-- 选型后加载的配置控件 --> |
| | | <Border Grid.Row="1" Style="{StaticResource CardBorder}" Margin="0,10,0,0"> |
| | | <ContentControl x:Name="ControlHost" Margin="4" Focusable="False" /> |
| | | </Border> |
| | | |
| | | <!-- 底部按钮 --> |
| | | <StackPanel Grid.Row="2" |
| | | Orientation="Horizontal" |
| | | HorizontalAlignment="Right" |
| | | Margin="0,10,0,0"> |
| | | <Button x:Name="OkButton" |
| | | Style="{StaticResource PrimaryButton}" |
| | | MinWidth="88" |
| | | Content="确定" |
| | | IsEnabled="False" |
| | | Click="OkButton_Click" /> |
| | | <Button x:Name="CancelButton" |
| | | Style="{StaticResource SecondaryButton}" |
| | | MinWidth="88" |
| | | Margin="8,0,0,0" |
| | | Content="取消" |
| | | Click="CancelButton_Click" /> |
| | | </StackPanel> |
| | | </Grid> |
| | | </Window> |
| New file |
| | |
| | | using DeviceService.Core; |
| | | using DeviceService.Device; |
| | | using System.Windows; |
| | | using System.Windows.Controls; |
| | | |
| | | namespace DeviceService; |
| | | |
| | | /// <summary> |
| | | /// 添加设备弹窗:从 AllDeviceType 中选择设备类型, |
| | | /// 选型后在下方加载该设备类型对应的配置 UserControl,确定后交回设备实例。 |
| | | /// </summary> |
| | | public partial class AddDeviceWindow : Window |
| | | { |
| | | private IDevice? _device; |
| | | |
| | | /// <summary>点击确定后返回待添加的设备实例;取消时为 null。</summary> |
| | | public IDevice? Device { get; private set; } |
| | | |
| | | public AddDeviceWindow() |
| | | { |
| | | InitializeComponent(); |
| | | TypeCombo.ItemsSource = DeviceHelper.AllDeviceType; |
| | | } |
| | | |
| | | private void TypeCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) |
| | | { |
| | | _device = null; |
| | | ControlHost.Content = null; |
| | | |
| | | if (TypeCombo.SelectedItem is string type |
| | | && DeviceHelper.TryCreateDevice(type, out IDevice? device) |
| | | && device != null) |
| | | { |
| | | // 默认 Id:类型名-序号,可在配置控件中看到 |
| | | int seq = DeviceManager.Devices.Count(d => d.Type == type) + 1; |
| | | SetDeviceId(device, $"{type}-{seq}"); |
| | | _device = device; |
| | | |
| | | UserControl? control = DeviceConfigControlFactory.Create(device); |
| | | if (control == null) |
| | | { |
| | | ControlHost.Content = new TextBlock |
| | | { |
| | | Text = $"未找到 {device.GetType().Name}ConfigControl,该设备类型暂无配置界面。", |
| | | Margin = new Thickness(8), |
| | | TextWrapping = TextWrapping.Wrap, |
| | | TextAlignment = TextAlignment.Center, |
| | | VerticalAlignment = VerticalAlignment.Center |
| | | }; |
| | | } |
| | | else |
| | | { |
| | | control.DataContext = device; |
| | | ControlHost.Content = control; |
| | | } |
| | | } |
| | | |
| | | OkButton.IsEnabled = _device != null; |
| | | } |
| | | |
| | | private void OkButton_Click(object sender, RoutedEventArgs e) |
| | | { |
| | | if (_device == null) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | string id = _device.DeviceId; |
| | | if (string.IsNullOrWhiteSpace(id)) |
| | | { |
| | | MessageBox.Show(this, "设备 ID 不能为空。", "添加设备", |
| | | MessageBoxButton.OK, MessageBoxImage.Warning); |
| | | return; |
| | | } |
| | | |
| | | if (DeviceManager.Devices.Any(d => d.DeviceId == id)) |
| | | { |
| | | MessageBox.Show(this, $"设备 ID \"{id}\" 已存在,请修改后重试。", "添加设备", |
| | | MessageBoxButton.OK, MessageBoxImage.Warning); |
| | | return; |
| | | } |
| | | |
| | | Device = _device; |
| | | DialogResult = true; |
| | | } |
| | | |
| | | private void CancelButton_Click(object sender, RoutedEventArgs e) |
| | | { |
| | | DialogResult = false; |
| | | } |
| | | |
| | | /// <summary>IDevice 接口只暴露 Id getter,实际实现类一般有 setter,通过反射赋值。</summary> |
| | | private static void SetDeviceId(IDevice device, string id) |
| | | { |
| | | device.GetType().GetProperty(nameof(IDevice.DeviceId))?.SetValue(device, id); |
| | | } |
| | | } |
| New file |
| | |
| | | <Application x:Class="DeviceService.App" |
| | | xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" |
| | | xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
| | | xmlns:local="clr-namespace:DeviceService" |
| | | StartupUri="MainWindow.xaml"> |
| | | <Application.Resources> |
| | | |
| | | <!-- ============ 调色板 ============ --> |
| | | <SolidColorBrush x:Key="PageBackgroundBrush" Color="#F3F4F6" /> |
| | | <SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF" /> |
| | | <SolidColorBrush x:Key="CardBorderBrush" Color="#E5E7EB" /> |
| | | <SolidColorBrush x:Key="AccentBrush" Color="#2563EB" /> |
| | | <SolidColorBrush x:Key="AccentHoverBrush" Color="#1D4ED8" /> |
| | | <SolidColorBrush x:Key="AccentPressedBrush" Color="#1E40AF" /> |
| | | <SolidColorBrush x:Key="AccentSoftBrush" Color="#DBEAFE" /> |
| | | <SolidColorBrush x:Key="AccentFaintBrush" Color="#EFF6FF" /> |
| | | <SolidColorBrush x:Key="TextPrimaryBrush" Color="#111827" /> |
| | | <SolidColorBrush x:Key="TextSecondaryBrush" Color="#6B7280" /> |
| | | <SolidColorBrush x:Key="InputBorderBrush" Color="#D1D5DB" /> |
| | | <SolidColorBrush x:Key="InputHoverBorderBrush" Color="#9CA3AF" /> |
| | | <SolidColorBrush x:Key="TerminalBackgroundBrush" Color="#1E293B" /> |
| | | <SolidColorBrush x:Key="TerminalForegroundBrush" Color="#E2E8F0" /> |
| | | |
| | | <!-- ============ 卡片容器 ============ --> |
| | | <Style x:Key="CardBorder" TargetType="Border"> |
| | | <Setter Property="Background" Value="{StaticResource CardBackgroundBrush}" /> |
| | | <Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" /> |
| | | <Setter Property="BorderThickness" Value="1" /> |
| | | <Setter Property="CornerRadius" Value="8" /> |
| | | <Setter Property="Padding" Value="8" /> |
| | | </Style> |
| | | |
| | | <Style x:Key="TitleText" TargetType="TextBlock"> |
| | | <Setter Property="FontSize" Value="14" /> |
| | | <Setter Property="FontWeight" Value="Bold" /> |
| | | <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}" /> |
| | | </Style> |
| | | |
| | | <Style x:Key="SectionText" TargetType="TextBlock"> |
| | | <Setter Property="FontSize" Value="17" /> |
| | | <Setter Property="FontWeight" Value="Bold" /> |
| | | <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}" /> |
| | | </Style> |
| | | |
| | | <!-- ============ 按钮 ============ --> |
| | | <Style x:Key="PrimaryButton" TargetType="Button"> |
| | | <Setter Property="Background" Value="{StaticResource AccentBrush}" /> |
| | | <Setter Property="Foreground" Value="White" /> |
| | | <Setter Property="FontSize" Value="13" /> |
| | | <Setter Property="FontWeight" Value="SemiBold" /> |
| | | <Setter Property="Padding" Value="12,7" /> |
| | | <Setter Property="Cursor" Value="Hand" /> |
| | | <Setter Property="Template"> |
| | | <Setter.Value> |
| | | <ControlTemplate TargetType="Button"> |
| | | <Border x:Name="Bd" |
| | | Background="{TemplateBinding Background}" |
| | | CornerRadius="6" |
| | | Padding="{TemplateBinding Padding}"> |
| | | <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" /> |
| | | </Border> |
| | | <ControlTemplate.Triggers> |
| | | <Trigger Property="IsMouseOver" Value="True"> |
| | | <Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentHoverBrush}" /> |
| | | </Trigger> |
| | | <Trigger Property="IsPressed" Value="True"> |
| | | <Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentPressedBrush}" /> |
| | | </Trigger> |
| | | <Trigger Property="IsEnabled" Value="False"> |
| | | <Setter TargetName="Bd" Property="Background" Value="#93C5FD" /> |
| | | </Trigger> |
| | | </ControlTemplate.Triggers> |
| | | </ControlTemplate> |
| | | </Setter.Value> |
| | | </Setter> |
| | | </Style> |
| | | |
| | | <Style x:Key="SecondaryButton" TargetType="Button"> |
| | | <Setter Property="Background" Value="#FFFFFF" /> |
| | | <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}" /> |
| | | <Setter Property="BorderBrush" Value="{StaticResource InputBorderBrush}" /> |
| | | <Setter Property="FontSize" Value="13" /> |
| | | <Setter Property="Padding" Value="12,6" /> |
| | | <Setter Property="Cursor" Value="Hand" /> |
| | | <Setter Property="Template"> |
| | | <Setter.Value> |
| | | <ControlTemplate TargetType="Button"> |
| | | <Border x:Name="Bd" |
| | | Background="{TemplateBinding Background}" |
| | | BorderBrush="{TemplateBinding BorderBrush}" |
| | | BorderThickness="1" |
| | | CornerRadius="6" |
| | | Padding="{TemplateBinding Padding}"> |
| | | <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" /> |
| | | </Border> |
| | | <ControlTemplate.Triggers> |
| | | <Trigger Property="IsMouseOver" Value="True"> |
| | | <Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentFaintBrush}" /> |
| | | <Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}" /> |
| | | </Trigger> |
| | | <Trigger Property="IsPressed" Value="True"> |
| | | <Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentSoftBrush}" /> |
| | | </Trigger> |
| | | <Trigger Property="IsEnabled" Value="False"> |
| | | <Setter TargetName="Bd" Property="Opacity" Value="0.5" /> |
| | | </Trigger> |
| | | </ControlTemplate.Triggers> |
| | | </ControlTemplate> |
| | | </Setter.Value> |
| | | </Setter> |
| | | </Style> |
| | | |
| | | <!-- ============ 输入框 ============ --> |
| | | <Style x:Key="InputTextBox" TargetType="TextBox"> |
| | | <Setter Property="Background" Value="#FFFFFF" /> |
| | | <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}" /> |
| | | <Setter Property="BorderBrush" Value="{StaticResource InputBorderBrush}" /> |
| | | <Setter Property="BorderThickness" Value="1" /> |
| | | <Setter Property="Padding" Value="10,6" /> |
| | | <Setter Property="VerticalContentAlignment" Value="Center" /> |
| | | <Setter Property="CaretBrush" Value="{StaticResource AccentBrush}" /> |
| | | <Setter Property="Template"> |
| | | <Setter.Value> |
| | | <ControlTemplate TargetType="TextBox"> |
| | | <Border x:Name="Bd" |
| | | Background="{TemplateBinding Background}" |
| | | BorderBrush="{TemplateBinding BorderBrush}" |
| | | BorderThickness="{TemplateBinding BorderThickness}" |
| | | CornerRadius="6"> |
| | | <ScrollViewer x:Name="PART_ContentHost" |
| | | Margin="{TemplateBinding Padding}" |
| | | VerticalAlignment="{TemplateBinding VerticalContentAlignment}" /> |
| | | </Border> |
| | | <ControlTemplate.Triggers> |
| | | <Trigger Property="IsMouseOver" Value="True"> |
| | | <Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource InputHoverBorderBrush}" /> |
| | | </Trigger> |
| | | <Trigger Property="IsKeyboardFocusWithin" Value="True"> |
| | | <Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource AccentBrush}" /> |
| | | </Trigger> |
| | | <Trigger Property="IsEnabled" Value="False"> |
| | | <Setter TargetName="Bd" Property="Background" Value="#F9FAFB" /> |
| | | </Trigger> |
| | | </ControlTemplate.Triggers> |
| | | </ControlTemplate> |
| | | </Setter.Value> |
| | | </Setter> |
| | | </Style> |
| | | |
| | | <!-- 终端风格日志框 --> |
| | | <Style x:Key="TerminalTextBox" TargetType="TextBox" BasedOn="{StaticResource InputTextBox}"> |
| | | <Setter Property="Background" Value="{StaticResource TerminalBackgroundBrush}" /> |
| | | <Setter Property="Foreground" Value="{StaticResource TerminalForegroundBrush}" /> |
| | | <Setter Property="BorderBrush" Value="#334155" /> |
| | | <Setter Property="Padding" Value="10,8" /> |
| | | <Setter Property="FontFamily" Value="Consolas" /> |
| | | <Setter Property="FontSize" Value="12.5" /> |
| | | <Setter Property="VerticalContentAlignment" Value="Top" /> |
| | | </Style> |
| | | |
| | | <!-- ============ TreeViewItem ============ --> |
| | | <Style x:Key="DeviceTreeViewItem" TargetType="TreeViewItem"> |
| | | <Setter Property="Background" Value="Transparent" /> |
| | | <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}" /> |
| | | <Setter Property="Padding" Value="6,6" /> |
| | | <Setter Property="HorizontalContentAlignment" Value="Stretch" /> |
| | | <Setter Property="Template"> |
| | | <Setter.Value> |
| | | <ControlTemplate TargetType="TreeViewItem"> |
| | | <StackPanel> |
| | | <Border x:Name="HeaderBorder" |
| | | Background="Transparent" |
| | | CornerRadius="6" |
| | | Padding="{TemplateBinding Padding}" |
| | | Margin="0,1"> |
| | | <Grid> |
| | | <Grid.ColumnDefinitions> |
| | | <ColumnDefinition Width="16" /> |
| | | <ColumnDefinition Width="*" /> |
| | | </Grid.ColumnDefinitions> |
| | | <ToggleButton x:Name="Expander" |
| | | Focusable="False" |
| | | ClickMode="Press" |
| | | IsChecked="{Binding IsExpanded, RelativeSource={RelativeSource TemplatedParent}}"> |
| | | <ToggleButton.Template> |
| | | <ControlTemplate TargetType="ToggleButton"> |
| | | <Border Background="Transparent"> |
| | | <Path x:Name="Arrow" |
| | | Data="M0,0 L4,4 L0,8 Z" |
| | | Fill="{StaticResource TextSecondaryBrush}" |
| | | HorizontalAlignment="Center" |
| | | VerticalAlignment="Center" |
| | | RenderTransformOrigin="0.5,0.5"> |
| | | <Path.RenderTransform> |
| | | <RotateTransform Angle="0" /> |
| | | </Path.RenderTransform> |
| | | </Path> |
| | | </Border> |
| | | <ControlTemplate.Triggers> |
| | | <Trigger Property="IsChecked" Value="True"> |
| | | <Setter TargetName="Arrow" Property="RenderTransform"> |
| | | <Setter.Value> |
| | | <RotateTransform Angle="90" /> |
| | | </Setter.Value> |
| | | </Setter> |
| | | </Trigger> |
| | | <Trigger Property="IsMouseOver" Value="True"> |
| | | <Setter TargetName="Arrow" Property="Fill" Value="{StaticResource AccentBrush}" /> |
| | | </Trigger> |
| | | </ControlTemplate.Triggers> |
| | | </ControlTemplate> |
| | | </ToggleButton.Template> |
| | | </ToggleButton> |
| | | <ContentPresenter Grid.Column="1" ContentSource="Header" /> |
| | | </Grid> |
| | | </Border> |
| | | <ItemsPresenter x:Name="ItemsHost" Margin="16,0,0,0" Visibility="Collapsed" /> |
| | | </StackPanel> |
| | | <ControlTemplate.Triggers> |
| | | <Trigger Property="IsExpanded" Value="True"> |
| | | <Setter TargetName="ItemsHost" Property="Visibility" Value="Visible" /> |
| | | </Trigger> |
| | | <Trigger Property="HasItems" Value="False"> |
| | | <Setter TargetName="Expander" Property="Visibility" Value="Hidden" /> |
| | | </Trigger> |
| | | <Trigger SourceName="HeaderBorder" Property="IsMouseOver" Value="True"> |
| | | <Setter TargetName="HeaderBorder" Property="Background" Value="{StaticResource AccentFaintBrush}" /> |
| | | </Trigger> |
| | | <Trigger Property="IsSelected" Value="True"> |
| | | <Setter TargetName="HeaderBorder" Property="Background" Value="{StaticResource AccentSoftBrush}" /> |
| | | <Setter Property="Foreground" Value="{StaticResource AccentPressedBrush}" /> |
| | | </Trigger> |
| | | </ControlTemplate.Triggers> |
| | | </ControlTemplate> |
| | | </Setter.Value> |
| | | </Setter> |
| | | </Style> |
| | | |
| | | <!-- ============ ListBox 项 ============ --> |
| | | <Style x:Key="RowListBoxItem" TargetType="ListBoxItem"> |
| | | <Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}" /> |
| | | <Setter Property="Template"> |
| | | <Setter.Value> |
| | | <ControlTemplate TargetType="ListBoxItem"> |
| | | <Border x:Name="Bd" |
| | | Background="Transparent" |
| | | CornerRadius="6" |
| | | Padding="10,7" |
| | | Margin="0,1"> |
| | | <ContentPresenter /> |
| | | </Border> |
| | | <ControlTemplate.Triggers> |
| | | <Trigger Property="IsMouseOver" Value="True"> |
| | | <Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentFaintBrush}" /> |
| | | </Trigger> |
| | | <Trigger Property="IsSelected" Value="True"> |
| | | <Setter TargetName="Bd" Property="Background" Value="{StaticResource AccentSoftBrush}" /> |
| | | <Setter Property="Foreground" Value="{StaticResource AccentPressedBrush}" /> |
| | | <Setter Property="FontWeight" Value="SemiBold" /> |
| | | </Trigger> |
| | | </ControlTemplate.Triggers> |
| | | </ControlTemplate> |
| | | </Setter.Value> |
| | | </Setter> |
| | | </Style> |
| | | |
| | | </Application.Resources> |
| | | </Application> |
| New file |
| | |
| | | using System.Windows; |
| | | |
| | | namespace DeviceService; |
| | | |
| | | /// <summary> |
| | | /// Interaction logic for App.xaml |
| | | /// </summary> |
| | | public partial class App : Application |
| | | { |
| | | // log4net 由 AssemblyInfo.cs 中的 [assembly: XmlConfigurator(ConfigFile = "Config/log4net.config")] |
| | | // 依据配置文件完成初始化(Console / RollingFile / Memory 三个 Appender 均在配置文件中定义)。 |
| | | } |
| New file |
| | |
| | | using log4net.Config; |
| | | 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: XmlConfigurator(ConfigFile = "Config/log4net.config", Watch = true)] |
| New file |
| | |
| | | <?xml version="1.0" encoding="utf-8"?> |
| | | <log4net> |
| | | <!-- 控制台日志配置 --> |
| | | <appender name="Console" type="log4net.Appender.ConsoleAppender"> |
| | | <!-- 日志输出格式 --> |
| | | <layout type="log4net.Layout.PatternLayout"> |
| | | <conversionPattern value="%5level [%thread] (%file:%line) - %message%newline" /> |
| | | </layout> |
| | | </appender> |
| | | |
| | | <!-- 文件存储日志配置 --> |
| | | <appender name="RollingFile" type="log4net.Appender.RollingFileAppender"> |
| | | <appendToFile value="true" /> |
| | | <rollingStyle value="Date" /> |
| | | <datePattern value="'log_'yyyyMMdd'.log'"/> |
| | | <staticLogFileName value="false"/> |
| | | |
| | | <!-- 保存文件的名称 --> |
| | | <file value="./Log/" /> |
| | | |
| | | <!-- 文件的编码方式 --> |
| | | <param name="Encoding" value="UTF-8"/> |
| | | <!-- 每个文件的大小 --> |
| | | <maximumFileSize value="100KB" /> |
| | | <!-- 保存文件数量 --> |
| | | <maxSizeRollBackups value="2" /> |
| | | <!-- 日志输出格式 --> |
| | | <layout type="log4net.Layout.PatternLayout"> |
| | | <conversionPattern value="%logger: [%date] [%thread] [%level] [%class:%L] - %message%newline" /> |
| | | </layout> |
| | | </appender> |
| | | |
| | | <!-- 内存日志缓冲:只保留最近 1000 条,供日志窗口查看,不写文件 --> |
| | | <appender name="Memory" type="DeviceService.Logging.MemoryAppender, DeviceService"> |
| | | <!-- 内存中保留的日志条数 --> |
| | | <capacity value="1000" /> |
| | | </appender> |
| | | |
| | | <root> |
| | | <level value="DEBUG" /> |
| | | <appender-ref ref="Console" /> |
| | | <appender-ref ref="RollingFile" /> |
| | | <appender-ref ref="Memory" /> |
| | | </root> |
| | | </log4net> |
| New file |
| | |
| | | <Window x:Class="DeviceService.DebugWindow" |
| | | 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:DeviceService" |
| | | mc:Ignorable="d" |
| | | Title="调试窗口" Height="470" Width="840" |
| | | Background="{StaticResource PageBackgroundBrush}"> |
| | | <Grid Margin="12"> |
| | | <Grid.ColumnDefinitions> |
| | | <ColumnDefinition Width="200" /> |
| | | <ColumnDefinition Width="*" /> |
| | | </Grid.ColumnDefinitions> |
| | | |
| | | <!-- 左侧:已连接客户端 --> |
| | | <Border Grid.Column="0" Style="{StaticResource CardBorder}" Margin="0,0,10,0"> |
| | | <DockPanel Margin="4"> |
| | | <TextBlock DockPanel.Dock="Top" |
| | | Style="{StaticResource TitleText}" |
| | | Text="已连接客户端" |
| | | Margin="4,2,0,10" /> |
| | | <ListBox x:Name="ClientList" |
| | | Background="Transparent" |
| | | BorderThickness="0" |
| | | ItemContainerStyle="{StaticResource RowListBoxItem}" /> |
| | | </DockPanel> |
| | | </Border> |
| | | |
| | | <!-- 右侧:日志 + 发送区 --> |
| | | <Grid Grid.Column="1"> |
| | | <Grid.RowDefinitions> |
| | | <RowDefinition Height="*" /> |
| | | <RowDefinition Height="Auto" /> |
| | | </Grid.RowDefinitions> |
| | | |
| | | <Border Grid.Row="0" Style="{StaticResource CardBorder}" Padding="4"> |
| | | <TextBox x:Name="ReceiveBox" |
| | | IsReadOnly="True" |
| | | Background="Transparent" |
| | | BorderThickness="0" |
| | | VerticalScrollBarVisibility="Auto" |
| | | HorizontalScrollBarVisibility="Auto" |
| | | TextWrapping="NoWrap" |
| | | FontFamily="Consolas" |
| | | FontSize="12.5" |
| | | Foreground="{StaticResource TextPrimaryBrush}" |
| | | Padding="6" /> |
| | | </Border> |
| | | |
| | | <DockPanel Grid.Row="1" Margin="0,10,0,0"> |
| | | <Button x:Name="SendTestButton" |
| | | DockPanel.Dock="Right" |
| | | Style="{StaticResource SecondaryButton}" |
| | | MinWidth="110" |
| | | Margin="8,0,0,0" |
| | | Content="发送测试消息" |
| | | Click="SendTestButton_Click" /> |
| | | <Button x:Name="SendToClientButton" |
| | | DockPanel.Dock="Right" |
| | | Style="{StaticResource PrimaryButton}" |
| | | MinWidth="130" |
| | | Margin="8,0,0,0" |
| | | Content="发送到选中客户端" |
| | | Click="SendToClientButton_Click" /> |
| | | <TextBox x:Name="SendBox" |
| | | Style="{StaticResource InputTextBox}" |
| | | KeyDown="SendBox_KeyDown" /> |
| | | </DockPanel> |
| | | </Grid> |
| | | </Grid> |
| | | </Window> |
| New file |
| | |
| | | using DeviceService.Service; |
| | | using DeviceService.Service.NamePipe; |
| | | using System.Collections.ObjectModel; |
| | | using System.IO.Pipes; |
| | | using System.Text; |
| | | using System.Windows; |
| | | using System.Windows.Input; |
| | | |
| | | namespace DeviceService; |
| | | |
| | | /// <summary> |
| | | /// Interaction logic for DebugWindow.xaml |
| | | /// </summary> |
| | | public partial class DebugWindow : Window |
| | | { |
| | | private const string PipeName = "DeviceService"; |
| | | |
| | | private class ClientItem |
| | | { |
| | | public ClientItem(IClient client, string name) |
| | | { |
| | | Client = client; |
| | | Name = name; |
| | | } |
| | | |
| | | public IClient Client { get; } |
| | | |
| | | public string Name { get; } |
| | | |
| | | public override string ToString() => Name; |
| | | } |
| | | |
| | | private NamePipeService? _pipeService; |
| | | private readonly ObservableCollection<ClientItem> _clients = new ObservableCollection<ClientItem>(); |
| | | |
| | | public DebugWindow() |
| | | { |
| | | InitializeComponent(); |
| | | ClientList.ItemsSource = _clients; |
| | | Loaded += DebugWindow_Loaded; |
| | | Closed += DebugWindow_Closed; |
| | | } |
| | | |
| | | private void DebugWindow_Loaded(object sender, RoutedEventArgs e) |
| | | { |
| | | } |
| | | |
| | | |
| | | |
| | | private string FindClientName(IClient client) |
| | | { |
| | | ClientItem? item = _clients.FirstOrDefault(c => ReferenceEquals(c.Client, client)); |
| | | return item?.Name ?? "未知客户端"; |
| | | } |
| | | |
| | | private async void SendTestButton_Click(object sender, RoutedEventArgs e) |
| | | { |
| | | string text = SendBox.Text; |
| | | if (string.IsNullOrEmpty(text)) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | SendTestButton.IsEnabled = false; |
| | | try |
| | | { |
| | | string error = await Task.Run(() => SendViaPipe(text + "\r\n")); |
| | | AppendLine(string.IsNullOrEmpty(error) |
| | | ? $"[测试] 已发送:{text}" |
| | | : $"[测试] 发送失败:{error}"); |
| | | } |
| | | finally |
| | | { |
| | | SendTestButton.IsEnabled = true; |
| | | } |
| | | } |
| | | |
| | | private async void SendToClientButton_Click(object sender, RoutedEventArgs e) |
| | | { |
| | | if (ClientList.SelectedItem is not ClientItem item) |
| | | { |
| | | AppendLine("[发送] 请先在左侧列表中选择一个客户端。"); |
| | | return; |
| | | } |
| | | |
| | | string text = SendBox.Text; |
| | | if (string.IsNullOrEmpty(text)) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | string name = item.Name; |
| | | IClient client = item.Client; |
| | | |
| | | SendToClientButton.IsEnabled = false; |
| | | try |
| | | { |
| | | string error = await Task.Run(() => client.Send(text, out string err) ? string.Empty : err); |
| | | AppendLine(string.IsNullOrEmpty(error) |
| | | ? $"[发送→{name}] {text}" |
| | | : $"[发送失败→{name}] {error}"); |
| | | } |
| | | finally |
| | | { |
| | | SendToClientButton.IsEnabled = true; |
| | | } |
| | | } |
| | | |
| | | private void SendBox_KeyDown(object sender, KeyEventArgs e) |
| | | { |
| | | if (e.Key == Key.Enter) |
| | | { |
| | | // 已选中客户端则定向发送,否则走测试通道 |
| | | if (ClientList.SelectedItem is ClientItem) |
| | | { |
| | | SendToClientButton_Click(sender, e); |
| | | } |
| | | else |
| | | { |
| | | SendTestButton_Click(sender, e); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 以客户端身份连接本进程的管道,按行协议写入一条 UTF8 消息(以 \n 结尾)。 |
| | | /// 返回空字符串表示成功,否则为错误信息。 |
| | | /// </summary> |
| | | private static string SendViaPipe(string text) |
| | | { |
| | | try |
| | | { |
| | | using var pipe = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut); |
| | | pipe.Connect(2000); |
| | | |
| | | byte[] payload = Encoding.UTF8.GetBytes(text + "\n"); |
| | | pipe.Write(payload, 0, payload.Length); |
| | | pipe.Flush(); |
| | | return string.Empty; |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | return ex.Message; |
| | | } |
| | | } |
| | | |
| | | private void AppendLine(string text) |
| | | { |
| | | ReceiveBox.AppendText(text + Environment.NewLine); |
| | | ReceiveBox.ScrollToEnd(); |
| | | } |
| | | |
| | | private void DebugWindow_Closed(object? sender, EventArgs e) |
| | | { |
| | | } |
| | | } |
| New file |
| | |
| | | using DeviceService.Core; |
| | | using System.Reflection; |
| | | using System.Windows.Controls; |
| | | |
| | | namespace DeviceService |
| | | { |
| | | /// <summary> |
| | | /// 按命名规则创建设备配置控件:设备类名 CanDevice 对应 CanDeviceConfigControl。 |
| | | /// </summary> |
| | | public static class DeviceConfigControlFactory |
| | | { |
| | | public static UserControl? Create(IDevice device) |
| | | { |
| | | if (device == null) throw new ArgumentNullException(nameof(device)); |
| | | |
| | | string controlName = device.GetType().Name + "ConfigControl"; |
| | | |
| | | try |
| | | { |
| | | Type? type = typeof(DeviceConfigControlFactory).Assembly.GetTypes() |
| | | .FirstOrDefault(t => t.Name == controlName |
| | | && typeof(UserControl).IsAssignableFrom(t) |
| | | && !t.IsAbstract); |
| | | |
| | | return type == null ? null : (UserControl?)Activator.CreateInstance(type); |
| | | } |
| | | catch (ReflectionTypeLoadException ex) |
| | | { |
| | | Type? type = ex.Types.FirstOrDefault(t => t?.Name == controlName |
| | | && typeof(UserControl).IsAssignableFrom(t) |
| | | && !t.IsAbstract); |
| | | return type == null ? null : (UserControl?)Activator.CreateInstance(type); |
| | | } |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <UserControl x:Class="DeviceService.CanDeviceConfigControl" |
| | | 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:sys="clr-namespace:System;assembly=mscorlib" |
| | | xmlns:dm="clr-namespace:DeviceService.Core;assembly=DeviceService.Core" |
| | | mc:Ignorable="d" |
| | | d:DesignHeight="220" d:DesignWidth="420"> |
| | | <UserControl.Resources> |
| | | <ObjectDataProvider x:Key="AccessTypeValues" MethodName="GetValues" ObjectType="{x:Type sys:Enum}"> |
| | | <ObjectDataProvider.MethodParameters> |
| | | <x:Type TypeName="dm:AccessType" /> |
| | | </ObjectDataProvider.MethodParameters> |
| | | </ObjectDataProvider> |
| | | </UserControl.Resources> |
| | | <Grid VerticalAlignment="Top"> |
| | | <StackPanel> |
| | | <Grid> |
| | | <Grid.ColumnDefinitions> |
| | | <ColumnDefinition Width="80" /> |
| | | <ColumnDefinition Width="*" /> |
| | | </Grid.ColumnDefinitions> |
| | | <Grid.RowDefinitions> |
| | | <RowDefinition Height="Auto" /> |
| | | <RowDefinition Height="Auto" /> |
| | | <RowDefinition Height="Auto" /> |
| | | <RowDefinition Height="Auto" /> |
| | | <RowDefinition Height="Auto" /> |
| | | </Grid.RowDefinitions> |
| | | |
| | | <TextBlock Grid.Row="0" Grid.Column="0" Text="ID" VerticalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,6" /> |
| | | <TextBox Grid.Row="0" Grid.Column="1" Style="{StaticResource InputTextBox}" |
| | | Text="{Binding DeviceId, UpdateSourceTrigger=PropertyChanged}" Margin="0,4" /> |
| | | |
| | | <TextBlock Grid.Row="2" Grid.Column="0" Text="品牌" VerticalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,6" /> |
| | | <TextBox Grid.Row="2" Grid.Column="1" Style="{StaticResource InputTextBox}" |
| | | Text="{Binding Brand, UpdateSourceTrigger=PropertyChanged}" Margin="0,4" /> |
| | | |
| | | <TextBlock Grid.Row="3" Grid.Column="0" Text="连接端点" VerticalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,6" /> |
| | | <TextBox Grid.Row="3" Grid.Column="1" Style="{StaticResource InputTextBox}" |
| | | Text="{Binding ConnectStr, UpdateSourceTrigger=PropertyChanged}" Margin="0,4" /> |
| | | |
| | | <TextBlock Grid.Row="4" Grid.Column="0" Text="访问类型" VerticalAlignment="Center" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,6" /> |
| | | <ComboBox Grid.Row="4" Grid.Column="1" Margin="0,4" |
| | | Padding="8,6" |
| | | ItemsSource="{Binding Source={StaticResource AccessTypeValues}}" |
| | | SelectedItem="{Binding AccessType, Mode=TwoWay}" /> |
| | | </Grid> |
| | | </StackPanel> |
| | | </Grid> |
| | | </UserControl> |
| New file |
| | |
| | | using DeviceService.Device; |
| | | using System.Windows.Controls; |
| | | |
| | | namespace DeviceService; |
| | | |
| | | /// <summary> |
| | | /// CanDevice 的配置控件。命名规则:设备类型名 CanDevice + ConfigControl。 |
| | | /// DataContext 由 MainWindow 注入为对应的 IDevice 实例。 |
| | | /// </summary> |
| | | public partial class CanDeviceConfigControl : UserControl |
| | | { |
| | | public CanDeviceConfigControl() |
| | | { |
| | | InitializeComponent(); |
| | | DataContext = new CanDevice(); |
| | | } |
| | | } |
| New file |
| | |
| | | using DeviceService.Core; |
| | | using DeviceService.Driver; |
| | | using DeviceService.Entity; |
| | | using System.Windows.Interop; |
| | | |
| | | namespace DeviceService.Device |
| | | { |
| | | [DeviceDesc("CAN")] |
| | | public class CanDevice : NotifyBase, IDevice |
| | | { |
| | | private long _id; |
| | | public long Id |
| | | { |
| | | get => _id; |
| | | set |
| | | { |
| | | SetProperty(ref _id, value); |
| | | } |
| | | } |
| | | |
| | | private string _deviceId; |
| | | public string DeviceId |
| | | { |
| | | get => _deviceId; |
| | | set |
| | | { |
| | | SetProperty(ref _deviceId, value); |
| | | } |
| | | } |
| | | |
| | | private string _type = "CAN"; |
| | | public string Type |
| | | { |
| | | get => _type; |
| | | private set |
| | | { |
| | | SetProperty(ref _type, value); |
| | | } |
| | | } |
| | | |
| | | private string _brand; |
| | | public string Brand |
| | | { |
| | | get => _brand; |
| | | set |
| | | { |
| | | SetProperty(ref _brand, value); |
| | | } |
| | | } |
| | | |
| | | private string _connectStr; |
| | | public string ConnectStr |
| | | { |
| | | get => _connectStr; |
| | | set |
| | | { |
| | | SetProperty(ref _connectStr, value); |
| | | } |
| | | } |
| | | |
| | | private DeviceStatus _status; |
| | | public DeviceStatus Status |
| | | { |
| | | get => _status; |
| | | set |
| | | { |
| | | SetProperty(ref _status, value); |
| | | } |
| | | } |
| | | |
| | | private AccessType _accessType; |
| | | public AccessType AccessType |
| | | { |
| | | get => _accessType; |
| | | set |
| | | { |
| | | SetProperty(ref _accessType, value); |
| | | } |
| | | } |
| | | |
| | | private ICanDriver driver; |
| | | |
| | | public bool Init(out string msg) |
| | | { |
| | | msg = string.Empty; |
| | | try |
| | | { |
| | | driver = DriverManager.Instance.CreateDriverByTypeAndBrand<ICanDriver>(Type, Brand); |
| | | return true; |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | msg = ex.Message; |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | public bool Open(out string msg) |
| | | { |
| | | if (driver == null) |
| | | { |
| | | msg = "设备尚未初始化或初始化失败。"; |
| | | return false; |
| | | } |
| | | |
| | | var res = driver.Open(out msg); |
| | | if (res) |
| | | { |
| | | Status = DeviceStatus.Busy; |
| | | } |
| | | return res; |
| | | } |
| | | public bool Close(out string msg) |
| | | { |
| | | if (driver == null) |
| | | { |
| | | msg = "设备尚未初始化或初始化失败。"; |
| | | return false; |
| | | } |
| | | |
| | | var res = driver.Close(out msg); |
| | | if (res) |
| | | { |
| | | Status = DeviceStatus.Idle; |
| | | } |
| | | return res; |
| | | } |
| | | |
| | | public ResponseData HandleRequest(RequestData req) |
| | | { |
| | | try |
| | | { |
| | | if (req.DeviceId != DeviceId) |
| | | { |
| | | return new ResponseData |
| | | { |
| | | Success = false, |
| | | Message = "设备ID不匹配" |
| | | }; |
| | | } |
| | | if (string.Equals("open", req.Opt, StringComparison.OrdinalIgnoreCase)) |
| | | { |
| | | if (driver.Open(out string msg)) |
| | | { |
| | | return new ResponseData |
| | | { |
| | | Success = true, |
| | | Message = msg |
| | | }; |
| | | } |
| | | else |
| | | { |
| | | return new ResponseData |
| | | { |
| | | Success = false, |
| | | Message = msg |
| | | }; |
| | | } |
| | | } |
| | | |
| | | return new ResponseData |
| | | { |
| | | Success = false, |
| | | Message = "无效的操作" |
| | | }; |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | return new ResponseData |
| | | { |
| | | Success = false, |
| | | Message = ex.Message |
| | | }; |
| | | } |
| | | |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | using DeviceService.Core; |
| | | using DeviceService.Logging; |
| | | using System.Data.SQLite; |
| | | using System.IO; |
| | | |
| | | namespace DeviceService.Device |
| | | { |
| | | /// <summary>设备记录(Device 表的一行)。</summary> |
| | | public sealed class DeviceRecord |
| | | { |
| | | /// <summary>自增主键,对应设备实例的 Id;0 表示尚未入库。</summary> |
| | | public long Id { get; set; } |
| | | |
| | | /// <summary>设备业务标识(唯一),对应设备实例的 DeviceId。</summary> |
| | | public string DeviceId { get; set; } = string.Empty; |
| | | |
| | | public string Type { get; set; } = string.Empty; |
| | | |
| | | public string Brand { get; set; } = string.Empty; |
| | | |
| | | public string ConnectStr { get; set; } = string.Empty; |
| | | |
| | | public AccessType AccessType { get; set; } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 设备信息 SQLite 持久化:数据库文件位于 应用根目录\Config\Devices.db。 |
| | | /// |
| | | /// 表结构: |
| | | /// - Device:Id 为自增主键,DeviceId 为唯一业务标识,Type 用于定位设备实现类 |
| | | /// |
| | | /// 所有操作失败仅通过 log 上报,不会中断设备加载流程。 |
| | | /// </summary> |
| | | public sealed class DeviceDatabase |
| | | { |
| | | private const string CreateTableSql = @" |
| | | CREATE TABLE IF NOT EXISTS Device ( |
| | | Id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | | DeviceId TEXT NOT NULL, |
| | | Type TEXT NOT NULL, |
| | | Brand TEXT, |
| | | ConnectStr TEXT, |
| | | AccessType INTEGER NOT NULL DEFAULT 0 |
| | | );"; |
| | | |
| | | private static readonly object _staticSync = new object(); |
| | | private static DeviceDatabase? _instance; |
| | | |
| | | public static DeviceDatabase Instance |
| | | { |
| | | get |
| | | { |
| | | lock (_staticSync) |
| | | { |
| | | return _instance ??= new DeviceDatabase(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /// <summary>配置目录:应用根目录\Config。</summary> |
| | | public static string ConfigDirectory => |
| | | Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Config"); |
| | | |
| | | /// <summary>数据库文件路径。</summary> |
| | | public static string DatabasePath => |
| | | Path.Combine(ConfigDirectory, "Devices.db"); |
| | | |
| | | private readonly object _sync = new object(); |
| | | private bool _initialized; |
| | | |
| | | private DeviceDatabase() |
| | | { |
| | | } |
| | | |
| | | /// <summary>确保 Config 目录与库表结构存在。失败返回 false 并通过 log 上报。</summary> |
| | | private bool EnsureReady() |
| | | { |
| | | if (_initialized) |
| | | { |
| | | return true; |
| | | } |
| | | |
| | | try |
| | | { |
| | | if (!Directory.Exists(ConfigDirectory)) |
| | | { |
| | | Directory.CreateDirectory(ConfigDirectory); |
| | | } |
| | | |
| | | using (var conn = Open()) |
| | | { |
| | | ExecuteNonQuery(conn, CreateTableSql, null); |
| | | |
| | | // 旧库的结构是 DeviceId 主键,升级为 Id 自增主键 |
| | | MigrateToAutoIncrementId(conn); |
| | | |
| | | ExecuteNonQuery(conn, |
| | | "CREATE UNIQUE INDEX IF NOT EXISTS IX_Device_DeviceId ON Device(DeviceId);", |
| | | null); |
| | | } |
| | | |
| | | _initialized = true; |
| | | return true; |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | GlobalLogger.Logger.Error("DeviceManager", $"[设备库] 初始化失败({DatabasePath}): {ex.Message}"); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | private SQLiteConnection Open() |
| | | { |
| | | var conn = new SQLiteConnection($"Data Source={DatabasePath};Version=3;"); |
| | | conn.Open(); |
| | | return conn; |
| | | } |
| | | |
| | | /// <summary>读取库中全部设备记录(按 Type、DeviceId 排序)。</summary> |
| | | public IReadOnlyList<DeviceRecord> LoadAll() |
| | | { |
| | | var result = new List<DeviceRecord>(); |
| | | if (!EnsureReady()) |
| | | { |
| | | return result; |
| | | } |
| | | |
| | | try |
| | | { |
| | | lock (_sync) |
| | | { |
| | | using (var conn = Open()) |
| | | using (var cmd = conn.CreateCommand()) |
| | | { |
| | | cmd.CommandText = |
| | | "SELECT * FROM Device ORDER BY Type, DeviceId"; |
| | | using (var reader = cmd.ExecuteReader()) |
| | | { |
| | | while (reader.Read()) |
| | | { |
| | | result.Add(new DeviceRecord |
| | | { |
| | | Id = GetInt64(reader, "Id"), |
| | | DeviceId = GetString(reader, "DeviceId"), |
| | | Type = GetString(reader, "Type"), |
| | | Brand = GetString(reader, "Brand"), |
| | | ConnectStr = GetString(reader, "ConnectStr"), |
| | | AccessType = (AccessType)GetInt32(reader, "AccessType") |
| | | }); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | GlobalLogger.Logger.Error("DeviceManager", $"[设备库] 读取设备列表失败: {ex.Message}"); |
| | | } |
| | | |
| | | return result; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 写入(覆盖)一台设备的记录,返回其自增主键 Id;失败返回 0。 |
| | | /// Id 为 0 时新增,否则按 Id 更新。 |
| | | /// </summary> |
| | | public long Save(DeviceRecord record, Action<string>? log = null) |
| | | { |
| | | if (record == null || string.IsNullOrWhiteSpace(record.DeviceId)) |
| | | { |
| | | return 0; |
| | | } |
| | | |
| | | if (!EnsureReady()) |
| | | { |
| | | return 0; |
| | | } |
| | | |
| | | try |
| | | { |
| | | lock (_sync) |
| | | { |
| | | using (var conn = Open()) |
| | | { |
| | | if (record.Id > 0) |
| | | { |
| | | int affected; |
| | | using (var cmd = conn.CreateCommand()) |
| | | { |
| | | cmd.CommandText = @" |
| | | UPDATE Device SET DeviceId = @id, Type = @t, Brand = @b, ConnectStr = @c, AccessType = @a |
| | | WHERE Id = @pk"; |
| | | cmd.Parameters.AddWithValue("@id", record.DeviceId); |
| | | cmd.Parameters.AddWithValue("@t", record.Type ?? string.Empty); |
| | | cmd.Parameters.AddWithValue("@b", record.Brand ?? string.Empty); |
| | | cmd.Parameters.AddWithValue("@c", record.ConnectStr ?? string.Empty); |
| | | cmd.Parameters.AddWithValue("@a", (int)record.AccessType); |
| | | cmd.Parameters.AddWithValue("@pk", record.Id); |
| | | affected = cmd.ExecuteNonQuery(); |
| | | } |
| | | |
| | | if (affected > 0) |
| | | { |
| | | return record.Id; |
| | | } |
| | | } |
| | | |
| | | using (var cmd = conn.CreateCommand()) |
| | | { |
| | | cmd.CommandText = @" |
| | | INSERT INTO Device(DeviceId, Type, Brand, ConnectStr, AccessType) |
| | | VALUES(@id, @t, @b, @c, @a)"; |
| | | cmd.Parameters.AddWithValue("@id", record.DeviceId); |
| | | cmd.Parameters.AddWithValue("@t", record.Type ?? string.Empty); |
| | | cmd.Parameters.AddWithValue("@b", record.Brand ?? string.Empty); |
| | | cmd.Parameters.AddWithValue("@c", record.ConnectStr ?? string.Empty); |
| | | cmd.Parameters.AddWithValue("@a", (int)record.AccessType); |
| | | cmd.ExecuteNonQuery(); |
| | | } |
| | | |
| | | using (var cmd = conn.CreateCommand()) |
| | | { |
| | | cmd.CommandText = "SELECT last_insert_rowid()"; |
| | | return Convert.ToInt64(cmd.ExecuteScalar()); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | log?.Invoke($"[设备库] 保存 {record.DeviceId} 失败: {ex.Message}"); |
| | | return 0; |
| | | } |
| | | } |
| | | |
| | | /// <summary>删除一台设备的记录。</summary> |
| | | public void Remove(string deviceId) |
| | | { |
| | | if (string.IsNullOrEmpty(deviceId) || !EnsureReady()) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | try |
| | | { |
| | | lock (_sync) |
| | | { |
| | | using (var conn = Open()) |
| | | using (var cmd = conn.CreateCommand()) |
| | | { |
| | | cmd.CommandText = "DELETE FROM Device WHERE DeviceId = @id"; |
| | | cmd.Parameters.AddWithValue("@id", deviceId); |
| | | cmd.ExecuteNonQuery(); |
| | | } |
| | | } |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | GlobalLogger.Logger.Error("DeviceManager", $"[设备库] 移除 {deviceId} 失败: {ex.Message}"); |
| | | } |
| | | } |
| | | |
| | | /// <summary>旧库(DeviceId 作主键)升级为 Id 自增主键。</summary> |
| | | private static void MigrateToAutoIncrementId(SQLiteConnection conn) |
| | | { |
| | | var columns = new List<string>(); |
| | | using (var cmd = conn.CreateCommand()) |
| | | { |
| | | cmd.CommandText = "PRAGMA table_info(Device)"; |
| | | using (var reader = cmd.ExecuteReader()) |
| | | { |
| | | while (reader.Read()) |
| | | { |
| | | columns.Add(GetString(reader, "name")); |
| | | } |
| | | } |
| | | } |
| | | |
| | | if (columns.Count == 0 || columns.Contains("Id")) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | lock (_staticSync) |
| | | { |
| | | using (var tx = conn.BeginTransaction()) |
| | | { |
| | | ExecuteNonQuery(conn, "DROP TABLE IF EXISTS Device_old;", tx); |
| | | ExecuteNonQuery(conn, "ALTER TABLE Device RENAME TO Device_old;", tx); |
| | | ExecuteNonQuery(conn, CreateTableSql, tx); |
| | | ExecuteNonQuery(conn, @" |
| | | INSERT INTO Device(DeviceId, Type, Brand, ConnectStr, AccessType) |
| | | SELECT DeviceId, Type, Brand, ConnectStr, AccessType FROM Device_old;", tx); |
| | | ExecuteNonQuery(conn, "DROP TABLE Device_old;", tx); |
| | | tx.Commit(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | private static void ExecuteNonQuery(SQLiteConnection conn, string sql, SQLiteTransaction? tx) |
| | | { |
| | | using (var cmd = conn.CreateCommand()) |
| | | { |
| | | cmd.Transaction = tx; |
| | | cmd.CommandText = sql; |
| | | cmd.ExecuteNonQuery(); |
| | | } |
| | | } |
| | | |
| | | private static string GetString(SQLiteDataReader reader, string column) |
| | | { |
| | | object value = reader[column]; |
| | | return value == DBNull.Value ? string.Empty : value.ToString() ?? string.Empty; |
| | | } |
| | | |
| | | private static int GetInt32(SQLiteDataReader reader, string column) |
| | | { |
| | | object value = reader[column]; |
| | | return value == DBNull.Value ? 0 : Convert.ToInt32(value); |
| | | } |
| | | |
| | | private static long GetInt64(SQLiteDataReader reader, string column) |
| | | { |
| | | object value = reader[column]; |
| | | return value == DBNull.Value ? 0L : Convert.ToInt64(value); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | using System; |
| | | using System.Runtime.InteropServices; |
| | | |
| | | namespace DeviceService.Device |
| | | { |
| | | [AttributeUsage(AttributeTargets.Class, Inherited = false)] |
| | | [ComVisible(true)] |
| | | public class DeviceDescAttribute : Attribute |
| | | { |
| | | public string Type { get; private set; } |
| | | |
| | | public DeviceDescAttribute(string Type) |
| | | { |
| | | this.Type = Type; |
| | | } |
| | | } |
| | | |
| | | [AttributeUsage(AttributeTargets.Property, Inherited = false)] |
| | | [ComVisible(true)] |
| | | public class DevicePropertyAttribute : Attribute |
| | | { |
| | | public string Name { get; private set; } |
| | | |
| | | public DevicePropertyAttribute(string Name) |
| | | { |
| | | this.Name = Name; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | using DeviceService.Core; |
| | | using DeviceService.Logging; |
| | | using System.Reflection; |
| | | |
| | | namespace DeviceService.Device |
| | | { |
| | | /// <summary> |
| | | /// 设备信息持久化辅助:负责设备信息与 Config\Devices.db 之间的读写。 |
| | | /// DeviceManager 只负责服务正式启动后的设备管理,数据库读写一律经由本类。 |
| | | /// </summary> |
| | | public static class DeviceHelper |
| | | { |
| | | |
| | | private static readonly object _scanSync = new object(); |
| | | private static readonly Dictionary<string, Type> _deviceTypeMap = new Dictionary<string, Type>(StringComparer.Ordinal); |
| | | |
| | | /// <summary> |
| | | /// 扫描到的设备类型列表(取自 DeviceDescAttribute.Type)。 |
| | | /// 程序启动后调用 <see cref="ScanDeviceTypes"/> 填充。 |
| | | /// </summary> |
| | | public static List<string> AllDeviceType { get; } = new List<string>(); |
| | | public static List<IDevice> AllDevices { get; } = new List<IDevice>(); |
| | | |
| | | /// <summary> |
| | | /// 扫描本程序集中所有标注了 <see cref="DeviceDescAttribute"/> 且继承 <see cref="IDevice"/> 的类, |
| | | /// 将注解中的 Type 存入 <see cref="AllDeviceType"/>,并记录 Type 到设备类的映射。 |
| | | /// </summary> |
| | | public static void ScanDeviceTypes() |
| | | { |
| | | lock (_scanSync) |
| | | { |
| | | AllDeviceType.Clear(); |
| | | _deviceTypeMap.Clear(); |
| | | |
| | | IEnumerable<Type?> types; |
| | | try |
| | | { |
| | | types = Assembly.GetExecutingAssembly().GetTypes(); |
| | | } |
| | | catch (ReflectionTypeLoadException ex) |
| | | { |
| | | types = ex.Types; |
| | | } |
| | | |
| | | foreach (Type? t in types) |
| | | { |
| | | if (t == null || !typeof(IDevice).IsAssignableFrom(t) || t.IsAbstract) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | DeviceDescAttribute? attr = t.GetCustomAttribute<DeviceDescAttribute>(false); |
| | | if (attr == null || string.IsNullOrEmpty(attr.Type)) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | _deviceTypeMap[attr.Type] = t; |
| | | AllDeviceType.Add(attr.Type); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /// <summary>按注解 Type 创建对应的设备实例。</summary> |
| | | public static bool TryCreateDevice(string type, out IDevice? device) |
| | | { |
| | | device = null; |
| | | lock (_scanSync) |
| | | { |
| | | if (type == null || !_deviceTypeMap.TryGetValue(type, out Type? deviceType)) |
| | | { |
| | | return false; |
| | | } |
| | | |
| | | device = (IDevice?)Activator.CreateInstance(deviceType); |
| | | return device != null; |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 保存设备信息到 Config\Devices.db(仅持久化,不调用 Init、不影响 DeviceManager 的内存集合)。 |
| | | /// 返回是否保存成功;新增记录会回填自增 Id。 |
| | | /// </summary> |
| | | public static bool SaveDevice(IDevice device) |
| | | { |
| | | if (device == null) throw new ArgumentNullException(nameof(device)); |
| | | |
| | | // 写入 Config\Devices.db,下次启动时据此恢复设备列表;新增记录回填自增 Id |
| | | long id = DeviceDatabase.Instance.Save(ToRecord(device)); |
| | | if (id > 0) |
| | | { |
| | | TrySet(device, nameof(IDevice.Id), id); |
| | | } |
| | | |
| | | return id > 0; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 从 Config\Devices.db 读取全部设备并创建实例(不调用 Init、不加入任何集合)。 |
| | | /// 调用方按需加入 DeviceManager,服务正式启动后再逐一 <see cref="DeviceManager.Register"/>。 |
| | | /// </summary> |
| | | public static IReadOnlyList<IDevice> LoadDevices() |
| | | { |
| | | var devices = new List<IDevice>(); |
| | | AllDevices.Clear(); |
| | | |
| | | foreach (DeviceRecord record in DeviceDatabase.Instance.LoadAll()) |
| | | { |
| | | if (string.IsNullOrWhiteSpace(record.DeviceId) || string.IsNullOrWhiteSpace(record.Type)) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | if (!DeviceHelper.TryCreateDevice(record.Type, out IDevice? device) || device == null) |
| | | { |
| | | GlobalLogger.Logger.Warn("DeviceHelper", $"[设备库] 未知设备类型 {record.Type},已跳过 {record.DeviceId}"); |
| | | continue; |
| | | } |
| | | |
| | | ApplyRecord(device, record); |
| | | devices.Add(device); |
| | | AllDevices.Add(device); |
| | | } |
| | | |
| | | return devices; |
| | | } |
| | | |
| | | /// <summary>从 Config\Devices.db 删除设备记录。</summary> |
| | | public static void RemoveDevice(string deviceId) |
| | | { |
| | | DeviceDatabase.Instance.Remove(deviceId); |
| | | } |
| | | |
| | | private static DeviceRecord ToRecord(IDevice device) |
| | | { |
| | | return new DeviceRecord |
| | | { |
| | | Id = device.Id, |
| | | DeviceId = device.DeviceId ?? string.Empty, |
| | | Type = device.Type ?? string.Empty, |
| | | Brand = device.Brand ?? string.Empty, |
| | | ConnectStr = device.ConnectStr ?? string.Empty, |
| | | AccessType = device.AccessType |
| | | }; |
| | | } |
| | | |
| | | /// <summary>IDevice 接口只暴露 getter,实现类一般有 setter,通过反射回填。</summary> |
| | | private static void ApplyRecord(IDevice device, DeviceRecord record) |
| | | { |
| | | TrySet(device, nameof(IDevice.Id), record.Id); |
| | | TrySet(device, nameof(IDevice.DeviceId), record.DeviceId); |
| | | TrySet(device, nameof(IDevice.Brand), record.Brand); |
| | | TrySet(device, nameof(IDevice.ConnectStr), record.ConnectStr); |
| | | TrySet(device, nameof(IDevice.AccessType), record.AccessType); |
| | | } |
| | | |
| | | private static void TrySet(object target, string propertyName, object value) |
| | | { |
| | | PropertyInfo? property = target.GetType().GetProperty(propertyName); |
| | | if (property == null || !property.CanWrite || !property.PropertyType.IsInstanceOfType(value)) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | property.SetValue(target, value); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | using DeviceService.Core; |
| | | using DeviceService.Service; |
| | | using System.Text.Encodings.Web; |
| | | using System.Text.Json; |
| | | using System.Text.Unicode; |
| | | |
| | | namespace DeviceService.Device |
| | | { |
| | | /// <summary> |
| | | /// 服务正式启动后的设备管理:设备实例的内存集合、类型扫描与注册(Register)。 |
| | | /// 设备信息的数据库读写由 <see cref="DeviceHelper"/> 负责,本类不做任何持久化。 |
| | | /// </summary> |
| | | public class DeviceManager |
| | | { |
| | | |
| | | private readonly static object _sync = new object(); |
| | | private readonly static Dictionary<string, IDevice> _deviceCollection = new Dictionary<string, IDevice>(); |
| | | |
| | | public static IReadOnlyList<IDevice> Devices |
| | | { |
| | | get |
| | | { |
| | | lock (_sync) |
| | | { |
| | | return _deviceCollection.Values.ToList(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // ==================== 设备注册 ==================== |
| | | |
| | | /// <summary>服务启动时注册设备(内部会调用 Init),无论初始化是否成功都会加入集合。</summary> |
| | | public static bool Register(IDevice device, out string msg) |
| | | { |
| | | if (device == null) throw new ArgumentNullException(nameof(device)); |
| | | |
| | | bool initOk = device.Init(out msg); |
| | | if (initOk) |
| | | { |
| | | lock (_sync) |
| | | { |
| | | _deviceCollection[device.DeviceId] = device; |
| | | } |
| | | } |
| | | return initOk; |
| | | } |
| | | |
| | | public static void UnRegisterAll() |
| | | { |
| | | lock (_sync) |
| | | { |
| | | _deviceCollection.Clear(); |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 用数据库读取到的设备刷新内存集合(不调用 Init)。 |
| | | /// 已存在于内存中的实例保持不变,避免丢失已注册/已打开的运行状态; |
| | | /// 新增记录使用传入的(从数据库还原的)实例。 |
| | | /// </summary> |
| | | public static void Refresh(IEnumerable<IDevice> devices) |
| | | { |
| | | if (devices == null) throw new ArgumentNullException(nameof(devices)); |
| | | |
| | | lock (_sync) |
| | | { |
| | | var refreshed = new Dictionary<string, IDevice>(StringComparer.Ordinal); |
| | | foreach (IDevice device in devices) |
| | | { |
| | | if (device == null || string.IsNullOrWhiteSpace(device.DeviceId)) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | refreshed[device.DeviceId] = _deviceCollection.TryGetValue(device.DeviceId, out IDevice? existing) |
| | | ? existing |
| | | : device; |
| | | } |
| | | |
| | | _deviceCollection.Clear(); |
| | | foreach (KeyValuePair<string, IDevice> pair in refreshed) |
| | | { |
| | | _deviceCollection[pair.Key] = pair.Value; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /// <summary>按 DeviceId 从内存集合移除设备(数据库记录的删除由 <see cref="DeviceHelper.RemoveDevice"/> 负责)。</summary> |
| | | public static bool Remove(string id) |
| | | { |
| | | lock (_sync) |
| | | { |
| | | return _deviceCollection.Remove(id); |
| | | } |
| | | } |
| | | |
| | | private static readonly JsonSerializerOptions SafeOptions = new() |
| | | { |
| | | Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) |
| | | }; |
| | | |
| | | public static void HandleClientMessage(RequestArg arg) |
| | | { |
| | | var data = arg.Data; |
| | | string deviceId = data.DeviceId; |
| | | if (_deviceCollection.TryGetValue(deviceId, out IDevice device)) |
| | | { |
| | | var res = device.HandleRequest(data); |
| | | arg.Client.Send(JsonSerializer.Serialize(res, SafeOptions), out _); |
| | | } |
| | | else |
| | | { |
| | | var res = new ResponseData |
| | | { |
| | | Success = false, |
| | | Message = "无效的DeviceId" |
| | | }; |
| | | arg.Client.Send(JsonSerializer.Serialize(res, SafeOptions), out _); |
| | | } |
| | | } |
| | | |
| | | public static void ReleaseClient(IClient client) { |
| | | |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | using DeviceService.Core; |
| | | using DeviceService.Core.Api; |
| | | using DeviceService.Driver; |
| | | using DeviceService.Entity; |
| | | |
| | | namespace DeviceService.Device |
| | | { |
| | | [DeviceDesc("PLC")] |
| | | public class PLCDevice : NotifyBase, IDevice |
| | | { |
| | | private long _id; |
| | | public long Id |
| | | { |
| | | get => _id; |
| | | set |
| | | { |
| | | SetProperty(ref _id, value); |
| | | } |
| | | } |
| | | |
| | | private string _deviceId; |
| | | public string DeviceId |
| | | { |
| | | get => _deviceId; |
| | | set |
| | | { |
| | | SetProperty(ref _deviceId, value); |
| | | } |
| | | } |
| | | |
| | | private string _type = "PLC"; |
| | | public string Type |
| | | { |
| | | get => _type; |
| | | private set |
| | | { |
| | | SetProperty(ref _type, value); |
| | | } |
| | | } |
| | | |
| | | private string _brand; |
| | | public string Brand |
| | | { |
| | | get => _brand; |
| | | set |
| | | { |
| | | SetProperty(ref _brand, value); |
| | | } |
| | | } |
| | | |
| | | private string _connectStr; |
| | | public string ConnectStr |
| | | { |
| | | get => _connectStr; |
| | | set |
| | | { |
| | | SetProperty(ref _connectStr, value); |
| | | } |
| | | } |
| | | |
| | | private DeviceStatus _status; |
| | | public DeviceStatus Status |
| | | { |
| | | get => _status; |
| | | set |
| | | { |
| | | SetProperty(ref _status, value); |
| | | } |
| | | } |
| | | |
| | | private AccessType _accessType; |
| | | public AccessType AccessType |
| | | { |
| | | get => _accessType; |
| | | set |
| | | { |
| | | SetProperty(ref _accessType, value); |
| | | } |
| | | } |
| | | |
| | | private IPLCDriver driver; |
| | | |
| | | public bool Init(out string msg) |
| | | { |
| | | msg = string.Empty; |
| | | try |
| | | { |
| | | driver = DriverManager.Instance.CreateDriverByTypeAndBrand<IPLCDriver>(Type, Brand); |
| | | return true; |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | msg = ex.Message; |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | public bool Open(out string msg) |
| | | { |
| | | if (driver == null) |
| | | { |
| | | msg = "设备尚未初始化或初始化失败。"; |
| | | return false; |
| | | } |
| | | |
| | | var res = driver.Open(out msg); |
| | | if (res) |
| | | { |
| | | Status = DeviceStatus.Busy; |
| | | } |
| | | return res; |
| | | } |
| | | public bool Close(out string msg) |
| | | { |
| | | if (driver == null) |
| | | { |
| | | msg = "设备尚未初始化或初始化失败。"; |
| | | return false; |
| | | } |
| | | |
| | | var res = driver.Close(out msg); |
| | | if (res) |
| | | { |
| | | Status = DeviceStatus.Idle; |
| | | } |
| | | return res; |
| | | } |
| | | |
| | | public ResponseData HandleRequest(RequestData req) |
| | | { |
| | | return new ResponseData |
| | | { |
| | | Success = false, |
| | | Message = "未实现" |
| | | }; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <Project Sdk="Microsoft.NET.Sdk"> |
| | | |
| | | <PropertyGroup> |
| | | <OutputType>WinExe</OutputType> |
| | | <TargetFramework>net472</TargetFramework> |
| | | <LangVersion>12.0</LangVersion> |
| | | <Nullable>enable</Nullable> |
| | | <ImplicitUsings>enable</ImplicitUsings> |
| | | <UseWPF>true</UseWPF> |
| | | </PropertyGroup> |
| | | |
| | | <ItemGroup> |
| | | <PackageReference Include="System.Reflection.DispatchProxy" Version="4.8.2" /> |
| | | <PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" /> |
| | | <PackageReference Include="log4net" Version="3.5.0" /> |
| | | <PackageReference Include="System.Text.Json" Version="10.0.14" /> |
| | | </ItemGroup> |
| | | |
| | | <ItemGroup> |
| | | <ProjectReference Include="..\DeviceService.Core\DeviceService.Core.csproj" /> |
| | | </ItemGroup> |
| | | |
| | | <ItemGroup> |
| | | <None Update="Config\log4net.config"> |
| | | <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> |
| | | </None> |
| | | </ItemGroup> |
| | | |
| | | </Project> |
| New file |
| | |
| | | using System; |
| | | using System.Collections.Generic; |
| | | using System.Data.SQLite; |
| | | using System.IO; |
| | | |
| | | namespace DeviceService.Driver |
| | | { |
| | | /// <summary> |
| | | /// 驱动信息 SQLite 持久化:数据库文件位于 应用根目录\Config\Drivers.db。 |
| | | /// |
| | | /// 表结构: |
| | | /// - DriverPackage:已加载的驱动包(Driver 根目录下的一个子文件夹) |
| | | /// - DriverType:驱动包内带 [DriverDesc] 注解的驱动类型((类型, 品牌) 定位) |
| | | /// |
| | | /// 所有操作失败仅通过 log 上报,不会中断驱动加载流程。 |
| | | /// </summary> |
| | | public sealed class DriverDatabase |
| | | { |
| | | private static readonly object _staticSync = new object(); |
| | | private static DriverDatabase _instance; |
| | | |
| | | public static DriverDatabase Instance |
| | | { |
| | | get |
| | | { |
| | | lock (_staticSync) |
| | | { |
| | | return _instance ??= new DriverDatabase(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /// <summary>配置目录:应用根目录\Config。</summary> |
| | | public static string ConfigDirectory => |
| | | Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Config"); |
| | | |
| | | /// <summary>数据库文件路径。</summary> |
| | | public static string DatabasePath => |
| | | Path.Combine(ConfigDirectory, "Drivers.db"); |
| | | |
| | | private readonly object _sync = new object(); |
| | | private bool _initialized; |
| | | |
| | | private DriverDatabase() |
| | | { |
| | | } |
| | | |
| | | /// <summary>确保 Config 目录与库表结构存在。失败返回 false 并通过 log 上报。</summary> |
| | | private bool EnsureReady(Action<string> log) |
| | | { |
| | | if (_initialized) |
| | | { |
| | | return true; |
| | | } |
| | | |
| | | try |
| | | { |
| | | if (!Directory.Exists(ConfigDirectory)) |
| | | { |
| | | Directory.CreateDirectory(ConfigDirectory); |
| | | } |
| | | |
| | | using (var conn = Open()) |
| | | using (var cmd = conn.CreateCommand()) |
| | | { |
| | | cmd.CommandText = @" |
| | | CREATE TABLE IF NOT EXISTS DriverPackage ( |
| | | Folder TEXT PRIMARY KEY, |
| | | Directory TEXT NOT NULL, |
| | | AssemblyCount INTEGER NOT NULL DEFAULT 0, |
| | | ScanTime TEXT NOT NULL |
| | | ); |
| | | CREATE TABLE IF NOT EXISTS DriverType ( |
| | | Id INTEGER PRIMARY KEY AUTOINCREMENT, |
| | | PackageFolder TEXT NOT NULL, |
| | | AssemblyFile TEXT NOT NULL, |
| | | FullName TEXT NOT NULL, |
| | | DriverType TEXT, |
| | | Brand TEXT, |
| | | UNIQUE(PackageFolder, FullName) |
| | | ); |
| | | CREATE INDEX IF NOT EXISTS IX_DriverType_Type_Brand ON DriverType(DriverType, Brand);"; |
| | | cmd.ExecuteNonQuery(); |
| | | } |
| | | |
| | | _initialized = true; |
| | | return true; |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | log?.Invoke($"[驱动库] 初始化失败({DatabasePath}): {ex.Message}"); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | private SQLiteConnection Open() |
| | | { |
| | | var conn = new SQLiteConnection($"Data Source={DatabasePath};Version=3;"); |
| | | conn.Open(); |
| | | return conn; |
| | | } |
| | | |
| | | /// <summary>写入(覆盖)一个驱动包及其内部全部驱动类型的记录。</summary> |
| | | public void SavePackage(DriverPackage package, Action<string> log = null) |
| | | { |
| | | if (package == null) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | if (!EnsureReady(log)) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | try |
| | | { |
| | | lock (_sync) |
| | | { |
| | | using (var conn = Open()) |
| | | using (var tx = conn.BeginTransaction()) |
| | | { |
| | | ExecuteNonQuery(conn, "DELETE FROM DriverType WHERE PackageFolder = @f", |
| | | tx, ("@f", package.Folder)); |
| | | ExecuteNonQuery(conn, "DELETE FROM DriverPackage WHERE Folder = @f", |
| | | tx, ("@f", package.Folder)); |
| | | |
| | | var assemblies = package.Assemblies ?? new DriverAssemblyInfo[0]; |
| | | ExecuteNonQuery(conn, |
| | | "INSERT INTO DriverPackage(Folder, Directory, AssemblyCount, ScanTime) VALUES(@f, @d, @c, @t)", |
| | | tx, |
| | | ("@f", package.Folder), |
| | | ("@d", package.Directory ?? string.Empty), |
| | | ("@c", assemblies.Length.ToString()), |
| | | ("@t", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))); |
| | | |
| | | int typeCount = 0; |
| | | foreach (var asm in assemblies) |
| | | { |
| | | foreach (var type in asm.Types ?? new DriverTypeInfo[0]) |
| | | { |
| | | ExecuteNonQuery(conn, |
| | | @"INSERT OR REPLACE INTO DriverType(PackageFolder, AssemblyFile, FullName, DriverType, Brand) |
| | | VALUES(@p, @a, @n, @t, @b)", |
| | | tx, |
| | | ("@p", package.Folder), |
| | | ("@a", asm.FileName ?? string.Empty), |
| | | ("@n", type.FullName ?? string.Empty), |
| | | ("@t", type.DriverType ?? string.Empty), |
| | | ("@b", type.Brand ?? string.Empty)); |
| | | typeCount++; |
| | | } |
| | | } |
| | | |
| | | tx.Commit(); |
| | | log?.Invoke($"[驱动库] 已保存 {package.Folder}({assemblies.Length} 个 dll,{typeCount} 个驱动类型)"); |
| | | } |
| | | } |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | log?.Invoke($"[驱动库] 保存 {package.Folder} 失败: {ex.Message}"); |
| | | } |
| | | } |
| | | |
| | | /// <summary>删除一个驱动包(及其全部驱动类型)的记录。</summary> |
| | | public void RemovePackage(string folder, Action<string> log = null) |
| | | { |
| | | if (string.IsNullOrEmpty(folder) || !EnsureReady(log)) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | try |
| | | { |
| | | lock (_sync) |
| | | { |
| | | using (var conn = Open()) |
| | | using (var tx = conn.BeginTransaction()) |
| | | { |
| | | ExecuteNonQuery(conn, "DELETE FROM DriverType WHERE PackageFolder = @f", tx, ("@f", folder)); |
| | | ExecuteNonQuery(conn, "DELETE FROM DriverPackage WHERE Folder = @f", tx, ("@f", folder)); |
| | | tx.Commit(); |
| | | } |
| | | } |
| | | |
| | | log?.Invoke($"[驱动库] 已移除 {folder} 的记录"); |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | log?.Invoke($"[驱动库] 移除 {folder} 失败: {ex.Message}"); |
| | | } |
| | | } |
| | | |
| | | /// <summary>读取库中全部驱动类型记录(供界面/查询使用)。</summary> |
| | | public IReadOnlyList<DriverTypeInfo> LoadDriverTypes(Action<string> log = null) |
| | | { |
| | | var result = new List<DriverTypeInfo>(); |
| | | if (!EnsureReady(log)) |
| | | { |
| | | return result; |
| | | } |
| | | |
| | | try |
| | | { |
| | | lock (_sync) |
| | | { |
| | | using (var conn = Open()) |
| | | using (var cmd = conn.CreateCommand()) |
| | | { |
| | | cmd.CommandText = "SELECT PackageFolder, AssemblyFile, FullName, DriverType, Brand FROM DriverType ORDER BY DriverType, Brand, FullName"; |
| | | using (var reader = cmd.ExecuteReader()) |
| | | { |
| | | while (reader.Read()) |
| | | { |
| | | result.Add(new DriverTypeInfo |
| | | { |
| | | FullName = GetString(reader, "FullName"), |
| | | DriverType = GetString(reader, "DriverType"), |
| | | Brand = GetString(reader, "Brand") |
| | | }); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | log?.Invoke($"[驱动库] 读取驱动类型失败: {ex.Message}"); |
| | | } |
| | | |
| | | return result; |
| | | } |
| | | |
| | | private static void ExecuteNonQuery(SQLiteConnection conn, string sql, SQLiteTransaction tx, |
| | | params (string Name, string Value)[] parameters) |
| | | { |
| | | using (var cmd = conn.CreateCommand()) |
| | | { |
| | | cmd.Transaction = tx; |
| | | cmd.CommandText = sql; |
| | | foreach (var (name, value) in parameters) |
| | | { |
| | | cmd.Parameters.AddWithValue(name, value); |
| | | } |
| | | |
| | | cmd.ExecuteNonQuery(); |
| | | } |
| | | } |
| | | |
| | | private static string GetString(SQLiteDataReader reader, string column) |
| | | { |
| | | var value = reader[column]; |
| | | return value == DBNull.Value ? string.Empty : value.ToString(); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | using System.Reflection; |
| | | |
| | | namespace DeviceService.Driver |
| | | { |
| | | /// <summary> |
| | | /// 基于 DispatchProxy 的驱动动态代理(.NET Framework 4.7.1+ 内置 API)。 |
| | | /// |
| | | /// 用法:宿主自定义一个"鸭子接口"(方法名、参数类型与驱动方法一一对应), |
| | | /// 驱动无需实现该接口、无需引用宿主程序集。接口调用按"方法名 + 参数个数" |
| | | /// 匹配到驱动方法,经 <see cref="DriverInstance"/>(MBR 透明代理)转发到 |
| | | /// 驱动所在的 AppDomain 内反射执行。 |
| | | /// |
| | | /// 与 RealProxy 的区别: |
| | | /// - 生成的代理是宿主域内的普通对象,不再有 Remoting 相关的怪异行为 |
| | | /// (Object.GetType/ToString 等基类方法不会被误路由,无需拦截); |
| | | /// - out/ref 参数通过直接改写 args 数组回填; |
| | | /// - 异常直接抛出,不再经过 ReturnMessage 包装; |
| | | /// - API 在 .NET Core / .NET 5+ 同样可用,宿主未来升级可无缝迁移。 |
| | | /// |
| | | /// 约束:s |
| | | /// - T 必须是接口(DispatchProxy 只针对接口生成代理类); |
| | | /// - 接口方法的参数/返回值必须可跨域封送(基元类型、string、[Serializable] DTO)。 |
| | | /// </summary> |
| | | // 注意:不能 sealed —— DispatchProxy 在运行时生成其派生类作为代理类型 |
| | | public class DriverDispatchProxy : DispatchProxy |
| | | { |
| | | private DriverInstance _instance; |
| | | |
| | | /// <summary> |
| | | /// 创建实现 T 的驱动动态代理(T 为宿主自定义的鸭子接口)。 |
| | | /// </summary> |
| | | public static T Create<T>(DriverInstance instance) where T : class |
| | | { |
| | | if (!typeof(T).IsInterface) |
| | | { |
| | | throw new ArgumentException( |
| | | $"T 必须是接口(驱动类型不可跨域封送,宿主也无法引用厂商程序集),实际为 {typeof(T)}", |
| | | nameof(T)); |
| | | } |
| | | |
| | | // DispatchProxy.Create<T, TProxy> 要求 TProxy 为非泛型类, |
| | | // 因此代理实例的创建与状态注入分两步完成 |
| | | var proxy = (DriverDispatchProxy)(object)Create<T, DriverDispatchProxy>(); |
| | | proxy._instance = instance; |
| | | return (T)(object)proxy; |
| | | } |
| | | |
| | | protected override object Invoke(MethodInfo targetMethod, object[] args) |
| | | { |
| | | if (targetMethod == null) |
| | | { |
| | | throw new NotSupportedException("无法解析接口方法(可能由泛型方法调用引起)"); |
| | | } |
| | | |
| | | var mi = _instance.GetMethods().FirstOrDefault(m => |
| | | string.Equals(m.Name, targetMethod.Name, StringComparison.Ordinal) |
| | | && m.ParameterCount == (args?.Length ?? 0)); |
| | | |
| | | if (mi == null) |
| | | { |
| | | throw new MissingMethodException(_instance.TypeFullName, $"{targetMethod.Name}({args?.Length ?? 0} 参数)——驱动未提供同名且参数个数一致的方法"); |
| | | } |
| | | |
| | | var direct = _instance.InvokeDirect(mi.Index, args); |
| | | if (!direct.Success) |
| | | { |
| | | throw new InvalidOperationException($"驱动方法 {targetMethod.Name} 执行失败: {direct.Error}"); |
| | | } |
| | | |
| | | // out/ref 回填:把驱动域内的回填值写回 args 数组, |
| | | // DispatchProxy 会自动同步回调用方的 out/ref 变量 |
| | | if (direct.ArgsOut != null && args != null) |
| | | { |
| | | var count = Math.Min(args.Length, direct.ArgsOut.Length); |
| | | for (var i = 0; i < count; i++) |
| | | { |
| | | args[i] = direct.ArgsOut[i]; |
| | | } |
| | | } |
| | | |
| | | return direct.Value; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | namespace DeviceService.Driver |
| | | { |
| | | /// <summary> |
| | | /// 由 DriverManager 创建并统一管理的驱动实例代理。 |
| | | /// |
| | | /// 驱动对象本身活在驱动包的 AppDomain 中(WPF/普通类均不可跨域封送), |
| | | /// 因此宿主拿到的是本类的透明代理:宿主调用 <see cref="Invoke"/>、 |
| | | /// <see cref="GetMethods"/>、<see cref="Release"/> 时,实际执行都发生在驱动自己的域内。 |
| | | /// </summary> |
| | | public sealed class DriverInstance : MarshalByRefObject |
| | | { |
| | | private readonly DriverLoader _owner; |
| | | |
| | | /// <summary>实例句柄(宿主与加载器之间定位实例的唯一标识)</summary> |
| | | public string Handle { get; } |
| | | |
| | | /// <summary>驱动类型的 CLR 全名</summary> |
| | | public string TypeFullName { get; } |
| | | |
| | | /// <summary>注解中的"类型"(如 CAN)</summary> |
| | | public string DriverType { get; } |
| | | |
| | | /// <summary>注解中的"品牌"(如 BA)</summary> |
| | | public string Brand { get; } |
| | | |
| | | /// <summary>所属驱动包名(由 DriverManager 回填,仅用于展示)</summary> |
| | | public string PackageFolder { get; set; } |
| | | |
| | | // 实例由 DriverManager 长期持有,无限生存期,避免远程租约到期被回收 |
| | | public override object InitializeLifetimeService() => null; |
| | | |
| | | internal DriverInstance(DriverLoader owner, string handle, string typeFullName, |
| | | string driverType, string brand) |
| | | { |
| | | _owner = owner; |
| | | Handle = handle; |
| | | TypeFullName = typeFullName; |
| | | DriverType = driverType; |
| | | Brand = brand; |
| | | } |
| | | |
| | | /// <summary>列出该驱动类型可调用的公共方法(用于确定 methodIndex)</summary> |
| | | public DriverMethodInfo[] GetMethods() |
| | | { |
| | | return _owner.GetMethods(TypeFullName); |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 在该驱动实例上反射调用方法。参数为字符串形式,按目标类型自动转换, |
| | | /// out/ref 参数的回填值会包含在结果中。 |
| | | /// </summary> |
| | | public InvokeResult Invoke(int methodIndex, string[] rawArgs) |
| | | { |
| | | return _owner.InvokeOnInstance(Handle, methodIndex, rawArgs); |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 直接调用(原始对象参数/返回值),供动态代理使用。 |
| | | /// 注意:参数与返回值必须可跨域封送(基元类型、string、[Serializable] DTO)。 |
| | | /// </summary> |
| | | public InvokeDirectResult InvokeDirect(int methodIndex, object[] args) |
| | | { |
| | | return _owner.InvokeOnInstanceDirect(Handle, methodIndex, args); |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 释放该实例(若驱动实现了 IDisposable 则调用 Dispose)。 |
| | | /// </summary> |
| | | public void Release() |
| | | { |
| | | _owner.ReleaseInstance(Handle); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | using System.IO; |
| | | using System.Reflection; |
| | | |
| | | namespace DeviceService.Driver |
| | | { |
| | | /// <summary> |
| | | /// 驱动的程序集描述(跨 AppDomain 按值封送)。 |
| | | /// 注意:这些 DTO 只服务于"宿主 <-> 加载器"之间的通信, |
| | | /// 驱动 dll 不需要引用本程序集,也不需要实现任何接口。 |
| | | /// </summary> |
| | | [Serializable] |
| | | public sealed class DriverAssemblyInfo |
| | | { |
| | | /// <summary>dll 文件名</summary> |
| | | public string FileName { get; set; } |
| | | |
| | | /// <summary>程序集完整标识(加载成功时)</summary> |
| | | public string AssemblyFullName { get; set; } |
| | | |
| | | /// <summary>加载失败原因(成功时为 null)</summary> |
| | | public string Error { get; set; } |
| | | |
| | | /// <summary>带 [DriverDesc] 注解的驱动类型(按类型名排序)</summary> |
| | | public DriverTypeInfo[] Types { get; set; } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 一个驱动类型的描述信息(来自 DriverDescAttribute)。 |
| | | /// 通过 (类型, 品牌) 可唯一定位到该类型。 |
| | | /// </summary> |
| | | [Serializable] |
| | | public sealed class DriverTypeInfo |
| | | { |
| | | /// <summary>CLR 类型全名</summary> |
| | | public string FullName { get; set; } |
| | | |
| | | /// <summary>注解中的"类型"(如 CAN、串口)</summary> |
| | | public string DriverType { get; set; } |
| | | |
| | | /// <summary>注解中的"品牌"(如 BA、BB)</summary> |
| | | public string Brand { get; set; } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 驱动类型上的一个公共方法。 |
| | | /// </summary> |
| | | [Serializable] |
| | | public sealed class DriverMethodInfo |
| | | { |
| | | /// <summary>方法在该类型方法表中的索引(调用时回传)</summary> |
| | | public int Index { get; set; } |
| | | |
| | | public string Name { get; set; } |
| | | |
| | | /// <summary>可读签名,例如 int ReadStatus(int channel)</summary> |
| | | public string Signature { get; set; } |
| | | |
| | | public bool IsStatic { get; set; } |
| | | |
| | | public int ParameterCount { get; set; } |
| | | |
| | | /// <summary>参数列表(类型名 + 参数名),用于界面提示</summary> |
| | | public string[] Parameters { get; set; } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 反射调用结果。 |
| | | /// </summary> |
| | | [Serializable] |
| | | public sealed class InvokeResult |
| | | { |
| | | public bool Success { get; set; } |
| | | |
| | | /// <summary>返回值文本(成功时)</summary> |
| | | public string Output { get; set; } |
| | | |
| | | /// <summary>错误信息(失败时)</summary> |
| | | public string Error { get; set; } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 直接调用结果:保留原始返回值与 out/ref 回填值, |
| | | /// 供 RealProxy 动态代理把驱动调用适配成强类型接口方法。 |
| | | /// 注意:Value / ArgsOut 只能携带可跨域封送的数据(基元类型、string、[Serializable] DTO)。 |
| | | /// </summary> |
| | | [Serializable] |
| | | public sealed class InvokeDirectResult |
| | | { |
| | | public bool Success { get; set; } |
| | | |
| | | /// <summary>方法返回值(void 方法为 null)</summary> |
| | | public object Value { get; set; } |
| | | |
| | | /// <summary>调用后的参数数组(out/ref 位置为回填值,其余位置原值)</summary> |
| | | public object[] ArgsOut { get; set; } |
| | | |
| | | /// <summary>错误信息(失败时)</summary> |
| | | public string Error { get; set; } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 运行在每个驱动包专属 AppDomain 内的加载器。 |
| | | /// |
| | | /// 设计前提:驱动 dll 来自不同厂商,彼此没有任何共同接口/基类约定, |
| | | /// 甚至互不兼容(同一第三方依赖的不同版本)。 |
| | | /// 因此这里只做通用反射:枚举公开类型、枚举公共方法、反射创建实例、反射调用。 |
| | | /// |
| | | /// 该域的 ApplicationBase 被设置为某个驱动子文件夹, |
| | | /// 所以驱动的全部依赖只能从自己目录解析,天然隔离版本冲突。 |
| | | /// </summary> |
| | | public class DriverLoader : MarshalByRefObject |
| | | { |
| | | private static readonly HashSet<string> FailedAssemblies = |
| | | new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| | | |
| | | private readonly Dictionary<string, Type> _types = new Dictionary<string, Type>(StringComparer.Ordinal); |
| | | private readonly Dictionary<string, MethodInfo[]> _methodCache = new Dictionary<string, MethodInfo[]>(StringComparer.Ordinal); |
| | | private readonly Dictionary<Type, object> _instances = new Dictionary<Type, object>(); |
| | | |
| | | /// <summary>由 DriverManager 创建、按句柄管理的驱动实例</summary> |
| | | private readonly Dictionary<string, ManagedInstance> _managedInstances = |
| | | new Dictionary<string, ManagedInstance>(StringComparer.Ordinal); |
| | | |
| | | /// <summary>驱动索引:(类型, 品牌) → 驱动信息,key 形如 "CAN|BA"</summary> |
| | | private readonly Dictionary<string, DriverTypeInfo> _driverIndex = |
| | | new Dictionary<string, DriverTypeInfo>(StringComparer.OrdinalIgnoreCase); |
| | | |
| | | // 无限生存期,避免远程代理被回收 |
| | | public override object InitializeLifetimeService() => null; |
| | | |
| | | public DriverLoader() |
| | | { |
| | | AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve; |
| | | } |
| | | |
| | | #region 发现 |
| | | |
| | | /// <summary> |
| | | /// 扫描本驱动目录下的所有 dll(忽略宿主自身的 MultiPlugin.* 运行时文件), |
| | | /// 返回每个程序集的信息与公开类型列表。 |
| | | /// </summary> |
| | | public DriverAssemblyInfo[] LoadAssemblies() |
| | | { |
| | | var dir = AppDomain.CurrentDomain.BaseDirectory; |
| | | var result = new List<DriverAssemblyInfo>(); |
| | | |
| | | foreach (var path in Directory.GetFiles(dir, "*.dll").OrderBy(p => p, StringComparer.OrdinalIgnoreCase)) |
| | | { |
| | | var fileName = Path.GetFileName(path); |
| | | if (fileName.StartsWith("MultiPlugin.", StringComparison.OrdinalIgnoreCase)) |
| | | { |
| | | continue; // 宿主基础设施,非驱动 |
| | | } |
| | | |
| | | var info = new DriverAssemblyInfo { FileName = fileName }; |
| | | try |
| | | { |
| | | var assembly = Assembly.Load(Path.GetFileNameWithoutExtension(path)); |
| | | info.AssemblyFullName = assembly.FullName; |
| | | |
| | | // 只收录带 [DriverDesc] 注解的类,其余类型一律忽略 |
| | | var drivers = new List<DriverTypeInfo>(); |
| | | foreach (var type in assembly.GetExportedTypes()) |
| | | { |
| | | if (!type.IsClass || type.IsAbstract) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | var desc = ReadDriverDesc(type); |
| | | if (desc == null) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | _types[type.FullName] = type; |
| | | |
| | | var driverInfo = new DriverTypeInfo |
| | | { |
| | | FullName = type.FullName, |
| | | DriverType = desc.Item1, |
| | | Brand = desc.Item2 |
| | | }; |
| | | drivers.Add(driverInfo); |
| | | _driverIndex[DriverKey(desc.Item1, desc.Item2)] = driverInfo; |
| | | } |
| | | |
| | | info.Types = drivers.OrderBy(t => t.FullName, StringComparer.Ordinal).ToArray(); |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | // 非托管 dll、缺少依赖、架构不匹配等都会到这里 |
| | | info.Error = ex.Message; |
| | | } |
| | | |
| | | result.Add(info); |
| | | } |
| | | |
| | | return result.ToArray(); |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 按 (类型, 品牌) 查找驱动。任一字段留空视为通配。 |
| | | /// </summary> |
| | | /// <returns>命中的第一个驱动信息;未命中返回 null</returns> |
| | | public DriverTypeInfo FindDriver(string driverType, string brand) |
| | | { |
| | | var key = DriverKey(driverType, brand); |
| | | if (_driverIndex.TryGetValue(key, out var exact)) |
| | | { |
| | | return exact; |
| | | } |
| | | |
| | | // 精确未命中且存在通配字段时线性扫描 |
| | | var anyType = key.StartsWith("*|", StringComparison.Ordinal); |
| | | var anyBrand = key.EndsWith("|*", StringComparison.Ordinal); |
| | | if (!anyType && !anyBrand) |
| | | { |
| | | return null; |
| | | } |
| | | |
| | | foreach (var info in _driverIndex.Values) |
| | | { |
| | | if (!anyType && !string.Equals(info.DriverType?.Trim(), driverType?.Trim(), StringComparison.OrdinalIgnoreCase)) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | if (!anyBrand && !string.Equals(info.Brand?.Trim(), brand?.Trim(), StringComparison.OrdinalIgnoreCase)) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | return info; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 用鸭子反射读取 [DriverDesc] 注解的 Type/Brand 属性值。 |
| | | /// 宿主基础设施不引用 Driver.Core,只按"属性名"解耦读取, |
| | | /// 这样厂商 SDK 升级/换版本不会影响宿主。 |
| | | /// </summary> |
| | | private static Tuple<string, string> ReadDriverDesc(Type type) |
| | | { |
| | | foreach (var attr in type.GetCustomAttributes(false)) |
| | | { |
| | | var attrType = attr.GetType(); |
| | | if (!attrType.Name.Equals("DriverDescAttribute", StringComparison.OrdinalIgnoreCase)) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | var typeProp = attrType.GetProperty("Type"); |
| | | var brandProp = attrType.GetProperty("Brand"); |
| | | if (typeProp == null || brandProp == null) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | return Tuple.Create(typeProp.GetValue(attr, null) as string, |
| | | brandProp.GetValue(attr, null) as string); |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | private static string DriverKey(string driverType, string brand) |
| | | { |
| | | var t = string.IsNullOrWhiteSpace(driverType) ? "*" : driverType.Trim().ToUpperInvariant(); |
| | | var b = string.IsNullOrWhiteSpace(brand) ? "*" : brand.Trim().ToUpperInvariant(); |
| | | return $"{t}|{b}"; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 列出某个类型的公共方法(属性访问器会被过滤掉)。 |
| | | /// </summary> |
| | | public DriverMethodInfo[] GetMethods(string typeFullName) |
| | | { |
| | | var type = ResolveType(typeFullName); |
| | | if (type == null) |
| | | { |
| | | return new DriverMethodInfo[0]; |
| | | } |
| | | |
| | | return GetMethodInfos(type) |
| | | .Select((m, i) => |
| | | { |
| | | var parameters = m.GetParameters(); |
| | | return new DriverMethodInfo |
| | | { |
| | | Index = i, |
| | | Name = m.Name, |
| | | IsStatic = m.IsStatic, |
| | | ParameterCount = parameters.Length, |
| | | Signature = BuildSignature(m), |
| | | Parameters = parameters |
| | | .Select(p => $"{FormatTypeName(p.ParameterType)} {p.Name}") |
| | | .ToArray() |
| | | }; |
| | | }) |
| | | .ToArray(); |
| | | } |
| | | |
| | | #endregion |
| | | |
| | | #region 实例管理 |
| | | |
| | | private sealed class ManagedInstance |
| | | { |
| | | public Type Type; |
| | | public object Target; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 创建驱动类型的新实例(不走 Invoke 的默认缓存实例),按句柄登记供 DriverManager 管理。 |
| | | /// </summary> |
| | | public DriverInstance CreateInstance(string typeFullName, string driverType, string brand) |
| | | { |
| | | var type = ResolveType(typeFullName) |
| | | ?? throw new InvalidOperationException($"未找到类型: {typeFullName}"); |
| | | |
| | | var ctor = type.GetConstructor(Type.EmptyTypes) |
| | | ?? throw new MissingMethodException(typeFullName, ".ctor(缺少公共无参构造函数)"); |
| | | |
| | | var target = ctor.Invoke(null); |
| | | |
| | | var handle = Guid.NewGuid().ToString("N"); |
| | | _managedInstances[handle] = new ManagedInstance { Type = type, Target = target }; |
| | | |
| | | return new DriverInstance(this, handle, type.FullName, driverType, brand); |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 在指定句柄的驱动实例上按"原始对象参数"直接调用方法, |
| | | /// 保留原始返回值与 out/ref 回填值(供 RealProxy 动态代理使用)。 |
| | | /// </summary> |
| | | public InvokeDirectResult InvokeOnInstanceDirect(string handle, int methodIndex, object[] args) |
| | | { |
| | | var result = new InvokeDirectResult(); |
| | | if (!_managedInstances.TryGetValue(handle, out var managed)) |
| | | { |
| | | result.Success = false; |
| | | result.Error = $"实例不存在或已释放: {handle}"; |
| | | return result; |
| | | } |
| | | |
| | | var methods = GetMethodInfos(managed.Type); |
| | | if (methodIndex < 0 || methodIndex >= methods.Length) |
| | | { |
| | | result.Success = false; |
| | | result.Error = $"方法索引无效: {methodIndex}(共 {methods.Length} 个方法)"; |
| | | return result; |
| | | } |
| | | |
| | | try |
| | | { |
| | | var method = methods[methodIndex]; |
| | | var parameters = method.GetParameters(); |
| | | args = args ?? new object[0]; |
| | | |
| | | if (parameters.Length != args.Length) |
| | | { |
| | | result.Success = false; |
| | | result.Error = $"参数数量不匹配: {method.Name} 需要 {parameters.Length} 个,实际传入 {args.Length} 个"; |
| | | return result; |
| | | } |
| | | |
| | | var converted = new object[parameters.Length]; |
| | | for (var i = 0; i < parameters.Length; i++) |
| | | { |
| | | converted[i] = CoerceArg(args[i], parameters[i].ParameterType, parameters[i].Name); |
| | | } |
| | | |
| | | result.Value = method.Invoke(managed.Target, converted); |
| | | result.ArgsOut = converted; |
| | | result.Success = true; |
| | | return result; |
| | | } |
| | | catch (TargetInvocationException ex) |
| | | { |
| | | var inner = ex.InnerException ?? ex; |
| | | result.Success = false; |
| | | result.Error = $"{inner.GetType().Name}: {inner.Message}"; |
| | | return result; |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | result.Success = false; |
| | | result.Error = $"{ex.GetType().Name}: {ex.Message}"; |
| | | return result; |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 在指定句柄的驱动实例上反射调用方法(实际执行发生在驱动自己的域内)。 |
| | | /// </summary> |
| | | public InvokeResult InvokeOnInstance(string handle, int methodIndex, string[] rawArgs) |
| | | { |
| | | if (!_managedInstances.TryGetValue(handle, out var managed)) |
| | | { |
| | | return Fail($"实例不存在或已释放: {handle}"); |
| | | } |
| | | |
| | | var methods = GetMethodInfos(managed.Type); |
| | | if (methodIndex < 0 || methodIndex >= methods.Length) |
| | | { |
| | | return Fail($"方法索引无效: {methodIndex}(共 {methods.Length} 个方法)"); |
| | | } |
| | | |
| | | return ExecuteMethod(methods[methodIndex], managed.Target, rawArgs); |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 释放指定句柄的实例;驱动实现 IDisposable 时会调用 Dispose。 |
| | | /// </summary> |
| | | public void ReleaseInstance(string handle) |
| | | { |
| | | if (!_managedInstances.TryGetValue(handle, out var managed)) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | _managedInstances.Remove(handle); |
| | | (managed.Target as IDisposable)?.Dispose(); |
| | | } |
| | | |
| | | #endregion |
| | | |
| | | #region 调用 |
| | | |
| | | /// <summary> |
| | | /// 反射调用指定类型的方法。 |
| | | /// 实例方法会自动用公共无参构造函数创建(并缓存)实例; |
| | | /// 静态方法无需实例。需要长期持有实例请使用 <see cref="CreateInstance"/>。 |
| | | /// </summary> |
| | | /// <param name="rawArgs">字符串形式的实参,由 <see cref="ConvertArg"/> 按目标参数类型转换</param> |
| | | public InvokeResult Invoke(string typeFullName, int methodIndex, string[] rawArgs) |
| | | { |
| | | try |
| | | { |
| | | var type = ResolveType(typeFullName); |
| | | if (type == null) |
| | | { |
| | | return Fail($"未找到类型: {typeFullName}"); |
| | | } |
| | | |
| | | var methods = GetMethodInfos(type); |
| | | if (methodIndex < 0 || methodIndex >= methods.Length) |
| | | { |
| | | return Fail($"方法索引无效: {methodIndex}(共 {methods.Length} 个方法)"); |
| | | } |
| | | |
| | | var method = methods[methodIndex]; |
| | | |
| | | object target = null; |
| | | if (!method.IsStatic) |
| | | { |
| | | target = GetOrCreateInstance(type); |
| | | if (target == null) |
| | | { |
| | | return Fail($"无法实例化 {type.FullName}: 缺少公共无参构造函数。可改为只调用该类型的静态方法。"); |
| | | } |
| | | } |
| | | |
| | | return ExecuteMethod(method, target, rawArgs); |
| | | } |
| | | catch (TargetInvocationException ex) |
| | | { |
| | | // 驱动内部抛出的异常 |
| | | var inner = ex.InnerException ?? ex; |
| | | return Fail($"{inner.GetType().Name}: {inner.Message}"); |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | return Fail($"{ex.GetType().Name}: {ex.Message}"); |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 参数转换 → 反射调用 → 汇总返回值与 out/ref 回填值。 |
| | | /// </summary> |
| | | private static InvokeResult ExecuteMethod(MethodInfo method, object target, string[] rawArgs) |
| | | { |
| | | try |
| | | { |
| | | var parameters = method.GetParameters(); |
| | | rawArgs = rawArgs ?? new string[0]; |
| | | |
| | | if (parameters.Length != rawArgs.Length) |
| | | { |
| | | return Fail($"参数数量不匹配: {method.Name} 需要 {parameters.Length} 个参数,实际传入 {rawArgs.Length} 个"); |
| | | } |
| | | |
| | | var args = new object[parameters.Length]; |
| | | for (var i = 0; i < parameters.Length; i++) |
| | | { |
| | | args[i] = ConvertArg(rawArgs[i], parameters[i].ParameterType, parameters[i].Name); |
| | | } |
| | | |
| | | var returnValue = method.Invoke(target, args); |
| | | |
| | | // 汇总返回值与 out/ref 回填值(如 Open(out string msg) 的 msg) |
| | | var parts = new List<string>(); |
| | | if (method.ReturnType != typeof(void)) |
| | | { |
| | | parts.Add($"返回: {Format(returnValue)}"); |
| | | } |
| | | |
| | | for (var i = 0; i < parameters.Length; i++) |
| | | { |
| | | if (parameters[i].ParameterType.IsByRef) |
| | | { |
| | | parts.Add($"{parameters[i].Name} = {Format(args[i])}"); |
| | | } |
| | | } |
| | | |
| | | return new InvokeResult { Success = true, Output = parts.Count == 0 ? "null" : string.Join("; ", parts) }; |
| | | } |
| | | catch (TargetInvocationException ex) |
| | | { |
| | | // 驱动内部抛出的异常 |
| | | var inner = ex.InnerException ?? ex; |
| | | return Fail($"{inner.GetType().Name}: {inner.Message}"); |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | return Fail($"{ex.GetType().Name}: {ex.Message}"); |
| | | } |
| | | } |
| | | |
| | | private object GetOrCreateInstance(Type type) |
| | | { |
| | | if (_instances.TryGetValue(type, out var existing)) |
| | | { |
| | | return existing; |
| | | } |
| | | |
| | | if (type.IsAbstract || type.IsInterface) |
| | | { |
| | | return null; |
| | | } |
| | | |
| | | var ctor = type.GetConstructor(Type.EmptyTypes); |
| | | if (ctor == null) |
| | | { |
| | | return null; |
| | | } |
| | | |
| | | var instance = ctor.Invoke(null); |
| | | _instances[type] = instance; |
| | | return instance; |
| | | } |
| | | |
| | | #endregion |
| | | |
| | | #region 辅助 |
| | | |
| | | private Type ResolveType(string typeFullName) |
| | | { |
| | | if (_types.TryGetValue(typeFullName, out var type)) |
| | | { |
| | | return type; |
| | | } |
| | | |
| | | // 兜底:在已加载的驱动程序集里查找 |
| | | type = AppDomain.CurrentDomain.GetAssemblies() |
| | | .Where(a => !a.IsDynamic) |
| | | .Select(a => a.GetType(typeFullName)) |
| | | .FirstOrDefault(t => t != null); |
| | | |
| | | if (type != null) |
| | | { |
| | | _types[typeFullName] = type; |
| | | } |
| | | |
| | | return type; |
| | | } |
| | | |
| | | private MethodInfo[] GetMethodInfos(Type type) |
| | | { |
| | | if (_methodCache.TryGetValue(type.FullName, out var cached)) |
| | | { |
| | | return cached; |
| | | } |
| | | |
| | | var methods = type |
| | | .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) |
| | | .Where(m => !m.IsSpecialName) // 过滤 get_/set_/add_/remove_ 等 |
| | | .ToArray(); |
| | | |
| | | _methodCache[type.FullName] = methods; |
| | | return methods; |
| | | } |
| | | |
| | | private static string BuildSignature(MethodInfo method) |
| | | { |
| | | var parameters = method.GetParameters() |
| | | .Select(p => $"{FormatTypeName(p.ParameterType)} {p.Name}"); |
| | | |
| | | return $"{(method.IsStatic ? "static " : string.Empty)}{FormatTypeName(method.ReturnType)} " |
| | | + $"{method.Name}({string.Join(", ", parameters)})"; |
| | | } |
| | | |
| | | private static string FormatTypeName(Type type) |
| | | { |
| | | if (!type.IsGenericType) |
| | | { |
| | | return type.Name; |
| | | } |
| | | |
| | | var name = type.Name.Substring(0, type.Name.IndexOf('`')); |
| | | var args = string.Join(", ", type.GetGenericArguments().Select(FormatTypeName)); |
| | | return $"{name}<{args}>"; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 把跨域代理传来的对象参数适配为目标参数类型: |
| | | /// 类型兼容直接用,否则退化为字符串转换(鸭子式宽松匹配)。 |
| | | /// </summary> |
| | | private static object CoerceArg(object arg, Type targetType, string parameterName) |
| | | { |
| | | if (targetType.IsByRef) |
| | | { |
| | | targetType = targetType.GetElementType(); |
| | | } |
| | | |
| | | if (arg == null) |
| | | { |
| | | return targetType.IsValueType ? Activator.CreateInstance(targetType) : null; |
| | | } |
| | | |
| | | if (targetType.IsInstanceOfType(arg)) |
| | | { |
| | | return arg; |
| | | } |
| | | |
| | | return ConvertArg(arg.ToString(), targetType, parameterName); |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 把 UI 传来的字符串转换为目标参数类型。 |
| | | /// 支持常用基元类型、枚举、Guid、DateTime、字符串数组(以分号分隔)。 |
| | | /// </summary> |
| | | private static object ConvertArg(string raw, Type targetType, string parameterName) |
| | | { |
| | | raw = raw ?? string.Empty; |
| | | |
| | | // out/ref 参数实际按其元素类型转换(调用后由 CLR 回填) |
| | | if (targetType.IsByRef) |
| | | { |
| | | targetType = targetType.GetElementType(); |
| | | } |
| | | |
| | | if (targetType == typeof(string) || targetType == typeof(object)) |
| | | { |
| | | return raw; |
| | | } |
| | | |
| | | if (raw.Length == 0 && !targetType.IsValueType) |
| | | { |
| | | return null; |
| | | } |
| | | |
| | | if (targetType == typeof(bool)) return bool.Parse(raw); |
| | | if (targetType == typeof(char)) return raw[0]; |
| | | if (targetType == typeof(byte)) return byte.Parse(raw); |
| | | if (targetType == typeof(sbyte)) return sbyte.Parse(raw); |
| | | if (targetType == typeof(short)) return short.Parse(raw); |
| | | if (targetType == typeof(ushort)) return ushort.Parse(raw); |
| | | if (targetType == typeof(int)) return int.Parse(raw); |
| | | if (targetType == typeof(uint)) return uint.Parse(raw); |
| | | if (targetType == typeof(long)) return long.Parse(raw); |
| | | if (targetType == typeof(ulong)) return ulong.Parse(raw); |
| | | if (targetType == typeof(float)) return float.Parse(raw); |
| | | if (targetType == typeof(double)) return double.Parse(raw); |
| | | if (targetType == typeof(decimal)) return decimal.Parse(raw); |
| | | if (targetType == typeof(Guid)) return Guid.Parse(raw); |
| | | if (targetType == typeof(DateTime)) return DateTime.Parse(raw); |
| | | if (targetType.IsEnum) return Enum.Parse(targetType, raw, true); |
| | | |
| | | if (targetType.IsArray) |
| | | { |
| | | var elementType = targetType.GetElementType(); |
| | | var parts = raw.Length == 0 ? new string[0] : raw.Split(';'); |
| | | var array = Array.CreateInstance(elementType, parts.Length); |
| | | for (var i = 0; i < parts.Length; i++) |
| | | { |
| | | array.SetValue(ConvertArg(parts[i], elementType, parameterName), i); |
| | | } |
| | | |
| | | return array; |
| | | } |
| | | |
| | | throw new InvalidOperationException( |
| | | $"参数 {parameterName} 的类型 {targetType.Name} 暂不支持从字符串转换(支持:字符串、数字、bool、枚举、Guid、DateTime、数组)"); |
| | | } |
| | | |
| | | private static string Format(object value) |
| | | { |
| | | if (value == null) |
| | | { |
| | | return "null"; |
| | | } |
| | | |
| | | if (value is string text) |
| | | { |
| | | return Truncate(text); |
| | | } |
| | | |
| | | if (value is Array array) |
| | | { |
| | | var items = array.Cast<object>().Select(x => x == null ? "null" : x.ToString()); |
| | | return Truncate($"[{string.Join(", ", items)}]"); |
| | | } |
| | | |
| | | return Truncate(value.ToString()); |
| | | } |
| | | |
| | | private static string Truncate(string text) |
| | | { |
| | | const int max = 4000; |
| | | return text.Length <= max ? text : text.Substring(0, max) + " ...(已截断)"; |
| | | } |
| | | |
| | | private static InvokeResult Fail(string message) => new InvokeResult { Success = false, Error = message }; |
| | | |
| | | /// <summary> |
| | | /// 依赖解析兜底:只在本驱动目录(及加载器所在目录)内查找, |
| | | /// 保证不会串到其他驱动包的同名程序集。 |
| | | /// </summary> |
| | | private static Assembly OnAssemblyResolve(object sender, ResolveEventArgs args) |
| | | { |
| | | var simpleName = new AssemblyName(args.Name).Name; |
| | | if (FailedAssemblies.Contains(simpleName)) |
| | | { |
| | | return null; |
| | | } |
| | | |
| | | var dirs = new List<string>(); |
| | | |
| | | // 优先按加载器所在目录(宿主 bin)解析宿主基础设施,再兜底驱动目录 |
| | | var loaderLocation = typeof(DriverLoader).Assembly.Location; |
| | | var loaderDir = string.IsNullOrEmpty(loaderLocation) ? null : Path.GetDirectoryName(loaderLocation); |
| | | if (!string.IsNullOrEmpty(loaderDir)) |
| | | { |
| | | dirs.Add(loaderDir); |
| | | } |
| | | |
| | | var baseDir = AppDomain.CurrentDomain.BaseDirectory; |
| | | if (!dirs.Contains(baseDir)) |
| | | { |
| | | dirs.Add(baseDir); |
| | | } |
| | | |
| | | foreach (var dir in dirs) |
| | | { |
| | | foreach (var ext in new[] { ".dll", ".exe" }) |
| | | { |
| | | var candidate = Path.Combine(dir, simpleName + ext); |
| | | if (!File.Exists(candidate)) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | try |
| | | { |
| | | return Assembly.LoadFrom(candidate); |
| | | } |
| | | catch |
| | | { |
| | | // 尝试下一个候选 |
| | | } |
| | | } |
| | | } |
| | | |
| | | FailedAssemblies.Add(simpleName); |
| | | return null; |
| | | } |
| | | |
| | | #endregion |
| | | } |
| | | } |
| New file |
| | |
| | | using System.IO; |
| | | |
| | | namespace DeviceService.Driver |
| | | { |
| | | /// <summary> |
| | | /// 一个已隔离加载的驱动包(对应驱动根目录下的一个子文件夹)。 |
| | | /// </summary> |
| | | public sealed class DriverPackage |
| | | { |
| | | /// <summary>子文件夹名(通常为厂商/设备名)</summary> |
| | | public string Folder { get; set; } |
| | | |
| | | /// <summary>驱动包所在物理目录</summary> |
| | | public string Directory { get; set; } |
| | | |
| | | /// <summary>驱动包专属 AppDomain</summary> |
| | | public AppDomain Domain { get; set; } |
| | | |
| | | /// <summary>运行在该域内的加载器代理</summary> |
| | | public DriverLoader Loader { get; set; } |
| | | |
| | | /// <summary>该包内的程序集与公开类型</summary> |
| | | public DriverAssemblyInfo[] Assemblies { get; set; } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// (类型, 品牌) 查找结果。 |
| | | /// </summary> |
| | | public sealed class DriverFindResult |
| | | { |
| | | public DriverPackage Package { get; set; } |
| | | public DriverTypeInfo Info { get; set; } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 驱动管理器:扫描根目录下所有子文件夹,每个子文件夹一个隔离域。 |
| | | /// |
| | | /// 适用于"不同厂商提供的驱动 dll 之间没有任何契约"的场景: |
| | | /// - 驱动 dll 不需要实现接口、不需要引用宿主程序集; |
| | | /// - 宿主只通过反射枚举类型/方法、创建实例、调用方法; |
| | | /// - 每个驱动包自带自己的依赖,互不干扰(可存在同名不同版本); |
| | | /// - 需要替换驱动文件时先卸载域,文件不会被锁定(影子复制)。 |
| | | /// </summary> |
| | | public sealed class DriverManager |
| | | { |
| | | private static DriverManager _instance; |
| | | |
| | | public static DriverManager Instance |
| | | { |
| | | get |
| | | { |
| | | if (_instance == null) |
| | | { |
| | | _instance = new DriverManager(); |
| | | } |
| | | return _instance; |
| | | } |
| | | } |
| | | |
| | | private DriverManager() |
| | | { |
| | | } |
| | | |
| | | private readonly List<DriverPackage> _packages = new List<DriverPackage>(); |
| | | |
| | | /// <summary>由 DriverManager 统一登记管理的驱动实例(跨域透明代理)</summary> |
| | | private readonly Dictionary<string, DriverInstance> _managedDrivers = |
| | | new Dictionary<string, DriverInstance>(StringComparer.Ordinal); |
| | | |
| | | public IReadOnlyList<DriverPackage> Packages => _packages; |
| | | |
| | | /// <summary>当前由 DriverManager 管理的全部驱动实例</summary> |
| | | public IReadOnlyCollection<DriverInstance> ManagedDrivers => _managedDrivers.Values; |
| | | |
| | | /// <summary> |
| | | /// 扫描 rootPath 下的每个子文件夹并创建隔离域加载。 |
| | | /// </summary> |
| | | public void Scan(string rootPath, Action<string> log) |
| | | { |
| | | if (!Directory.Exists(rootPath)) |
| | | { |
| | | log?.Invoke($"[错误] 目录不存在: {rootPath}"); |
| | | return; |
| | | } |
| | | |
| | | var subDirs = Directory.GetDirectories(rootPath); |
| | | if (subDirs.Length == 0) |
| | | { |
| | | log?.Invoke($"[提示] {rootPath} 下没有子文件夹(每个子文件夹应为一个驱动包)"); |
| | | return; |
| | | } |
| | | |
| | | foreach (var dir in subDirs) |
| | | { |
| | | var brandDirs = Directory.GetDirectories(dir); |
| | | |
| | | foreach (var brandDir in brandDirs) |
| | | { |
| | | var name = Path.GetFileName(brandDir); |
| | | |
| | | try |
| | | { |
| | | LoadPackage(brandDir, name, log); |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | log?.Invoke($"[失败] {name}: {ex.Message}"); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 列出某个类型可调用的公共方法。 |
| | | /// </summary> |
| | | public DriverMethodInfo[] GetMethods(DriverPackage package, string typeFullName) |
| | | { |
| | | return package.Loader.GetMethods(typeFullName); |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 在所有已加载驱动包中按 (类型, 品牌) 查找驱动。 |
| | | /// 任一字段留空视为通配;同一组合命中多个包时记录警告并返回第一个。 |
| | | /// </summary> |
| | | public DriverFindResult FindDriver(string driverType, string brand, Action<string> log) |
| | | { |
| | | DriverFindResult first = null; |
| | | |
| | | foreach (var package in _packages) |
| | | { |
| | | var info = package.Loader.FindDriver(driverType, brand); |
| | | if (info == null) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | if (first == null) |
| | | { |
| | | first = new DriverFindResult { Package = package, Info = info }; |
| | | } |
| | | else |
| | | { |
| | | log?.Invoke($"[警告] (类型={driverType}, 品牌={brand}) 在多个驱动包中重复: " |
| | | + $"{first.Package.Folder} 与 {package.Folder},返回第一个"); |
| | | } |
| | | } |
| | | |
| | | return first; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 反射调用驱动方法(实际执行发生在该驱动自己的域内)。 |
| | | /// </summary> |
| | | public InvokeResult Invoke(DriverPackage package, string typeFullName, int methodIndex, string[] args) |
| | | { |
| | | return package.Loader.Invoke(typeFullName, methodIndex, args); |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 根据 (类型, 品牌) 找到对应的驱动类型,在其所属驱动包的 AppDomain 内 |
| | | /// 创建一个新实例并以透明代理形式返回,由 DriverManager 统一登记管理。 |
| | | /// |
| | | /// 说明:驱动类型本身不可跨域封送(非 MarshalByRefObject,宿主也不引用厂商程序集), |
| | | /// 因此无法用泛型 T 返回"原生类型";宿主一律通过 <see cref="DriverInstance"/> 代理操作, |
| | | /// 每次调用实际执行发生在驱动自己的域内。 |
| | | /// </summary> |
| | | /// <param name="driverType">注解中的"类型",如 CAN;留空为通配</param> |
| | | /// <param name="brand">注解中的"品牌",如 BA;留空为通配</param> |
| | | /// <param name="log">日志回调(可空)</param> |
| | | /// <returns>驱动实例代理;未找到或创建失败返回 null</returns> |
| | | public DriverInstance CreateDriverByTypeAndBrand(string driverType, string brand, Action<string> log = null) |
| | | { |
| | | var hit = FindDriver(driverType, brand, log); |
| | | if (hit == null) |
| | | { |
| | | log?.Invoke($"[创建] 未找到 (类型={driverType}, 品牌={brand}) 对应的驱动"); |
| | | return null; |
| | | } |
| | | |
| | | try |
| | | { |
| | | var instance = hit.Package.Loader.CreateInstance(hit.Info.FullName, hit.Info.DriverType, hit.Info.Brand); |
| | | instance.PackageFolder = hit.Package.Folder; |
| | | _managedDrivers[instance.Handle] = instance; |
| | | |
| | | log?.Invoke($"[创建] {hit.Info.FullName}({hit.Info.DriverType}/{hit.Info.Brand},包 {hit.Package.Folder})" |
| | | + $" => 实例 {ShortHandle(instance.Handle)}"); |
| | | return instance; |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | log?.Invoke($"[创建失败] {hit.Info.FullName}: {ex.Message}"); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 泛型版本:T 为宿主自定义的"鸭子接口"(方法名、参数类型与驱动方法一一对应)。 |
| | | /// 返回 DispatchProxy 动态代理:接口调用按"方法名 + 参数个数"转发到驱动域内反射执行。 |
| | | /// |
| | | /// 驱动无需实现 T、无需引用宿主程序集——这正是动态代理的意义: |
| | | /// 不同厂商的驱动可以共享宿主定义的一套接口约定。 |
| | | /// |
| | | /// 约束: |
| | | /// - T 必须是接口(DispatchProxy 只针对接口生成代理类); |
| | | /// - 接口方法的参数/返回值必须可跨域封送(基元类型、string、[Serializable] DTO); |
| | | /// - 每次调用都是一次跨域反射,性能低于直接调用,适合设备控制类低频操作。 |
| | | /// </summary> |
| | | public T CreateDriverByTypeAndBrand<T>(string driverType, string brand, Action<string> log = null) |
| | | where T : class |
| | | { |
| | | var instance = CreateDriverByTypeAndBrand(driverType, brand, log); |
| | | if (instance == null) |
| | | { |
| | | return null; |
| | | } |
| | | |
| | | log?.Invoke($"[代理] {instance.TypeFullName} => {typeof(T).Name} 动态代理(实例 {ShortHandle(instance.Handle)})"); |
| | | return DriverDispatchProxy.Create<T>(instance); |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 释放一个由 DriverManager 管理的驱动实例(驱动实现 IDisposable 时会调用 Dispose)。 |
| | | /// </summary> |
| | | public void ReleaseDriver(DriverInstance instance, Action<string> log = null) |
| | | { |
| | | if (instance == null) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | _managedDrivers.Remove(instance.Handle); |
| | | try |
| | | { |
| | | instance.Release(); |
| | | log?.Invoke($"[释放] 实例 {ShortHandle(instance.Handle)}({instance.TypeFullName})"); |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | log?.Invoke($"[释放失败] 实例 {ShortHandle(instance.Handle)}: {ex.Message}"); |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 卸载某个驱动包前,先通过代理释放其名下所有托管实例(域卸载后代理会失效)。 |
| | | /// </summary> |
| | | private void ReleaseInstancesOf(DriverPackage package, Action<string> log) |
| | | { |
| | | var targets = _managedDrivers.Values |
| | | .Where(i => i.PackageFolder == package.Folder) |
| | | .ToList(); |
| | | |
| | | foreach (var instance in targets) |
| | | { |
| | | ReleaseDriver(instance, log); |
| | | } |
| | | } |
| | | |
| | | private static string ShortHandle(string handle) => handle.Substring(0, Math.Min(8, handle.Length)); |
| | | |
| | | /// <summary> |
| | | /// 卸载所有驱动域(释放程序集、解锁 dll 文件)。 |
| | | /// </summary> |
| | | public void UnloadAll(Action<string> log) |
| | | { |
| | | // 先通过代理释放所有托管实例,域卸载后代理将失效 |
| | | foreach (var instance in _managedDrivers.Values.ToList()) |
| | | { |
| | | ReleaseDriver(instance, log); |
| | | } |
| | | |
| | | var domains = _packages.Select(p => p.Domain).Distinct().ToList(); |
| | | _packages.Clear(); |
| | | |
| | | foreach (var domain in domains) |
| | | { |
| | | // 注意:必须在卸载前读取 FriendlyName,域卸载后再访问其属性会抛 AppDomainUnloadedException |
| | | var name = domain.FriendlyName; |
| | | try |
| | | { |
| | | AppDomain.Unload(domain); |
| | | log?.Invoke($"[卸载] {name}"); |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | log?.Invoke($"[卸载失败] {name}: {ex.Message}"); |
| | | } |
| | | } |
| | | } |
| | | |
| | | private DriverPackage LoadPackage(string dir, string name, Action<string> log) |
| | | { |
| | | // 加载器与 DTO 契约统一使用宿主目录中的唯一副本,不再向驱动目录复制 |
| | | var loaderPath = typeof(DriverLoader).Assembly.Location; |
| | | WarnIfStaleRuntimeFiles(dir, name, log); |
| | | |
| | | var setup = new AppDomainSetup |
| | | { |
| | | ApplicationName = name, |
| | | // 关键:探测根固定为该驱动目录,依赖只会从自己目录解析 |
| | | ApplicationBase = dir, |
| | | // 影子复制:dll 加载后不锁定,可随时卸载/替换文件 |
| | | ShadowCopyFiles = "true", |
| | | ShadowCopyDirectories = dir, |
| | | CachePath = Path.Combine(Path.GetTempPath(), "MultiPluginCache", name) |
| | | }; |
| | | |
| | | var domain = AppDomain.CreateDomain("Driver_" + name, null, setup); |
| | | try |
| | | { |
| | | var loader = (DriverLoader)domain.CreateInstanceFromAndUnwrap(loaderPath, typeof(DriverLoader).FullName); |
| | | |
| | | var assemblies = loader.LoadAssemblies(); |
| | | var package = new DriverPackage |
| | | { |
| | | Folder = name, |
| | | Directory = dir, |
| | | Domain = domain, |
| | | Loader = loader, |
| | | Assemblies = assemblies |
| | | }; |
| | | _packages.Add(package); |
| | | |
| | | // 驱动信息持久化到 Config\Drivers.db |
| | | DriverDatabase.Instance.SavePackage(package, log); |
| | | |
| | | var totalDrivers = assemblies.Where(a => a.Types != null).Sum(a => a.Types.Length); |
| | | var failed = assemblies.Count(a => a.Error != null); |
| | | log?.Invoke($"[驱动包] {name}: {assemblies.Length} 个 dll,{totalDrivers} 个驱动类型" |
| | | + (failed > 0 ? $",{failed} 个无法加载" : string.Empty)); |
| | | |
| | | foreach (var asm in assemblies.Where(a => a.Error != null)) |
| | | { |
| | | log?.Invoke($" [!] {asm.FileName}: {asm.Error}"); |
| | | } |
| | | |
| | | return package; |
| | | } |
| | | catch |
| | | { |
| | | try { AppDomain.Unload(domain); } catch { /* 忽略 */ } |
| | | throw; |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 卸载指定的驱动包(释放该域中的所有程序集)。 |
| | | /// </summary> |
| | | public void Unload(DriverPackage package, Action<string> log) |
| | | { |
| | | // 域卸载前先释放该包名下的托管实例,避免操作失效代理 |
| | | ReleaseInstancesOf(package, log); |
| | | |
| | | _packages.Remove(package); |
| | | |
| | | // 同步移除该驱动包在 Config\Drivers.db 中的记录 |
| | | DriverDatabase.Instance.RemovePackage(package.Folder, log); |
| | | |
| | | try |
| | | { |
| | | AppDomain.Unload(package.Domain); |
| | | log?.Invoke($"[卸载] {package.Folder}"); |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | log?.Invoke($"[卸载失败] {package.Folder}: {ex.Message}"); |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 重载指定的驱动包:卸载旧域后用同一目录重新创建域。 |
| | | /// 依赖影子复制,替换 dll 后无需重启宿主即可生效。 |
| | | /// </summary> |
| | | public DriverPackage Reload(DriverPackage package, Action<string> log) |
| | | { |
| | | var dir = package.Directory; |
| | | var name = package.Folder; |
| | | |
| | | Unload(package, log); |
| | | log?.Invoke($"[重载] {name} ..."); |
| | | |
| | | return LoadPackage(dir, name, log); |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 早期版本会把加载器/DTO 复制到每个驱动目录,此处清理残留,避免旧版本 Contracts 干扰。 |
| | | /// 驱动目录本身不需要、也不应该包含宿主运行时文件。 |
| | | /// </summary> |
| | | private static void WarnIfStaleRuntimeFiles(string dir, string name, Action<string> log) |
| | | { |
| | | foreach (var fileName in new[] { "MultiPlugin.Loader.dll", "MultiPlugin.Contracts.dll" }) |
| | | { |
| | | var path = Path.Combine(dir, fileName); |
| | | if (!File.Exists(path)) |
| | | { |
| | | continue; |
| | | } |
| | | |
| | | try |
| | | { |
| | | File.Delete(path); |
| | | log?.Invoke($"[清理] {name}: 已删除多余的 {fileName}(运行时统一使用宿主目录版本)"); |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | log?.Invoke($"[提示] {name}: 存在多余的 {fileName},已忽略({ex.Message})"); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | using System.ComponentModel; |
| | | using System.Runtime.CompilerServices; |
| | | |
| | | namespace DeviceService.Entity |
| | | { |
| | | public abstract class NotifyBase : INotifyPropertyChanged |
| | | { |
| | | public event PropertyChangedEventHandler? PropertyChanged; |
| | | |
| | | /// <summary> |
| | | /// 设置字段值并触发通知。值与旧值相等(或同为 null)时不触发,返回是否真正赋值。 |
| | | /// </summary> |
| | | protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null) |
| | | { |
| | | if (Equals(field, value)) |
| | | { |
| | | return false; |
| | | } |
| | | |
| | | field = value; |
| | | OnPropertyChanged(propertyName); |
| | | return true; |
| | | } |
| | | |
| | | /// <summary>触发当前属性的通知,propertyName 缺省为调用方属性名。</summary> |
| | | protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) |
| | | { |
| | | RaiseChanged(propertyName); |
| | | } |
| | | |
| | | /// <summary>显式触发指定属性的通知(如依赖属性联动)。</summary> |
| | | protected void OnPropertyChanged(string propertyName, params string[] morePropertyNames) |
| | | { |
| | | RaiseChanged(propertyName); |
| | | foreach (var name in morePropertyNames) |
| | | { |
| | | RaiseChanged(name); |
| | | } |
| | | } |
| | | |
| | | /// <summary>通知全部属性已变更(配合 DataTemplate 时刷新所有绑定,等效于 Text 通知)。</summary> |
| | | protected void RaiseAllChanged() |
| | | { |
| | | RaiseChanged(string.Empty); |
| | | } |
| | | |
| | | private void RaiseChanged(string? propertyName) |
| | | { |
| | | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName ?? string.Empty)); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <Window x:Class="DeviceService.LogWindow" |
| | | 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="日志" Height="480" Width="760" |
| | | Background="{StaticResource PageBackgroundBrush}"> |
| | | <Grid Margin="12"> |
| | | <Grid.RowDefinitions> |
| | | <RowDefinition Height="*" /> |
| | | <RowDefinition Height="Auto" /> |
| | | </Grid.RowDefinitions> |
| | | |
| | | <Border Grid.Row="0" Style="{StaticResource CardBorder}" Padding="4"> |
| | | <ListBox x:Name="LogList" |
| | | Background="Transparent" |
| | | BorderThickness="0" |
| | | FontFamily="Consolas" |
| | | FontSize="12.5" |
| | | ScrollViewer.HorizontalScrollBarVisibility="Auto" /> |
| | | </Border> |
| | | |
| | | <DockPanel Grid.Row="1" Margin="0,10,0,0"> |
| | | <Button x:Name="ClearButton" |
| | | DockPanel.Dock="Right" |
| | | Style="{StaticResource SecondaryButton}" |
| | | MinWidth="72" |
| | | Content="清空" |
| | | Click="ClearButton_Click" /> |
| | | <CheckBox x:Name="AutoScrollBox" |
| | | DockPanel.Dock="Right" |
| | | VerticalAlignment="Center" |
| | | Margin="0,0,12,0" |
| | | IsChecked="True" |
| | | Content="自动滚动" /> |
| | | <TextBlock x:Name="CountText" |
| | | VerticalAlignment="Center" |
| | | Foreground="{StaticResource TextSecondaryBrush}" /> |
| | | </DockPanel> |
| | | </Grid> |
| | | </Window> |
| New file |
| | |
| | | using System; |
| | | using System.Collections.ObjectModel; |
| | | using System.Linq; |
| | | using System.Windows; |
| | | using DeviceService.Logging; |
| | | |
| | | namespace DeviceService; |
| | | |
| | | /// <summary> |
| | | /// 日志窗口:展示 MemoryAppender 内存缓冲中的最近 1000 条日志,并实时追加新日志。 |
| | | /// </summary> |
| | | public partial class LogWindow : Window |
| | | { |
| | | private readonly ObservableCollection<LogEntry> _entries = new ObservableCollection<LogEntry>(); |
| | | |
| | | public LogWindow() |
| | | { |
| | | InitializeComponent(); |
| | | LogList.ItemsSource = _entries; |
| | | Loaded += LogWindow_Loaded; |
| | | Closed += LogWindow_Closed; |
| | | } |
| | | |
| | | private void LogWindow_Loaded(object sender, RoutedEventArgs e) |
| | | { |
| | | foreach (LogEntry entry in MemoryAppender.Current.Snapshot) |
| | | { |
| | | _entries.Add(entry); |
| | | } |
| | | |
| | | UpdateCount(); |
| | | MemoryAppender.Current.EntryAppended += OnEntryAppended; |
| | | ScrollToEnd(); |
| | | } |
| | | |
| | | private void OnEntryAppended(LogEntry entry) |
| | | { |
| | | // 日志事件可能来自后台线程,切回 UI 线程更新列表 |
| | | Dispatcher.BeginInvoke(new Action(() => |
| | | { |
| | | _entries.Add(entry); |
| | | while (_entries.Count > MemoryAppender.Current.Capacity) |
| | | { |
| | | _entries.RemoveAt(0); |
| | | } |
| | | |
| | | UpdateCount(); |
| | | ScrollToEnd(); |
| | | })); |
| | | } |
| | | |
| | | private void ClearButton_Click(object sender, RoutedEventArgs e) |
| | | { |
| | | MemoryAppender.Current.Clear(); |
| | | _entries.Clear(); |
| | | UpdateCount(); |
| | | } |
| | | |
| | | private void UpdateCount() |
| | | { |
| | | CountText.Text = $"共 {_entries.Count} 条(最多保留 {MemoryAppender.Current.Capacity} 条)"; |
| | | } |
| | | |
| | | private void ScrollToEnd() |
| | | { |
| | | if (AutoScrollBox.IsChecked == true && _entries.Count > 0) |
| | | { |
| | | LogList.ScrollIntoView(_entries.Last()); |
| | | } |
| | | } |
| | | |
| | | private void LogWindow_Closed(object? sender, EventArgs e) |
| | | { |
| | | MemoryAppender.Current.EntryAppended -= OnEntryAppended; |
| | | } |
| | | } |
| New file |
| | |
| | | using DeviceService.Core.Api; |
| | | using log4net; |
| | | using System.Collections.Concurrent; |
| | | |
| | | namespace DeviceService.Logging |
| | | { |
| | | public class GlobalLogger : IServiceLogger |
| | | { |
| | | private static GlobalLogger? _logger; |
| | | |
| | | public static GlobalLogger Logger => _logger ??= new GlobalLogger(); |
| | | |
| | | private static ConcurrentDictionary<string, ILog> _allLogger = new ConcurrentDictionary<string, ILog>(); |
| | | private GlobalLogger() |
| | | { |
| | | |
| | | } |
| | | |
| | | private ILog GetLog(string source) |
| | | { |
| | | if (!_allLogger.ContainsKey(source)) |
| | | { |
| | | _allLogger[source] = LogManager.GetLogger(source); |
| | | } |
| | | return _allLogger[source]; |
| | | } |
| | | |
| | | public void Debug(string source, string message) |
| | | { |
| | | GetLog(source).Debug(message); |
| | | } |
| | | |
| | | public void Error(string source, string message, Exception ex = null) |
| | | { |
| | | |
| | | if (ex == null) |
| | | { |
| | | GetLog(source).Error(message); |
| | | } |
| | | else |
| | | { |
| | | GetLog(source).Error(message, ex); |
| | | } |
| | | } |
| | | |
| | | public void Info(string source, string message) |
| | | { |
| | | GetLog(source).Info(message); |
| | | } |
| | | |
| | | public void Warn(string source, string message, Exception ex = null) |
| | | { |
| | | if (ex == null) |
| | | { |
| | | GetLog(source).Warn(message); |
| | | } |
| | | else |
| | | { |
| | | GetLog(source).Warn(message, ex); |
| | | } |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | using log4net; |
| | | using log4net.Appender; |
| | | using log4net.Core; |
| | | |
| | | namespace DeviceService.Logging; |
| | | |
| | | /// <summary>内存缓冲中的一条日志。</summary> |
| | | public sealed class LogEntry |
| | | { |
| | | public DateTime Time { get; set; } |
| | | |
| | | public string Level { get; set; } = string.Empty; |
| | | |
| | | public string Logger { get; set; } = string.Empty; |
| | | |
| | | public string Message { get; set; } = string.Empty; |
| | | |
| | | public string Display => $"{Time:yyyy-MM-dd HH:mm:ss.fff} [{Level}] {Logger} - {Message}"; |
| | | |
| | | public override string ToString() => Display; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 自定义 Appender:只在内存环形缓冲中保留最近 <see cref="Capacity"/> 条日志, |
| | | /// 通过 <see cref="EntryAppended"/> 与 <see cref="Snapshot"/> 供日志窗口查看。 |
| | | /// 文件输出由 log4net.config 中的 RollingFileAppender 负责,本 Appender 不写任何文件。 |
| | | /// 实例由 log4net 依据 Config/log4net.config 创建(appender name="Memory")。 |
| | | /// </summary> |
| | | public sealed class MemoryAppender : AppenderSkeleton |
| | | { |
| | | /// <summary>内存中最多保留的日志条数,可由 log4net.config 的 capacity 参数覆盖。</summary> |
| | | public int Capacity { get; set; } = 1000; |
| | | |
| | | /// <summary>log4net.config 中配置的 MemoryAppender 实例。</summary> |
| | | public static MemoryAppender Current => |
| | | LogManager.GetRepository(typeof(MemoryAppender).Assembly) |
| | | .GetAppenders() |
| | | .OfType<MemoryAppender>() |
| | | .FirstOrDefault() |
| | | ?? throw new InvalidOperationException( |
| | | "未找到 MemoryAppender,请检查 Config/log4net.config 中是否配置了 name=\"Memory\" 的 appender,并在 root 中引用。"); |
| | | |
| | | private readonly object _sync = new object(); |
| | | private readonly Queue<LogEntry> _entries = new Queue<LogEntry>(); |
| | | |
| | | /// <summary>新日志到达(可能在后台线程触发,UI 订阅方需切换回 UI 线程)。</summary> |
| | | public event Action<LogEntry>? EntryAppended; |
| | | |
| | | /// <summary>当前内存中日志的快照。</summary> |
| | | public IReadOnlyList<LogEntry> Snapshot |
| | | { |
| | | get |
| | | { |
| | | lock (_sync) |
| | | { |
| | | return _entries.ToList(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | public MemoryAppender() |
| | | { |
| | | Name = "Memory"; |
| | | } |
| | | |
| | | /// <summary>清空内存缓冲(不影响文件)。</summary> |
| | | public void Clear() |
| | | { |
| | | lock (_sync) |
| | | { |
| | | _entries.Clear(); |
| | | } |
| | | } |
| | | |
| | | protected override void Append(LoggingEvent loggingEvent) |
| | | { |
| | | if (loggingEvent == null) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | string message = loggingEvent.RenderedMessage ?? string.Empty; |
| | | if (loggingEvent.ExceptionObject != null) |
| | | { |
| | | message += Environment.NewLine + loggingEvent.ExceptionObject; |
| | | } |
| | | |
| | | var entry = new LogEntry |
| | | { |
| | | Time = loggingEvent.TimeStamp, |
| | | Level = loggingEvent.Level?.Name ?? string.Empty, |
| | | Logger = loggingEvent.LoggerName, |
| | | Message = message |
| | | }; |
| | | |
| | | lock (_sync) |
| | | { |
| | | _entries.Enqueue(entry); |
| | | while (_entries.Count > Capacity) |
| | | { |
| | | _entries.Dequeue(); |
| | | } |
| | | |
| | | // Monitor 锁可重入,回调内部若调用 Snapshot/Clear 不会死锁 |
| | | EntryAppended?.Invoke(entry); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | <Window x:Class="DeviceService.MainWindow" |
| | | 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:DeviceService" |
| | | xmlns:service="clr-namespace:DeviceService.Service" |
| | | mc:Ignorable="d" |
| | | Title="设备服务" Height="560" Width="960" |
| | | Background="{StaticResource PageBackgroundBrush}"> |
| | | <Grid Margin="12"> |
| | | <Grid.ColumnDefinitions> |
| | | <ColumnDefinition Width="250" /> |
| | | <ColumnDefinition Width="*" /> |
| | | </Grid.ColumnDefinitions> |
| | | |
| | | <!-- 左侧:按 Type 分组的设备树 --> |
| | | <Border Grid.Column="0" Style="{StaticResource CardBorder}" Margin="0,0,10,0"> |
| | | <DockPanel Margin="4"> |
| | | <TextBlock DockPanel.Dock="Top" |
| | | Style="{StaticResource TitleText}" |
| | | Text="设备列表" |
| | | Margin="6,2,0,10" /> |
| | | <TreeView x:Name="DeviceTree" |
| | | Background="Transparent" |
| | | BorderThickness="0" |
| | | ItemContainerStyle="{StaticResource DeviceTreeViewItem}" |
| | | SelectedItemChanged="DeviceTree_SelectedItemChanged"> |
| | | <TreeView.Resources> |
| | | <HierarchicalDataTemplate DataType="{x:Type local:DeviceGroupNode}" |
| | | ItemsSource="{Binding Children}"> |
| | | <StackPanel Orientation="Horizontal"> |
| | | <Border Background="{StaticResource AccentFaintBrush}" |
| | | CornerRadius="4" |
| | | Padding="5,1" |
| | | Margin="0,0,6,0"> |
| | | <TextBlock Text="{Binding Type}" |
| | | FontSize="11" |
| | | FontWeight="Bold" |
| | | Foreground="{StaticResource AccentPressedBrush}" /> |
| | | </Border> |
| | | </StackPanel> |
| | | </HierarchicalDataTemplate> |
| | | <DataTemplate DataType="{x:Type local:DeviceNode}"> |
| | | <TextBlock Text="{Binding DisplayName}" TextTrimming="CharacterEllipsis" /> |
| | | </DataTemplate> |
| | | </TreeView.Resources> |
| | | </TreeView> |
| | | </DockPanel> |
| | | </Border> |
| | | |
| | | <!-- 右侧:工具栏 + 设备配置区域 --> |
| | | <Grid Grid.Column="1"> |
| | | <Grid.RowDefinitions> |
| | | <RowDefinition Height="Auto" /> |
| | | <RowDefinition Height="*" /> |
| | | </Grid.RowDefinitions> |
| | | |
| | | <DockPanel Grid.Row="0" Margin="0,0,0,10"> |
| | | <StackPanel Orientation="Horizontal" DockPanel.Dock="Right" VerticalAlignment="Center"> |
| | | <!-- 服务状态徽标:绑定全局状态机 ServiceStateMachine.GlobalState.State --> |
| | | <Border x:Name="ServiceStateBadge" |
| | | CornerRadius="12" |
| | | Padding="12,5" |
| | | Margin="0,0,12,0" |
| | | VerticalAlignment="Center"> |
| | | <Border.Style> |
| | | <Style TargetType="Border"> |
| | | <Setter Property="Background" Value="#F3F4F6" /> |
| | | <Style.Triggers> |
| | | <DataTrigger Binding="{Binding State, Source={x:Static service:ServiceStateMachine.GlobalState}}" |
| | | Value="Running"> |
| | | <Setter Property="Background" Value="#D1FAE5" /> |
| | | </DataTrigger> |
| | | <DataTrigger Binding="{Binding State, Source={x:Static service:ServiceStateMachine.GlobalState}}" |
| | | Value="Error"> |
| | | <Setter Property="Background" Value="#FEE2E2" /> |
| | | </DataTrigger> |
| | | </Style.Triggers> |
| | | </Style> |
| | | </Border.Style> |
| | | <TextBlock x:Name="ServiceStateText" FontSize="12.5" FontWeight="SemiBold"> |
| | | <TextBlock.Style> |
| | | <Style TargetType="TextBlock"> |
| | | <Setter Property="Text" Value="未启动" /> |
| | | <Setter Property="Foreground" Value="{StaticResource TextSecondaryBrush}" /> |
| | | <Style.Triggers> |
| | | <DataTrigger Binding="{Binding State, Source={x:Static service:ServiceStateMachine.GlobalState}}" |
| | | Value="Running"> |
| | | <Setter Property="Text" Value="运行中" /> |
| | | <Setter Property="Foreground" Value="#047857" /> |
| | | </DataTrigger> |
| | | <DataTrigger Binding="{Binding State, Source={x:Static service:ServiceStateMachine.GlobalState}}" |
| | | Value="Error"> |
| | | <Setter Property="Text" Value="异常" /> |
| | | <Setter Property="Foreground" Value="#B91C1C" /> |
| | | </DataTrigger> |
| | | </Style.Triggers> |
| | | </Style> |
| | | </TextBlock.Style> |
| | | </TextBlock> |
| | | </Border> |
| | | |
| | | <!-- 启动/停止:Running 时文案变为“停止”,点击走停止逻辑 --> |
| | | <Button x:Name="StartServiceBtn" |
| | | MinWidth="92" |
| | | Click="StartService_Click"> |
| | | <Button.Style> |
| | | <Style TargetType="Button" BasedOn="{StaticResource PrimaryButton}"> |
| | | <Setter Property="Content" Value="启动" /> |
| | | <Style.Triggers> |
| | | <DataTrigger Binding="{Binding State, Source={x:Static service:ServiceStateMachine.GlobalState}}" |
| | | Value="Running"> |
| | | <Setter Property="Content" Value="停止" /> |
| | | <Setter Property="Background" Value="#B91C1C" /> |
| | | </DataTrigger> |
| | | </Style.Triggers> |
| | | </Style> |
| | | </Button.Style> |
| | | </Button> |
| | | |
| | | <!-- 添加设备:服务运行中禁止操作 --> |
| | | <Button x:Name="AddDeviceButton" |
| | | MinWidth="92" |
| | | Margin="8,0,0,0" |
| | | Content="添加设备" |
| | | Click="AddDeviceButton_Click"> |
| | | <Button.Style> |
| | | <Style TargetType="Button" BasedOn="{StaticResource PrimaryButton}"> |
| | | <Style.Triggers> |
| | | <DataTrigger Binding="{Binding State, Source={x:Static service:ServiceStateMachine.GlobalState}}" |
| | | Value="Running"> |
| | | <Setter Property="IsEnabled" Value="False" /> |
| | | </DataTrigger> |
| | | </Style.Triggers> |
| | | </Style> |
| | | </Button.Style> |
| | | </Button> |
| | | |
| | | <!-- 刷新:服务运行中禁止操作 --> |
| | | <Button x:Name="RefreshButton" |
| | | MinWidth="72" |
| | | Margin="8,0,0,0" |
| | | Content="刷新" |
| | | Click="RefreshButton_Click"> |
| | | <Button.Style> |
| | | <Style TargetType="Button" BasedOn="{StaticResource SecondaryButton}"> |
| | | <Style.Triggers> |
| | | <DataTrigger Binding="{Binding State, Source={x:Static service:ServiceStateMachine.GlobalState}}" |
| | | Value="Running"> |
| | | <Setter Property="IsEnabled" Value="False" /> |
| | | </DataTrigger> |
| | | </Style.Triggers> |
| | | </Style> |
| | | </Button.Style> |
| | | </Button> |
| | | <Button x:Name="DebugWindowButton" |
| | | Style="{StaticResource SecondaryButton}" |
| | | MinWidth="92" |
| | | Margin="8,0,0,0" |
| | | Content="调试窗口" |
| | | Click="DebugWindowButton_Click" /> |
| | | <Button x:Name="LogWindowButton" |
| | | Style="{StaticResource SecondaryButton}" |
| | | MinWidth="72" |
| | | Margin="8,0,0,0" |
| | | Content="日志" |
| | | Click="LogWindowButton_Click" /> |
| | | </StackPanel> |
| | | <TextBlock x:Name="HeaderText" |
| | | Style="{StaticResource SectionText}" |
| | | Text="设备配置" |
| | | VerticalAlignment="Center" /> |
| | | </DockPanel> |
| | | |
| | | <Border Grid.Row="1" Style="{StaticResource CardBorder}"> |
| | | <ContentControl x:Name="ConfigHost" Margin="4" Focusable="False" /> |
| | | </Border> |
| | | </Grid> |
| | | </Grid> |
| | | </Window> |
| New file |
| | |
| | | using DeviceService.Core; |
| | | using DeviceService.Device; |
| | | using DeviceService.Driver; |
| | | using DeviceService.Logging; |
| | | using DeviceService.Service; |
| | | using System.Collections.ObjectModel; |
| | | using System.IO; |
| | | using System.Windows; |
| | | using System.Windows.Controls; |
| | | using System.Windows.Controls.Primitives; |
| | | using System.Windows.Threading; |
| | | |
| | | namespace DeviceService; |
| | | |
| | | /// <summary> |
| | | /// 设备树分组节点(按 IDevice.Type 分组)。 |
| | | /// </summary> |
| | | public class DeviceGroupNode |
| | | { |
| | | public DeviceGroupNode(string type, IEnumerable<DeviceNode> children) |
| | | { |
| | | Type = type; |
| | | Children = new ObservableCollection<DeviceNode>(children); |
| | | } |
| | | |
| | | public string Type { get; } |
| | | |
| | | public ObservableCollection<DeviceNode> Children { get; } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 设备树叶子节点。 |
| | | /// </summary> |
| | | public class DeviceNode |
| | | { |
| | | public DeviceNode(IDevice device) |
| | | { |
| | | Device = device; |
| | | } |
| | | |
| | | public IDevice Device { get; } |
| | | |
| | | public string DisplayName => $"{Device.DeviceId}({Device.Brand})"; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// Interaction logic for MainWindow.xaml |
| | | /// </summary> |
| | | public partial class MainWindow : Window |
| | | { |
| | | private readonly DriverManager _manager = DriverManager.Instance; |
| | | private readonly ObservableCollection<DeviceGroupNode> _groups = new ObservableCollection<DeviceGroupNode>(); |
| | | |
| | | public MainWindow() |
| | | { |
| | | InitializeComponent(); |
| | | DeviceTree.ItemsSource = _groups; |
| | | Loaded += MainWindow_Loaded; |
| | | } |
| | | |
| | | private void MainWindow_Loaded(object sender, RoutedEventArgs e) |
| | | { |
| | | // 加载驱动包,供设备 Init 时查找驱动 |
| | | _manager.Scan(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Driver"), s => { }); |
| | | |
| | | // 扫描 DeviceDesc 注解,填充 AllDeviceType |
| | | DeviceHelper.ScanDeviceTypes(); |
| | | |
| | | // 以数据库为准恢复设备列表(不调用 Init,避免启动即占用硬件) |
| | | ReloadDeviceList(); |
| | | |
| | | RefreshDeviceTree(); |
| | | } |
| | | |
| | | private void RefreshButton_Click(object sender, RoutedEventArgs e) |
| | | { |
| | | RefreshDeviceTree(); |
| | | } |
| | | |
| | | private void AddDeviceButton_Click(object sender, RoutedEventArgs e) |
| | | { |
| | | var dialog = new AddDeviceWindow { Owner = this }; |
| | | if (dialog.ShowDialog() != true || dialog.Device == null) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | IDevice device = dialog.Device; |
| | | if (!DeviceHelper.SaveDevice(device)) |
| | | { |
| | | HeaderText.Text = $"设备 {device.DeviceId} 保存失败"; |
| | | return; |
| | | } |
| | | |
| | | // 保存成功后从数据库重新读取设备列表,而不是把弹窗里新建的实例直接塞进列表 |
| | | ReloadDeviceList(); |
| | | RefreshDeviceTree(); |
| | | |
| | | HeaderText.Text = $"已添加设备 {device.DeviceId}"; |
| | | } |
| | | |
| | | /// <summary>以 Config\Devices.db 为准刷新内存中的设备列表(不调用 Init)。</summary> |
| | | private static void ReloadDeviceList() |
| | | { |
| | | DeviceManager.Refresh(DeviceHelper.LoadDevices()); |
| | | } |
| | | |
| | | private void StartService_Click(object sender, RoutedEventArgs e) |
| | | { |
| | | // 运行中点击即为“停止” |
| | | if (ServiceStateMachine.GlobalState.State == ServiceStatus.Running) |
| | | { |
| | | ServiceManager.Stop(); |
| | | HeaderText.Text = "服务已停止"; |
| | | return; |
| | | } |
| | | |
| | | try |
| | | { |
| | | ServiceManager.Start(); |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | MessageBox.Show(ex.Message); |
| | | } |
| | | } |
| | | |
| | | private void DebugWindowButton_Click(object sender, RoutedEventArgs e) |
| | | { |
| | | var debug = new DebugWindow { Owner = this }; |
| | | debug.Show(); |
| | | } |
| | | |
| | | private void LogWindowButton_Click(object sender, RoutedEventArgs e) |
| | | { |
| | | var log = new LogWindow { Owner = this }; |
| | | log.Show(); |
| | | } |
| | | |
| | | private void DeviceTree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e) |
| | | { |
| | | if (e.NewValue is DeviceNode node) |
| | | { |
| | | ShowDeviceConfig(node.Device); |
| | | } |
| | | else |
| | | { |
| | | ConfigHost.Content = null; |
| | | HeaderText.Text = "设备配置"; |
| | | } |
| | | } |
| | | |
| | | private void ShowDeviceConfig(IDevice device) |
| | | { |
| | | HeaderText.Text = $"{device.Type} - {device.DeviceId}"; |
| | | |
| | | UserControl? control = DeviceConfigControlFactory.Create(device); |
| | | if (control == null) |
| | | { |
| | | // 未按命名规则找到对应配置控件 |
| | | ConfigHost.Content = new TextBlock |
| | | { |
| | | Text = $"未找到 {device.GetType().Name}ConfigControl,无法显示该设备的配置界面。", |
| | | Margin = new Thickness(8), |
| | | TextWrapping = TextWrapping.Wrap, |
| | | TextAlignment = TextAlignment.Center, |
| | | VerticalAlignment = VerticalAlignment.Center |
| | | }; |
| | | return; |
| | | } |
| | | |
| | | control.DataContext = device; |
| | | ConfigHost.Content = control; |
| | | } |
| | | |
| | | private void RefreshDeviceTree() |
| | | { |
| | | string? selectedId = (DeviceTree.SelectedItem as DeviceNode)?.Device.DeviceId; |
| | | |
| | | _groups.Clear(); |
| | | foreach (var group in DeviceManager.Devices |
| | | .GroupBy(d => d.Type) |
| | | .OrderBy(g => g.Key, StringComparer.Ordinal)) |
| | | { |
| | | _groups.Add(new DeviceGroupNode(group.Key, group.Select(d => new DeviceNode(d)))); |
| | | } |
| | | |
| | | // 容器在本次布局之后才生成,延后展开,确保新增节点能显示出来 |
| | | Dispatcher.BeginInvoke(new Action(ExpandAllGroups), DispatcherPriority.Loaded); |
| | | } |
| | | |
| | | /// <summary>展开所有分组节点。</summary> |
| | | private void ExpandAllGroups() |
| | | { |
| | | if (DeviceTree.ItemContainerGenerator.Status != GeneratorStatus.ContainersGenerated) |
| | | { |
| | | EventHandler? handler = null; |
| | | handler = (s, e) => |
| | | { |
| | | if (DeviceTree.ItemContainerGenerator.Status == GeneratorStatus.ContainersGenerated) |
| | | { |
| | | DeviceTree.ItemContainerGenerator.StatusChanged -= handler; |
| | | ExpandAllGroups(); |
| | | } |
| | | }; |
| | | DeviceTree.ItemContainerGenerator.StatusChanged += handler; |
| | | return; |
| | | } |
| | | |
| | | for (int i = 0; i < DeviceTree.Items.Count; i++) |
| | | { |
| | | if (DeviceTree.ItemContainerGenerator.ContainerFromIndex(i) is TreeViewItem item) |
| | | { |
| | | item.IsExpanded = true; |
| | | } |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | namespace DeviceService.Service |
| | | { |
| | | public interface IClient |
| | | { |
| | | bool Send(string data, out string msg); |
| | | bool Send(byte[] data, out string msg); |
| | | } |
| | | } |
| New file |
| | |
| | | using DeviceService.Core; |
| | | |
| | | namespace DeviceService.Service |
| | | { |
| | | |
| | | public class RequestArg |
| | | { |
| | | public RequestData Data { get; set; } |
| | | public string Raw { get; set; } |
| | | public IClient Client { get; set; } |
| | | } |
| | | |
| | | public interface IService : IDisposable |
| | | { |
| | | event EventHandler<IClient> ClientConnected; |
| | | event EventHandler<IClient> ClientDisConnected; |
| | | event EventHandler<RequestArg> DataReceived; |
| | | |
| | | bool Start(out string msg); |
| | | |
| | | bool Stop(out string msg); |
| | | } |
| | | } |
| New file |
| | |
| | | using System.IO; |
| | | using System.IO.Pipes; |
| | | using System.Text; |
| | | |
| | | namespace DeviceService.Service.NamePipe |
| | | { |
| | | /// <summary> |
| | | /// 命名管道客户端连接封装。 |
| | | /// 每个实例对应管道服务端上的一条已建立连接,收发均通过 <see cref="StreamReader"/> / <see cref="StreamWriter"/> 完成。 |
| | | /// 协议约定:每条消息为一行 UTF-8 文本(以 \n 结尾),发送端单次写入“内容+换行”, |
| | | /// 使 Message 模式下一条消息恰好是完整的一行。 |
| | | /// </summary> |
| | | public class NamePipeClient : IClient, IDisposable |
| | | { |
| | | /// <summary>单条消息允许的最大字节数(16MB)。</summary> |
| | | internal const int MaxMessageLength = 16 * 1024 * 1024; |
| | | |
| | | /// <summary>等待发送锁的超时时间(毫秒)。</summary> |
| | | private const int SendLockTimeout = 5000; |
| | | |
| | | /// <summary>消息行结束符。StreamWriter 每次写入都会触发 AutoFlush,必须与内容在同一次 Write 中写入。</summary> |
| | | private const char LineEnding = '\n'; |
| | | |
| | | private readonly NamedPipeServerStream _stream; |
| | | private readonly StreamWriter writer; |
| | | private readonly StreamReader reader; |
| | | private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); |
| | | private volatile bool _disposed; |
| | | |
| | | public NamePipeClient(NamedPipeServerStream serverStream) |
| | | { |
| | | var encoding = new UTF8Encoding(false); |
| | | _stream = serverStream; |
| | | |
| | | reader = new StreamReader(serverStream, encoding, false, 1024, true); |
| | | writer = new StreamWriter(serverStream, encoding, 1024, true) { AutoFlush = true }; |
| | | } |
| | | |
| | | /// <summary>连接是否仍然可用。</summary> |
| | | public bool IsConnected |
| | | { |
| | | get |
| | | { |
| | | if (_disposed) |
| | | { |
| | | return false; |
| | | } |
| | | return _stream.IsConnected; |
| | | } |
| | | } |
| | | |
| | | public bool Send(string data, out string msg) |
| | | { |
| | | if (data == null) |
| | | { |
| | | msg = "待发送的数据不能为空。"; |
| | | return false; |
| | | } |
| | | |
| | | return SendLine(data, Encoding.UTF8.GetByteCount(data) + 1, out msg); |
| | | } |
| | | |
| | | public bool Send(byte[] data, out string msg) |
| | | { |
| | | if (data == null) |
| | | { |
| | | msg = "待发送的数据不能为空。"; |
| | | return false; |
| | | } |
| | | |
| | | // 负载按 UTF-8 文本处理,与行协议保持一致 |
| | | return SendLine(Encoding.UTF8.GetString(data), data.Length + 1, out msg); |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 写入一行消息。内容与换行符必须单次 Write 写入,配合 AutoFlush 才能成为一条完整消息。 |
| | | /// </summary> |
| | | private bool SendLine(string text, int byteLength, out string msg) |
| | | { |
| | | if (byteLength > MaxMessageLength) |
| | | { |
| | | msg = $"数据长度 {byteLength} 字节超过单条消息上限 {MaxMessageLength} 字节。"; |
| | | return false; |
| | | } |
| | | |
| | | if (_disposed) |
| | | { |
| | | msg = "管道连接已关闭,无法发送数据。"; |
| | | return false; |
| | | } |
| | | |
| | | if (!IsConnected) |
| | | { |
| | | msg = "管道连接已断开,无法发送数据。"; |
| | | return false; |
| | | } |
| | | |
| | | bool lockTaken = false; |
| | | try |
| | | { |
| | | lockTaken = _sendLock.Wait(SendLockTimeout); |
| | | if (!lockTaken) |
| | | { |
| | | msg = "发送超时:上一条数据仍未写出。"; |
| | | return false; |
| | | } |
| | | |
| | | // writer 非 thread-safe,统一由 _sendLock 串行化 |
| | | writer.Write(text + LineEnding); |
| | | writer.Flush(); |
| | | msg = string.Empty; |
| | | return true; |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | msg = $"发送失败:{ex.Message}"; |
| | | return false; |
| | | } |
| | | finally |
| | | { |
| | | if (lockTaken) |
| | | { |
| | | _sendLock.Release(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 读取一行消息。返回 null 表示对端已关闭连接。 |
| | | /// </summary> |
| | | internal async Task<string?> ReadMessageAsync() |
| | | { |
| | | if (_disposed) |
| | | { |
| | | return null; |
| | | } |
| | | |
| | | string? line = await reader.ReadLineAsync().ConfigureAwait(false); |
| | | return line; |
| | | } |
| | | |
| | | public void Dispose() |
| | | { |
| | | if (_disposed) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | _disposed = true; |
| | | try |
| | | { |
| | | reader.Dispose(); |
| | | } |
| | | catch |
| | | { |
| | | // 忽略关闭异常 |
| | | } |
| | | |
| | | try |
| | | { |
| | | writer.Dispose(); |
| | | } |
| | | catch |
| | | { |
| | | // 忽略关闭异常 |
| | | } |
| | | |
| | | try |
| | | { |
| | | _stream.Dispose(); |
| | | } |
| | | catch |
| | | { |
| | | // 关闭过程中的异常可以忽略 |
| | | } |
| | | |
| | | _sendLock.Dispose(); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | using DeviceService.Core; |
| | | using System.IO; |
| | | using System.IO.Pipes; |
| | | using System.Text.Json; |
| | | |
| | | namespace DeviceService.Service.NamePipe |
| | | { |
| | | /// <summary> |
| | | /// 基于命名管道的服务端实现(Message 模式)。 |
| | | /// 启动后持续监听客户端连接,收到的每条消息通过 <see cref="DataReceived"/> 事件上抛, |
| | | /// 并携带可回写数据的 <see cref="NamePipeClient"/> 实例。 |
| | | /// </summary> |
| | | public class NamePipeService : IService, IDisposable |
| | | { |
| | | /// <summary>默认允许的最大管道实例数(即最大并发连接数)。</summary> |
| | | public const int DefaultMaxInstances = 254; |
| | | |
| | | /// <summary>停止服务时等待后台任务结束的最长时间。</summary> |
| | | private static readonly TimeSpan StopTimeout = TimeSpan.FromSeconds(3); |
| | | |
| | | private readonly string _pipeName; |
| | | private readonly int _maxInstances; |
| | | private readonly object _sync = new object(); |
| | | private readonly Dictionary<NamePipeClient, Task> _clients = new Dictionary<NamePipeClient, Task>(); |
| | | |
| | | private CancellationTokenSource? _cts; |
| | | private NamedPipeServerStream? _listenStream; |
| | | private Task? _acceptTask; |
| | | private bool _running; |
| | | private bool _disposed; |
| | | |
| | | /// <param name="pipeName">管道名称,为空时在 Start 阶段校验失败。</param> |
| | | /// <param name="maxInstances">最大并发连接数。</param> |
| | | public NamePipeService(string? pipeName = null, int maxInstances = DefaultMaxInstances) |
| | | { |
| | | if (maxInstances <= 0) |
| | | { |
| | | throw new ArgumentOutOfRangeException(nameof(maxInstances), "最大实例数必须大于 0。"); |
| | | } |
| | | _pipeName = pipeName ?? string.Empty; |
| | | _maxInstances = maxInstances; |
| | | } |
| | | public event EventHandler<IClient>? ClientConnected; |
| | | public event EventHandler<IClient>? ClientDisConnected; |
| | | public event EventHandler<RequestArg>? DataReceived; |
| | | |
| | | /// <summary>管道名称。</summary> |
| | | public string PipeName => _pipeName; |
| | | |
| | | /// <summary>服务是否处于监听状态。</summary> |
| | | public bool IsRunning |
| | | { |
| | | get |
| | | { |
| | | lock (_sync) |
| | | { |
| | | return _running; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /// <summary>当前已连接的客户端数量。</summary> |
| | | public int ClientCount |
| | | { |
| | | get |
| | | { |
| | | lock (_sync) |
| | | { |
| | | return _clients.Count; |
| | | } |
| | | } |
| | | } |
| | | |
| | | public bool Start(out string msg) |
| | | { |
| | | lock (_sync) |
| | | { |
| | | if (_disposed) |
| | | { |
| | | msg = "服务已释放,无法启动。"; |
| | | return false; |
| | | } |
| | | |
| | | if (_running) |
| | | { |
| | | msg = "服务已处于运行状态。"; |
| | | return false; |
| | | } |
| | | |
| | | if (string.IsNullOrWhiteSpace(_pipeName)) |
| | | { |
| | | msg = "管道名称未设置,无法启动。"; |
| | | return false; |
| | | } |
| | | |
| | | NamedPipeServerStream listenStream; |
| | | try |
| | | { |
| | | listenStream = CreatePipeStream(); |
| | | } |
| | | catch (Exception ex) |
| | | { |
| | | msg = $"创建命名管道 \"{_pipeName}\" 失败:{ex.Message}"; |
| | | return false; |
| | | } |
| | | |
| | | var cts = new CancellationTokenSource(); |
| | | _cts = cts; |
| | | _listenStream = listenStream; |
| | | _running = true; |
| | | _acceptTask = Task.Run(() => AcceptLoopAsync(listenStream, cts.Token)); |
| | | } |
| | | |
| | | msg = $"命名管道 \"{_pipeName}\" 已启动监听。"; |
| | | return true; |
| | | } |
| | | |
| | | public bool Stop(out string msg) |
| | | { |
| | | Task? acceptTask; |
| | | List<Task> clientTasks = new List<Task>(); |
| | | |
| | | lock (_sync) |
| | | { |
| | | if (!_running) |
| | | { |
| | | msg = "服务未运行。"; |
| | | return false; |
| | | } |
| | | |
| | | _running = false; |
| | | acceptTask = _acceptTask; |
| | | _acceptTask = null; |
| | | |
| | | try |
| | | { |
| | | _cts?.Cancel(); |
| | | } |
| | | catch |
| | | { |
| | | // 忽略取消过程中的异常 |
| | | } |
| | | |
| | | try |
| | | { |
| | | _listenStream?.Dispose(); |
| | | } |
| | | catch |
| | | { |
| | | // 忽略关闭监听流的异常 |
| | | } |
| | | |
| | | _listenStream = null; |
| | | |
| | | // 关闭所有客户端连接,使阻塞中的读取立即结束 |
| | | foreach (KeyValuePair<NamePipeClient, Task> item in _clients) |
| | | { |
| | | try |
| | | { |
| | | item.Key.Dispose(); |
| | | } |
| | | catch |
| | | { |
| | | // 忽略单个连接的关闭异常 |
| | | } |
| | | |
| | | clientTasks.Add(item.Value); |
| | | } |
| | | |
| | | _clients.Clear(); |
| | | |
| | | _cts?.Dispose(); |
| | | _cts = null; |
| | | } |
| | | |
| | | try |
| | | { |
| | | acceptTask?.Wait(StopTimeout); |
| | | if (clientTasks.Count > 0) |
| | | { |
| | | Task.WaitAll(clientTasks.ToArray(), StopTimeout); |
| | | } |
| | | } |
| | | catch (AggregateException) |
| | | { |
| | | // 后台任务在校验/取消阶段抛出的异常无需向上传播 |
| | | } |
| | | |
| | | msg = "服务已停止。"; |
| | | return true; |
| | | } |
| | | |
| | | private NamedPipeServerStream CreatePipeStream() |
| | | { |
| | | var pipe = new NamedPipeServerStream( |
| | | _pipeName, |
| | | PipeDirection.InOut, |
| | | _maxInstances, |
| | | PipeTransmissionMode.Message, |
| | | PipeOptions.Asynchronous); |
| | | pipe.ReadMode = PipeTransmissionMode.Message; |
| | | return pipe; |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 连接接受循环:每接受一个连接就交给独立的处理任务,并准备下一个管道实例继续监听。 |
| | | /// </summary> |
| | | private async Task AcceptLoopAsync(NamedPipeServerStream initial, CancellationToken token) |
| | | { |
| | | NamedPipeServerStream? listenStream = initial; |
| | | try |
| | | { |
| | | while (!token.IsCancellationRequested) |
| | | { |
| | | NamedPipeServerStream stream = listenStream!; |
| | | listenStream = null; |
| | | |
| | | try |
| | | { |
| | | await stream.WaitForConnectionAsync(token).ConfigureAwait(false); |
| | | } |
| | | catch (OperationCanceledException) |
| | | { |
| | | stream.Dispose(); |
| | | break; |
| | | } |
| | | catch (ObjectDisposedException) |
| | | { |
| | | break; |
| | | } |
| | | catch (IOException) |
| | | { |
| | | stream.Dispose(); |
| | | break; |
| | | } |
| | | |
| | | // .NET Framework 下 WaitForConnectionAsync 可能提前返回,此处校验连接确实建立 |
| | | if (!stream.IsConnected) |
| | | { |
| | | try |
| | | { |
| | | await Task.Delay(50, token).ConfigureAwait(false); |
| | | } |
| | | catch (OperationCanceledException) |
| | | { |
| | | break; |
| | | } |
| | | |
| | | listenStream = stream; |
| | | continue; |
| | | } |
| | | |
| | | var client = new NamePipeClient(stream); |
| | | Task handler = Task.Run(() => HandleClientAsync(client, token)); |
| | | RegisterClient(client, handler); |
| | | |
| | | // 为下一个连接准备新的管道实例 |
| | | try |
| | | { |
| | | listenStream = CreatePipeStream(); |
| | | } |
| | | catch (Exception) |
| | | { |
| | | break; |
| | | } |
| | | |
| | | lock (_sync) |
| | | { |
| | | if (!_running) |
| | | { |
| | | break; |
| | | } |
| | | |
| | | _listenStream = listenStream; |
| | | } |
| | | } |
| | | } |
| | | catch (Exception) |
| | | { |
| | | // 监听循环中的未知异常直接结束循环,由 Stop 负责收尾 |
| | | } |
| | | finally |
| | | { |
| | | try |
| | | { |
| | | listenStream?.Dispose(); |
| | | } |
| | | catch |
| | | { |
| | | // 忽略关闭异常 |
| | | } |
| | | |
| | | lock (_sync) |
| | | { |
| | | _listenStream = null; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /// <summary> |
| | | /// 单个连接的读取循环:按行读取消息并触发 <see cref="DataReceived"/>。 |
| | | /// </summary> |
| | | private async Task HandleClientAsync(NamePipeClient client, CancellationToken token) |
| | | { |
| | | OnClientEvent(ClientConnected, client); |
| | | try |
| | | { |
| | | while (!token.IsCancellationRequested) |
| | | { |
| | | string? message = await client.ReadMessageAsync().ConfigureAwait(false); |
| | | if (message == null) |
| | | { |
| | | break; |
| | | } |
| | | |
| | | RaiseDataReceived(client, message); |
| | | } |
| | | } |
| | | catch (OperationCanceledException) |
| | | { |
| | | // 服务停止导致的正常取消 |
| | | } |
| | | catch (Exception) |
| | | { |
| | | // 连接断开、数据非法等情况直接结束该连接的读取 |
| | | } |
| | | finally |
| | | { |
| | | OnClientEvent(ClientDisConnected, client); |
| | | UnregisterClient(client); |
| | | client.Dispose(); |
| | | } |
| | | } |
| | | |
| | | private void OnClientEvent(EventHandler<IClient>? handler, IClient client) |
| | | { |
| | | if (handler == null) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | try |
| | | { |
| | | handler(this, client); |
| | | } |
| | | catch |
| | | { |
| | | // 订阅方的异常不应影响连接的建立与释放 |
| | | } |
| | | } |
| | | |
| | | private void RaiseDataReceived(NamePipeClient client, string raw) |
| | | { |
| | | var data = JsonSerializer.Deserialize<RequestData>(raw); |
| | | var arg = new RequestArg |
| | | { |
| | | Data = data, |
| | | Raw = raw, |
| | | Client = client |
| | | }; |
| | | OnDataReceived(arg); |
| | | } |
| | | |
| | | private void RegisterClient(NamePipeClient client, Task handler) |
| | | { |
| | | lock (_sync) |
| | | { |
| | | if (_running) |
| | | { |
| | | _clients[client] = handler; |
| | | return; |
| | | } |
| | | } |
| | | |
| | | // 服务正在停止,直接释放该连接 |
| | | client.Dispose(); |
| | | } |
| | | |
| | | private void UnregisterClient(NamePipeClient client) |
| | | { |
| | | lock (_sync) |
| | | { |
| | | _clients.Remove(client); |
| | | } |
| | | } |
| | | |
| | | private void OnDataReceived(RequestArg arg) |
| | | { |
| | | EventHandler<RequestArg>? handler = DataReceived; |
| | | if (handler == null) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | try |
| | | { |
| | | handler?.Invoke(this, arg); |
| | | } |
| | | catch |
| | | { |
| | | // 订阅方的异常不应中断管道读取 |
| | | } |
| | | } |
| | | |
| | | public void Dispose() |
| | | { |
| | | if (_disposed) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | try |
| | | { |
| | | Stop(out _); |
| | | } |
| | | catch |
| | | { |
| | | // 释放阶段的异常忽略 |
| | | } |
| | | |
| | | _disposed = true; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | using DeviceService.Device; |
| | | using DeviceService.Entity; |
| | | using DeviceService.Logging; |
| | | using DeviceService.Service.NamePipe; |
| | | using System.Windows; |
| | | |
| | | namespace DeviceService.Service |
| | | { |
| | | public enum ServiceStatus |
| | | { |
| | | Idle = 0, |
| | | Running = 1, |
| | | Error = 2, |
| | | } |
| | | |
| | | public class ServiceStateMachine : NotifyBase |
| | | { |
| | | public static ServiceStateMachine GlobalState = new ServiceStateMachine(); |
| | | |
| | | private ServiceStateMachine() |
| | | { |
| | | |
| | | } |
| | | |
| | | private ServiceStatus _state; |
| | | public ServiceStatus State |
| | | { |
| | | get => _state; |
| | | set |
| | | { |
| | | SetProperty(ref _state, value); |
| | | } |
| | | } |
| | | |
| | | private bool _isRunning; |
| | | public bool IsRunning |
| | | { |
| | | get => _isRunning; |
| | | set |
| | | { |
| | | SetProperty(ref _isRunning, value); |
| | | } |
| | | } |
| | | |
| | | } |
| | | |
| | | public class ServiceManager |
| | | { |
| | | private static IService _serviceImpl; |
| | | |
| | | private const string PipeName = "DeviceService"; |
| | | |
| | | public static void Start() |
| | | { |
| | | if (ServiceStateMachine.GlobalState.State == ServiceStatus.Running) |
| | | { |
| | | MessageBox.Show("已启动"); |
| | | return; |
| | | } |
| | | _serviceImpl = new NamePipeService(PipeName); |
| | | _serviceImpl.DataReceived += PipeService_DataReceived; |
| | | _serviceImpl.ClientConnected += PipeService_ClientConnected; |
| | | _serviceImpl.ClientDisConnected += PipeService_ClientDisConnected; |
| | | |
| | | string err = string.Empty; |
| | | bool allDone = true; |
| | | |
| | | foreach (var device in DeviceHelper.AllDevices) |
| | | { |
| | | if (!DeviceManager.Register(device, out string fail)) |
| | | { |
| | | allDone = false; |
| | | err += $"{fail}\r\n"; |
| | | } |
| | | } |
| | | |
| | | if (!allDone && MessageBox.Show(err, "提示", MessageBoxButton.OKCancel) == MessageBoxResult.Cancel) |
| | | { |
| | | DeviceManager.UnRegisterAll(); |
| | | ServiceStateMachine.GlobalState.State = ServiceStatus.Error; |
| | | return; |
| | | } |
| | | |
| | | |
| | | if (!_serviceImpl.Start(out string msg)) |
| | | { |
| | | GlobalLogger.Logger.Error("ServiceManager", $"[服务] 管道服务启动失败:{msg}"); |
| | | Stop(); |
| | | // Stop 会把状态置为 Idle,这里再标记为异常,供界面显示 |
| | | ServiceStateMachine.GlobalState.State = ServiceStatus.Error; |
| | | throw new Exception(msg); |
| | | } |
| | | ServiceStateMachine.GlobalState.State = ServiceStatus.Running; |
| | | } |
| | | |
| | | public static void Stop() |
| | | { |
| | | if (_serviceImpl == null) |
| | | { |
| | | return; |
| | | } |
| | | |
| | | _serviceImpl.DataReceived -= PipeService_DataReceived; |
| | | _serviceImpl.ClientConnected -= PipeService_ClientConnected; |
| | | _serviceImpl.ClientDisConnected -= PipeService_ClientDisConnected; |
| | | _serviceImpl.Stop(out _); |
| | | _serviceImpl.Dispose(); |
| | | _serviceImpl = null; |
| | | ServiceStateMachine.GlobalState.State = ServiceStatus.Idle; |
| | | } |
| | | |
| | | |
| | | private static void PipeService_DataReceived(object? sender, RequestArg e) |
| | | { |
| | | // 数据到达事件来自后台线程,需要切换到 UI 线程显示 |
| | | DeviceManager.HandleClientMessage(e); |
| | | } |
| | | |
| | | private static void PipeService_ClientConnected(object? sender, IClient client) |
| | | { |
| | | // 连接/断开事件来自后台线程,列表更新需切换到 UI 线程 |
| | | |
| | | } |
| | | |
| | | private static void PipeService_ClientDisConnected(object? sender, IClient client) |
| | | { |
| | | |
| | | } |
| | | } |
| | | } |