Merge branch 'Develop/XP' into turbo-002-cnc

# Conflicts:
#	XplorePlane/Views/Main/MainWindow.xaml
This commit is contained in:
zhengxuan.zhang
2026-05-22 17:17:29 +08:00
165 changed files with 60389 additions and 420 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-13.0.0.0" newVersion="13.0.0.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
+74
View File
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<Config>
<Group ID="WriteCommon" DBNumber="31">
<Signal Name="SoftLive" Type="byte" StartAddr="0" IndexOrLength="" Remark="软件心跳,01周期变化表示PLC存活" />
<Signal Name="EmergencyStop" Type="byte" StartAddr="5" IndexOrLength="" Remark="急停,0:缺省,1:触发急停" />
<Signal Name="MC_SourceZ_Target" Type="single" StartAddr="10" IndexOrLength="" Remark="射线源Z目标位置" />
<Signal Name="MC_SourceZ_Speed" Type="single" StartAddr="14" IndexOrLength="" Remark="射线源Z运动速度" />
<Signal Name="MC_SourceZ_JogPos" Type="byte" StartAddr="18" IndexOrLength="" Remark="射线源Z正向Jog0:缺省,1:正向点动" />
<Signal Name="MC_SourceZ_JogNeg" Type="byte" StartAddr="19" IndexOrLength="" Remark="射线源Z反向Jog0:缺省,1:反向点动" />
<Signal Name="MC_SourceZ_Home" Type="byte" StartAddr="20" IndexOrLength="" Remark="射线源Z回零,0:缺省,1:触发回零" />
<Signal Name="MC_SourceZ_Stop" Type="byte" StartAddr="21" IndexOrLength="" Remark="射线源Z停止,0:缺省,1:触发停止" />
<Signal Name="MC_DetZ_Target" Type="single" StartAddr="22" IndexOrLength="" Remark="探测器Z目标位置" />
<Signal Name="MC_DetZ_Speed" Type="single" StartAddr="26" IndexOrLength="" Remark="探测器Z运动速度" />
<Signal Name="MC_DetZ_JogPos" Type="byte" StartAddr="30" IndexOrLength="" Remark="探测器Z正向Jog0:缺省,1:正向点动" />
<Signal Name="MC_DetZ_JogNeg" Type="byte" StartAddr="31" IndexOrLength="" Remark="探测器Z反向Jog0:缺省,1:反向点动" />
<Signal Name="MC_DetZ_Home" Type="byte" StartAddr="32" IndexOrLength="" Remark="探测器Z回零,0:缺省,1:触发回零" />
<Signal Name="MC_DetZ_Stop" Type="byte" StartAddr="33" IndexOrLength="" Remark="探测器Z停止,0:缺省,1:触发停止" />
<Signal Name="MC_StageX_Target" Type="single" StartAddr="34" IndexOrLength="" Remark="载物台X目标位置" />
<Signal Name="MC_StageX_Speed" Type="single" StartAddr="38" IndexOrLength="" Remark="载物台X运动速度" />
<Signal Name="MC_StageX_JogPos" Type="byte" StartAddr="42" IndexOrLength="" Remark="载物台X正向Jog0:缺省,1:正向点动" />
<Signal Name="MC_StageX_JogNeg" Type="byte" StartAddr="43" IndexOrLength="" Remark="载物台X反向Jog0:缺省,1:反向点动" />
<Signal Name="MC_StageX_Home" Type="byte" StartAddr="44" IndexOrLength="" Remark="载物台X回零,0:缺省,1:触发回零" />
<Signal Name="MC_StageX_Stop" Type="byte" StartAddr="45" IndexOrLength="" Remark="载物台X停止,0:缺省,1:触发停止" />
<Signal Name="MC_StageY_Target" Type="single" StartAddr="46" IndexOrLength="" Remark="载物台Y目标位置" />
<Signal Name="MC_StageY_Speed" Type="single" StartAddr="50" IndexOrLength="" Remark="载物台Y运动速度" />
<Signal Name="MC_StageY_JogPos" Type="byte" StartAddr="54" IndexOrLength="" Remark="载物台Y正向Jog0:缺省,1:正向点动" />
<Signal Name="MC_StageY_JogNeg" Type="byte" StartAddr="55" IndexOrLength="" Remark="载物台Y反向Jog0:缺省,1:反向点动" />
<Signal Name="MC_StageY_Home" Type="byte" StartAddr="56" IndexOrLength="" Remark="载物台Y回零,0:缺省,1:触发回零" />
<Signal Name="MC_StageY_Stop" Type="byte" StartAddr="57" IndexOrLength="" Remark="载物台Y停止,0:缺省,1:触发停止" />
<Signal Name="MC_DetSwing_Target" Type="single" StartAddr="58" IndexOrLength="" Remark="探测器摆动目标角度" />
<Signal Name="MC_DetSwing_Speed" Type="single" StartAddr="62" IndexOrLength="" Remark="探测器摆动运动速度" />
<Signal Name="MC_DetSwing_JogPos" Type="byte" StartAddr="66" IndexOrLength="" Remark="探测器摆动正向Jog,0:缺省,1:正向点动" />
<Signal Name="MC_DetSwing_JogNeg" Type="byte" StartAddr="67" IndexOrLength="" Remark="探测器摆动反向Jog,0:缺省,1:反向点动" />
<Signal Name="MC_DetSwing_Home" Type="byte" StartAddr="68" IndexOrLength="" Remark="探测器摆动回零,0:缺省,1:触发回零" />
<Signal Name="MC_DetSwing_Stop" Type="byte" StartAddr="69" IndexOrLength="" Remark="探测器摆动停止,0:缺省,1:触发停止" />
<Signal Name="MC_StageRot_Target" Type="single" StartAddr="70" IndexOrLength="" Remark="载物台旋转目标角度" />
<Signal Name="MC_StageRot_Speed" Type="single" StartAddr="74" IndexOrLength="" Remark="载物台旋转运动速度" />
<Signal Name="MC_StageRot_JogPos" Type="byte" StartAddr="78" IndexOrLength="" Remark="载物台旋转正向Jog,0:缺省,1:正向点动" />
<Signal Name="MC_StageRot_JogNeg" Type="byte" StartAddr="79" IndexOrLength="" Remark="载物台旋转反向Jog,0:缺省,1:反向点动" />
<Signal Name="MC_StageRot_Home" Type="byte" StartAddr="80" IndexOrLength="" Remark="载物台旋转回零,0:缺省,1:触发回零" />
<Signal Name="MC_StageRot_Stop" Type="byte" StartAddr="81" IndexOrLength="" Remark="载物台旋转停止,0:缺省,1:触发停止" />
<Signal Name="MC_FixRot_Target" Type="single" StartAddr="82" IndexOrLength="" Remark="夹具旋转目标角度" />
<Signal Name="MC_FixRot_Speed" Type="single" StartAddr="86" IndexOrLength="" Remark="夹具旋转运动速度" />
<Signal Name="MC_FixRot_JogPos" Type="byte" StartAddr="90" IndexOrLength="" Remark="夹具旋转正向Jog,0:缺省,1:正向点动" />
<Signal Name="MC_FixRot_JogNeg" Type="byte" StartAddr="91" IndexOrLength="" Remark="夹具旋转反向Jog,0:缺省,1:反向点动" />
<Signal Name="MC_FixRot_Home" Type="byte" StartAddr="92" IndexOrLength="" Remark="夹具旋转回零,0:缺省,1:触发回零" />
<Signal Name="MC_FixRot_Stop" Type="byte" StartAddr="93" IndexOrLength="" Remark="夹具旋转停止,0:缺省,1:触发停止" />
<Signal Name="MC_Door_Open" Type="byte" StartAddr="94" IndexOrLength="" Remark="安全门开门,0:缺省,1:触发开门" />
<Signal Name="MC_Door_Close" Type="byte" StartAddr="95" IndexOrLength="" Remark="安全门关门,0:缺省,1:触发关门" />
<Signal Name="MC_Door_Stop" Type="byte" StartAddr="96" IndexOrLength="" Remark="安全门停止,0:缺省,1:触发停止" />
<Signal Name="MC_VirtualJoystick_Enable" Type="bool" StartAddr="111" IndexOrLength="" Remark="虚拟摇杆使能"/>
<Signal Name="MC_SourceDetZ_Linkage_Enable" Type="bool" StartAddr="101" IndexOrLength="" Remark="联动使能"/>
</Group>
<Group ID="ReadCommon" DBNumber="31">
<Signal Name="ProbeA" Type="single" StartAddr="228" IndexOrLength="" Remark="测座角度A" />
<Signal Name="ProbeB" Type="string" StartAddr="225" IndexOrLength="20" Remark="测座角度B" />
<Signal Name="test" Type="byte" StartAddr="222" IndexOrLength="" Remark="" />
<Signal Name="PlcLive" Type="byte" StartAddr="200" IndexOrLength="" Remark="PLC心跳,01周期变化表示PLC存活" />
<Signal Name="PlcAlarm" Type="byte" StartAddr="201" IndexOrLength="" Remark="系统报警,0:缺省,10:有报警" />
<Signal Name="MC_SourceZ_Pos" Type="single" StartAddr="100" IndexOrLength="" Remark="射线源Z实际位置" />
<Signal Name="MC_DetZ_Pos" Type="single" StartAddr="104" IndexOrLength="" Remark="探测器Z实际位置" />
<Signal Name="MC_StageX_Pos" Type="single" StartAddr="108" IndexOrLength="" Remark="载物台X实际位置" />
<Signal Name="MC_StageY_Pos" Type="single" StartAddr="112" IndexOrLength="" Remark="载物台Y实际位置" />
<Signal Name="MC_DetSwing_Angle" Type="single" StartAddr="116" IndexOrLength="" Remark="探测器摆动实际角度" />
<Signal Name="MC_StageRot_Angle" Type="single" StartAddr="120" IndexOrLength="" Remark="载物台旋转实际角度" />
<Signal Name="MC_FixRot_Angle" Type="single" StartAddr="124" IndexOrLength="" Remark="夹具旋转实际角度" />
<Signal Name="MC_Door_Status" Type="byte" StartAddr="128" IndexOrLength="" Remark="安全门状态,0:未知,1:正在开门,2:已开,3:正在关门,4:已关,5:已锁定,6:故障" />
<Signal Name="MC_Door_Interlock" Type="byte" StartAddr="130" IndexOrLength="" Remark="安全门联锁信号,0:缺省(无联锁),10:联锁有效(禁止开门)" />
<Signal Name="MC_Joystick_Active" Type="bool" StartAddr="110" IndexOrLength="" Remark="实体摇杆输入"/>
</Group>
<Group ID="Status" DBNumber="4100">
<Signal Name="ScanMode" Type="byte" StartAddr="201" IndexOrLength="" Remark="扫描模式,0:缺省(空闲),具体值由PLC定义" />
</Group>
</Config>
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

@@ -0,0 +1,222 @@
{
"document": {
"pageSize": "A4",
"orientation": "Portrait",
"margins": { "top": 40, "bottom": 20, "left": 20, "right": 20 },
"header": {
"enabled": true,
"left": [
"${loc:Report_Title}",
"${loc:Report_Id}${metadata.reportId} | ${loc:Report_Date}${formatDate(metadata.inspectionDate)} | ${loc:Report_Sample}${metadata.sampleName}"
],
"rightImageKey": "companyLogo",
"fontSize": 7,
"color": "#666666",
"showLine": true
},
"footer": {
"enabled": true,
"left": ["${CompanyName}"],
"right": ["{currentPage} / {totalPages}"],
"fontSize": 8,
"color": "#666666",
"showLine": true
}
},
"pages": [
{
"type": "homepage",
"elements": [
{
"type": "row", "size": [170, 40], "positioning": "flow",
"children": [
{
"type": "column", "align": "left",
"children": [
{ "type": "image", "dataKey": "companyLogo", "size": [50, 30], "align": "left" },
{ "type": "text", "content": "${CompanyName}", "style": "companyName", "align": "left" }
]
},
{
"type": "column", "align": "right",
"children": [
{ "type": "image", "dataKey": "softwareLogo", "size": [15, 20], "align": "right" },
{ "type": "text", "content": "${SoftwareName}", "style": "companyName", "align": "right" }
]
}
]
},
{ "type": "spacer", "size": [170, 15], "positioning": "flow" },
{ "type": "text", "content": "${loc:Report_Title}", "style": "title", "positioning": "flow" },
{ "type": "spacer", "size": [170, 10], "positioning": "flow" },
{ "type": "text", "content": "${loc:Report_Id}${metadata.reportId}", "style": "homepageInfo", "positioning": "flow" },
{ "type": "text", "content": "${loc:Report_Sample}${metadata.sampleName}", "style": "homepageInfo", "positioning": "flow" },
{ "type": "text", "content": "${loc:Report_Description}${metadata.description}", "style": "homepageInfo", "positioning": "flow" },
{ "type": "image", "dataKey": "workpieceImage", "size": [160, 110], "border": true, "align": "center", "style": "imageDefault", "positioning": "flow" },
{ "type": "text", "content": "${loc:Report_Operator}${metadata.operatorName}", "style": "homepageFooter", "positioning": "flow" },
{ "type": "text", "content": "${loc:Report_Date}${formatDate(metadata.inspectionDate)}", "style": "homepageFooter", "positioning": "flow" }
]
},
{
"type": "summary",
"elements": [
{ "type": "text", "content": "${loc:Report_Summary}", "style": "homepageHeading", "positioning": "flow" },
{
"type": "table", "dataKey": "summaryTable", "positioning": "flow", "size": [170, 0], "style": "tableDefault",
"columns": [
{ "header": "${loc:Field_InspectionType}", "field": "inspectionType", "width": 50, "align": "left" },
{ "header": "${loc:Field_Result}", "field": "classification", "width": 30, "align": "center", "colorRules": { "Pass": "#008000", "Fail": "#FF0000" } },
{ "header": "${loc:Field_Status}", "field": "status", "width": 30, "align": "center", "colorRules": { "合格": "#008000", "PASS": "#008000", "不合格": "#FF0000", "FAIL": "#FF0000" } }
]
}
]
},
{
"type": "metricData",
"elements": [
{ "type": "text", "content": "${loc:Page_MetricData}", "style": "heading", "positioning": "flow" },
{
"type": "row", "size": [170, 100], "widths": [6, 4], "positioning": "flow",
"children": [
{
"type": "column", "align": "left",
"children": [
{ "type": "image", "dataKey": "lineMeasurementImage", "size": [110, 90], "border": true, "align": "left" }
]
},
{
"type": "column", "align": "left",
"children": [
{ "type": "text", "content": "${loc:Measurement_Type}${measurementType}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Field_Point1}${point1}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Field_Point2}${point2}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Measurement_Distance}${actualDistance} ${unit}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Measurement_Angle}${angle}°", "style": "body", "align": "left" }
]
}
]
}
]
},
{
"type": "bgaInspection",
"elements": [
{ "type": "text", "content": "${loc:Page_BgaInspection}", "style": "heading", "positioning": "flow" },
{
"type": "row", "size": [170, 100], "widths": [6, 4], "positioning": "flow",
"children": [
{
"type": "column", "align": "left",
"children": [
{ "type": "image", "dataKey": "bgaInspectionImage", "size": [110, 90], "border": true, "align": "left" }
]
},
{
"type": "column", "align": "left",
"children": [
{ "type": "text", "content": "${loc:Bga_Count}${bgaCount}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Measurement_VoidRate}${formatPercent(voidRate)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Measurement_FillRate}${formatPercent(fillRate)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Bga_TotalArea}${formatNumber(totalBgaArea, 2)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Bga_TotalVoidArea}${formatNumber(totalVoidArea, 2)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Bga_VoidLimit}${formatPercent(voidLimit)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Table_Classification}${classification}", "style": "conclusion", "align": "left", "colorRules": { "Pass": "#008000", "Fail": "#FF0000" } }
]
}
]
},
{
"type": "table", "dataKey": "bgaBallsTable", "positioning": "flow", "size": [170, 0], "style": "tableDefault",
"columns": [
{ "header": "${loc:Bga_BallIndex}", "field": "index", "width": 25, "align": "center" },
{ "header": "${loc:Table_VoidRate}", "field": "voidRate", "width": 40, "align": "center" },
{ "header": "${loc:Table_Area}", "field": "area", "width": 40, "align": "center" },
{ "header": "${loc:Table_Classification}", "field": "classification", "width": 35, "align": "center", "colorRules": { "Pass": "#008000", "Fail": "#FF0000" } }
]
}
]
},
{
"type": "voidInspection",
"elements": [
{ "type": "text", "content": "${loc:Page_VoidInspection}", "style": "heading", "positioning": "flow" },
{
"type": "row", "size": [170, 100], "widths": [6, 4], "positioning": "flow",
"children": [
{
"type": "column", "align": "left",
"children": [
{ "type": "image", "dataKey": "voidInspectionImage", "size": [110, 90], "border": true, "align": "left" }
]
},
{
"type": "column", "align": "left",
"children": [
{ "type": "text", "content": "${loc:Void_RoiArea}${formatNumber(roiArea, 2)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Void_TotalArea}${formatNumber(totalVoidArea, 2)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Measurement_VoidRate}${formatPercent(voidRate)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Void_Limit}${formatPercent(voidLimit)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Void_Count}${voidCount}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Void_MaxArea}${formatNumber(maxVoidArea, 2)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Table_Classification}${classification}", "style": "conclusion", "align": "left", "colorRules": { "Pass": "#008000", "Fail": "#FF0000" } }
]
}
]
},
{
"type": "table", "dataKey": "voidsTable", "positioning": "flow", "size": [170, 0], "style": "tableDefault",
"columns": [
{ "header": "${loc:Table_Index}", "field": "index", "width": 20, "align": "center" },
{ "header": "${loc:Table_Area}", "field": "area", "width": 35, "align": "center" },
{ "header": "${loc:Table_AreaPercent}", "field": "areaPercent", "width": 35, "align": "center" },
{ "header": "${loc:Table_CenterX}", "field": "centerX", "width": 30, "align": "center" },
{ "header": "${loc:Table_CenterY}", "field": "centerY", "width": 30, "align": "center" }
]
}
]
},
{
"type": "viaFillInspection",
"elements": [
{ "type": "text", "content": "${loc:Page_ViaFillInspection}", "style": "heading", "positioning": "flow" },
{
"type": "row", "size": [170, 100], "widths": [6, 4], "positioning": "flow",
"children": [
{
"type": "column", "align": "left",
"children": [
{ "type": "image", "dataKey": "viaFillImage", "size": [110, 90], "border": true, "align": "left" }
]
},
{
"type": "column", "align": "left",
"children": [
{ "type": "text", "content": "${loc:Fill_Rate}${formatPercent(fillRate)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Measurement_VoidRate}${formatPercent(voidRate)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Fill_FullDistance}${formatNumber(fullDistance, 2)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Fill_FillDistance}${formatNumber(fillDistance, 2)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Fill_THTLimit}${formatPercent(thtLimit)}", "style": "body", "align": "left" },
{ "type": "text", "content": "${loc:Table_Classification}${classification}", "style": "conclusion", "align": "left", "colorRules": { "Pass": "#008000", "Fail": "#FF0000" } }
]
}
]
}
]
}
],
"styles": {
"title": { "font": "auto", "size": 28, "bold": true, "italic": false, "color": "#1a1a1a", "align": "center" },
"companyName": { "font": "auto", "size": 10, "bold": false, "italic": false, "color": "#333333", "align": "center" },
"homepageInfo": { "font": "auto", "size": 12, "bold": false, "italic": false, "color": "#333333", "align": "left", "paddingLeft": 10 },
"homepageFooter": { "font": "auto", "size": 12, "bold": false, "italic": false, "color": "#333333", "align": "center" },
"homepageHeading": { "font": "auto", "size": 14, "bold": true, "italic": false, "color": "#333333", "align": "left" },
"heading": { "font": "auto", "size": 16, "bold": true, "italic": false, "color": "#333333", "align": "left", "marginBottom": 3 },
"body": { "font": "auto", "size": 11, "bold": false, "italic": false, "color": "#333333", "align": "left" },
"bodyBold": { "font": "auto", "size": 11, "bold": true, "italic": false, "color": "#1a1a1a", "align": "left" },
"conclusion": { "font": "auto", "size": 12, "bold": true, "italic": false, "color": "#333333", "align": "left", "marginTop": 3 },
"imageDefault": { "font": "auto", "size": 12, "bold": false, "italic": false, "color": "#333333", "align": "center", "marginTop": 5, "marginBottom": 5 },
"tableHeader": { "font": "auto", "size": 10, "bold": true, "italic": false, "color": "#ffffff", "align": "center", "backgroundColor": "#4472C4" },
"tableDefault": { "font": "auto", "size": 10, "bold": false, "italic": false, "color": "#333333", "align": "center", "marginTop": 5 },
"tableCell": { "font": "auto", "size": 10, "bold": false, "italic": false, "color": "#333333", "align": "center" }
}
}
Binary file not shown.
@@ -0,0 +1,4 @@
// Flag, ob die Trace-Meldungen asynchron zu protokolliern sind: 1 für asynchron, 0 für synchron.
1
// Kategorie Konfigurationen: <Kategorie-Name> <Enabled[0/1]>
FXDriver 0
+2 -1
View File
@@ -1,4 +1,5 @@
{
"Language": "zh-CN",
"LogLevel": "Debug"
"LogLevel": "Debug",
"CameraType": "Hikvision"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.
+11 -4
View File
@@ -11,6 +11,11 @@
<add key="UserManual" value="UserManual.pdf" />
<add key="DeviceId" value="PlanerCT001" />
<!-- 授权配置 | License configuration -->
<add key="License:LicenseMode" value="0" />
<add key="License:ModuleId" value="4" />
<add key="License:UseSma" value="false" />
<add key="License:LicenseState" value="20" />
<!-- Serilog日志配置 -->
<add key="Serilog:LogPath" value="D:\XplorePlane\Logs" />
@@ -66,7 +71,6 @@
<add key="Detector:Port" value="5000" />
<add key="Detector:SavePath" value="D:\XplorePlane\DetectorImages" />
<add key="Detector:AutoSave" value="true" />
<!-- Varex 探测器专属配置 | Varex Detector Specific Configuration -->
<!-- Binning 模式: Bin1x1, Bin2x2, Bin4x4 | Binning mode: Bin1x1, Bin2x2, Bin4x4 -->
<add key="Detector:Varex:BinningMode" value="Bin1x1" />
<!-- 增益模式: Low, High | Gain mode: Low, High -->
@@ -78,14 +82,13 @@
<add key="Detector:Varex:ROI_Y" value="0" />
<add key="Detector:Varex:ROI_Width" value="2880" />
<add key="Detector:Varex:ROI_Height" value="2880" />
<!-- iRay 探测器专属配置 | iRay Detector Specific Configuration -->
<!-- 采集模式: Continuous, SingleFrame | Acquisition mode: Continuous, SingleFrame -->
<add key="Detector:IRay:AcquisitionMode" value="Continuous" />
<!-- 默认增益值 | Default gain value -->
<add key="Detector:IRay:DefaultGain" value="1.0" />
<!-- 校正配置 | Correction Configuration -->
<add key="Detector:Correction:DarkFrameCount" value="10" />
<add key="Detector:Correction:GainFrameCount" value="10" />
<add key="Detector:Correction:DarkFrameCount" value="64" />
<add key="Detector:Correction:GainFrameCount" value="64" />
<add key="Detector:Correction:SaveCorrectionData" value="true" />
<!-- 操作超时配置 | Operation Timeout Configuration -->
<add key="Detector:InitializationTimeout" value="30000" />
@@ -163,6 +166,10 @@
<!-- 运行参数 | Runtime parameters -->
<add key="MotionControl:PollingInterval" value="500" />
<add key="MotionControl:DefaultVelocity" value="500" />
<!-- 射线源与探测器Z轴联动配置 | Source-Detector Z-axis linkage configuration -->
<add key="MotionControl:SourceDetectorZLinkage:Enabled" value="true" />
<add key="MotionControl:SourceDetectorZLinkage:TriggerThreshold" value="1.0" />
<add key="MotionControl:SourceDetectorZLinkage:SpeedPercent" value="100" />
<!-- 报告输出文件夹路径(绝对路径)| Report output directory (absolute path) -->
<add key="Report:OutputDirectory" value="D:\XplorePlane\Report" />
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-13.0.0.0" newVersion="13.0.0.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17
View File
@@ -1018,6 +1018,14 @@
}
}
},
"SixLabors.ImageSharp/3.1.12": {
"runtime": {
"lib/net6.0/SixLabors.ImageSharp.dll": {
"assemblyVersion": "3.0.0.0",
"fileVersion": "3.1.12.0"
}
}
},
"SQLitePCLRaw.bundle_e_sqlite3/2.1.11": {
"dependencies": {
"SQLitePCLRaw.lib.e_sqlite3": "2.1.11",
@@ -2256,6 +2264,7 @@
"Serilog.Settings.Configuration": "10.0.0",
"Serilog.Sinks.Console": "6.1.1",
"Serilog.Sinks.File": "7.0.0",
"SixLabors.ImageSharp": "3.1.12",
"Telerik.UI.for.Wpf.NetCore.Xaml": "2024.1.408"
},
"runtime": {
@@ -2422,6 +2431,7 @@
"Serilog": "4.3.1",
"Serilog.Sinks.Console": "6.1.1",
"Serilog.Sinks.File": "7.0.0",
"XP.Common": "1.0.0",
"XP.ImageProcessing.Core": "1.0.0"
},
"runtime": {
@@ -3311,6 +3321,13 @@
"path": "sharpdx.mathematics/4.2.0",
"hashPath": "sharpdx.mathematics.4.2.0.nupkg.sha512"
},
"SixLabors.ImageSharp/3.1.12": {
"type": "package",
"serviceable": true,
"sha512": "sha512-iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==",
"path": "sixlabors.imagesharp/3.1.12",
"hashPath": "sixlabors.imagesharp.3.1.12.nupkg.sha512"
},
"SQLitePCLRaw.bundle_e_sqlite3/2.1.11": {
"type": "package",
"serviceable": true,
Binary file not shown.
+9 -62
View File
@@ -5,127 +5,88 @@
</sectionGroup>
</configSections>
<appSettings>
<!-- 语言配置 可选值: ZhCN, ZhTW, EnUS| Language Configuration -->
<add key="Language" value="ZhCN" />
<add key="XpData:RootPath" value="D:\XPData" />
<add key="UserManual" value="UserManual.pdf" />
<add key="DeviceId" value="PlanerCT001" />
<!-- Serilog日志配置 -->
<add key="License:LicenseMode" value="0" />
<add key="License:ModuleId" value="4" />
<add key="License:UseSma" value="false" />
<add key="License:LicenseState" value="10" />
<add key="Serilog:LogPath" value="D:\XplorePlane\Logs" />
<add key="Serilog:MinimumLevel" value="Debug" />
<add key="Serilog:EnableConsole" value="true" />
<add key="Serilog:RollingInterval" value="Day" />
<add key="Serilog:FileSizeLimitMB" value="100" />
<add key="Serilog:RetainedFileCountLimit" value="365" />
<!-- 数据库SQLite配置 -->
<add key="Sqlite:DbFilePath" value="D:\XplorePlane\DataBase\XP.db" />
<add key="Sqlite:ConnectionTimeout" value="10" />
<add key="Sqlite:CreateIfNotExists" value="true" />
<!-- 是否启用SQLite WAL模式(提升并发读写性能,默认true) -->
<add key="Sqlite:EnableWalMode" value="true" />
<!-- 是否开启SQL操作日志(记录所有执行的SQL语句,默认false -->
<add key="Sqlite:EnableSqlLogging" value="false" />
<!-- 射线源配置 -->
<!-- 射线源类型 | Ray Source Type -->
<!-- 可选值: Comet225 | Available values: Comet225 -->
<add key="RaySource:SourceType" value="Comet225" />
<add key="RaySource:SerialNumber" value="SN08602861" />
<add key="RaySource:TotalLifeThreshold" value="10" />
<!-- PVI通讯参数 | PVI Communication Parameters -->
<add key="RaySource:PlcIpAddress" value="192.168.12.10" />
<add key="RaySource:PlcPort" value="11159" />
<add key="RaySource:PortNumber" value="11" />
<add key="RaySource:StationNumber" value="1" />
<add key="RaySource:CpuName" value="cpu" />
<add key="RaySource:ConnectionTimeout" value="30000" />
<!-- 硬件参数范围 | Hardware Parameter Ranges -->
<add key="RaySource:MinVoltage" value="20" />
<add key="RaySource:MaxVoltage" value="225" />
<add key="RaySource:MinCurrent" value="10" />
<add key="RaySource:MaxCurrent" value="1440" />
<!-- 外部程序配置 | External Program Configuration -->
<add key="RaySource:AdvanceExePath" value="C:\Program Files (x86)\Feinfocus\FXEControl_3.1.1.65\FXEControl.exe" />
<!-- 操作超时配置 | Operation Timeout Configuration -->
<add key="RaySource:RaySource:InitializationTimeout" value="30000" />
<add key="RaySource:WarmUpTimeout" value="600000" />
<add key="RaySource:StartUpTimeout" value="1800000" />
<add key="RaySource:AutoCenterTimeout" value="1200000" />
<add key="RaySource:FilamentAdjustTimeout" value="1200000" />
<add key="RaySource:GeneralOperationTimeout" value="100000" />
<!-- 探测器配置 -->
<!-- 探测器类型 | Detector Type -->
<!-- 可选值: Varex, IRay, Hamamatsu | Available values: Varex, IRay, Hamamatsu -->
<add key="Detector:Type" value="Varex" />
<!-- 通用配置 | Common Configuration -->
<add key="Detector:IP" value="192.168.1.200" />
<add key="Detector:Port" value="5000" />
<add key="Detector:SavePath" value="D:\XplorePlane\DetectorImages" />
<add key="Detector:AutoSave" value="true" />
<!-- Varex 探测器专属配置 | Varex Detector Specific Configuration -->
<!-- Binning 模式: Bin1x1, Bin2x2, Bin4x4 | Binning mode: Bin1x1, Bin2x2, Bin4x4 -->
<add key="Detector:Varex:BinningMode" value="Bin1x1" />
<!-- 增益模式: Low, High | Gain mode: Low, High -->
<add key="Detector:Varex:GainMode" value="High" />
<!-- 曝光时间(毫秒)| Exposure time (milliseconds) -->
<add key="Detector:Varex:ExposureTime" value="100" />
<!-- ROI 区域 | ROI Region -->
<add key="Detector:Varex:ROI_X" value="0" />
<add key="Detector:Varex:ROI_Y" value="0" />
<add key="Detector:Varex:ROI_Width" value="2880" />
<add key="Detector:Varex:ROI_Height" value="2880" />
<!-- iRay 探测器专属配置 | iRay Detector Specific Configuration -->
<!-- 采集模式: Continuous, SingleFrame | Acquisition mode: Continuous, SingleFrame -->
<add key="Detector:IRay:AcquisitionMode" value="Continuous" />
<!-- 默认增益值 | Default gain value -->
<add key="Detector:IRay:DefaultGain" value="1.0" />
<!-- 校正配置 | Correction Configuration -->
<add key="Detector:Correction:DarkFrameCount" value="10" />
<add key="Detector:Correction:GainFrameCount" value="10" />
<add key="Detector:Correction:SaveCorrectionData" value="true" />
<!-- 操作超时配置 | Operation Timeout Configuration -->
<add key="Detector:InitializationTimeout" value="30000" />
<add key="Detector:AcquisitionTimeout" value="10000" />
<add key="Detector:CorrectionTimeout" value="60000" />
<!-- 主界面实时图像与探测器帧流水线 -->
<add key="MainViewport:RealtimeEnabledDefault" value="true" />
<add key="DetectorPipeline:AcquireQueueCapacity" value="16" />
<add key="DetectorPipeline:ProcessQueueCapacity" value="8" />
<add key="DetectorPipeline:ProcessEveryNFrames" value="1" />
<!-- Dump 配置 | Dump Configuration -->
<add key="Dump:StoragePath" value="D:\XplorePlane\Dump" />
<add key="Dump:EnableScheduledDump" value="false" />
<add key="Dump:ScheduledIntervalMinutes" value="60" />
<add key="Dump:MiniDumpSizeLimitMB" value="100" />
<add key="Dump:RetentionDays" value="7" />
<!-- PLC 配置 | PLC Configuration -->
<add key="Plc:IpAddress" value="192.168.0.1" />
<add key="Plc:Port" value="102" />
<add key="Plc:Rack" value="0" />
<add key="Plc:Slot" value="1" />
<!-- PlcType可选值: S200Smart, S300, S400, S1200, S1500 | PlcType Available values: S200Smart, S300, S400, S1200, S1500 -->
<add key="Plc:PlcType" value="S1200" />
<!-- 数据块配置 | Data Block Configuration -->
<add key="Plc:ReadDbBlock" value="DB31" />
<add key="Plc:WriteDbBlock" value="DB31" />
<add key="Plc:ReadStartAddress" value="0" />
<add key="Plc:ReadLength" value="200" />
<!-- 批量读取周期(毫秒)| Bulk read interval (ms) -->
<add key="Plc:BulkReadIntervalMs" value="250" />
<!-- 超时配置 | Timeout Configuration -->
<add key="Plc:ConnectTimeoutMs" value="3000" />
<add key="Plc:ReadTimeoutMs" value="1000" />
<add key="Plc:WriteTimeoutMs" value="1000" />
<!-- 自动重连 | Auto Reconnection -->
<add key="Plc:bReConnect" value="true" />
<!-- 直线轴配置(单位:mm| Linear axis config (unit: mm) -->
<add key="MotionControl:SourceZ:Min" value="-500" />
<add key="MotionControl:SourceZ:Max" value="500" />
<add key="MotionControl:SourceZ:Origin" value="0" />
@@ -138,7 +99,6 @@
<add key="MotionControl:StageY:Min" value="-150" />
<add key="MotionControl:StageY:Max" value="150" />
<add key="MotionControl:StageY:Origin" value="0" />
<!-- 旋转轴配置(单位:度)| Rotary axis config (unit: degrees) -->
<add key="MotionControl:DetectorSwing:Min" value="-45" />
<add key="MotionControl:DetectorSwing:Max" value="45" />
<add key="MotionControl:DetectorSwing:Origin" value="0" />
@@ -151,46 +111,33 @@
<add key="MotionControl:FixtureRotation:Max" value="90" />
<add key="MotionControl:FixtureRotation:Origin" value="0" />
<add key="MotionControl:FixtureRotation:Enabled" value="false" />
<!-- 几何原点(mm| Geometry origins (mm) -->
<add key="MotionControl:Geometry:SourceZOrigin" value="0" />
<add key="MotionControl:Geometry:DetectorZOrigin" value="600" />
<add key="MotionControl:Geometry:StageRotationCenterZ" value="300" />
<!-- 探测器摆动几何参数(mm| Detector swing geometry parameters (mm) -->
<!-- SwingPivotOffset: Pivot 相对于 DetectorZ 绝对坐标的 Z 方向偏移,正值表示 Pivot 在 DetectorZ_abs 上方 -->
<add key="MotionControl:Geometry:SwingPivotOffset" value="80" />
<!-- SwingRadius: Pivot 到探测器感光面中心的距离,为 0 时退化为无摆动模型 -->
<add key="MotionControl:Geometry:SwingRadius" value="200" />
<!-- 运行参数 | Runtime parameters -->
<add key="MotionControl:PollingInterval" value="500" />
<add key="MotionControl:DefaultVelocity" value="500" />
<!-- 报告输出文件夹路径(绝对路径)| Report output directory (absolute path) -->
<add key="MotionControl:SourceDetectorZLinkage:Enabled" value="true" />
<add key="MotionControl:SourceDetectorZLinkage:TriggerThreshold"
value="1.0" />
<add key="MotionControl:SourceDetectorZLinkage:SpeedPercent"
value="100" />
<add key="Report:OutputDirectory" value="D:\XplorePlane\Report" />
<!-- 报告模板文件路径(相对于应用程序目录或绝对路径)| Template file path (relative to app dir or absolute) -->
<add key="Report:TemplatePath" value="Templates\StandardReportTemplate.json" />
<!-- 输出文件名模式 | File name pattern -->
<add key="Report:FileNamePattern" value="{Date}_{ProductName}_{WorkpieceSN}_{ReportId}" />
<!-- 文件名重复时是否自动累加序号 | Auto-increment suffix when file name duplicates-->
<add key="Report:AutoIncrementOnDuplicate" value="true" />
<!-- 生成后是否自动打开 PDF 阅读器 | Auto-open PDF viewer after generation (true/false) -->
<add key="Report:AutoOpenAfterGenerate" value="false" />
<!-- 默认页面尺寸 | Default page size (A4) -->
<add key="Report:DefaultPageSize" value="A4" />
<!-- 默认页面方向 | Default orientation (Portrait / Landscape) -->
<add key="Report:DefaultOrientation" value="Portrait" />
<!-- 页面边距(mm),有效范围 0-100 | Page margins (mm), valid range 0-100 -->
<add key="Report:MarginTop" value="20" />
<add key="Report:MarginBottom" value="20" />
<add key="Report:MarginLeft" value="20" />
<add key="Report:MarginRight" value="20" />
<!-- 报告中显示的公司名称 | Company name displayed in report -->
<add key="Report:CompanyName" value="海克斯康制造智能技术(青岛)有限公司" />
<!-- 公司 Logo 图片路径(可选,为空则不显示)| Company logo path (optional, empty = no logo) -->
<add key="Report:CompanyLogo" value="Templates\Logo.png" />
<!-- 软件信息配置 | Software Information Configuration -->
<add key="Report:SoftwareName" value="XplorePlane" />
<add key="Report:SoftwareLogo" value="Templates\Logo2.png" />
</appSettings>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
Binary file not shown.
Binary file not shown.
+147 -2
View File
@@ -10,6 +10,7 @@ public static class PixelConverter
{
/// <summary>
/// 将原始像素数据转换为 WPF 的 BitmapSource 对象。
/// 支持 Mono8、BGR8、RGB8、BGRA8 以及 Bayer 8-bit 格式(自动解码为 BGR24)。
/// 返回的 BitmapSource 已调用 Freeze(),可跨线程访问。
/// </summary>
public static BitmapSource ToBitmapSource(byte[] pixelData, int width, int height, string pixelFormat)
@@ -19,11 +20,23 @@ public static class PixelConverter
if (height <= 0) throw new ArgumentException("Height must be a positive integer.", nameof(height));
ArgumentNullException.ThrowIfNull(pixelFormat);
var (format, stride) = pixelFormat switch
string normalized = NormalizePixelFormat(pixelFormat);
// Bayer 格式需要解码
if (normalized.StartsWith("Bayer"))
{
byte[] bgrData = DemosaicBayer(pixelData, width, height, normalized);
var bmp = BitmapSource.Create(width, height, 96, 96, PixelFormats.Bgr24, null, bgrData, width * 3);
bmp.Freeze();
return bmp;
}
var (format, stride) = normalized switch
{
"Mono8" => (PixelFormats.Gray8, width),
"BGR8" => (PixelFormats.Bgr24, width * 3),
"BGRA8" => (PixelFormats.Bgra32, width * 4),
"RGB8" => (PixelFormats.Rgb24, width * 3),
_ => throw new NotSupportedException($"Pixel format '{pixelFormat}' is not supported.")
};
@@ -31,4 +44,136 @@ public static class PixelConverter
bitmap.Freeze();
return bitmap;
}
}
/// <summary>
/// 将不同 SDK 的像素格式名称统一为标准名称。
/// </summary>
private static string NormalizePixelFormat(string pixelFormat)
{
if (pixelFormat is "Mono8" or "BGR8" or "BGRA8" or "RGB8")
return pixelFormat;
var upper = pixelFormat.ToUpperInvariant();
if (upper.Contains("MONO8")) return "Mono8";
if (upper.Contains("BGR8")) return "BGR8";
if (upper.Contains("BGRA8")) return "BGRA8";
if (upper.Contains("RGB8") && !upper.Contains("BAYER")) return "RGB8";
// Bayer 格式
if (upper.Contains("BAYERRG8") || upper.Contains("BAYER_RG8")) return "BayerRG8";
if (upper.Contains("BAYERGR8") || upper.Contains("BAYER_GR8")) return "BayerGR8";
if (upper.Contains("BAYERGB8") || upper.Contains("BAYER_GB8")) return "BayerGB8";
if (upper.Contains("BAYERBG8") || upper.Contains("BAYER_BG8")) return "BayerBG8";
return pixelFormat;
}
/// <summary>
/// 简单 Bayer 解码(双线性插值),输出 BGR24。
/// </summary>
private static byte[] DemosaicBayer(byte[] bayer, int width, int height, string pattern)
{
// pattern: BayerRG8, BayerGR8, BayerGB8, BayerBG8
// RG: R G GR: G R GB: G B BG: B G
// G B B G R G G R
int rRow, rCol; // 红色像素在2x2块中的位置
switch (pattern)
{
case "BayerRG8": rRow = 0; rCol = 0; break;
case "BayerGR8": rRow = 0; rCol = 1; break;
case "BayerGB8": rRow = 1; rCol = 0; break;
case "BayerBG8": rRow = 1; rCol = 1; break;
default: rRow = 0; rCol = 0; break;
}
byte[] bgr = new byte[width * height * 3];
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int srcIdx = y * width + x;
int dstIdx = (y * width + x) * 3;
// 确定当前像素在 Bayer 模式中的角色
int py = (y + rRow) % 2; // 0=红行, 1=蓝行
int px = (x + rCol) % 2; // 0=红列/蓝列, 1=绿列
byte r, g, b;
if (py == 0 && px == 0)
{
// 红色像素位置
r = bayer[srcIdx];
g = AvgNeighbors4(bayer, width, height, x, y);
b = AvgDiagonal(bayer, width, height, x, y);
}
else if (py == 1 && px == 1)
{
// 蓝色像素位置
b = bayer[srcIdx];
g = AvgNeighbors4(bayer, width, height, x, y);
r = AvgDiagonal(bayer, width, height, x, y);
}
else if (py == 0 && px == 1)
{
// 绿色像素(红行)
g = bayer[srcIdx];
r = AvgHorizontal(bayer, width, x, y);
b = AvgVertical(bayer, width, height, x, y);
}
else
{
// 绿色像素(蓝行)
g = bayer[srcIdx];
b = AvgHorizontal(bayer, width, x, y);
r = AvgVertical(bayer, width, height, x, y);
}
bgr[dstIdx] = b;
bgr[dstIdx + 1] = g;
bgr[dstIdx + 2] = r;
}
}
return bgr;
}
private static byte AvgNeighbors4(byte[] data, int w, int h, int x, int y)
{
int sum = 0, count = 0;
if (x > 0) { sum += data[y * w + x - 1]; count++; }
if (x < w - 1) { sum += data[y * w + x + 1]; count++; }
if (y > 0) { sum += data[(y - 1) * w + x]; count++; }
if (y < h - 1) { sum += data[(y + 1) * w + x]; count++; }
return count > 0 ? (byte)(sum / count) : (byte)0;
}
private static byte AvgDiagonal(byte[] data, int w, int h, int x, int y)
{
int sum = 0, count = 0;
if (x > 0 && y > 0) { sum += data[(y - 1) * w + x - 1]; count++; }
if (x < w - 1 && y > 0) { sum += data[(y - 1) * w + x + 1]; count++; }
if (x > 0 && y < h - 1) { sum += data[(y + 1) * w + x - 1]; count++; }
if (x < w - 1 && y < h - 1) { sum += data[(y + 1) * w + x + 1]; count++; }
return count > 0 ? (byte)(sum / count) : (byte)0;
}
private static byte AvgHorizontal(byte[] data, int w, int x, int y)
{
int sum = 0, count = 0;
if (x > 0) { sum += data[y * w + x - 1]; count++; }
if (x < w - 1) { sum += data[y * w + x + 1]; count++; }
return count > 0 ? (byte)(sum / count) : (byte)0;
}
private static byte AvgVertical(byte[] data, int w, int h, int x, int y)
{
int sum = 0, count = 0;
if (y > 0) { sum += data[(y - 1) * w + x]; count++; }
if (y < h - 1) { sum += data[(y + 1) * w + x]; count++; }
return count > 0 ? (byte)(sum / count) : (byte)0;
}
}
+2 -2
View File
@@ -11,8 +11,8 @@ public class CameraFactory : ICameraFactory
return cameraType switch
{
"Basler" => new BaslerCameraController(),
// "Hikvision" => new HikvisionCameraController(),
_ => throw new NotSupportedException($"不支持的相机品牌: {cameraType}")
"Hikvision" => new HikvisionCameraController(),
_ => throw new NotSupportedException($"Unsupported Camera Type: {cameraType}")
};
}
}
@@ -0,0 +1,535 @@
using MvCameraControl;
using Serilog;
namespace XP.Camera;
/// <summary>
/// 海康威视相机控制器,封装 MvCameraControl.Net SDK 实现 <see cref="ICameraController"/>。
/// </summary>
/// <remarks>
/// <para>所有公共方法通过内部 <c>_syncLock</c> 对象进行 lock 同步,保证线程安全。</para>
/// <para>事件回调(ImageGrabbed、GrabError)在 SDK 回调线程上触发,不持有 _syncLock,避免死锁。</para>
/// </remarks>
public class HikvisionCameraController : ICameraController
{
private static readonly ILogger _logger = Log.ForContext<HikvisionCameraController>();
private static bool _sdkInitialized;
private static readonly object _sdkInitLock = new();
private readonly object _syncLock = new();
private IDevice? _device;
private CameraInfo? _cachedCameraInfo;
private bool _isConnected;
private bool _isGrabbing;
public HikvisionCameraController()
{
// SDK 初始化延迟到 Open() 中执行
}
/// <inheritdoc />
public bool IsConnected
{
get { lock (_syncLock) { return _isConnected; } }
}
/// <inheritdoc />
public bool IsGrabbing
{
get { lock (_syncLock) { return _isGrabbing; } }
}
/// <inheritdoc />
public event EventHandler<ImageGrabbedEventArgs>? ImageGrabbed;
/// <inheritdoc />
public event EventHandler<GrabErrorEventArgs>? GrabError;
/// <inheritdoc />
public event EventHandler? ConnectionLost;
/// <inheritdoc />
public CameraInfo Open()
{
lock (_syncLock)
{
if (_isConnected && _cachedCameraInfo != null)
{
_logger.Information("Hikvision camera already connected, returning cached info.");
return _cachedCameraInfo;
}
try
{
_logger.Information("Opening Hikvision camera connection...");
// 确保 SDK 初始化
EnsureSdkInitialized();
// 枚举设备
DeviceTLayerType layerType = DeviceTLayerType.MvGigEDevice
| DeviceTLayerType.MvUsbDevice;
List<IDeviceInfo> deviceInfoList;
int ret = DeviceEnumerator.EnumDevices(layerType, out deviceInfoList);
_logger.Information("EnumDevices(GigE|USB) returned: 0x{RetCode:X8}, device count: {Count}",
ret, deviceInfoList?.Count ?? 0);
// 如果没找到,分别尝试
if (ret == MvError.MV_OK && (deviceInfoList == null || deviceInfoList.Count == 0))
{
// 单独尝试 GigE
List<IDeviceInfo> gigeList;
int retGige = DeviceEnumerator.EnumDevices(DeviceTLayerType.MvGigEDevice, out gigeList);
_logger.Information("EnumDevices(GigE only) returned: 0x{RetCode:X8}, count: {Count}",
retGige, gigeList?.Count ?? 0);
// 单独尝试 USB
List<IDeviceInfo> usbList;
int retUsb = DeviceEnumerator.EnumDevices(DeviceTLayerType.MvUsbDevice, out usbList);
_logger.Information("EnumDevices(USB only) returned: 0x{RetCode:X8}, count: {Count}",
retUsb, usbList?.Count ?? 0);
// 合并结果
deviceInfoList = new List<IDeviceInfo>();
if (gigeList != null) deviceInfoList.AddRange(gigeList);
if (usbList != null) deviceInfoList.AddRange(usbList);
}
if (ret != MvError.MV_OK)
{
throw new CameraException($"Enumerate Hikvision devices failed: 0x{ret:X8}");
}
if (deviceInfoList == null || deviceInfoList.Count == 0)
{
throw new DeviceNotFoundException("No Hikvision camera device found.");
}
// 选择第一个设备
IDeviceInfo deviceInfo = deviceInfoList[0];
_logger.Information("Found Hikvision device: {Model} (SN: {Serial})",
deviceInfo.ModelName, deviceInfo.SerialNumber);
// 创建设备
_device = DeviceFactory.CreateDevice(deviceInfo);
// 打开设备
ret = _device.Open();
if (ret != MvError.MV_OK)
{
_device.Dispose();
_device = null;
throw new CameraException($"Open Hikvision device failed: 0x{ret:X8}");
}
// GigE 设备优化包大小
if (_device is IGigEDevice gigEDevice)
{
int packetSize;
ret = gigEDevice.GetOptimalPacketSize(out packetSize);
if (ret == MvError.MV_OK && packetSize > 0)
{
_device.Parameters.SetIntValue("GevSCPSPacketSize", packetSize);
_logger.Debug("Set GigE packet size to {PacketSize}", packetSize);
}
}
// 配置软件触发模式
_device.Parameters.SetEnumValueByString("TriggerMode", "On");
_device.Parameters.SetEnumValueByString("TriggerSource", "Software");
// 彩色相机:尝试设置输出为 BGR8 以便直接显示
// 如果相机不支持 BGR8(如只支持 Bayer),则保持默认
int fmtRet = _device.Parameters.SetEnumValueByString("PixelFormat", "BGR8Packed");
if (fmtRet != MvError.MV_OK)
{
// 尝试 Mono8(黑白相机)
fmtRet = _device.Parameters.SetEnumValueByString("PixelFormat", "Mono8");
}
_logger.Debug("Set PixelFormat result: 0x{Ret:X8}", fmtRet);
_cachedCameraInfo = new CameraInfo(
ModelName: deviceInfo.ModelName ?? "",
SerialNumber: deviceInfo.SerialNumber ?? "",
VendorName: deviceInfo.ManufacturerName ?? "",
DeviceType: deviceInfo.TLayerType.ToString()
);
_isConnected = true;
_logger.Information("Hikvision camera connected: {ModelName} (SN: {SerialNumber})",
_cachedCameraInfo.ModelName, _cachedCameraInfo.SerialNumber);
return _cachedCameraInfo;
}
catch (Exception ex) when (ex is not CameraException)
{
_device?.Dispose();
_device = null;
_logger.Error(ex, "Failed to open Hikvision camera.");
throw new CameraException("Failed to open Hikvision camera device.", ex);
}
}
}
/// <inheritdoc />
public void Close()
{
lock (_syncLock)
{
if (!_isConnected)
{
_logger.Information("Hikvision camera not connected, Close() ignored.");
return;
}
try
{
if (_isGrabbing)
{
StopGrabbingInternal();
}
_logger.Information("Closing Hikvision camera connection...");
_device?.Close();
_device?.Dispose();
_device = null;
_isConnected = false;
_cachedCameraInfo = null;
_logger.Information("Hikvision camera connection closed.");
}
catch (Exception ex) when (ex is not CameraException)
{
_device = null;
_isConnected = false;
_isGrabbing = false;
_cachedCameraInfo = null;
_logger.Error(ex, "Error while closing Hikvision camera.");
throw new CameraException("Failed to close Hikvision camera device.", ex);
}
}
}
/// <inheritdoc />
public void StartGrabbing()
{
lock (_syncLock)
{
EnsureConnected();
if (_isGrabbing)
{
_logger.Information("Already grabbing, StartGrabbing() ignored.");
return;
}
try
{
_logger.Information("Starting Hikvision grabbing with software trigger...");
// 设置缓存节点数
_device!.StreamGrabber.SetImageNodeNum(5);
// 注册回调
_device.StreamGrabber.FrameGrabedEvent += OnFrameGrabbed;
// 开始采集
int ret = _device.StreamGrabber.StartGrabbing();
if (ret != MvError.MV_OK)
{
_device.StreamGrabber.FrameGrabedEvent -= OnFrameGrabbed;
throw new CameraException($"Start grabbing failed: 0x{ret:X8}");
}
_isGrabbing = true;
_logger.Information("Hikvision grabbing started.");
}
catch (Exception ex) when (ex is not CameraException)
{
_logger.Error(ex, "Failed to start Hikvision grabbing.");
throw new CameraException("Failed to start grabbing.", ex);
}
}
}
/// <inheritdoc />
public void ExecuteSoftwareTrigger()
{
lock (_syncLock)
{
if (!_isGrabbing)
{
throw new InvalidOperationException("Cannot execute software trigger: camera is not grabbing.");
}
try
{
int ret = _device!.Parameters.SetCommandValue("TriggerSoftware");
if (ret != MvError.MV_OK)
{
throw new CameraException($"Execute software trigger failed: 0x{ret:X8}");
}
}
catch (Exception ex) when (ex is not CameraException and not InvalidOperationException)
{
_logger.Error(ex, "Failed to execute software trigger.");
throw new CameraException("Failed to execute software trigger.", ex);
}
}
}
/// <inheritdoc />
public void StopGrabbing()
{
lock (_syncLock)
{
if (!_isGrabbing) return;
StopGrabbingInternal();
}
}
/// <inheritdoc />
public double GetExposureTime()
{
lock (_syncLock)
{
EnsureConnected();
IFloatValue floatValue;
int ret = _device!.Parameters.GetFloatValue("ExposureTime", out floatValue);
if (ret != MvError.MV_OK)
throw new CameraException($"Get ExposureTime failed: 0x{ret:X8}");
return floatValue.CurValue;
}
}
/// <inheritdoc />
public void SetExposureTime(double microseconds)
{
lock (_syncLock)
{
EnsureConnected();
// 关闭自动曝光
_device!.Parameters.SetEnumValueByString("ExposureAuto", "Off");
int ret = _device.Parameters.SetFloatValue("ExposureTime", (float)microseconds);
if (ret != MvError.MV_OK)
throw new CameraException($"Set ExposureTime failed: 0x{ret:X8}");
_logger.Information("Hikvision exposure time set to {Microseconds} µs.", microseconds);
}
}
/// <inheritdoc />
public double GetGain()
{
lock (_syncLock)
{
EnsureConnected();
IFloatValue floatValue;
int ret = _device!.Parameters.GetFloatValue("Gain", out floatValue);
if (ret != MvError.MV_OK)
throw new CameraException($"Get Gain failed: 0x{ret:X8}");
return floatValue.CurValue;
}
}
/// <inheritdoc />
public void SetGain(double value)
{
lock (_syncLock)
{
EnsureConnected();
_device!.Parameters.SetEnumValueByString("GainAuto", "Off");
int ret = _device.Parameters.SetFloatValue("Gain", (float)value);
if (ret != MvError.MV_OK)
throw new CameraException($"Set Gain failed: 0x{ret:X8}");
_logger.Information("Hikvision gain set to {Value}.", value);
}
}
/// <inheritdoc />
public int GetWidth()
{
lock (_syncLock)
{
EnsureConnected();
IIntValue intValue;
int ret = _device!.Parameters.GetIntValue("Width", out intValue);
if (ret != MvError.MV_OK)
throw new CameraException($"Get Width failed: 0x{ret:X8}");
return (int)intValue.CurValue;
}
}
/// <inheritdoc />
public void SetWidth(int value)
{
lock (_syncLock)
{
EnsureConnected();
int ret = _device!.Parameters.SetIntValue("Width", value);
if (ret != MvError.MV_OK)
throw new CameraException($"Set Width failed: 0x{ret:X8}");
_logger.Information("Hikvision width set to {Value}.", value);
}
}
/// <inheritdoc />
public int GetHeight()
{
lock (_syncLock)
{
EnsureConnected();
IIntValue intValue;
int ret = _device!.Parameters.GetIntValue("Height", out intValue);
if (ret != MvError.MV_OK)
throw new CameraException($"Get Height failed: 0x{ret:X8}");
return (int)intValue.CurValue;
}
}
/// <inheritdoc />
public void SetHeight(int value)
{
lock (_syncLock)
{
EnsureConnected();
int ret = _device!.Parameters.SetIntValue("Height", value);
if (ret != MvError.MV_OK)
throw new CameraException($"Set Height failed: 0x{ret:X8}");
_logger.Information("Hikvision height set to {Value}.", value);
}
}
/// <inheritdoc />
public string GetPixelFormat()
{
lock (_syncLock)
{
EnsureConnected();
IEnumValue enumValue;
int ret = _device!.Parameters.GetEnumValue("PixelFormat", out enumValue);
if (ret != MvError.MV_OK)
throw new CameraException($"Get PixelFormat failed: 0x{ret:X8}");
return enumValue.CurEnumEntry.Symbolic;
}
}
/// <inheritdoc />
public void SetPixelFormat(string format)
{
lock (_syncLock)
{
EnsureConnected();
int ret = _device!.Parameters.SetEnumValueByString("PixelFormat", format);
if (ret != MvError.MV_OK)
throw new CameraException($"Set PixelFormat failed: 0x{ret:X8}");
_logger.Information("Hikvision pixel format set to {Format}.", format);
}
}
/// <inheritdoc />
public void Dispose()
{
Close();
GC.SuppressFinalize(this);
}
// ══════════════════════════════════════════════════════════════
// 私有方法
// ══════════════════════════════════════════════════════════════
/// <summary>
/// SDK 回调:图像采集完成
/// </summary>
private void OnFrameGrabbed(object? sender, FrameGrabbedEventArgs e)
{
try
{
var frameOut = e.FrameOut;
if (frameOut == null || frameOut.Image == null)
{
_logger.Warning("Hikvision OnFrameGrabbed: FrameOut or Image is null");
GrabError?.Invoke(this, new GrabErrorEventArgs(-1, "FrameOut or Image is null."));
return;
}
var image = frameOut.Image;
int width = (int)image.Width;
int height = (int)image.Height;
int imageSize = (int)image.ImageSize;
string pixelFormat = image.PixelType.ToString();
// 提取像素数据
byte[] pixelData = image.PixelData ?? Array.Empty<byte>();
_logger.Debug("Hikvision frame: {Width}x{Height}, format={Format}, dataLen={Len}",
width, height, pixelFormat, pixelData.Length);
if (pixelData.Length == 0)
{
_logger.Warning("Hikvision OnFrameGrabbed: PixelData is empty");
return;
}
var args = new ImageGrabbedEventArgs(pixelData, width, height, pixelFormat);
ImageGrabbed?.Invoke(this, args);
}
catch (Exception ex)
{
_logger.Error(ex, "Exception in Hikvision OnFrameGrabbed handler.");
}
}
private void StopGrabbingInternal()
{
if (!_isGrabbing) return;
try
{
_device?.StreamGrabber.StopGrabbing();
if (_device != null)
_device.StreamGrabber.FrameGrabedEvent -= OnFrameGrabbed;
_isGrabbing = false;
_logger.Information("Hikvision grabbing stopped.");
}
catch (Exception ex) when (ex is not CameraException)
{
_isGrabbing = false;
_logger.Error(ex, "Error while stopping Hikvision grabbing.");
throw new CameraException("Failed to stop grabbing.", ex);
}
}
private void EnsureConnected()
{
if (!_isConnected)
throw new InvalidOperationException("Hikvision camera is not connected. Call Open() first.");
}
/// <summary>
/// 确保 SDK 全局初始化(只调用一次)
/// </summary>
private static void EnsureSdkInitialized()
{
if (_sdkInitialized) return;
lock (_sdkInitLock)
{
if (_sdkInitialized) return;
try
{
int ret = SDKSystem.Initialize();
if (ret != MvError.MV_OK)
{
_logger.Error("Hikvision SDK Initialize failed: 0x{ErrorCode:X8}", ret);
throw new CameraException($"Hikvision SDK Initialize failed: 0x{ret:X8}");
}
_sdkInitialized = true;
_logger.Information("Hikvision SDK initialized successfully.");
}
catch (Exception ex) when (ex is not CameraException)
{
_logger.Error(ex, "Failed to initialize Hikvision SDK.");
throw new CameraException("Failed to initialize Hikvision SDK.", ex);
}
}
}
}
+7
View File
@@ -7,12 +7,19 @@
<Nullable>enable</Nullable>
<RootNamespace>XP.Camera</RootNamespace>
<AssemblyName>XP.Camera</AssemblyName>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
</PropertyGroup>
<ItemGroup>
<Reference Include="Basler.Pylon">
<HintPath>..\ExternalLibraries\Basler.Pylon.dll</HintPath>
</Reference>
<Reference Include="MvCameraControl.Net">
<HintPath>..\ExternalLibraries\MvCameraControl.Net.dll</HintPath>
<Private>true</Private>
<CopyLocal>true</CopyLocal>
</Reference>
<PackageReference Include="Emgu.CV" Version="4.10.0.5680" />
<PackageReference Include="Emgu.CV.Bitmap" Version="4.10.0.5680" />
<PackageReference Include="Prism.DryIoc" Version="9.0.537" />
@@ -0,0 +1,288 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Telerik.Windows.Controls;
using Telerik.Windows.Controls.ChartView;
namespace XP.Common.Controls.ImageHistogram
{
/// <summary>
/// RadChartView 渲染适配器 | RadChartView rendering adapter
/// 负责将直方图频次数据渲染到 Telerik RadCartesianChart 控件
/// </summary>
internal sealed class ChartRenderer
{
private readonly RadCartesianChart _chart;
private readonly ScatterAreaSeries _areaSeries;
private readonly LinearAxis _xAxis;
/// <summary>
/// 16 位数据聚合因子 | 16-bit data aggregation factor
/// </summary>
private const int AggregationFactor = 256;
/// <summary>
/// 构造函数,接收 RadCartesianChart 实例 | Constructor, receives RadCartesianChart instance
/// </summary>
/// <param name="chart">图表控件实例 | Chart control instance</param>
/// <param name="areaSeries">面积图系列 | Area series</param>
/// <param name="xAxis">X 轴 | X axis</param>
public ChartRenderer(RadCartesianChart chart, ScatterAreaSeries areaSeries, LinearAxis xAxis)
{
_chart = chart ?? throw new ArgumentNullException(nameof(chart));
_areaSeries = areaSeries ?? throw new ArgumentNullException(nameof(areaSeries));
_xAxis = xAxis ?? throw new ArgumentNullException(nameof(xAxis));
}
/// <summary>
/// 更新直方图数据 | Update histogram data
/// </summary>
/// <param name="histogram">频次数组(256 或 65536 长度)| Frequency array (256 or 65536 length)</param>
/// <param name="isLogarithmic">是否使用对数 Y 轴 | Whether to use logarithmic Y axis</param>
public void UpdateData(long[] histogram, bool isLogarithmic)
{
if (histogram == null || histogram.Length == 0)
return;
// 确定是否需要聚合(16 位数据)| Determine if aggregation needed (16-bit data)
long[] displayData;
int xAxisMax;
if (histogram.Length == 65536)
{
// 16 位数据聚合为 256 个柱体 | Aggregate 16-bit data to 256 bars
displayData = Aggregate16BitHistogram(histogram);
xAxisMax = 65535;
}
else
{
// 8 位数据直接显示 | Display 8-bit data directly
displayData = histogram;
xAxisMax = 255;
}
// 设置 X 轴范围 | Set X axis range
_xAxis.Minimum = 0;
_xAxis.Maximum = xAxisMax;
// 根据范围自动设置刻度间隔(保持 4-5 个刻度)| Auto set major step based on range (keep 4-5 ticks)
_xAxis.MajorStep = xAxisMax <= 255 ? 64 : 16384;
// 构建数据点 | Build data points
var dataPoints = new List<HistogramDataPoint>();
if (isLogarithmic)
{
// 对数模式:频次为 0 的不绘制 | Logarithmic mode: skip zero frequency
for (int i = 0; i < displayData.Length; i++)
{
if (displayData[i] > 0)
{
double xValue = histogram.Length == 65536
? i * AggregationFactor + AggregationFactor / 2.0
: i;
dataPoints.Add(new HistogramDataPoint
{
GrayLevel = xValue,
Frequency = displayData[i]
});
}
}
}
else
{
// 线性模式:所有灰度级别都绘制 | Linear mode: draw all gray levels
for (int i = 0; i < displayData.Length; i++)
{
double xValue = histogram.Length == 65536
? i * AggregationFactor + AggregationFactor / 2.0
: i;
dataPoints.Add(new HistogramDataPoint
{
GrayLevel = xValue,
Frequency = displayData[i]
});
}
}
// 更新图表数据 | Update chart data
_areaSeries.ItemsSource = dataPoints;
// 设置 Y 轴范围 | Set Y axis range
UpdateYAxis(displayData, isLogarithmic);
}
/// <summary>
/// 清空图表,恢复初始状态 | Clear chart, restore initial state
/// </summary>
public void Clear()
{
_areaSeries.ItemsSource = null;
// X 轴范围重置为 0-255 | Reset X axis range to 0-255
_xAxis.Minimum = 0;
_xAxis.Maximum = 255;
// Y 轴范围重置为 0-1 | Reset Y axis range to 0-1
SetYAxisRange(0, 1, isLogarithmic: false);
}
/// <summary>
/// 获取当前数据点数量 | Get current data point count
/// </summary>
public int DataPointCount
{
get
{
if (_areaSeries.ItemsSource is ICollection<HistogramDataPoint> collection)
return collection.Count;
if (_areaSeries.ItemsSource is IEnumerable<HistogramDataPoint> enumerable)
return enumerable.Count();
return 0;
}
}
/// <summary>
/// 将 65536 长度的频次数组聚合为 256 个柱体 | Aggregate 65536-length array to 256 bars
/// </summary>
private static long[] Aggregate16BitHistogram(long[] histogram)
{
var aggregated = new long[256];
for (int i = 0; i < 256; i++)
{
long sum = 0;
int startIndex = i * AggregationFactor;
for (int j = 0; j < AggregationFactor; j++)
{
sum += histogram[startIndex + j];
}
aggregated[i] = sum;
}
return aggregated;
}
/// <summary>
/// 更新 Y 轴范围 | Update Y axis range
/// </summary>
private void UpdateYAxis(long[] displayData, bool isLogarithmic)
{
long maxValue = 0;
for (int i = 0; i < displayData.Length; i++)
{
if (displayData[i] > maxValue)
maxValue = displayData[i];
}
if (maxValue == 0)
maxValue = 1;
// 计算取整的 MajorStep(约 4 个刻度,对齐到 K/M 整数倍)| Calculate rounded MajorStep (~4 ticks, aligned to K/M multiples)
if (_chart.VerticalAxis is LinearAxis linearAxis)
{
long rawStep = maxValue / 4;
long step = RoundStepToNice(rawStep);
if (step < 1) step = 1;
linearAxis.MajorStep = step;
// 将最大值向上取整到 step 的整数倍 | Round max up to multiple of step
long roundedMax = ((maxValue / step) + 1) * step;
SetYAxisRange(0, roundedMax, isLogarithmic);
}
else
{
SetYAxisRange(0, maxValue, isLogarithmic);
}
}
/// <summary>
/// 将步长取整为"好看"的数值(1, 2, 5 的倍数 × 10^n| Round step to "nice" value (multiples of 1, 2, 5 × 10^n)
/// 例如:123456 → 100000350000 → 500000780000 → 1000000
/// </summary>
private static long RoundStepToNice(long rawStep)
{
if (rawStep <= 0) return 1;
// 找到数量级 | Find order of magnitude
double magnitude = Math.Pow(10, Math.Floor(Math.Log10(rawStep)));
double normalized = rawStep / magnitude;
// 取整到 1, 2, 5 中最近的 | Round to nearest of 1, 2, 5
double niceNormalized;
if (normalized <= 1.5)
niceNormalized = 1;
else if (normalized <= 3.5)
niceNormalized = 2;
else if (normalized <= 7.5)
niceNormalized = 5;
else
niceNormalized = 10;
return (long)(niceNormalized * magnitude);
}
/// <summary>
/// 设置 Y 轴范围和刻度类型 | Set Y axis range and scale type
/// </summary>
private void SetYAxisRange(double minimum, double maximum, bool isLogarithmic)
{
// 获取或创建 Y 轴 | Get or create Y axis
var verticalAxis = _chart.VerticalAxis;
if (isLogarithmic)
{
// 对数刻度 | Logarithmic scale
if (verticalAxis is LogarithmicAxis logAxis)
{
logAxis.Minimum = 1;
logAxis.Maximum = maximum;
logAxis.LogarithmBase = 10;
}
else
{
// 需要切换为对数轴 | Need to switch to logarithmic axis
var newLogAxis = new LogarithmicAxis
{
Minimum = 1,
Maximum = maximum,
LogarithmBase = 10
};
_chart.VerticalAxis = newLogAxis;
}
}
else
{
// 线性刻度 | Linear scale
if (verticalAxis is LinearAxis linearAxis)
{
linearAxis.Minimum = minimum;
linearAxis.Maximum = maximum;
}
else
{
// 需要切换为线性轴 | Need to switch to linear axis
var newLinearAxis = new LinearAxis
{
Minimum = minimum,
Maximum = maximum
};
_chart.VerticalAxis = newLinearAxis;
}
}
}
}
/// <summary>
/// 直方图数据点模型 | Histogram data point model
/// </summary>
internal class HistogramDataPoint
{
/// <summary>
/// 灰度级别(X 轴值)| Gray level (X axis value)
/// </summary>
public double GrayLevel { get; set; }
/// <summary>
/// 像素频次(Y 轴值)| Pixel frequency (Y axis value)
/// </summary>
public long Frequency { get; set; }
}
}
@@ -0,0 +1,207 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace XP.Common.Controls.ImageHistogram
{
/// <summary>
/// 帧率限流器,确保计算频率不超过 MaxFrameRate | Frame rate throttler
/// 支持从任意线程调用,使用 lock 保护内部状态
/// </summary>
internal sealed class FrameThrottler : IDisposable
{
private readonly object _lock = new();
private DateTime _lastProcessTime = DateTime.MinValue;
private Action? _pendingAction;
private CancellationTokenSource? _delayCts;
private bool _isProcessing;
private bool _disposed;
private int _maxFrameRate = 15;
/// <summary>
/// 最大刷新帧率(fps),有效范围 1-60,超出范围自动钳位 | Max frame rate (fps), valid range 1-60, auto-clamped
/// </summary>
public int MaxFrameRate
{
get => _maxFrameRate;
set => _maxFrameRate = Math.Clamp(value, 1, 60);
}
/// <summary>
/// 获取当前帧间隔(毫秒)| Get current frame interval (ms)
/// </summary>
private double FrameIntervalMs => 1000.0 / _maxFrameRate;
/// <summary>
/// 提交一帧计算动作 | Submit a frame compute action
/// 若未超过帧率限制则立即执行,否则缓存最新帧并延迟触发
/// </summary>
/// <param name="computeAction">计算动作 | Compute action</param>
/// <returns>是否被立即接受处理 | Whether it was immediately accepted</returns>
public bool TrySubmit(Action computeAction)
{
if (_disposed || computeAction == null)
return false;
lock (_lock)
{
var now = DateTime.UtcNow;
var elapsed = (now - _lastProcessTime).TotalMilliseconds;
if (elapsed >= FrameIntervalMs && !_isProcessing)
{
// 已超过间隔且无正在处理的任务,立即执行 | Interval exceeded and no processing, execute immediately
_isProcessing = true;
_lastProcessTime = now;
ExecuteAction(computeAction);
return true;
}
else
{
// 未超过间隔或正在处理中,缓存最新帧(丢弃之前的中间帧)| Cache latest frame, discard previous
_pendingAction = computeAction;
ScheduleDelayedExecution(elapsed);
return false;
}
}
}
/// <summary>
/// 执行计算动作(异步,完成后检查待处理帧)| Execute compute action asynchronously
/// </summary>
private void ExecuteAction(Action action)
{
Task.Run(() =>
{
try
{
action.Invoke();
}
catch
{
// 异常不外抛 | Do not propagate exceptions
}
finally
{
OnActionCompleted();
}
});
}
/// <summary>
/// 计算动作完成后的回调 | Callback after compute action completes
/// </summary>
private void OnActionCompleted()
{
Action? nextAction = null;
lock (_lock)
{
_isProcessing = false;
if (_pendingAction != null && !_disposed)
{
var now = DateTime.UtcNow;
var elapsed = (now - _lastProcessTime).TotalMilliseconds;
if (elapsed >= FrameIntervalMs)
{
// 间隔已到,立即执行待处理帧 | Interval reached, execute pending frame
nextAction = _pendingAction;
_pendingAction = null;
_isProcessing = true;
_lastProcessTime = now;
}
// 否则等待延迟触发 | Otherwise wait for delayed trigger
}
}
if (nextAction != null)
{
ExecuteAction(nextAction);
}
}
/// <summary>
/// 安排延迟执行(等待帧间隔到期后处理最新帧)| Schedule delayed execution
/// </summary>
private void ScheduleDelayedExecution(double elapsedMs)
{
// 取消之前的延迟任务 | Cancel previous delay task
_delayCts?.Cancel();
_delayCts?.Dispose();
_delayCts = new CancellationTokenSource();
var token = _delayCts.Token;
var delayMs = Math.Max(0, FrameIntervalMs - elapsedMs);
Task.Run(async () =>
{
try
{
await Task.Delay((int)delayMs, token);
if (token.IsCancellationRequested)
return;
Action? actionToExecute = null;
lock (_lock)
{
if (_pendingAction != null && !_isProcessing && !_disposed)
{
actionToExecute = _pendingAction;
_pendingAction = null;
_isProcessing = true;
_lastProcessTime = DateTime.UtcNow;
}
}
if (actionToExecute != null)
{
ExecuteAction(actionToExecute);
}
}
catch (OperationCanceledException)
{
// 延迟被取消,正常情况 | Delay cancelled, normal case
}
catch
{
// 异常不外抛 | Do not propagate exceptions
}
});
}
/// <summary>
/// 取消所有待处理任务 | Cancel all pending tasks
/// </summary>
public void Cancel()
{
lock (_lock)
{
_pendingAction = null;
_delayCts?.Cancel();
_delayCts?.Dispose();
_delayCts = null;
}
}
/// <summary>
/// 释放所有资源 | Dispose all resources
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
lock (_lock)
{
_pendingAction = null;
_delayCts?.Cancel();
_delayCts?.Dispose();
_delayCts = null;
}
}
}
}
@@ -0,0 +1,49 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace XP.Common.Controls.ImageHistogram
{
/// <summary>
/// 频次标签转换器:将大数值转为 K/M 缩写格式 | Frequency label converter: converts large values to K/M abbreviation format
/// 例如:500000 → "500K"1500000 → "1.5M"800 → "800"
/// </summary>
internal sealed class FrequencyLabelConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value == null) return "0";
double num;
if (value is double d)
num = d;
else if (value is decimal dec)
num = (double)dec;
else if (!double.TryParse(value.ToString(), out num))
return value.ToString() ?? "0";
if (num >= 1_000_000)
{
double mValue = num / 1_000_000.0;
return mValue == Math.Floor(mValue)
? $"{(int)mValue}M"
: $"{mValue:0.#}M";
}
if (num >= 1_000)
{
double kValue = num / 1_000.0;
return kValue == Math.Floor(kValue)
? $"{(int)kValue}K"
: $"{kValue:0.#}K";
}
return $"{(int)num}";
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
@@ -0,0 +1,211 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace XP.Common.Controls.ImageHistogram
{
/// <summary>
/// 直方图后台计算引擎 | Histogram background computation engine
/// 负责在后台线程中执行灰度值遍历和统计计算
/// </summary>
internal sealed class HistogramEngine : IDisposable
{
/// <summary>
/// 单帧计算超时时间(毫秒)| Single frame computation timeout (ms)
/// </summary>
private const int ComputeTimeoutMs = 5000;
private CancellationTokenSource? _timeoutCts;
private readonly object _lock = new();
private bool _disposed;
/// <summary>
/// 从 Image&lt;Rgba32&gt; 计算灰度直方图 | Compute histogram from Image&lt;Rgba32&gt;
/// 使用 ITU-R BT.601 亮度公式:Gray = 0.299R + 0.587G + 0.114B
/// </summary>
/// <param name="image">输入图像 | Input image</param>
/// <param name="ct">取消令牌 | Cancellation token</param>
/// <returns>256 长度的频次数组,失败返回 null | 256-length frequency array, null on failure</returns>
public Task<long[]?> ComputeAsync(Image<Rgba32> image, CancellationToken ct)
{
if (image == null)
return Task.FromResult<long[]?>(null);
// 创建超时令牌 | Create timeout token
var linkedCts = CreateLinkedTimeoutToken(ct);
var linkedToken = linkedCts.Token;
return Task.Run(() =>
{
try
{
var width = image.Width;
var height = image.Height;
var histogram = new long[256];
// 遍历像素,使用亮度公式计算灰度值 | Iterate pixels, compute grayscale using luminance formula
for (int y = 0; y < height; y++)
{
linkedToken.ThrowIfCancellationRequested();
for (int x = 0; x < width; x++)
{
var pixel = image[x, y];
// ITU-R BT.601 亮度公式 | ITU-R BT.601 luminance formula
var gray = (int)(0.299 * pixel.R + 0.587 * pixel.G + 0.114 * pixel.B);
// 钳位到 0-255 范围 | Clamp to 0-255 range
gray = Math.Clamp(gray, 0, 255);
histogram[gray]++;
}
}
return (long[]?)histogram;
}
catch (OperationCanceledException)
{
// 超时或取消,返回 null | Timeout or cancelled, return null
return null;
}
catch
{
// 所有异常内部捕获,不向外抛出 | Catch all exceptions internally
return null;
}
finally
{
linkedCts.Dispose();
}
}, linkedToken);
}
/// <summary>
/// 从原始字节数组计算灰度直方图 | Compute histogram from raw byte array
/// </summary>
/// <param name="rawData">原始像素数据 | Raw pixel data</param>
/// <param name="width">图像宽度 | Image width</param>
/// <param name="height">图像高度 | Image height</param>
/// <param name="bitDepth">位深度(8 或 16| Bit depth (8 or 16)</param>
/// <param name="ct">取消令牌 | Cancellation token</param>
/// <returns>频次数组(8位:256长度,16位:65536长度),失败返回 null | Frequency array, null on failure</returns>
public Task<long[]?> ComputeAsync(byte[] rawData, int width, int height, int bitDepth, CancellationToken ct)
{
// 参数有效性验证 | Parameter validation
if (rawData == null || width <= 0 || height <= 0)
return Task.FromResult<long[]?>(null);
if (bitDepth != 8 && bitDepth != 16)
return Task.FromResult<long[]?>(null);
int expectedLength = bitDepth == 8 ? width * height : width * height * 2;
if (rawData.Length != expectedLength)
return Task.FromResult<long[]?>(null);
// 创建超时令牌 | Create timeout token
var linkedCts = CreateLinkedTimeoutToken(ct);
var linkedToken = linkedCts.Token;
return Task.Run(() =>
{
try
{
if (bitDepth == 8)
{
return ComputeHistogram8Bit(rawData, width, height, linkedToken);
}
else
{
return ComputeHistogram16Bit(rawData, width, height, linkedToken);
}
}
catch (OperationCanceledException)
{
return null;
}
catch
{
return null;
}
finally
{
linkedCts.Dispose();
}
}, linkedToken);
}
/// <summary>
/// 计算 8 位灰度直方图 | Compute 8-bit grayscale histogram
/// </summary>
private static long[]? ComputeHistogram8Bit(byte[] rawData, int width, int height, CancellationToken ct)
{
var histogram = new long[256];
int totalPixels = width * height;
for (int i = 0; i < totalPixels; i++)
{
if (i % 65536 == 0)
ct.ThrowIfCancellationRequested();
histogram[rawData[i]]++;
}
return histogram;
}
/// <summary>
/// 计算 16 位灰度直方图 | Compute 16-bit grayscale histogram
/// </summary>
private static long[]? ComputeHistogram16Bit(byte[] rawData, int width, int height, CancellationToken ct)
{
var histogram = new long[65536];
int totalPixels = width * height;
for (int i = 0; i < totalPixels; i++)
{
if (i % 65536 == 0)
ct.ThrowIfCancellationRequested();
// 小端序读取 16 位值 | Read 16-bit value in little-endian
int offset = i * 2;
ushort value = (ushort)(rawData[offset] | (rawData[offset + 1] << 8));
histogram[value]++;
}
return histogram;
}
/// <summary>
/// 创建带超时的链接取消令牌 | Create linked cancellation token with timeout
/// </summary>
private CancellationTokenSource CreateLinkedTimeoutToken(CancellationToken externalToken)
{
var timeoutCts = new CancellationTokenSource(ComputeTimeoutMs);
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(externalToken, timeoutCts.Token);
lock (_lock)
{
_timeoutCts?.Dispose();
_timeoutCts = timeoutCts;
}
return linkedCts;
}
/// <summary>
/// 释放资源 | Dispose resources
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
lock (_lock)
{
_timeoutCts?.Cancel();
_timeoutCts?.Dispose();
_timeoutCts = null;
}
}
}
}
@@ -0,0 +1,70 @@
<UserControl x:Class="XP.Common.Controls.ImageHistogram.ImageHistogramControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
xmlns:loc="clr-namespace:XP.Common.Localization.Extensions"
xmlns:local="clr-namespace:XP.Common.Controls.ImageHistogram"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="400">
<UserControl.Resources>
<local:FrequencyLabelConverter x:Key="FreqConverter"/>
</UserControl.Resources>
<Grid>
<!-- 图表控件 | Chart control -->
<telerik:RadCartesianChart x:Name="HistogramChart" Padding="0">
<!-- 禁用 Telerik 自带的无数据提示 | Disable Telerik built-in empty content -->
<telerik:RadCartesianChart.EmptyContent>
<TextBlock/>
</telerik:RadCartesianChart.EmptyContent>
<!-- X 轴:灰度级别(缩小字体,控制刻度数量,K/M 缩写)| X Axis: Gray Level -->
<telerik:RadCartesianChart.HorizontalAxis>
<telerik:LinearAxis x:Name="XAxis"
Minimum="0"
Maximum="255"
MajorStep="64"
FontSize="9">
<telerik:LinearAxis.LabelTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource FreqConverter}}"/>
</DataTemplate>
</telerik:LinearAxis.LabelTemplate>
</telerik:LinearAxis>
</telerik:RadCartesianChart.HorizontalAxis>
<!-- Y 轴:像素频次(K/M 缩写标签)| Y Axis: Pixel Frequency (K/M abbreviation labels) -->
<telerik:RadCartesianChart.VerticalAxis>
<telerik:LinearAxis x:Name="YAxis"
Minimum="0"
Maximum="1"
FontSize="9">
<telerik:LinearAxis.LabelTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource FreqConverter}}"/>
</DataTemplate>
</telerik:LinearAxis.LabelTemplate>
</telerik:LinearAxis>
</telerik:RadCartesianChart.VerticalAxis>
<!-- 面积图系列(适合密集直方图数据)| Area Series (suitable for dense histogram data) -->
<telerik:RadCartesianChart.Series>
<telerik:ScatterAreaSeries x:Name="HistogramBarSeries"
XValueBinding="GrayLevel"
YValueBinding="Frequency"
Fill="#7F2196F3"
Stroke="#FF2196F3"
StrokeThickness="1"/>
</telerik:RadCartesianChart.Series>
</telerik:RadCartesianChart>
<!-- 无数据提示文本(叠加在图表上方)| No data placeholder text (overlaid on chart) -->
<TextBlock x:Name="NoDataPlaceholder"
Text="{loc:Localization Histogram_NoData}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="12"
Foreground="#9E9E9E"
Visibility="Visible"/>
</Grid>
</UserControl>
@@ -0,0 +1,348 @@
using System;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Threading;
using Prism.Ioc;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using XP.Common.Logging.Interfaces;
namespace XP.Common.Controls.ImageHistogram
{
/// <summary>
/// 图像灰度直方图通用控件 | Image grayscale histogram control
/// 支持单帧静态图像和高频流式图像输入,使用 Telerik RadChartView 进行可视化渲染
/// </summary>
public partial class ImageHistogramControl : UserControl
{
#region | Dependency Properties
/// <summary>
/// 最大刷新帧率依赖属性 | MaxFrameRate dependency property
/// </summary>
public static readonly DependencyProperty MaxFrameRateProperty =
DependencyProperty.Register(
nameof(MaxFrameRate),
typeof(int),
typeof(ImageHistogramControl),
new PropertyMetadata(15, OnMaxFrameRateChanged, CoerceMaxFrameRate));
/// <summary>
/// 是否使用对数 Y 轴依赖属性 | IsLogarithmic dependency property
/// </summary>
public static readonly DependencyProperty IsLogarithmicProperty =
DependencyProperty.Register(
nameof(IsLogarithmic),
typeof(bool),
typeof(ImageHistogramControl),
new PropertyMetadata(false));
/// <summary>
/// 最大刷新帧率(fps),有效范围 1-60,默认 15 | Max frame rate (fps), valid range 1-60, default 15
/// </summary>
public int MaxFrameRate
{
get => (int)GetValue(MaxFrameRateProperty);
set => SetValue(MaxFrameRateProperty, value);
}
/// <summary>
/// 是否使用对数 Y 轴,默认 false | Whether to use logarithmic Y axis, default false
/// </summary>
public bool IsLogarithmic
{
get => (bool)GetValue(IsLogarithmicProperty);
set => SetValue(IsLogarithmicProperty, value);
}
private static void OnMaxFrameRateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is ImageHistogramControl control)
{
var newValue = (int)e.NewValue;
control._frameThrottler.MaxFrameRate = newValue;
}
}
private static object CoerceMaxFrameRate(DependencyObject d, object baseValue)
{
var value = (int)baseValue;
var clamped = Math.Clamp(value, 1, 60);
if (clamped != value && d is ImageHistogramControl control)
{
control._logger?.Warn(
"MaxFrameRate 值 {Value} 超出有效范围,已钳位为 {Clamped} | MaxFrameRate value {Value} out of range, clamped to {Clamped}",
value, clamped);
}
return clamped;
}
#endregion
#region | Private Fields
private readonly FrameThrottler _frameThrottler;
private readonly HistogramEngine _histogramEngine;
private ChartRenderer? _chartRenderer;
private ILoggerService? _logger;
private CancellationTokenSource? _currentCts;
private readonly object _ctsLock = new();
#endregion
#region | Constructor
/// <summary>
/// 构造函数 | Constructor
/// </summary>
public ImageHistogramControl()
{
InitializeComponent();
// 初始化内部组件 | Initialize internal components
_frameThrottler = new FrameThrottler();
_histogramEngine = new HistogramEngine();
// 尝试解析日志服务 | Try to resolve logger service
try
{
var loggerService = ContainerLocator.Current?.Resolve<ILoggerService>();
_logger = loggerService?.ForModule<ImageHistogramControl>();
}
catch
{
// 日志服务不可用,静默降级 | Logger service unavailable, silent degradation
_logger = null;
}
// 订阅 Loaded 事件初始化 ChartRenderer | Subscribe Loaded event to initialize ChartRenderer
Loaded += OnLoaded;
Unloaded += OnUnloaded;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
// 初始化 ChartRenderer | Initialize ChartRenderer
_chartRenderer = new ChartRenderer(HistogramChart, HistogramBarSeries, XAxis);
}
#endregion
#region API | Public API
/// <summary>
/// 传入 ImageSharp 图像对象,计算并显示灰度直方图 | Update histogram from ImageSharp image
/// </summary>
/// <param name="image">ImageSharp 图像对象 | ImageSharp image object</param>
public void UpdateImage(Image<Rgba32> image)
{
try
{
if (image == null)
{
_logger?.Warn("UpdateImage 收到 null 图像,已忽略 | UpdateImage received null image, ignored");
return;
}
SubmitComputation(() => _histogramEngine.ComputeAsync(image, GetOrCreateCancellationToken()));
}
catch (Exception ex)
{
_logger?.Error(ex, "UpdateImage(Image) 异常:{Message} | UpdateImage(Image) error: {Message}", ex.Message);
}
}
/// <summary>
/// 传入原始像素数组,计算并显示灰度直方图 | Update histogram from raw byte array
/// </summary>
/// <param name="rawData">原始像素数据 | Raw pixel data</param>
/// <param name="width">图像宽度 | Image width</param>
/// <param name="height">图像高度 | Image height</param>
/// <param name="bitDepth">位深度(8 或 16| Bit depth (8 or 16)</param>
public void UpdateImage(byte[] rawData, int width, int height, int bitDepth)
{
try
{
// 参数有效性验证 | Parameter validation
if (rawData == null)
{
_logger?.Warn("UpdateImage 收到 null rawData,已忽略 | UpdateImage received null rawData, ignored");
return;
}
if (width <= 0 || height <= 0)
{
_logger?.Warn(
"UpdateImage 参数无效:width={Width}, height={Height} | Invalid params: width={Width}, height={Height}",
width, height);
return;
}
if (bitDepth != 8 && bitDepth != 16)
{
_logger?.Warn(
"UpdateImage 参数无效:bitDepth={BitDepth},仅支持 8 或 16 | Invalid bitDepth={BitDepth}, only 8 or 16 supported",
bitDepth);
return;
}
int expectedLength = bitDepth == 8 ? width * height : width * height * 2;
if (rawData.Length != expectedLength)
{
_logger?.Warn(
"UpdateImage 参数无效:rawData.Length={Length}, 预期={Expected} | Invalid params: rawData.Length={Length}, expected={Expected}",
rawData.Length, expectedLength);
return;
}
SubmitComputation(() => _histogramEngine.ComputeAsync(rawData, width, height, bitDepth, GetOrCreateCancellationToken()));
}
catch (Exception ex)
{
_logger?.Error(ex, "UpdateImage(byte[]) 异常:{Message} | UpdateImage(byte[]) error: {Message}", ex.Message);
}
}
/// <summary>
/// 清空直方图显示,恢复初始空白状态 | Clear histogram display, restore initial blank state
/// </summary>
public void Clear()
{
try
{
// 取消正在执行的后台任务 | Cancel running background task
CancelCurrentComputation();
// 取消帧率限流器中的待处理任务 | Cancel pending tasks in throttler
_frameThrottler.Cancel();
// 清空图表(捕获局部引用避免异步执行时为 null| Clear chart (capture local ref to avoid null during async)
var renderer = _chartRenderer;
if (renderer != null)
{
Dispatcher.InvokeAsync(() =>
{
try
{
renderer.Clear();
// 显示无数据提示 | Show no-data placeholder
NoDataPlaceholder.Visibility = Visibility.Visible;
}
catch
{
// 控件已卸载时忽略 | Ignore if control already unloaded
}
});
}
}
catch (Exception ex)
{
_logger?.Error(ex, "Clear() 异常:{Message} | Clear() error: {Message}", ex.Message);
}
}
#endregion
#region | Private Methods
/// <summary>
/// 通过帧率限流器提交计算任务 | Submit computation through frame throttler
/// </summary>
private void SubmitComputation(Func<System.Threading.Tasks.Task<long[]?>> computeFunc)
{
_frameThrottler.TrySubmit(() =>
{
try
{
var task = computeFunc();
task.ContinueWith(t =>
{
if (t.IsCompletedSuccessfully && t.Result != null)
{
var histogram = t.Result;
var isLog = false;
// 在 UI 线程获取 IsLogarithmic 值并更新图表 | Get IsLogarithmic on UI thread and update chart
Dispatcher.InvokeAsync(() =>
{
try
{
isLog = IsLogarithmic;
_chartRenderer?.UpdateData(histogram, isLog);
// 隐藏无数据提示 | Hide no-data placeholder
NoDataPlaceholder.Visibility = Visibility.Collapsed;
}
catch (Exception ex)
{
_logger?.Error(ex, "图表更新异常:{Message} | Chart update error: {Message}", ex.Message);
}
});
}
else if (t.IsFaulted)
{
_logger?.Error(t.Exception, "直方图计算异常:{Message} | Histogram computation error: {Message}",
t.Exception?.InnerException?.Message ?? "Unknown");
}
}, System.Threading.Tasks.TaskScheduler.Default);
}
catch (Exception ex)
{
_logger?.Error(ex, "提交计算任务异常:{Message} | Submit computation error: {Message}", ex.Message);
}
});
}
/// <summary>
/// 获取或创建取消令牌(取消上一个)| Get or create cancellation token (cancel previous)
/// </summary>
private CancellationToken GetOrCreateCancellationToken()
{
lock (_ctsLock)
{
_currentCts?.Cancel();
_currentCts?.Dispose();
_currentCts = new CancellationTokenSource();
return _currentCts.Token;
}
}
/// <summary>
/// 取消当前计算 | Cancel current computation
/// </summary>
private void CancelCurrentComputation()
{
lock (_ctsLock)
{
_currentCts?.Cancel();
_currentCts?.Dispose();
_currentCts = null;
}
}
/// <summary>
/// Unloaded 事件处理:释放所有资源 | Unloaded event handler: release all resources
/// </summary>
private void OnUnloaded(object sender, RoutedEventArgs e)
{
// 取消所有后台任务 | Cancel all background tasks
CancelCurrentComputation();
// 释放帧率限流器 | Dispose frame throttler
_frameThrottler.Cancel();
_frameThrottler.Dispose();
// 释放计算引擎 | Dispose histogram engine
_histogramEngine.Dispose();
// 清空引用 | Clear references
_chartRenderer = null;
}
#endregion
}
}
@@ -1,6 +1,6 @@
using System;
namespace XP.Common.Controls
namespace XP.Common.Controls.Joystick
{
/// <summary>
/// 虚拟摇杆核心计算逻辑(纯函数,无副作用)| Virtual joystick core calculation logic (pure functions, no side effects)
@@ -1,4 +1,4 @@
namespace XP.Common.Controls
namespace XP.Common.Controls.Joystick
{
/// <summary>
/// 虚拟摇杆轴模式枚举 | Virtual joystick axis mode enumeration
@@ -1,4 +1,4 @@
namespace XP.Common.Controls
namespace XP.Common.Controls.Joystick
{
/// <summary>
/// 鼠标按键类型枚举 | Mouse button type enumeration
@@ -6,7 +6,7 @@ using System.Windows.Media;
using System.Windows.Shapes;
using XP.Common.Logging.Interfaces;
namespace XP.Common.Controls
namespace XP.Common.Controls.Joystick
{
/// <summary>
/// 虚拟摇杆 UserControl,提供圆形区域内的鼠标拖拽操控能力 | Virtual joystick UserControl providing mouse drag interaction within a circular area
@@ -1,9 +1,9 @@
<UserControl x:Class="XP.Common.Controls.VirtualJoystick"
<UserControl x:Class="XP.Common.Controls.Joystick.VirtualJoystick"
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:XP.Common.Controls"
xmlns:local="clr-namespace:XP.Common.Controls.Joystick"
mc:Ignorable="d"
d:DesignWidth="200" d:DesignHeight="200"
Cursor="Hand">
@@ -0,0 +1,46 @@
using System.Configuration;
namespace XP.Common.Database.Configs
{
/// <summary>
/// SQLite 配置加载器,从 App.config 读取数据库相关配置项 | SQLite configuration loader, reads database-related configuration from App.config
/// </summary>
public static class ConfigLoader
{
/// <summary>
/// 配置键前缀 | Configuration key prefix
/// </summary>
private const string KeyPrefix = "Sqlite:";
/// <summary>
/// 从 App.config 加载 SQLite 配置 | Load SQLite configuration from App.config
/// </summary>
/// <returns>SQLite 配置实体,缺失或无效配置项使用默认值 | SQLite configuration entity, uses default values for missing or invalid items</returns>
public static SqliteConfig LoadSqliteConfig()
{
var config = new SqliteConfig();
// 加载数据库文件路径 | Load database file path
var dbPath = ConfigurationManager.AppSettings[KeyPrefix + "DbFilePath"];
if (!string.IsNullOrEmpty(dbPath)) config.DbFilePath = dbPath;
// 加载连接超时时间 | Load connection timeout
var timeout = ConfigurationManager.AppSettings[KeyPrefix + "ConnectionTimeout"];
if (int.TryParse(timeout, out var t) && t > 0) config.ConnectionTimeout = t;
// 加载是否自动创建 | Load create if not exists
var createIfNotExists = ConfigurationManager.AppSettings[KeyPrefix + "CreateIfNotExists"];
if (bool.TryParse(createIfNotExists, out var c)) config.CreateIfNotExists = c;
// 加载是否启用 WAL 模式 | Load enable WAL mode
var enableWal = ConfigurationManager.AppSettings[KeyPrefix + "EnableWalMode"];
if (bool.TryParse(enableWal, out var w)) config.EnableWalMode = w;
// 加载是否开启 SQL 日志 | Load enable SQL logging
var enableSqlLog = ConfigurationManager.AppSettings[KeyPrefix + "EnableSqlLogging"];
if (bool.TryParse(enableSqlLog, out var l)) config.EnableSqlLogging = l;
return config;
}
}
}
@@ -1,46 +1,42 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace XP.Common.Configs
namespace XP.Common.Database.Configs
{
/// <summary>
/// SQLite 配置实体
/// SQLite 配置实体(从 App.config 读取)| SQLite configuration entity (loaded from App.config)
/// </summary>
public class SqliteConfig
{
/// <summary>
/// 数据库文件路径
/// 数据库文件路径 | Database file path
/// </summary>
public string DbFilePath { get; set; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Files", "Data", "XP.db");
/// <summary>
/// 连接超时时间(秒,默认30
/// 连接超时时间(秒,默认30| Connection timeout (seconds, default 30)
/// </summary>
public int ConnectionTimeout { get; set; } = 30;
/// <summary>
/// 数据库不存在时是否自动创建(默认true)
/// 数据库不存在时是否自动创建(默认true)| Whether to auto-create if not exists (default true)
/// </summary>
public bool CreateIfNotExists { get; set; } = true;
/// <summary>
/// 是否启用 WAL 模式(提升并发性能,默认true)
/// 是否启用 WAL 模式(提升并发性能,默认true)| Whether to enable WAL mode (default true)
/// </summary>
public bool EnableWalMode { get; set; } = true;
/// <summary>
/// 是否开启日志记录(记录所有SQL操作,默认false)
/// 是否开启日志记录(记录所有SQL操作,默认false)| Whether to enable SQL logging (default false)
/// </summary>
public bool EnableSqlLogging { get; set; } = false;
/// <summary>
/// 获取SQLite连接字符串
/// 获取 SQLite 连接字符串 | Get SQLite connection string
/// </summary>
public string GetConnectionString()
{
@@ -7,7 +7,7 @@ using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using XP.Common.Configs;
using XP.Common.Database.Configs;
using XP.Common.Database.Interfaces;
using XP.Common.Database.Models;
using XP.Common.Helpers;
+184
View File
@@ -0,0 +1,184 @@
# 授权服务使用指南 | License Service Usage Guide
## 概述 | Overview
XplorePlane 通过 `XP.Common.License` 命名空间下的 `ILicenseService` 提供统一的授权管理。底层基于海克斯康 CLMSComputational License Management SystemSDK,使用 `MORCODE.dll` 进行许可证校验。
## 产品授权信息 | Product License Information
| 项目 | 值 |
| --- | --- |
| CLMS 模块 IDModule ID | `4` |
| 零件号(Part Number | `LS950-0071-5-1` |
`Module ID` 是 CLMS 在 SDK 中标识本产品的唯一编号,调用 `CLM_ModuleIsLicensed` 时必须传入此值;`Part Number` 是产品在 CLMS 许可发行系统中的物料编号,用于申请、签发、续期许可证。
## 授权模式 | License Modes
`LicenseMode` 枚举定义见 `XP.Common.License.Enums.LicenseMode`
| 模式 | 枚举值 | 数值 | 说明 |
| --- | --- | --- | --- |
| CLMS 正式授权 | `Clms` | `0` | 通过 CLMS SDK 进行完整授权校验,包含登录、许可范围、模块、SMA、到期日期等检查 |
| 临时测试模式 | `TemporaryTest` | `885` | 不调用 CLMS SDK,直接放行 15 分钟,用于研发/测试场景 |
## 配置项 | Configuration Items
授权配置位于主应用 `App.config` 中,键前缀为 `License:`,对应 `XP.Common.License.Configs.LicenseConfig`
```xml
<appSettings>
<!-- 授权配置 | License configuration -->
<add key="License:LicenseMode" value="0" /> <!-- 授权模式:0=CLMS 正式授权,885=临时测试模式 -->
<add key="License:ModuleId" value="4" /> <!-- 模块 IDXplorePlane 固定为 4 -->
<add key="License:UseSma" value="false" /> <!-- 是否启用 SMA 检查 -->
<add key="License:LicenseState" value="20" /> <!-- 上次授权状态:10=成功,20=失败(运行时由 LicenseService 自动写回)-->
</appSettings>
```
| 键 | 类型 | 默认值 | 有效值 | 说明 |
| --- | --- | --- | --- | --- |
| `License:LicenseMode` | int | `0` | `0``885` | 授权模式 |
| `License:ModuleId` | ushort | `4` | `1` ~ `65535` | CLMS 模块 ID |
| `License:UseSma` | bool | `false` | `true``false` | 是否启用 SMA(软件维护协议)校验 |
| `License:LicenseState` | int | `20` | `10``20` | 上次运行时的授权结果,由服务自动维护 |
> 配置中无效或缺失的键会回退到默认值,详见 `XP.Common.License.Configs.ConfigLoader`。
## 正式授权流程 | Formal Authorization Flow
`LicenseMode = 0``Clms`)时,`LicenseService.CheckAuthorization()` 依次执行以下步骤:
1. **系统时间检查**(可选):调用 `CLM_CheckSystemTime`,老版本 SDK 缺失此入口点时跳过。
2. **登录验证**:调用 `CLM_Login`(核心步骤,必须存在)。
3. **许可范围检查**:调用 `CLM_Login_Scope`(核心步骤)。
4. **SMA 验证**(可选):仅当 `License:UseSma = true` 时执行。比较 SMA 年份/季度与当前软件主版本号/次版本号,季度不匹配会判定失败。
5. **浮动许可信息**(可选):通过 `CLM_GetIP` 获取 IP 与端口。
6. **错误信息读取**(可选):通过 `CLM_GetError` 拉取 SDK 端错误描述。
7. **模块授权检查**:调用 `CLM_ModuleIsLicensed`,传入 `Module ID = 4`
8. **到期日期获取**:调用 `CLM_GetWarrantyExpiration`,剩余 ≤ 30 天时记录警告。
任意核心步骤失败即视为授权失败,写回 `License:LicenseState = 20`,主应用弹窗提示并退出。
## 临时测试模式 | Temporary Test Mode
`LicenseMode = 885``TemporaryTest`)用于跳过 CLMS 校验,便于离线开发和功能演示。
### 行为 | Behavior
- 不加载 `MORCODE.dll`,不调用任何 CLMS API,授权直接通过。
- 启动后立刻开启 15 分钟(900 秒)倒计时,到期触发 `TestModeTimeout` 事件,主应用应执行优雅关闭。
- 倒计时途中分别在剩余 5 分钟、1 分钟时触发 `TestModeWarning5Min``TestModeWarning1Min` 事件用于提醒。
- 上述三个事件只在临时测试模式下被触发,正式授权(`Clms`)下不会创建计时器。
### 启用方式 | How to Enable
修改主应用 `App.config`
```xml
<add key="License:LicenseMode" value="885" />
```
启动后将看到提示:「当前为临时测试模式,软件将在 15 分钟后自动关闭」。
> **注意**:临时测试模式仅用于研发与内部测试场景,**禁止用于生产或交付**。发布前请务必将 `License:LicenseMode` 还原为 `0`。
## 使用示例 | Usage Examples
### 注入并校验 | Inject and Verify
```csharp
using XP.Common.License.Interfaces;
using XP.Common.License.Enums;
public class StartupChecker
{
private readonly ILicenseService _licenseService;
private readonly ILoggerService _logger;
public StartupChecker(ILicenseService licenseService, ILoggerService logger)
{
_licenseService = licenseService ?? throw new ArgumentNullException(nameof(licenseService));
_logger = logger?.ForModule<StartupChecker>() ?? throw new ArgumentNullException(nameof(logger));
}
public bool Run()
{
var result = _licenseService.CheckAuthorization();
if (!result.IsAuthorized)
{
_logger.Error(null, "授权失败:{Message} | License failed: {Message}", result.Message);
return false;
}
// 仅在临时测试模式下订阅倒计时事件 | Subscribe countdown events only in test mode
if (_licenseService.LicenseMode == LicenseMode.TemporaryTest)
{
_licenseService.TestModeWarning5Min += (s, e) => _logger.Warn("临时测试模式剩余 5 分钟");
_licenseService.TestModeWarning1Min += (s, e) => _logger.Warn("临时测试模式剩余 1 分钟");
_licenseService.TestModeTimeout += (s, e) => Application.Current.Shutdown();
}
return true;
}
}
```
### 检查特定模块授权 | Check Module Authorization
```csharp
const ushort XplorePlaneModuleId = 4;
if (!_licenseService.IsModuleLicensed(XplorePlaneModuleId))
{
_logger.Warn("模块 {ModuleId} 未授权 | Module {ModuleId} not licensed", XplorePlaneModuleId);
}
```
### 读取授权信息 | Read License Information
```csharp
DateTime? expiration = _licenseService.GetExpirationDate(); // 授权到期日期
DateTime? sma = _licenseService.GetSmaDate(); // SMA 到期日期
int remaining = _licenseService.GetRemainingTestTime(); // 临时测试剩余秒数;非测试模式返回 -1
```
## 接口与事件 | Interface & Events
`ILicenseService` 提供以下成员(详见 `XP.Common.License.Interfaces.ILicenseService`):
- `LicenseCheckResult CheckAuthorization()`:执行完整授权校验。
- `bool IsAuthorized`:当前会话是否已授权。
- `LicenseMode LicenseMode`:当前授权模式。
- `DateTime? GetExpirationDate()`:授权到期日期。
- `DateTime? GetSmaDate()`SMA 到期日期。
- `bool IsModuleLicensed(ushort moduleId)`:检查指定模块是否被授权。
- `int GetRemainingTestTime()`:临时测试模式剩余秒数。
- 事件:`TestModeWarning5Min``TestModeWarning1Min``TestModeTimeout`
## 常见问题 | FAQ
### 1. 启动时提示 `MORCODE.dll 加载失败` | `Failed to load MORCODE.dll`
确认 `MORCODE.dll` 已随 `ReleaseFiles` 一同部署,并位于主程序同级目录。研发阶段可临时切换到临时测试模式绕过。
### 2. 提示「模块号码 4 不可用」| `Module 4 unavailable`
CLMS 服务器签发的许可证未包含 `Module ID = 4`(零件号 `LS950-0071-5-1`)。请联系海克斯康许可团队确认许可范围。
### 3. SMA 校验失败 | SMA validation failed
SMA 年份必须 ≥ 软件主版本号;同年时 SMA 季度必须 ≥ 软件次版本号。若需临时绕过,可将 `License:UseSma` 设为 `false`,但发布版本仍应保持 SMA 校验启用。
### 4. 授权成功后是否还有倒计时?| Will the countdown still trigger after a successful formal authorization?
不会。倒计时仅在 `LicenseMode = TemporaryTest` 时启动,`Clms` 正式授权下三个事件永远不会被触发。
## 相关文件 | Related Files
- `XP.Common/License/Interfaces/ILicenseService.cs` — 服务接口
- `XP.Common/License/Implementations/LicenseService.cs` — 服务实现
- `XP.Common/License/Configs/LicenseConfig.cs` — 配置实体
- `XP.Common/License/Configs/ConfigLoader.cs` — 配置加载器
- `XP.Common/License/Enums/LicenseMode.cs` — 授权模式枚举
- `XP.Common/License/Enums/LicenseState.cs` — 授权状态枚举
- `XP.Common/License/Native/NativeMethods.cs` — CLMS SDK P/Invoke 封装
- `XplorePlane/App.xaml.cs` — 主应用启动时调用 `PerformLicenseCheck()` 的入口
+46
View File
@@ -0,0 +1,46 @@
using System.Configuration;
namespace XP.Common.Dump.Configs
{
/// <summary>
/// Dump 配置加载器,从 App.config 读取 Dump 相关配置项 | Dump configuration loader, reads dump-related configuration from App.config
/// </summary>
public static class ConfigLoader
{
/// <summary>
/// 配置键前缀 | Configuration key prefix
/// </summary>
private const string KeyPrefix = "Dump:";
/// <summary>
/// 从 App.config 加载 Dump 配置 | Load Dump configuration from App.config
/// </summary>
/// <returns>Dump 配置实体,缺失或无效配置项使用默认值 | Dump configuration entity, uses default values for missing or invalid items</returns>
public static DumpConfig LoadDumpConfig()
{
var config = new DumpConfig();
// 加载存储路径 | Load storage path
var storagePath = ConfigurationManager.AppSettings[KeyPrefix + "StoragePath"];
if (!string.IsNullOrEmpty(storagePath)) config.StoragePath = storagePath;
// 加载是否启用定时触发 | Load enable scheduled dump
var enableScheduled = ConfigurationManager.AppSettings[KeyPrefix + "EnableScheduledDump"];
if (bool.TryParse(enableScheduled, out var enabled)) config.EnableScheduledDump = enabled;
// 加载定时触发间隔 | Load scheduled interval
var interval = ConfigurationManager.AppSettings[KeyPrefix + "ScheduledIntervalMinutes"];
if (int.TryParse(interval, out var min) && min > 0) config.ScheduledIntervalMinutes = min;
// 加载 Mini Dump 文件大小上限 | Load Mini Dump size limit
var sizeLimit = ConfigurationManager.AppSettings[KeyPrefix + "MiniDumpSizeLimitMB"];
if (long.TryParse(sizeLimit, out var size) && size > 0) config.MiniDumpSizeLimitMB = size;
// 加载文件保留天数 | Load retention days
var retentionDays = ConfigurationManager.AppSettings[KeyPrefix + "RetentionDays"];
if (int.TryParse(retentionDays, out var days) && days > 0) config.RetentionDays = days;
return config;
}
}
}
-91
View File
@@ -1,91 +0,0 @@
using System.Configuration;
using XP.Common.Configs;
using XP.Common.Dump.Configs;
namespace XP.Common.Helpers
{
/// <summary>
/// 通用配置加载工具(读取App.config)
/// </summary>
public static class ConfigLoader
{
/// <summary>
/// 加载Serilog配置
/// </summary>
public static SerilogConfig LoadSerilogConfig()
{
var config = new SerilogConfig();
var logPath = ConfigurationManager.AppSettings["Serilog:LogPath"];
if (!string.IsNullOrEmpty(logPath)) config.LogPath = logPath;
var minLevel = ConfigurationManager.AppSettings["Serilog:MinimumLevel"];
if (!string.IsNullOrEmpty(minLevel)) config.MinimumLevel = minLevel;
var enableConsole = ConfigurationManager.AppSettings["Serilog:EnableConsole"];
if (bool.TryParse(enableConsole, out var console)) config.EnableConsole = console;
var rollingInterval = ConfigurationManager.AppSettings["Serilog:RollingInterval"];
if (!string.IsNullOrEmpty(rollingInterval)) config.RollingInterval = rollingInterval;
var fileSize = ConfigurationManager.AppSettings["Serilog:FileSizeLimitMB"];
if (long.TryParse(fileSize, out var size)) config.FileSizeLimitMB = size;
var retainCount = ConfigurationManager.AppSettings["Serilog:RetainedFileCountLimit"];
if (int.TryParse(retainCount, out var count)) config.RetainedFileCountLimit = count;
return config;
}
/// <summary>
/// 加载SQLite配置
/// </summary>
public static SqliteConfig LoadSqliteConfig()
{
var config = new SqliteConfig();
var dbPath = ConfigurationManager.AppSettings["Sqlite:DbFilePath"];
if (!string.IsNullOrEmpty(dbPath)) config.DbFilePath = dbPath;
var timeout = ConfigurationManager.AppSettings["Sqlite:ConnectionTimeout"];
if (int.TryParse(timeout, out var t)) config.ConnectionTimeout = t;
var createIfNotExists = ConfigurationManager.AppSettings["Sqlite:CreateIfNotExists"];
if (bool.TryParse(createIfNotExists, out var c)) config.CreateIfNotExists = c;
var enableWal = ConfigurationManager.AppSettings["Sqlite:EnableWalMode"];
if (bool.TryParse(enableWal, out var w)) config.EnableWalMode = w;
var enableSqlLog = ConfigurationManager.AppSettings["Sqlite:EnableSqlLogging"];
if (bool.TryParse(enableSqlLog, out var l)) config.EnableSqlLogging = l;
return config;
}
/// <summary>
/// 加载 Dump 配置 | Load Dump configuration
/// </summary>
public static DumpConfig LoadDumpConfig()
{
var config = new DumpConfig();
var storagePath = ConfigurationManager.AppSettings["Dump:StoragePath"];
if (!string.IsNullOrEmpty(storagePath)) config.StoragePath = storagePath;
var enableScheduled = ConfigurationManager.AppSettings["Dump:EnableScheduledDump"];
if (bool.TryParse(enableScheduled, out var enabled)) config.EnableScheduledDump = enabled;
var interval = ConfigurationManager.AppSettings["Dump:ScheduledIntervalMinutes"];
if (int.TryParse(interval, out var min)) config.ScheduledIntervalMinutes = min;
var sizeLimit = ConfigurationManager.AppSettings["Dump:MiniDumpSizeLimitMB"];
if (long.TryParse(sizeLimit, out var size)) config.MiniDumpSizeLimitMB = size;
var retentionDays = ConfigurationManager.AppSettings["Dump:RetentionDays"];
if (int.TryParse(retentionDays, out var days)) config.RetentionDays = days;
return config;
}
}
}
+102
View File
@@ -0,0 +1,102 @@
using System.Configuration;
namespace XP.Common.License.Configs
{
/// <summary>
/// 授权配置加载器,从 App.config 读取授权相关配置项 | License configuration loader, reads license-related configuration from App.config
/// </summary>
public static class ConfigLoader
{
/// <summary>
/// 配置键前缀 | Configuration key prefix
/// </summary>
private const string KeyPrefix = "License:";
/// <summary>
/// LicenseMode 有效值集合 | Valid values for LicenseMode
/// </summary>
private static readonly int[] ValidLicenseModes = { 0, 885 };
/// <summary>
/// LicenseState 有效值集合 | Valid values for LicenseState
/// </summary>
private static readonly int[] ValidLicenseStates = { 10, 20 };
/// <summary>
/// 从 App.config 加载授权配置 | Load license configuration from App.config
/// </summary>
/// <returns>授权配置实体,缺失或无效配置项使用默认值 | License configuration entity, uses default values for missing or invalid items</returns>
public static LicenseConfig LoadLicenseConfig()
{
var config = new LicenseConfig();
// 加载 LicenseMode | Load LicenseMode
var licenseModeStr = ConfigurationManager.AppSettings[KeyPrefix + "LicenseMode"];
if (int.TryParse(licenseModeStr, out var licenseMode) && IsValidLicenseMode(licenseMode))
{
config.LicenseMode = licenseMode;
}
// 加载 ModuleId | Load ModuleId
var moduleIdStr = ConfigurationManager.AppSettings[KeyPrefix + "ModuleId"];
if (ushort.TryParse(moduleIdStr, out var moduleId) && IsValidModuleId(moduleId))
{
config.ModuleId = moduleId;
}
// 加载 UseSma | Load UseSma
var useSmaStr = ConfigurationManager.AppSettings[KeyPrefix + "UseSma"];
if (bool.TryParse(useSmaStr, out var useSma))
{
config.UseSma = useSma;
}
// 加载 LicenseState | Load LicenseState
var licenseStateStr = ConfigurationManager.AppSettings[KeyPrefix + "LicenseState"];
if (int.TryParse(licenseStateStr, out var licenseState) && IsValidLicenseState(licenseState))
{
config.LicenseState = licenseState;
}
return config;
}
/// <summary>
/// 验证 LicenseMode 值是否有效 | Validate whether LicenseMode value is valid
/// </summary>
/// <param name="value">待验证的值 | Value to validate</param>
/// <returns>true 表示有效,false 表示无效 | true if valid, false if invalid</returns>
private static bool IsValidLicenseMode(int value)
{
foreach (var valid in ValidLicenseModes)
{
if (value == valid) return true;
}
return false;
}
/// <summary>
/// 验证 ModuleId 值是否在有效范围内 | Validate whether ModuleId value is within valid range
/// </summary>
/// <param name="value">待验证的值 | Value to validate</param>
/// <returns>true 表示有效,false 表示无效 | true if valid, false if invalid</returns>
private static bool IsValidModuleId(ushort value)
{
return value >= 1 && value <= 65535;
}
/// <summary>
/// 验证 LicenseState 值是否有效 | Validate whether LicenseState value is valid
/// </summary>
/// <param name="value">待验证的值 | Value to validate</param>
/// <returns>true 表示有效,false 表示无效 | true if valid, false if invalid</returns>
private static bool IsValidLicenseState(int value)
{
foreach (var valid in ValidLicenseStates)
{
if (value == valid) return true;
}
return false;
}
}
}
@@ -0,0 +1,28 @@
namespace XP.Common.License.Configs
{
/// <summary>
/// 授权配置实体 | License configuration entity
/// </summary>
public class LicenseConfig
{
/// <summary>
/// 授权模式 | License mode
/// </summary>
public int LicenseMode { get; set; } = 0;
/// <summary>
/// 模块ID | Module ID
/// </summary>
public ushort ModuleId { get; set; } = 4;
/// <summary>
/// 是否使用SMA | Whether to use SMA
/// </summary>
public bool UseSma { get; set; } = false;
/// <summary>
/// 授权状态 | License state
/// </summary>
public int LicenseState { get; set; } = 20;
}
}
+17
View File
@@ -0,0 +1,17 @@
namespace XP.Common.License.Enums;
/// <summary>
/// 授权模式枚举 | License mode enumeration
/// </summary>
public enum LicenseMode : int
{
/// <summary>
/// CLMS 正式授权 | CLMS formal authorization
/// </summary>
Clms = 0,
/// <summary>
/// 临时测试模式(15分钟)| Temporary test mode (15 minutes)
/// </summary>
TemporaryTest = 885
}
+17
View File
@@ -0,0 +1,17 @@
namespace XP.Common.License.Enums;
/// <summary>
/// 授权状态枚举 | License state enumeration
/// </summary>
public enum LicenseState : int
{
/// <summary>
/// 授权成功 | Authorization successful
/// </summary>
Success = 10,
/// <summary>
/// 授权失败 | Authorization failed
/// </summary>
Fail = 20
}
@@ -0,0 +1,91 @@
using System;
using XP.Common.License.Enums;
namespace XP.Common.License.Implementations
{
/// <summary>
/// 授权检查结果(不可变)| License check result (immutable)
/// </summary>
public sealed class LicenseCheckResult
{
/// <summary>
/// 消息最大长度 | Maximum message length
/// </summary>
private const int MaxMessageLength = 512;
/// <summary>
/// 是否授权成功 | Whether authorization is successful
/// </summary>
public bool IsAuthorized { get; }
/// <summary>
/// 结果消息(最大512字符)| Result message (maximum 512 characters)
/// </summary>
public string Message { get; }
/// <summary>
/// 授权模式 | License mode
/// </summary>
public LicenseMode LicenseMode { get; }
/// <summary>
/// 模块ID | Module ID
/// </summary>
public ushort ModuleId { get; }
/// <summary>
/// 授权到期日期 | License expiration date
/// </summary>
public DateTime? ExpirationDate { get; }
/// <summary>
/// SMA到期日期 | SMA expiration date
/// </summary>
public DateTime? SmaDate { get; }
/// <summary>
/// 浮动许可IP地址 | Floating license IP address
/// </summary>
public string? FloatingLicenseIp { get; }
/// <summary>
/// 浮动许可端口 | Floating license port
/// </summary>
public string? FloatingLicensePort { get; }
/// <summary>
/// 构造函数 | Constructor
/// </summary>
/// <param name="isAuthorized">是否授权成功 | Whether authorization is successful</param>
/// <param name="message">结果消息 | Result message</param>
/// <param name="licenseMode">授权模式 | License mode</param>
/// <param name="moduleId">模块ID | Module ID</param>
/// <param name="expirationDate">授权到期日期 | License expiration date</param>
/// <param name="smaDate">SMA到期日期 | SMA expiration date</param>
/// <param name="floatingLicenseIp">浮动许可IP地址 | Floating license IP address</param>
/// <param name="floatingLicensePort">浮动许可端口 | Floating license port</param>
public LicenseCheckResult(
bool isAuthorized,
string message,
LicenseMode licenseMode,
ushort moduleId,
DateTime? expirationDate,
DateTime? smaDate,
string? floatingLicenseIp,
string? floatingLicensePort)
{
IsAuthorized = isAuthorized;
Message = string.IsNullOrEmpty(message)
? string.Empty
: message.Length > MaxMessageLength
? message[..MaxMessageLength]
: message;
LicenseMode = licenseMode;
ModuleId = moduleId;
ExpirationDate = expirationDate;
SmaDate = smaDate;
FloatingLicenseIp = floatingLicenseIp;
FloatingLicensePort = floatingLicensePort;
}
}
}
@@ -0,0 +1,595 @@
using System;
using System.Configuration;
using System.Reflection;
using System.Text;
using System.Threading;
using XP.Common.License.Configs;
using XP.Common.License.Enums;
using XP.Common.License.Interfaces;
using XP.Common.License.Native;
using XP.Common.Logging.Interfaces;
namespace XP.Common.License.Implementations
{
/// <summary>
/// 授权服务实现 | License service implementation
/// </summary>
public class LicenseService : ILicenseService, IDisposable
{
/// <summary>
/// 临时测试模式初始时间(秒)| Temporary test mode initial time (seconds)
/// </summary>
private const int TestModeInitialSeconds = 900;
/// <summary>
/// 授权配置 | License configuration
/// </summary>
private readonly LicenseConfig _config;
/// <summary>
/// 日志服务 | Logger service
/// </summary>
private readonly ILoggerService _logger;
/// <summary>
/// 同步锁对象 | Synchronization lock object
/// </summary>
private readonly object _lock = new object();
/// <summary>
/// 临时测试模式计时器 | Temporary test mode timer
/// </summary>
private Timer? _testModeTimer;
/// <summary>
/// 临时测试模式剩余时间(秒)| Temporary test mode remaining time (seconds)
/// </summary>
private int _remainingTestSeconds = TestModeInitialSeconds;
/// <summary>
/// 授权到期日期 | License expiration date
/// </summary>
private DateTime? _expirationDate;
/// <summary>
/// SMA到期日期 | SMA expiration date
/// </summary>
private DateTime? _smaDate;
/// <summary>
/// 浮动许可IP地址 | Floating license IP address
/// </summary>
private string? _floatingLicenseIp;
/// <summary>
/// 浮动许可端口 | Floating license port
/// </summary>
private string? _floatingLicensePort;
/// <summary>
/// 是否已释放 | Whether disposed
/// </summary>
private bool _disposed;
/// <summary>
/// 构造函数 | Constructor
/// </summary>
/// <param name="config">授权配置 | License configuration</param>
/// <param name="logger">日志服务 | Logger service</param>
public LicenseService(LicenseConfig config, ILoggerService logger)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_logger = logger?.ForModule<LicenseService>() ?? throw new ArgumentNullException(nameof(logger));
LicenseMode = (LicenseMode)_config.LicenseMode;
}
/// <summary>
/// 当前会话是否已授权 | Whether the current session is authorized
/// </summary>
public bool IsAuthorized { get; private set; }
/// <summary>
/// 当前授权模式 | Current license mode
/// </summary>
public LicenseMode LicenseMode { get; private set; }
/// <summary>
/// 临时测试模式超时事件(到期时触发)| Temporary test mode timeout event (fires when expired)
/// </summary>
public event EventHandler? TestModeTimeout;
/// <summary>
/// 临时测试模式剩余5分钟警告事件 | Temporary test mode 5-minute warning event
/// </summary>
public event EventHandler? TestModeWarning5Min;
/// <summary>
/// 临时测试模式剩余1分钟警告事件 | Temporary test mode 1-minute warning event
/// </summary>
public event EventHandler? TestModeWarning1Min;
/// <summary>
/// 是否已触发5分钟警告 | Whether 5-minute warning has been fired
/// </summary>
private bool _warning5MinFired;
/// <summary>
/// 是否已触发1分钟警告 | Whether 1-minute warning has been fired
/// </summary>
private bool _warning1MinFired;
/// <summary>
/// 执行授权检查 | Perform authorization check
/// </summary>
/// <returns>授权检查结果 | License check result</returns>
public LicenseCheckResult CheckAuthorization()
{
lock (_lock)
{
_logger.Info("开始授权检查,模式={Mode} | Starting authorization check, mode={Mode}", (int)LicenseMode);
if (LicenseMode == Enums.LicenseMode.TemporaryTest)
{
return HandleTemporaryTestMode();
}
return HandleClmsAuthorization();
}
}
/// <summary>
/// 获取授权到期日期 | Get license expiration date
/// </summary>
/// <returns>授权到期日期,未授权时返回 null | License expiration date, null if not authorized</returns>
public DateTime? GetExpirationDate()
{
lock (_lock)
{
return _expirationDate;
}
}
/// <summary>
/// 检查模块是否授权 | Check if module is licensed
/// </summary>
/// <param name="moduleId">模块ID | Module ID</param>
/// <returns>模块是否授权 | Whether the module is licensed</returns>
public bool IsModuleLicensed(ushort moduleId)
{
lock (_lock)
{
if (!IsAuthorized)
return false;
try
{
ushort mod = moduleId;
ushort type = 0;
return NativeMethods.CLM_ModuleIsLicensed(ref mod, ref type);
}
catch (DllNotFoundException ex)
{
_logger.Error(ex, "MORCODE.dll 加载失败 | Failed to load MORCODE.dll");
return false;
}
catch (EntryPointNotFoundException ex)
{
_logger.Error(ex, "MORCODE.dll 中缺少入口点 | Missing entry point in MORCODE.dll");
return false;
}
}
}
/// <summary>
/// 获取SMA到期日期 | Get SMA expiration date
/// </summary>
/// <returns>SMA到期日期,未启用时返回 null | SMA expiration date, null if not enabled</returns>
public DateTime? GetSmaDate()
{
lock (_lock)
{
return _smaDate;
}
}
/// <summary>
/// 获取临时测试模式剩余时间 | Get remaining time in temporary test mode
/// </summary>
/// <returns>剩余时间(秒),非测试模式返回 -1 | Remaining time in seconds, -1 if not in test mode</returns>
public int GetRemainingTestTime()
{
if (LicenseMode != Enums.LicenseMode.TemporaryTest)
return -1;
return Interlocked.CompareExchange(ref _remainingTestSeconds, 0, 0);
}
/// <summary>
/// 释放资源 | Release resources
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
// 停止计时器 | Stop timer
_testModeTimer?.Dispose();
_testModeTimer = null;
// 尝试登出 | Try to logout
try
{
NativeMethods.CLM_Logout();
}
catch (Exception ex)
{
_logger.Error(ex, "CLM_Logout 调用失败 | CLM_Logout call failed");
}
}
/// <summary>
/// 处理临时测试模式 | Handle temporary test mode
/// </summary>
/// <returns>授权检查结果 | License check result</returns>
private LicenseCheckResult HandleTemporaryTestMode()
{
IsAuthorized = true;
_remainingTestSeconds = TestModeInitialSeconds;
// 启动计时器,每秒递减 | Start timer, decrement every second
_testModeTimer?.Dispose();
_testModeTimer = new Timer(TestModeTimerCallback, null, 1000, 1000);
_logger.Info("临时测试模式已启动,剩余时间={Seconds}秒 | Temporary test mode started, remaining time={Seconds}s", TestModeInitialSeconds);
WriteLicenseStateToConfig(LicenseState.Success);
return new LicenseCheckResult(
isAuthorized: true,
message: "临时测试模式已启动,有效时间15分钟 | Temporary test mode started, valid for 15 minutes",
licenseMode: Enums.LicenseMode.TemporaryTest,
moduleId: _config.ModuleId,
expirationDate: null,
smaDate: null,
floatingLicenseIp: null,
floatingLicensePort: null);
}
/// <summary>
/// 临时测试模式计时器回调 | Temporary test mode timer callback
/// </summary>
/// <param name="state">状态对象 | State object</param>
private void TestModeTimerCallback(object? state)
{
int remaining = Interlocked.Decrement(ref _remainingTestSeconds);
// 剩余5分钟(300秒)时触发警告 | Fire warning at 5 minutes (300 seconds) remaining
if (remaining <= 300 && !_warning5MinFired)
{
_warning5MinFired = true;
_logger.Warn("临时测试模式剩余5分钟 | Temporary test mode: 5 minutes remaining");
TestModeWarning5Min?.Invoke(this, EventArgs.Empty);
}
// 剩余1分钟(60秒)时触发警告 | Fire warning at 1 minute (60 seconds) remaining
if (remaining <= 60 && !_warning1MinFired)
{
_warning1MinFired = true;
_logger.Warn("临时测试模式剩余1分钟 | Temporary test mode: 1 minute remaining");
TestModeWarning1Min?.Invoke(this, EventArgs.Empty);
}
// 到期时触发超时事件 | Fire timeout event when expired
if (remaining <= 0)
{
// 停止计时器 | Stop timer
_testModeTimer?.Dispose();
_testModeTimer = null;
_logger.Info("临时测试模式已超时 | Temporary test mode has timed out");
// 触发超时事件 | Raise timeout event
TestModeTimeout?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>
/// 处理 CLMS 正式授权流程 | Handle CLMS formal authorization flow
/// 兼容新旧版本 SDK:对可能不存在的入口点使用 TryInvoke 优雅降级 |
/// Compatible with old/new SDK: gracefully degrades for missing entry points via TryInvoke
/// </summary>
/// <returns>授权检查结果 | License check result</returns>
private LicenseCheckResult HandleClmsAuthorization()
{
try
{
// 步骤 1:检查系统时间(可选,老版本 SDK 可能不支持)| Step 1: Check system time (optional, old SDK may not support)
if (!TryInvokeOptional(() => NativeMethods.CLM_CheckSystemTime(), "CLM_CheckSystemTime", out bool checkTimeResult))
{
// 入口点不存在,跳过此步骤 | Entry point not found, skip this step
_logger.Warn("CLM_CheckSystemTime 入口点不存在,跳过系统时间检查(SDK版本较旧)| CLM_CheckSystemTime entry point not found, skipping system time check (older SDK version)");
}
else if (!checkTimeResult)
{
_logger.Error(new InvalidOperationException("CLM_CheckSystemTime"), "系统时间检查异常 | System time check anomaly");
return CreateFailureResult("系统时间检查异常 | System time check anomaly");
}
else
{
_logger.Info("系统时间检查正常 | System time check: OK");
}
// 步骤 2:登录验证(核心,必须存在)| Step 2: Login verification (core, must exist)
StringBuilder password = new StringBuilder("FnEoFWSNLpVeoNWYhVoHLfgITRvieSszJfylVsXOTsLkphgkPzPhbLQzQrvRbNOkVVIQyMWkyGVjWSaiYUEksfQsRmklksLxrmeTksKKNMoZoWfZeDaLDSyWwEmtQakvSNxBMBLHoLEZHtaoXNpTWiaUGaSLQdsHFZnbRyPehytarNTKpaNNqnjFNggqWifhFsrZasDsWbIGWDrhnGrdtUNDMjJdhlTunsssxCzYpsLQrWBxUkuUUEJraSbTlbuX");
if (!NativeMethods.CLM_Login(password))
{
_logger.Error(new InvalidOperationException("CLM_Login"), "CLM_Login 登录验证失败 | CLM_Login verification failed");
return CreateFailureResult("CLM_Login 登录验证失败 | CLM_Login verification failed");
}
_logger.Info("CLM_Login 登录验证成功 | CLM_Login verification: OK");
// 步骤 3:检查许可范围(核心,必须存在)| Step 3: Check license scope (core, must exist)
if (!NativeMethods.CLM_Login_Scope())
{
_logger.Error(new InvalidOperationException("CLM_Login_Scope"), "CLM_Login_Scope 许可范围检查失败 | CLM_Login_Scope scope check failed");
return CreateFailureResult("CLM_Login_Scope 许可范围检查失败 | CLM_Login_Scope scope check failed");
}
_logger.Info("CLM_Login_Scope 许可范围检查成功 | CLM_Login_Scope scope check: OK");
// 步骤 4:SMA 验证(如果启用,可选入口点)| Step 4: SMA validation (if enabled, optional entry point)
if (_config.UseSma)
{
var smaResult = ValidateSma();
if (smaResult != null)
return smaResult;
}
else
{
_logger.Info("放弃检查SMA | SMA check skipped");
}
// 步骤 5:获取浮动许可IP和端口(可选,老版本 SDK 可能不支持)| Step 5: Get floating license IP and port (optional, old SDK may not support)
StringBuilder ip = new StringBuilder(256);
StringBuilder port = new StringBuilder(256);
if (!TryInvokeOptional(() => NativeMethods.CLM_GetIP(ip, port), "CLM_GetIP", out bool getIpResult))
{
_logger.Warn("CLM_GetIP 入口点不存在,跳过浮动许可IP获取(SDK版本较旧)| CLM_GetIP entry point not found, skipping floating license IP retrieval (older SDK version)");
}
else if (!getIpResult)
{
_logger.Error(new InvalidOperationException("CLM_GetIP"), "CLM_GetIP 获取浮动许可IP失败 | CLM_GetIP floating license IP retrieval failed");
return CreateFailureResult("CLM_GetIP 获取浮动许可IP失败 | CLM_GetIP floating license IP retrieval failed");
}
else
{
_floatingLicenseIp = ip.ToString();
_floatingLicensePort = port.ToString();
_logger.Info("CLM_GetIP 成功: ip={Ip}/port={Port} | CLM_GetIP success: ip={Ip}/port={Port}", _floatingLicenseIp, _floatingLicensePort);
}
// 步骤 6:获取错误信息(可选,老版本 SDK 可能不支持)| Step 6: Get error message (optional, old SDK may not support)
StringBuilder error = new StringBuilder(512);
if (!TryInvokeOptional(() => NativeMethods.CLM_GetError(error), "CLM_GetError", out bool getErrorResult))
{
_logger.Warn("CLM_GetError 入口点不存在,跳过错误信息获取(SDK版本较旧)| CLM_GetError entry point not found, skipping error retrieval (older SDK version)");
}
else if (!getErrorResult)
{
_logger.Error(new InvalidOperationException("CLM_GetError"), "CLM_GetError 获取错误信息失败 | CLM_GetError error retrieval failed");
return CreateFailureResult("CLM_GetError 获取错误信息失败 | CLM_GetError error retrieval failed");
}
else
{
_logger.Info("CLM_GetError 成功: {Error} | CLM_GetError success: {Error}", error.ToString());
}
// 步骤 7:检查模块授权(核心,必须存在)| Step 7: Check module authorization (core, must exist)
ushort moduleId = _config.ModuleId;
ushort type = 0;
if (!NativeMethods.CLM_ModuleIsLicensed(ref moduleId, ref type))
{
_logger.Error(new InvalidOperationException("CLM_ModuleIsLicensed"), "模块号码{ModuleId}不可用 | Module {ModuleId} unavailable", moduleId);
return CreateFailureResult($"模块号码{moduleId}不可用 | Module {moduleId} unavailable");
}
_logger.Info("模块号码{ModuleId}有效 | Module {ModuleId} available", moduleId);
// 步骤 8:获取授权到期日期(核心,必须存在)| Step 8: Get warranty expiration date (core, must exist)
int month = 0, day = 0, year = 0;
if (!NativeMethods.CLM_GetWarrantyExpiration(ref month, ref day, ref year))
{
_logger.Error(new InvalidOperationException("CLM_GetWarrantyExpiration"), "获取授权到期日期失败 | Failed to get warranty expiration date");
return CreateFailureResult("获取授权到期日期失败 | Failed to get warranty expiration date");
}
_expirationDate = new DateTime(year, month, day);
_logger.Info("CLM_GetWarrantyExpiration 成功: {Year}/{Month}/{Day} | CLM_GetWarrantyExpiration success: {Year}/{Month}/{Day}", year, month, day);
// 检查是否在30天内到期 | Check if expiring within 30 days
string warningMessage = string.Empty;
TimeSpan timeToExpiry = _expirationDate.Value - DateTime.Now;
if (timeToExpiry.Days <= 30)
{
warningMessage = $"软件授权将于{year}年{month}月{day}日到期,请尽快联系海克斯康 | Software license will expire on {year}-{month}-{day}, please contact Hexagon";
_logger.Warn(warningMessage);
}
// 授权成功 | Authorization successful
IsAuthorized = true;
WriteLicenseStateToConfig(LicenseState.Success);
string successMessage = string.IsNullOrEmpty(warningMessage)
? "授权检查成功 | Authorization check successful"
: $"授权检查成功(警告:{warningMessage}| Authorization check successful (Warning: {warningMessage})";
_logger.Info("授权检查完成 | Authorization check completed: Mode={Mode}, State={State}, Expiration={Expiration}",
(int)LicenseMode,
(int)LicenseState.Success,
_expirationDate?.ToString("yyyy-MM-dd") ?? "N/A");
return new LicenseCheckResult(
isAuthorized: true,
message: successMessage,
licenseMode: LicenseMode,
moduleId: _config.ModuleId,
expirationDate: _expirationDate,
smaDate: _smaDate,
floatingLicenseIp: _floatingLicenseIp,
floatingLicensePort: _floatingLicensePort);
}
catch (DllNotFoundException ex)
{
_logger.Error(ex, "MORCODE.dll 加载失败 | Failed to load MORCODE.dll");
return CreateFailureResult("CLMS SDK 不可用 | CLMS SDK unavailable");
}
}
/// <summary>
/// 尝试调用可选的 SDK 方法,兼容老版本 SDK 中不存在的入口点 |
/// Try to invoke an optional SDK method, compatible with missing entry points in older SDK versions
/// </summary>
/// <param name="action">要调用的方法委托 | Method delegate to invoke</param>
/// <param name="methodName">方法名称(用于日志)| Method name (for logging)</param>
/// <param name="result">方法返回值,入口点不存在时为 default | Method return value, default if entry point not found</param>
/// <returns>true: 入口点存在并已调用; false: 入口点不存在(EntryPointNotFoundException| true: entry point exists and was invoked; false: entry point not found</returns>
private bool TryInvokeOptional(Func<bool> action, string methodName, out bool result)
{
try
{
result = action();
return true;
}
catch (EntryPointNotFoundException)
{
result = default;
return false;
}
}
/// <summary>
/// 验证 SMA | Validate SMA
/// 兼容老版本 SDKCLM_GetSmaDate 入口点不存在时跳过 SMA 验证 |
/// Compatible with old SDK: skips SMA validation if CLM_GetSmaDate entry point not found
/// </summary>
/// <returns>失败时返回失败结果,成功或跳过时返回 null | Returns failure result on failure, null on success or skip</returns>
private LicenseCheckResult? ValidateSma()
{
int yearSma = 0, monthSma = 0, daySma = 0;
// CLM_GetSmaDate 在老版本 SDK 中可能不存在 | CLM_GetSmaDate may not exist in older SDK
try
{
if (!NativeMethods.CLM_GetSmaDate(ref monthSma, ref daySma, ref yearSma))
{
_logger.Error(new InvalidOperationException("CLM_GetSmaDate"), "检查SMA失败 | SMA check failed");
return CreateFailureResult("检查SMA失败 | SMA check failed");
}
}
catch (EntryPointNotFoundException)
{
_logger.Warn("CLM_GetSmaDate 入口点不存在,跳过SMA验证(SDK版本较旧)| CLM_GetSmaDate entry point not found, skipping SMA validation (older SDK version)");
return null;
}
_logger.Info("CLM_GetSmaDate 成功: {Year}/{Month}/{Day} | CLM_GetSmaDate success: {Year}/{Month}/{Day}", yearSma, monthSma, daySma);
// 获取软件版本信息 | Get software version information
var version = Assembly.GetExecutingAssembly().GetName().Version;
int major = version?.Major ?? 0;
int minor = version?.Minor ?? 0;
// SMA 年份 < 软件主版本号 → 失败 | SMA year < software major version → failure
if (yearSma < major)
{
string msg = $"CLMS授权中SMA年份{yearSma}小于软件主版本号(年份){major},请联系海克斯康升级许可 | SMA year {yearSma} is less than software major version {major}, please contact Hexagon to upgrade license";
_logger.Error(new InvalidOperationException("SMA验证失败"), msg);
return CreateFailureResult(msg);
}
// SMA 年份 == 软件主版本号时,校验季度 | When SMA year == software major version, validate quarter
if (yearSma == major)
{
try
{
DateTime smaDate = new DateTime(yearSma, monthSma, daySma);
int smaQuarter = (smaDate.Month - 1) / 3 + 1;
if (minor > smaQuarter)
{
string msg = $"CLMS授权日期{yearSma}/{monthSma}/{daySma}属于{yearSma}年第{smaQuarter}季度,不支持当前{major}年第{minor}季度的软件版本 | SMA date {yearSma}/{monthSma}/{daySma} is in Q{smaQuarter} of {yearSma}, does not support current Q{minor} of {major} software version";
_logger.Error(new InvalidOperationException("SMA季度验证失败"), msg);
return CreateFailureResult(msg);
}
}
catch (Exception ex)
{
string msg = $"SMA授权日期{yearSma}/{monthSma}/{daySma}不合法 | SMA date {yearSma}/{monthSma}/{daySma} is invalid: {ex.Message}";
_logger.Error(ex, msg);
return CreateFailureResult(msg);
}
}
_smaDate = new DateTime(yearSma, monthSma, daySma);
_logger.Info("SMA校验成功,SMA有效期至{Year}/{Month}/{Day} | SMA validation successful, valid until {Year}/{Month}/{Day}", yearSma, monthSma, daySma);
return null;
}
/// <summary>
/// 创建失败结果 | Create failure result
/// </summary>
/// <param name="message">失败消息 | Failure message</param>
/// <returns>授权检查失败结果 | License check failure result</returns>
private LicenseCheckResult CreateFailureResult(string message)
{
IsAuthorized = false;
WriteLicenseStateToConfig(LicenseState.Fail);
_logger.Info("授权检查完成 | Authorization check completed: Mode={Mode}, State={State}, Expiration={Expiration}",
(int)LicenseMode,
(int)LicenseState.Fail,
"N/A");
return new LicenseCheckResult(
isAuthorized: false,
message: message,
licenseMode: LicenseMode,
moduleId: _config.ModuleId,
expirationDate: null,
smaDate: _smaDate,
floatingLicenseIp: _floatingLicenseIp,
floatingLicensePort: _floatingLicensePort);
}
/// <summary>
/// 写入授权状态到配置文件 | Write license state to configuration file
/// </summary>
/// <param name="state">授权状态 | License state</param>
private void WriteLicenseStateToConfig(LicenseState state)
{
try
{
var config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
var settings = config.AppSettings.Settings;
if (settings["License:LicenseState"] == null)
{
settings.Add("License:LicenseState", ((int)state).ToString());
}
else
{
settings["License:LicenseState"].Value = ((int)state).ToString();
}
config.Save(ConfigurationSaveMode.Modified);
ConfigurationManager.RefreshSection("appSettings");
_logger.Info("授权状态已写入配置: {State} | License state written to config: {State}", (int)state);
}
catch (Exception ex)
{
_logger.Error(ex, "写入授权状态到配置失败 | Failed to write license state to config");
}
}
}
}
@@ -0,0 +1,67 @@
using System;
using XP.Common.License.Enums;
using XP.Common.License.Implementations;
namespace XP.Common.License.Interfaces;
/// <summary>
/// 授权服务接口 | License service interface
/// </summary>
public interface ILicenseService
{
/// <summary>
/// 执行授权检查 | Perform authorization check
/// </summary>
/// <returns>授权检查结果 | License check result</returns>
LicenseCheckResult CheckAuthorization();
/// <summary>
/// 当前会话是否已授权 | Whether the current session is authorized
/// </summary>
bool IsAuthorized { get; }
/// <summary>
/// 获取授权到期日期 | Get license expiration date
/// </summary>
/// <returns>授权到期日期,未授权时返回 null | License expiration date, null if not authorized</returns>
DateTime? GetExpirationDate();
/// <summary>
/// 检查模块是否授权 | Check if module is licensed
/// </summary>
/// <param name="moduleId">模块ID | Module ID</param>
/// <returns>模块是否授权 | Whether the module is licensed</returns>
bool IsModuleLicensed(ushort moduleId);
/// <summary>
/// 获取SMA到期日期 | Get SMA expiration date
/// </summary>
/// <returns>SMA到期日期,未启用时返回 null | SMA expiration date, null if not enabled</returns>
DateTime? GetSmaDate();
/// <summary>
/// 当前授权模式 | Current license mode
/// </summary>
LicenseMode LicenseMode { get; }
/// <summary>
/// 临时测试模式超时事件(到期时触发,应用应执行正常关闭流程)| Temporary test mode timeout event (fires when expired, app should perform graceful shutdown)
/// </summary>
event EventHandler TestModeTimeout;
/// <summary>
/// 临时测试模式剩余5分钟警告事件 | Temporary test mode 5-minute warning event
/// </summary>
event EventHandler TestModeWarning5Min;
/// <summary>
/// 临时测试模式剩余1分钟警告事件 | Temporary test mode 1-minute warning event
/// </summary>
event EventHandler TestModeWarning1Min;
/// <summary>
/// 获取临时测试模式剩余时间 | Get remaining time in temporary test mode
/// </summary>
/// <returns>剩余时间(秒),非测试模式返回 0 | Remaining time in seconds, 0 if not in test mode</returns>
int GetRemainingTestTime();
}
+97
View File
@@ -0,0 +1,97 @@
using System;
using System.Runtime.InteropServices;
using System.Text;
using XP.Common.License.Enums;
namespace XP.Common.License.Native
{
/// <summary>
/// CLMS SDK 原生方法封装 | CLMS SDK native methods encapsulation
/// </summary>
internal static class NativeMethods
{
/// <summary>
/// 登录验证 | Login verification
/// </summary>
/// <param name="str">验证字符串 | Verification string</param>
/// <returns>TRUE: 成功 | TRUE: Success; FALSE: 失败 | FALSE: Failure</returns>
[DllImport("MORCODE.dll", EntryPoint = "CLM_Login", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern bool CLM_Login(StringBuilder str);
/// <summary>
/// 退出登录 | Logout
/// </summary>
/// <returns>TRUE: 成功 | TRUE: Success; FALSE: 失败 | FALSE: Failure</returns>
[DllImport("MORCODE.dll", EntryPoint = "CLM_Logout", CallingConvention = CallingConvention.Cdecl)]
internal static extern bool CLM_Logout();
/// <summary>
/// 检查许可范围 | Check license scope
/// </summary>
/// <returns>TRUE: 有许可 | TRUE: Has license; FALSE: 无许可 | FALSE: No license</returns>
[DllImport("MORCODE.dll", EntryPoint = "CLM_Login_Scope", CallingConvention = CallingConvention.Cdecl)]
internal static extern bool CLM_Login_Scope();
/// <summary>
/// 检查模块是否授权 | Check if module is licensed
/// </summary>
/// <param name="mod">模块ID | Module ID</param>
/// <param name="type">类型(暂无定义)| Type (undefined)</param>
/// <returns>TRUE: 模块可用 | TRUE: Module available; FALSE: 模块不可用 | FALSE: Module unavailable</returns>
[DllImport("MORCODE.dll", EntryPoint = "CLM_ModuleIsLicensed", CallingConvention = CallingConvention.Cdecl)]
internal static extern bool CLM_ModuleIsLicensed(ref ushort mod, ref ushort type);
/// <summary>
/// 获取保修到期日期 | Get warranty expiration date
/// </summary>
/// <param name="mon">月份 | Month</param>
/// <param name="day">日期 | Day</param>
/// <param name="year">年份 | Year</param>
/// <returns>TRUE: 成功 | TRUE: Success; FALSE: 失败 | FALSE: Failure</returns>
[DllImport("MORCODE.dll", EntryPoint = "CLM_GetWarrantyExpiration", CallingConvention = CallingConvention.Cdecl)]
internal static extern bool CLM_GetWarrantyExpiration(ref int mon, ref int day, ref int year);
/// <summary>
/// 获取浮动许可的IP地址和端口 | Get floating license IP and port
/// </summary>
/// <param name="ip">IP地址 | IP address</param>
/// <param name="port">端口 | Port</param>
/// <returns>TRUE: 成功 | TRUE: Success; FALSE: 失败 | FALSE: Failure</returns>
[DllImport("MORCODE.dll", EntryPoint = "CLM_GetIP", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern bool CLM_GetIP(StringBuilder ip, StringBuilder port);
/// <summary>
/// 获取错误信息 | Get error message
/// </summary>
/// <param name="error">错误信息 | Error message</param>
/// <returns>TRUE: 成功 | TRUE: Success; FALSE: 失败 | FALSE: Failure</returns>
[DllImport("MORCODE.dll", EntryPoint = "CLM_GetError", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern bool CLM_GetError(StringBuilder error);
/// <summary>
/// 检查系统时间 | Check system time
/// </summary>
/// <returns>TRUE: 系统时间正常 | TRUE: System time normal; FALSE: 系统时间异常 | FALSE: System time anomaly</returns>
[DllImport("MORCODE.dll", EntryPoint = "CLM_CheckSystemTime", CallingConvention = CallingConvention.Cdecl)]
internal static extern bool CLM_CheckSystemTime();
/// <summary>
/// 获取SmartService信息 | Get SmartService information
/// </summary>
/// <param name="ControllerId">控制器ID | Controller ID</param>
/// <param name="UserName">用户名 | User name</param>
/// <returns>TRUE: 成功 | TRUE: Success; FALSE: 失败 | FALSE: Failure</returns>
[DllImport("MORCODE.dll", EntryPoint = "CLM_SmartService", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal static extern bool CLM_SmartService(StringBuilder ControllerId, StringBuilder UserName);
/// <summary>
/// 获取SMA日期 | Get SMA date
/// </summary>
/// <param name="mon">月份 | Month</param>
/// <param name="day">日期 | Day</param>
/// <param name="year">年份 | Year</param>
/// <returns>TRUE: 成功 | TRUE: Success; FALSE: 失败 | FALSE: Failure</returns>
[DllImport("MORCODE.dll", EntryPoint = "CLM_GetSmaDate", CallingConvention = CallingConvention.Cdecl)]
internal static extern bool CLM_GetSmaDate(ref int mon, ref int day, ref int year);
}
}
+50
View File
@@ -0,0 +1,50 @@
using System.Configuration;
namespace XP.Common.Logging.Configs
{
/// <summary>
/// Serilog 配置加载器,从 App.config 读取日志相关配置项 | Serilog configuration loader, reads logging-related configuration from App.config
/// </summary>
public static class ConfigLoader
{
/// <summary>
/// 配置键前缀 | Configuration key prefix
/// </summary>
private const string KeyPrefix = "Serilog:";
/// <summary>
/// 从 App.config 加载 Serilog 配置 | Load Serilog configuration from App.config
/// </summary>
/// <returns>Serilog 配置实体,缺失或无效配置项使用默认值 | Serilog configuration entity, uses default values for missing or invalid items</returns>
public static SerilogConfig LoadSerilogConfig()
{
var config = new SerilogConfig();
// 加载日志路径 | Load log path
var logPath = ConfigurationManager.AppSettings[KeyPrefix + "LogPath"];
if (!string.IsNullOrEmpty(logPath)) config.LogPath = logPath;
// 加载最低日志级别 | Load minimum level
var minLevel = ConfigurationManager.AppSettings[KeyPrefix + "MinimumLevel"];
if (!string.IsNullOrEmpty(minLevel)) config.MinimumLevel = minLevel;
// 加载是否输出到控制台 | Load enable console
var enableConsole = ConfigurationManager.AppSettings[KeyPrefix + "EnableConsole"];
if (bool.TryParse(enableConsole, out var console)) config.EnableConsole = console;
// 加载日志文件分割规则 | Load rolling interval
var rollingInterval = ConfigurationManager.AppSettings[KeyPrefix + "RollingInterval"];
if (!string.IsNullOrEmpty(rollingInterval)) config.RollingInterval = rollingInterval;
// 加载单个日志文件最大大小 | Load file size limit
var fileSize = ConfigurationManager.AppSettings[KeyPrefix + "FileSizeLimitMB"];
if (long.TryParse(fileSize, out var size) && size > 0) config.FileSizeLimitMB = size;
// 加载保留日志文件数量 | Load retained file count limit
var retainCount = ConfigurationManager.AppSettings[KeyPrefix + "RetainedFileCountLimit"];
if (int.TryParse(retainCount, out var count) && count > 0) config.RetainedFileCountLimit = count;
return config;
}
}
}
@@ -1,43 +1,43 @@
using System;
using System.IO;
namespace XP.Common.Configs
namespace XP.Common.Logging.Configs
{
/// <summary>
/// Serilog日志配置实体(从App.config读取)
/// Serilog 日志配置实体(从 App.config 读取)| Serilog logging configuration entity (loaded from App.config)
/// </summary>
public class SerilogConfig
{
/// <summary>
/// 日志输出根路径(默认:AppData/Files/Logs
/// 日志输出根路径(默认:AppData/Files/Logs| Log output root path (default: AppData/Files/Logs)
/// </summary>
public string LogPath { get; set; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Files", "Logs");
/// <summary>
/// 最低日志级别(Debug/Info/Warn/Error/Fatal
/// 最低日志级别(Debug/Info/Warn/Error/Fatal| Minimum log level
/// </summary>
public string MinimumLevel { get; set; } = "Info";
/// <summary>
/// 是否输出到控制台(调试环境=true,生产环境=false
/// 是否输出到控制台(调试环境=true,生产环境=false| Whether to output to console
/// </summary>
public bool EnableConsole { get; set; } = true;
/// <summary>
/// 日志文件分割规则(Day/Month/Hour/Size
/// 日志文件分割规则(Day/Month/Hour/Size| Log file rolling interval
/// </summary>
public string RollingInterval { get; set; } = "Day";
/// <summary>
/// 单个日志文件最大大小(MB,仅Size分割时生效)
/// 单个日志文件最大大小(MB,仅 Size 分割时生效)| Single log file max size (MB)
/// </summary>
public long FileSizeLimitMB { get; set; } = 100;
/// <summary>
/// 保留日志文件数量(默认30天)
/// 保留日志文件数量(默认30天)| Retained file count limit (default 30)
/// </summary>
public int RetainedFileCountLimit { get; set; } = 30;
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
using System;
using Serilog;
using Serilog.Events;
using XP.Common.Configs;
using XP.Common.Logging.Configs;
using XP.Common.Logging.ViewModels;
namespace XP.Common.Logging
+11 -2
View File
@@ -4,7 +4,8 @@ using Prism.Modularity;
using XP.Common.Dump.Configs;
using XP.Common.Dump.Implementations;
using XP.Common.Dump.Interfaces;
using XP.Common.Helpers;
using XP.Common.License.Configs;
using XP.Common.License.Interfaces;
using XP.Common.Localization.Configs;
using XP.Common.Localization.Extensions;
using XP.Common.Localization.Implementations;
@@ -12,6 +13,8 @@ using XP.Common.Localization.Interfaces;
using XP.Common.Logging.Interfaces;
using XP.Common.PdfViewer.Implementations;
using XP.Common.PdfViewer.Interfaces;
using DumpConfigLoader = XP.Common.Dump.Configs.ConfigLoader;
using LicenseConfigLoader = XP.Common.License.Configs.ConfigLoader;
namespace XP.Common.Module
{
@@ -65,7 +68,7 @@ namespace XP.Common.Module
containerRegistry.RegisterSingleton<ILocalizationService, ResxLocalizationService>();
// 注册 Dump 配置为单例(通过工厂方法加载)| Register Dump config as singleton (via factory method)
containerRegistry.RegisterSingleton<DumpConfig>(() => ConfigLoader.LoadDumpConfig());
containerRegistry.RegisterSingleton<DumpConfig>(() => DumpConfigLoader.LoadDumpConfig());
// 注册 Dump 服务为单例 | Register Dump service as singleton
containerRegistry.RegisterSingleton<IDumpService, DumpService>();
@@ -75,6 +78,12 @@ namespace XP.Common.Module
// 注册 PDF 查看服务为单例 | Register PDF viewer service as singleton
containerRegistry.RegisterSingleton<IPdfViewerService, PdfViewerService>();
// 注册授权配置为单例(通过工厂方法加载)| Register license config as singleton (via factory method)
containerRegistry.RegisterSingleton<LicenseConfig>(() => LicenseConfigLoader.LoadLicenseConfig());
// 注册授权服务为单例 | Register license service as singleton
containerRegistry.RegisterSingleton<ILicenseService, XP.Common.License.Implementations.LicenseService>();
}
}
}
Binary file not shown.
+121
View File
@@ -1887,4 +1887,125 @@ Reprojection error: {1:F4} pixels</value>
<data name="ChessboardImageError" xml:space="preserve">
<value>Image{0}: {1:F4} pixels</value>
</data>
<!-- EdgeLineFitProcessor -->
<data name="EdgeLineFitProcessor_Name" xml:space="preserve">
<value>Edge Find Line Fit</value>
</data>
<data name="EdgeLineFitProcessor_Description" xml:space="preserve">
<value>Place calipers along a search line to detect edge points and fit a line (supports Least Squares and RANSAC)</value>
</data>
<data name="EdgeLineFitProcessor_CaliperCount" xml:space="preserve">
<value>Caliper Count</value>
</data>
<data name="EdgeLineFitProcessor_CaliperCount_Desc" xml:space="preserve">
<value>Number of calipers placed evenly along the search line</value>
</data>
<data name="EdgeLineFitProcessor_CaliperWidth" xml:space="preserve">
<value>Caliper Width</value>
</data>
<data name="EdgeLineFitProcessor_CaliperWidth_Desc" xml:space="preserve">
<value>Search length of each caliper (pixels), perpendicular to the search line</value>
</data>
<data name="EdgeLineFitProcessor_EdgePolarity" xml:space="preserve">
<value>Edge Polarity</value>
</data>
<data name="EdgeLineFitProcessor_EdgePolarity_Desc" xml:space="preserve">
<value>Edge direction: BrightToDark, DarkToBright, or Both</value>
</data>
<data name="EdgeLineFitProcessor_EdgeThreshold" xml:space="preserve">
<value>Edge Threshold</value>
</data>
<data name="EdgeLineFitProcessor_EdgeThreshold_Desc" xml:space="preserve">
<value>Gradient strength threshold; edges below this value are ignored</value>
</data>
<data name="EdgeLineFitProcessor_Sigma" xml:space="preserve">
<value>Smoothing Sigma</value>
</data>
<data name="EdgeLineFitProcessor_Sigma_Desc" xml:space="preserve">
<value>Gaussian smoothing standard deviation for noise suppression (larger = smoother)</value>
</data>
<data name="EdgeLineFitProcessor_FitMethod" xml:space="preserve">
<value>Fit Method</value>
</data>
<data name="EdgeLineFitProcessor_FitMethod_Desc" xml:space="preserve">
<value>Line fitting algorithm: LeastSquares or RANSAC (robust, rejects outliers)</value>
</data>
<data name="EdgeLineFitProcessor_RansacThreshold" xml:space="preserve">
<value>RANSAC Threshold</value>
</data>
<data name="EdgeLineFitProcessor_RansacThreshold_Desc" xml:space="preserve">
<value>RANSAC inlier distance threshold (pixels); points closer than this to the line are inliers</value>
</data>
<data name="EdgeLineFitProcessor_Thickness" xml:space="preserve">
<value>Line Thickness</value>
</data>
<data name="EdgeLineFitProcessor_Thickness_Desc" xml:space="preserve">
<value>Drawing thickness for result visualization</value>
</data>
<!-- EdgeCircleFitProcessor -->
<data name="EdgeCircleFitProcessor_Name" xml:space="preserve">
<value>Edge Find Circle Fit</value>
</data>
<data name="EdgeCircleFitProcessor_Description" xml:space="preserve">
<value>Place calipers along estimated circle to detect edge points and fit a circle (supports Least Squares and RANSAC)</value>
</data>
<data name="EdgeCircleFitProcessor_CaliperCount" xml:space="preserve">
<value>Caliper Count</value>
</data>
<data name="EdgeCircleFitProcessor_CaliperCount_Desc" xml:space="preserve">
<value>Number of calipers placed evenly around the circle</value>
</data>
<data name="EdgeCircleFitProcessor_CaliperWidth" xml:space="preserve">
<value>Caliper Width</value>
</data>
<data name="EdgeCircleFitProcessor_CaliperWidth_Desc" xml:space="preserve">
<value>Search length of each caliper along radial direction (pixels)</value>
</data>
<data name="EdgeCircleFitProcessor_EdgePolarity" xml:space="preserve">
<value>Edge Polarity</value>
</data>
<data name="EdgeCircleFitProcessor_EdgePolarity_Desc" xml:space="preserve">
<value>Edge direction: BrightToDark, DarkToBright, or Both</value>
</data>
<data name="EdgeCircleFitProcessor_EdgeThreshold" xml:space="preserve">
<value>Edge Threshold</value>
</data>
<data name="EdgeCircleFitProcessor_EdgeThreshold_Desc" xml:space="preserve">
<value>Gradient strength threshold; edges below this value are ignored</value>
</data>
<data name="EdgeCircleFitProcessor_Sigma" xml:space="preserve">
<value>Smoothing Sigma</value>
</data>
<data name="EdgeCircleFitProcessor_Sigma_Desc" xml:space="preserve">
<value>Gaussian smoothing standard deviation for noise suppression</value>
</data>
<data name="EdgeCircleFitProcessor_SearchDirection" xml:space="preserve">
<value>Search Direction</value>
</data>
<data name="EdgeCircleFitProcessor_SearchDirection_Desc" xml:space="preserve">
<value>Caliper search direction: Inward (toward center), Outward (away from center), Both</value>
</data>
<data name="EdgeCircleFitProcessor_FitMethod" xml:space="preserve">
<value>Fit Method</value>
</data>
<data name="EdgeCircleFitProcessor_FitMethod_Desc" xml:space="preserve">
<value>Circle fitting algorithm: LeastSquares or RANSAC (robust, rejects outliers)</value>
</data>
<data name="EdgeCircleFitProcessor_RansacThreshold" xml:space="preserve">
<value>RANSAC Threshold</value>
</data>
<data name="EdgeCircleFitProcessor_RansacThreshold_Desc" xml:space="preserve">
<value>RANSAC inlier distance threshold (pixels); points closer than this to the circle are inliers</value>
</data>
<data name="EdgeCircleFitProcessor_Thickness" xml:space="preserve">
<value>Line Thickness</value>
</data>
<data name="EdgeCircleFitProcessor_Thickness_Desc" xml:space="preserve">
<value>Drawing thickness for result visualization</value>
</data>
<data name="Histogram_NoData" xml:space="preserve">
<value>Histogram — No data</value>
<comment>ImageHistogramControl - Placeholder text when no image data</comment>
</data>
</root>

Some files were not shown because too many files have changed in this diff Show More