diff --git a/XplorePlane.Tests/Generators/RecipeGenerators.cs b/XplorePlane.Tests/Generators/RecipeGenerators.cs new file mode 100644 index 0000000..976ef23 --- /dev/null +++ b/XplorePlane.Tests/Generators/RecipeGenerators.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FsCheck; +using XplorePlane.Models; + +namespace XplorePlane.Tests.Generators +{ + /// + /// FsCheck 自定义生成器:为配方相关模型定义 Arbitrary 生成器。 + /// 使用 Arb.Register<RecipeGenerators>() 注册。 + /// + public class RecipeGenerators + { + // ── PipelineModel: class with settable properties ── + + public static Arbitrary PipelineModelArb() + { + var gen = from id in Arb.Default.Guid().Generator + from name in Arb.Default.NonEmptyString().Generator + from deviceId in Arb.Default.NonEmptyString().Generator + from ticks in Gen.Choose(0, int.MaxValue) + let createdAt = DateTime.MinValue.AddTicks((long)ticks * 10000) + let updatedAt = createdAt + select new PipelineModel + { + Id = id, + Name = name.Get, + DeviceId = deviceId.Get, + CreatedAt = createdAt, + UpdatedAt = updatedAt, + Nodes = new List() + }; + return Arb.From(gen); + } + + // ── RecipeStep: reuses StateGenerators for hardware states ── + + public static Arbitrary RecipeStepArb() + { + var gen = from stepIndex in Gen.Choose(0, 100) + from motion in StateGenerators.MotionStateArb().Generator + from ray in StateGenerators.RaySourceStateArb().Generator + from detector in StateGenerators.DetectorStateArb().Generator + from pipeline in PipelineModelArb().Generator + select new RecipeStep(stepIndex, motion, ray, detector, pipeline); + return Arb.From(gen); + } + + // ── InspectionRecipe: 0-10 steps with sequential StepIndex ── + + public static Arbitrary InspectionRecipeArb() + { + var gen = from id in Arb.Default.Guid().Generator + from name in Arb.Default.NonEmptyString().Generator + from ticks in Gen.Choose(0, int.MaxValue) + let createdAt = DateTime.MinValue.AddTicks((long)ticks * 10000) + let updatedAt = createdAt + from stepCount in Gen.Choose(0, 10) + from steps in GenSequentialSteps(stepCount) + select new InspectionRecipe( + id, + name.Get, + createdAt, + updatedAt, + steps.AsReadOnly()); + return Arb.From(gen); + } + + // ── RecipeExecutionState ── + + public static Arbitrary RecipeExecutionStateArb() + { + var gen = from totalSteps in Gen.Choose(0, 50) + from currentStep in Gen.Choose(0, Math.Max(totalSteps, 1) - 1) + from status in Gen.Elements( + RecipeExecutionStatus.Idle, + RecipeExecutionStatus.Running, + RecipeExecutionStatus.Paused, + RecipeExecutionStatus.Completed, + RecipeExecutionStatus.Error) + from recipeName in Arb.Default.NonEmptyString().Generator + select new RecipeExecutionState(currentStep, totalSteps, status, recipeName.Get); + return Arb.From(gen); + } + + // ── Helper: generate a list of steps with sequential StepIndex 0..n-1 ── + + private static Gen> GenSequentialSteps(int count) + { + if (count == 0) + return Gen.Constant(new List()); + + return Gen.Sequence( + Enumerable.Range(0, count).Select(i => + from motion in StateGenerators.MotionStateArb().Generator + from ray in StateGenerators.RaySourceStateArb().Generator + from detector in StateGenerators.DetectorStateArb().Generator + from pipeline in PipelineModelArb().Generator + select new RecipeStep(i, motion, ray, detector, pipeline) + ) + ).Select(steps => steps.ToList()); + } + } +} diff --git a/XplorePlane.Tests/Generators/StateGenerators.cs b/XplorePlane.Tests/Generators/StateGenerators.cs new file mode 100644 index 0000000..863c9fe --- /dev/null +++ b/XplorePlane.Tests/Generators/StateGenerators.cs @@ -0,0 +1,146 @@ +using System; +using FsCheck; +using XplorePlane.Models; + +namespace XplorePlane.Tests.Generators +{ + /// + /// FsCheck 自定义生成器:为所有状态模型定义 Arbitrary 生成器。 + /// 使用 Arb.Register<StateGenerators>() 注册。 + /// + public class StateGenerators + { + // ── 位置范围: -10000 ~ 10000, 速度范围: 0 ~ 1000 ── + + private static Gen PositionGen => + Gen.Choose(-10000000, 10000000).Select(i => i / 1000.0); + + private static Gen SpeedGen => + Gen.Choose(0, 1000000).Select(i => i / 1000.0); + + private static Gen VoltageGen => + Gen.Choose(0, 450000).Select(i => i / 1000.0); + + private static Gen PowerGen => + Gen.Choose(0, 3000000).Select(i => i / 1000.0); + + private static Gen FrameRateGen => + Gen.Choose(0, 120000).Select(i => i / 1000.0); + + private static Gen MatrixGen => + Gen.Choose(-10000000, 10000000).Select(i => i / 1000.0); + + // ── MotionState: 6 positions + 6 speeds ── + + public static Arbitrary MotionStateArb() + { + var gen = from xm in PositionGen + from ym in PositionGen + from zt in PositionGen + from zd in PositionGen + from tiltD in PositionGen + from dist in PositionGen + from xmSpd in SpeedGen + from ymSpd in SpeedGen + from ztSpd in SpeedGen + from zdSpd in SpeedGen + from tiltDSpd in SpeedGen + from distSpd in SpeedGen + select new MotionState(xm, ym, zt, zd, tiltD, dist, + xmSpd, ymSpd, ztSpd, zdSpd, tiltDSpd, distSpd); + return Arb.From(gen); + } + + // ── RaySourceState: bool + 2 doubles ── + + public static Arbitrary RaySourceStateArb() + { + var gen = from isOn in Arb.Default.Bool().Generator + from voltage in VoltageGen + from power in PowerGen + select new RaySourceState(isOn, voltage, power); + return Arb.From(gen); + } + + // ── DetectorState: 2 bools + double + string ── + + public static Arbitrary DetectorStateArb() + { + var gen = from isConnected in Arb.Default.Bool().Generator + from isAcquiring in Arb.Default.Bool().Generator + from frameRate in FrameRateGen + from res in Gen.Elements("1024x1024", "2048x2048", "2880x2880", "3072x3072", "4260x4260") + select new DetectorState(isConnected, isAcquiring, frameRate, res); + return Arb.From(gen); + } + + // ── SystemState: OperationMode enum + bool + string ── + + public static Arbitrary SystemStateArb() + { + var gen = from mode in Gen.Elements( + OperationMode.Idle, + OperationMode.Scanning, + OperationMode.CTAcquire, + OperationMode.RecipeRun) + from hasError in Arb.Default.Bool().Generator + from msg in Arb.Default.NonEmptyString().Generator + let errorMessage = hasError ? msg.Get : string.Empty + select new SystemState(mode, hasError, errorMessage); + return Arb.From(gen); + } + + // ── CameraState: 2 bools + null CurrentFrame + 2 ints + double ── + + public static Arbitrary CameraStateArb() + { + var gen = from isConnected in Arb.Default.Bool().Generator + from isStreaming in Arb.Default.Bool().Generator + from width in Gen.Choose(0, 8192) + from height in Gen.Choose(0, 8192) + from frameRate in FrameRateGen + select new CameraState(isConnected, isStreaming, null, width, height, frameRate); + return Arb.From(gen); + } + + // ── CalibrationMatrix: 9 doubles ── + + public static Arbitrary CalibrationMatrixArb() + { + var gen = from m11 in MatrixGen + from m12 in MatrixGen + from m13 in MatrixGen + from m21 in MatrixGen + from m22 in MatrixGen + from m23 in MatrixGen + from m31 in MatrixGen + from m32 in MatrixGen + from m33 in MatrixGen + select new CalibrationMatrix(m11, m12, m13, m21, m22, m23, m31, m32, m33); + return Arb.From(gen); + } + + // ── PhysicalPosition: 3 doubles ── + + public static Arbitrary PhysicalPositionArb() + { + var gen = from x in PositionGen + from y in PositionGen + from z in PositionGen + select new PhysicalPosition(x, y, z); + return Arb.From(gen); + } + + // ── LinkedViewState: PhysicalPosition + bool + DateTime ── + + public static Arbitrary LinkedViewStateArb() + { + var gen = from pos in PhysicalPositionArb().Generator + from isExecuting in Arb.Default.Bool().Generator + from ticks in Gen.Choose(0, int.MaxValue) + let dt = DateTime.MinValue.AddTicks((long)ticks * 10000) + select new LinkedViewState(pos, isExecuting, dt); + return Arb.From(gen); + } + } +} diff --git a/XplorePlane.Tests/Models/StateModelsTests.cs b/XplorePlane.Tests/Models/StateModelsTests.cs new file mode 100644 index 0000000..6861525 --- /dev/null +++ b/XplorePlane.Tests/Models/StateModelsTests.cs @@ -0,0 +1,153 @@ +using System; +using Xunit; +using XplorePlane.Models; + +namespace XplorePlane.Tests.Models +{ + public class StateModelsTests + { + // ── Default Value Tests ─────────────────────────────────────── + + [Fact] + public void MotionState_Default_AllZeros() + { + var state = MotionState.Default; + + Assert.Equal(0, state.XM); + Assert.Equal(0, state.YM); + Assert.Equal(0, state.ZT); + Assert.Equal(0, state.ZD); + Assert.Equal(0, state.TiltD); + Assert.Equal(0, state.Dist); + Assert.Equal(0, state.XMSpeed); + Assert.Equal(0, state.YMSpeed); + Assert.Equal(0, state.ZTSpeed); + Assert.Equal(0, state.ZDSpeed); + Assert.Equal(0, state.TiltDSpeed); + Assert.Equal(0, state.DistSpeed); + } + + [Fact] + public void RaySourceState_Default_IsOffAndZeros() + { + var state = RaySourceState.Default; + + Assert.False(state.IsOn); + Assert.Equal(0, state.Voltage); + Assert.Equal(0, state.Power); + } + + [Fact] + public void DetectorState_Default_DisconnectedAndZeros() + { + var state = DetectorState.Default; + + Assert.False(state.IsConnected); + Assert.False(state.IsAcquiring); + Assert.Equal(0, state.FrameRate); + Assert.Equal(string.Empty, state.Resolution); + } + + [Fact] + public void SystemState_Default_IdleNoError() + { + var state = SystemState.Default; + + Assert.Equal(OperationMode.Idle, state.OperationMode); + Assert.False(state.HasError); + Assert.Equal(string.Empty, state.ErrorMessage); + } + + [Fact] + public void CameraState_Default_DisconnectedAndZeros() + { + var state = CameraState.Default; + + Assert.False(state.IsConnected); + Assert.False(state.IsStreaming); + Assert.Null(state.CurrentFrame); + Assert.Equal(0, state.Width); + Assert.Equal(0, state.Height); + Assert.Equal(0, state.FrameRate); + } + + [Fact] + public void LinkedViewState_Default_ZeroPositionNotExecuting() + { + var state = LinkedViewState.Default; + + Assert.Equal(0, state.TargetPosition.X); + Assert.Equal(0, state.TargetPosition.Y); + Assert.Equal(0, state.TargetPosition.Z); + Assert.False(state.IsExecuting); + Assert.Equal(DateTime.MinValue, state.LastRequestTime); + } + + [Fact] + public void RecipeExecutionState_Default_IdleAndZeros() + { + var state = RecipeExecutionState.Default; + + Assert.Equal(0, state.CurrentStepIndex); + Assert.Equal(0, state.TotalSteps); + Assert.Equal(RecipeExecutionStatus.Idle, state.Status); + Assert.Equal(string.Empty, state.CurrentRecipeName); + } + + // ── Immutability Test ───────────────────────────────────────── + + [Fact] + public void MotionState_WithExpression_ProducesNewInstance() + { + var original = MotionState.Default; + + var modified = original with { XM = 100 }; + + // New instance is different from original + Assert.NotSame(original, modified); + Assert.Equal(100, modified.XM); + + // Original is unchanged + Assert.Equal(0, original.XM); + } + + // ── CalibrationMatrix Transform Tests ───────────────────────── + + [Fact] + public void CalibrationMatrix_Transform_CorrectCalculation() + { + // Known matrix: scaling X by 2, Y by 3, with offsets + var matrix = new CalibrationMatrix( + M11: 2, M12: 0, M13: 10, + M21: 0, M22: 3, M23: 20, + M31: 0, M32: 0, M33: 0 + ); + + var (x, y, z) = matrix.Transform(pixelX: 5, pixelY: 8); + + // x = 2*5 + 0*8 + 10 = 20 + Assert.Equal(20, x); + // y = 0*5 + 3*8 + 20 = 44 + Assert.Equal(44, y); + // z = 0*5 + 0*8 + 0 = 0 + Assert.Equal(0, z); + } + + [Fact] + public void CalibrationMatrix_Transform_IdentityMatrix() + { + // Identity-like matrix: should return (pixelX, pixelY, 0) approximately + var identity = new CalibrationMatrix( + M11: 1, M12: 0, M13: 0, + M21: 0, M22: 1, M23: 0, + M31: 0, M32: 0, M33: 0 + ); + + var (x, y, z) = identity.Transform(pixelX: 42.5, pixelY: 99.1); + + Assert.Equal(42.5, x, precision: 10); + Assert.Equal(99.1, y, precision: 10); + Assert.Equal(0, z, precision: 10); + } + } +} diff --git a/XplorePlane.Tests/Services/AppStateServiceTests.cs b/XplorePlane.Tests/Services/AppStateServiceTests.cs new file mode 100644 index 0000000..5289d97 --- /dev/null +++ b/XplorePlane.Tests/Services/AppStateServiceTests.cs @@ -0,0 +1,121 @@ +using System; +using System.Windows; +using Moq; +using Serilog; +using Xunit; +using XP.Hardware.RaySource.Services; +using XplorePlane.Models; +using XplorePlane.Services.AppState; + +namespace XplorePlane.Tests.Services +{ + /// + /// AppStateService 单元测试。 + /// 验证默认状态值、Dispose 后行为、null 参数校验、CalibrationMatrix 缺失时的错误处理。 + /// + public class AppStateServiceTests : IDisposable + { + private readonly AppStateService _service; + private readonly Mock _mockRaySource; + private readonly Mock _mockLogger; + + public AppStateServiceTests() + { + // Ensure WPF Application exists for Dispatcher + if (Application.Current == null) + { + new Application(); + } + + _mockRaySource = new Mock(); + _mockLogger = new Mock(); + _service = new AppStateService(_mockRaySource.Object, _mockLogger.Object); + } + + public void Dispose() + { + _service.Dispose(); + } + + // ── 默认状态值验证 ── + + [Fact] + public void DefaultState_MotionState_IsDefault() + { + Assert.Same(MotionState.Default, _service.MotionState); + } + + [Fact] + public void DefaultState_RaySourceState_IsDefault() + { + Assert.Same(RaySourceState.Default, _service.RaySourceState); + } + + [Fact] + public void DefaultState_SystemState_IsDefault() + { + Assert.Same(SystemState.Default, _service.SystemState); + } + + [Fact] + public void DefaultState_CalibrationMatrix_IsNull() + { + Assert.Null(_service.CalibrationMatrix); + } + + // ── null 参数抛出 ArgumentNullException ── + + [Fact] + public void UpdateMotionState_NullArgument_ThrowsArgumentNullException() + { + Assert.Throws(() => _service.UpdateMotionState(null!)); + } + + [Fact] + public void UpdateRaySourceState_NullArgument_ThrowsArgumentNullException() + { + Assert.Throws(() => _service.UpdateRaySourceState(null!)); + } + + [Fact] + public void UpdateDetectorState_NullArgument_ThrowsArgumentNullException() + { + Assert.Throws(() => _service.UpdateDetectorState(null!)); + } + + [Fact] + public void UpdateSystemState_NullArgument_ThrowsArgumentNullException() + { + Assert.Throws(() => _service.UpdateSystemState(null!)); + } + + // ── Dispose 后 Update 被忽略 ── + + [Fact] + public void Dispose_ThenUpdate_IsIgnored() + { + var originalState = _service.MotionState; + _service.Dispose(); + + // Should not throw, and state should remain unchanged + var newState = new MotionState(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); + _service.UpdateMotionState(newState); + + Assert.Same(originalState, _service.MotionState); + } + + // ── CalibrationMatrix 为 null 时 RequestLinkedView 设置错误状态 ── + + [Fact] + public void RequestLinkedView_NoCalibrationMatrix_SetsErrorState() + { + // CalibrationMatrix is null by default + Assert.Null(_service.CalibrationMatrix); + + _service.RequestLinkedView(100.0, 200.0); + + Assert.True(_service.SystemState.HasError); + Assert.NotEmpty(_service.SystemState.ErrorMessage); + } + } +} diff --git a/XplorePlane.Tests/Services/RecipeServiceTests.cs b/XplorePlane.Tests/Services/RecipeServiceTests.cs new file mode 100644 index 0000000..132af1e --- /dev/null +++ b/XplorePlane.Tests/Services/RecipeServiceTests.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Moq; +using Serilog; +using Xunit; +using XplorePlane.Models; +using XplorePlane.Services; +using XplorePlane.Services.AppState; +using XplorePlane.Services.Recipe; + +namespace XplorePlane.Tests.Services +{ + /// + /// RecipeService 单元测试。 + /// 验证配方创建、步骤记录、文件加载错误处理和名称校验。 + /// + public class RecipeServiceTests + { + private readonly Mock _mockAppState; + private readonly Mock _mockPipeline; + private readonly Mock _mockLogger; + private readonly RecipeService _service; + + public RecipeServiceTests() + { + _mockAppState = new Mock(); + _mockPipeline = new Mock(); + _mockLogger = new Mock(); + + // Setup default state returns + _mockAppState.Setup(s => s.MotionState).Returns(MotionState.Default); + _mockAppState.Setup(s => s.RaySourceState).Returns(RaySourceState.Default); + _mockAppState.Setup(s => s.DetectorState).Returns(DetectorState.Default); + + _service = new RecipeService( + _mockAppState.Object, + _mockPipeline.Object, + _mockLogger.Object); + } + + // ── CreateRecipe 验证 ── + + [Fact] + public void CreateRecipe_ValidName_ReturnsEmptyRecipe() + { + var recipe = _service.CreateRecipe("TestRecipe"); + + Assert.Equal("TestRecipe", recipe.Name); + Assert.Empty(recipe.Steps); + Assert.NotEqual(Guid.Empty, recipe.Id); + Assert.True(recipe.CreatedAt <= DateTime.UtcNow); + Assert.True(recipe.UpdatedAt <= DateTime.UtcNow); + } + + [Fact] + public void CreateRecipe_EmptyName_ThrowsArgumentException() + { + Assert.Throws(() => _service.CreateRecipe(string.Empty)); + } + + [Fact] + public void CreateRecipe_NullName_ThrowsArgumentException() + { + Assert.Throws(() => _service.CreateRecipe(null!)); + } + + [Fact] + public void CreateRecipe_WhitespaceName_ThrowsArgumentException() + { + Assert.Throws(() => _service.CreateRecipe(" ")); + } + + // ── RecordCurrentStep 验证 ── + + [Fact] + public void RecordCurrentStep_CapturesCurrentState() + { + var motionState = new MotionState(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); + var raySourceState = new RaySourceState(true, 120.0, 80.0); + var detectorState = new DetectorState(true, true, 30.0, "2048x2048"); + + _mockAppState.Setup(s => s.MotionState).Returns(motionState); + _mockAppState.Setup(s => s.RaySourceState).Returns(raySourceState); + _mockAppState.Setup(s => s.DetectorState).Returns(detectorState); + + var recipe = _service.CreateRecipe("TestRecipe"); + var pipeline = new PipelineModel { Name = "TestPipeline" }; + + var step = _service.RecordCurrentStep(recipe, pipeline); + + Assert.Equal(0, step.StepIndex); + Assert.Same(motionState, step.MotionState); + Assert.Same(raySourceState, step.RaySourceState); + Assert.Same(detectorState, step.DetectorState); + Assert.Same(pipeline, step.Pipeline); + } + + // ── LoadAsync 错误处理 ── + + [Fact] + public async Task LoadAsync_FileNotExists_ThrowsFileNotFoundException() + { + var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".json"); + + await Assert.ThrowsAsync( + () => _service.LoadAsync(nonExistentPath)); + } + + [Fact] + public async Task LoadAsync_InvalidJson_ThrowsInvalidDataException() + { + var tempFile = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(tempFile, "{ this is not valid json !!! }"); + + await Assert.ThrowsAsync( + () => _service.LoadAsync(tempFile)); + } + finally + { + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + } + } +} diff --git a/XplorePlane.Tests/XplorePlane.Tests.csproj b/XplorePlane.Tests/XplorePlane.Tests.csproj index b2c1ace..229c12e 100644 --- a/XplorePlane.Tests/XplorePlane.Tests.csproj +++ b/XplorePlane.Tests/XplorePlane.Tests.csproj @@ -1,33 +1,34 @@ + net8.0-windows true - XplorePlane.Tests false - enable + XplorePlane.Tests - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - + + + + + + + + + + - - - ..\XplorePlane\Libs\ImageProcessing\ImageProcessing.Core.dll + + ..\XplorePlane\Libs\Hardware\XP.Hardware.RaySource.dll True + diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs index 14f3155..ba2bc35 100644 --- a/XplorePlane/App.xaml.cs +++ b/XplorePlane/App.xaml.cs @@ -6,6 +6,8 @@ using System.Windows; using XplorePlane.Views; using XplorePlane.ViewModels; using XplorePlane.Services; +using XplorePlane.Services.AppState; +using XplorePlane.Services.Recipe; using Prism.Ioc; using Prism.DryIoc; using Prism.Modularity; @@ -88,6 +90,22 @@ namespace XplorePlane Log.Information("XplorePlane 应用程序退出"); Log.Information("========================================"); + // 释放全局状态服务资源 + try + { + var bootstrapper = AppBootstrapper.Instance; + if (bootstrapper != null) + { + var appStateService = bootstrapper.Container.Resolve(); + appStateService?.Dispose(); + Log.Information("全局状态服务资源已释放"); + } + } + catch (Exception ex) + { + Log.Error(ex, "全局状态服务资源释放失败"); + } + // 释放射线源资源 try { @@ -179,6 +197,12 @@ namespace XplorePlane containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); + // 注册全局状态服务(单例) + containerRegistry.RegisterSingleton(); + + // 注册检测配方服务(单例) + containerRegistry.RegisterSingleton(); + // 注册流水线 ViewModel(每次解析创建新实例) containerRegistry.Register(); containerRegistry.Register(); diff --git a/XplorePlane/Assets/Icons/crosshair.png b/XplorePlane/Assets/Icons/crosshair.png new file mode 100644 index 0000000..313f75f Binary files /dev/null and b/XplorePlane/Assets/Icons/crosshair.png differ diff --git a/XplorePlane/Assets/Icons/dynamic-range.png b/XplorePlane/Assets/Icons/dynamic-range.png new file mode 100644 index 0000000..5fcee92 Binary files /dev/null and b/XplorePlane/Assets/Icons/dynamic-range.png differ diff --git a/XplorePlane/Assets/Icons/film-darken.png b/XplorePlane/Assets/Icons/film-darken.png new file mode 100644 index 0000000..605cd9c Binary files /dev/null and b/XplorePlane/Assets/Icons/film-darken.png differ diff --git a/XplorePlane/Assets/Icons/sharpen.png b/XplorePlane/Assets/Icons/sharpen.png new file mode 100644 index 0000000..366616e Binary files /dev/null and b/XplorePlane/Assets/Icons/sharpen.png differ diff --git a/XplorePlane/Models/RecipeModels.cs b/XplorePlane/Models/RecipeModels.cs new file mode 100644 index 0000000..3753ba5 --- /dev/null +++ b/XplorePlane/Models/RecipeModels.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace XplorePlane.Models +{ + /// 检测配方中的单个步骤 + public record RecipeStep( + int StepIndex, + MotionState MotionState, + RaySourceState RaySourceState, + DetectorState DetectorState, + PipelineModel Pipeline + ); + + /// 检测配方(CNC 自动编程) + public record InspectionRecipe( + Guid Id, + string Name, + DateTime CreatedAt, + DateTime UpdatedAt, + IReadOnlyList Steps + ) + { + /// 追加步骤,返回新的 InspectionRecipe + public InspectionRecipe AddStep(RecipeStep step) => + this with + { + Steps = Steps.Append(step).ToList().AsReadOnly(), + UpdatedAt = DateTime.UtcNow + }; + } + + /// 配方执行状态(不可变) + public record RecipeExecutionState( + int CurrentStepIndex, + int TotalSteps, + RecipeExecutionStatus Status, + string CurrentRecipeName + ) + { + public static readonly RecipeExecutionState Default = new(0, 0, RecipeExecutionStatus.Idle, string.Empty); + } +} diff --git a/XplorePlane/Models/StateModels.cs b/XplorePlane/Models/StateModels.cs new file mode 100644 index 0000000..a494ca5 --- /dev/null +++ b/XplorePlane/Models/StateModels.cs @@ -0,0 +1,120 @@ +using System; + +namespace XplorePlane.Models +{ + // ── Enumerations ────────────────────────────────────────────────── + + /// 系统操作模式 + public enum OperationMode + { + Idle, // 空闲 + Scanning, // 扫描 + CTAcquire, // CT 采集 + RecipeRun // 配方执行中 + } + + /// 配方执行状态 + public enum RecipeExecutionStatus + { + Idle, // 空闲 + Running, // 运行中 + Paused, // 暂停 + Completed, // 已完成 + Error // 出错 + } + + // ── State Records ───────────────────────────────────────────────── + + /// 运动控制状态(不可变) + public record MotionState( + double XM, // X 轴位置 (μm) + double YM, // Y 轴位置 (μm) + double ZT, // Z 上轴位置 (μm) + double ZD, // Z 下轴位置 (μm) + double TiltD, // 倾斜角度 (m°) + double Dist, // 距离 (μm) + double XMSpeed, // X 轴速度 (μm/s) + double YMSpeed, // Y 轴速度 (μm/s) + double ZTSpeed, // Z 上轴速度 (μm/s) + double ZDSpeed, // Z 下轴速度 (μm/s) + double TiltDSpeed, // 倾斜速度 (m°/s) + double DistSpeed // 距离速度 (μm/s) + ) + { + public static readonly MotionState Default = new(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + + /// 射线源状态(不可变) + public record RaySourceState( + bool IsOn, // 开关状态 + double Voltage, // 电压 (kV) + double Power // 功率 (W) + ) + { + public static readonly RaySourceState Default = new(false, 0, 0); + } + + /// 探测器状态(不可变) + public record DetectorState( + bool IsConnected, // 连接状态 + bool IsAcquiring, // 是否正在采集 + double FrameRate, // 当前帧率 (fps) + string Resolution // 分辨率描述,如 "2048x2048" + ) + { + public static readonly DetectorState Default = new(false, false, 0, string.Empty); + } + + /// 系统级状态(不可变) + public record SystemState( + OperationMode OperationMode, // 当前操作模式 + bool HasError, // 是否存在系统错误 + string ErrorMessage // 错误描述 + ) + { + public static readonly SystemState Default = new(OperationMode.Idle, false, string.Empty); + } + + /// 摄像头视频流状态(不可变) + public record CameraState( + bool IsConnected, // 连接状态 + bool IsStreaming, // 是否正在推流 + object CurrentFrame, // 当前帧数据引用(BitmapSource 或 byte[],Frozen) + int Width, // 分辨率宽 + int Height, // 分辨率高 + double FrameRate // 帧率 (fps) + ) + { + public static readonly CameraState Default = new(false, false, null, 0, 0, 0); + } + + /// 物理坐标 + public record PhysicalPosition(double X, double Y, double Z); + + /// 图像标定矩阵,像素坐标 → 物理坐标映射 + public record CalibrationMatrix( + double M11, double M12, double M13, // 3x3 仿射变换矩阵 + double M21, double M22, double M23, + double M31, double M32, double M33 + ) + { + /// 将像素坐标转换为物理坐标 + public (double X, double Y, double Z) Transform(double pixelX, double pixelY) + { + double x = M11 * pixelX + M12 * pixelY + M13; + double y = M21 * pixelX + M22 * pixelY + M23; + double z = M31 * pixelX + M32 * pixelY + M33; + return (x, y, z); + } + } + + /// 画面联动状态(不可变) + public record LinkedViewState( + PhysicalPosition TargetPosition, // 目标物理坐标 + bool IsExecuting, // 联动是否正在执行 + DateTime LastRequestTime // 最近一次联动请求时间 + ) + { + public static readonly LinkedViewState Default = new(new PhysicalPosition(0, 0, 0), false, DateTime.MinValue); + } +} diff --git a/XplorePlane/Services/AppState/AppStateService.cs b/XplorePlane/Services/AppState/AppStateService.cs new file mode 100644 index 0000000..74d127d --- /dev/null +++ b/XplorePlane/Services/AppState/AppStateService.cs @@ -0,0 +1,232 @@ +using System; +using System.Threading; +using System.Windows; +using System.Windows.Threading; +using Prism.Mvvm; +using Serilog; +using XP.Hardware.RaySource.Services; +using XplorePlane.Models; + +namespace XplorePlane.Services.AppState +{ + /// + /// 全局应用状态管理服务实现。 + /// 继承 BindableBase 以支持 WPF 数据绑定,使用 Interlocked.Exchange 保证线程安全写入, + /// 通过 Dispatcher.BeginInvoke 将事件调度到 UI 线程。 + /// + public class AppStateService : BindableBase, IAppStateService + { + private readonly Dispatcher _dispatcher; + private readonly IRaySourceService _raySourceService; + private readonly ILogger _logger; + private bool _disposed; + + // ── 状态字段(通过 Interlocked.Exchange 原子替换)── + private MotionState _motionState = MotionState.Default; + private RaySourceState _raySourceState = RaySourceState.Default; + private DetectorState _detectorState = DetectorState.Default; + private SystemState _systemState = SystemState.Default; + private CameraState _cameraState = CameraState.Default; + private CalibrationMatrix _calibrationMatrix; + private LinkedViewState _linkedViewState = LinkedViewState.Default; + private RecipeExecutionState _recipeExecutionState = RecipeExecutionState.Default; + + // ── 类型化状态变更事件 ── + public event EventHandler> MotionStateChanged; + public event EventHandler> RaySourceStateChanged; + public event EventHandler> DetectorStateChanged; + public event EventHandler> SystemStateChanged; + public event EventHandler> CameraStateChanged; + public event EventHandler> LinkedViewStateChanged; + public event EventHandler> RecipeExecutionStateChanged; + public event EventHandler LinkedViewRequested; + + // ── 状态属性(只读)── + public MotionState MotionState => _motionState; + public RaySourceState RaySourceState => _raySourceState; + public DetectorState DetectorState => _detectorState; + public SystemState SystemState => _systemState; + public CameraState CameraState => _cameraState; + public CalibrationMatrix CalibrationMatrix => _calibrationMatrix; + public LinkedViewState LinkedViewState => _linkedViewState; + public RecipeExecutionState RecipeExecutionState => _recipeExecutionState; + + public AppStateService( + IRaySourceService raySourceService, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(raySourceService); + ArgumentNullException.ThrowIfNull(logger); + + _raySourceService = raySourceService; + _logger = logger; + _dispatcher = Application.Current.Dispatcher; + + SubscribeToExistingServices(); + _logger.Information("AppStateService 已初始化"); + } + + // ── 状态更新方法 ── + + public void UpdateMotionState(MotionState newState) + { + ArgumentNullException.ThrowIfNull(newState); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateMotionState 调用"); return; } + + var old = Interlocked.Exchange(ref _motionState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, MotionStateChanged, nameof(MotionState)); + } + + public void UpdateRaySourceState(RaySourceState newState) + { + ArgumentNullException.ThrowIfNull(newState); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateRaySourceState 调用"); return; } + + var old = Interlocked.Exchange(ref _raySourceState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, RaySourceStateChanged, nameof(RaySourceState)); + } + + public void UpdateDetectorState(DetectorState newState) + { + ArgumentNullException.ThrowIfNull(newState); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateDetectorState 调用"); return; } + + var old = Interlocked.Exchange(ref _detectorState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, DetectorStateChanged, nameof(DetectorState)); + } + + public void UpdateSystemState(SystemState newState) + { + ArgumentNullException.ThrowIfNull(newState); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateSystemState 调用"); return; } + + var old = Interlocked.Exchange(ref _systemState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, SystemStateChanged, nameof(SystemState)); + } + + public void UpdateCameraState(CameraState newState) + { + ArgumentNullException.ThrowIfNull(newState); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateCameraState 调用"); return; } + + var old = Interlocked.Exchange(ref _cameraState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, CameraStateChanged, nameof(CameraState)); + } + + public void UpdateCalibrationMatrix(CalibrationMatrix newMatrix) + { + ArgumentNullException.ThrowIfNull(newMatrix); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateCalibrationMatrix 调用"); return; } + + var old = Interlocked.Exchange(ref _calibrationMatrix, newMatrix); + if (ReferenceEquals(old, newMatrix)) return; + + _dispatcher.BeginInvoke(() => + { + RaisePropertyChanged(nameof(CalibrationMatrix)); + }); + } + + public void UpdateLinkedViewState(LinkedViewState newState) + { + ArgumentNullException.ThrowIfNull(newState); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateLinkedViewState 调用"); return; } + + var old = Interlocked.Exchange(ref _linkedViewState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, LinkedViewStateChanged, nameof(LinkedViewState)); + } + + public void UpdateRecipeExecutionState(RecipeExecutionState newState) + { + ArgumentNullException.ThrowIfNull(newState); + if (_disposed) { _logger.Warning("AppStateService 已释放,忽略 UpdateRecipeExecutionState 调用"); return; } + + var old = Interlocked.Exchange(ref _recipeExecutionState, newState); + if (ReferenceEquals(old, newState)) return; + RaiseOnDispatcher(old, newState, RecipeExecutionStateChanged, nameof(RecipeExecutionState)); + } + + // ── 画面联动 ── + + public void RequestLinkedView(double pixelX, double pixelY) + { + var matrix = _calibrationMatrix; + if (matrix is null) + { + _logger.Warning("CalibrationMatrix 未设置,无法执行画面联动 (pixelX={PixelX}, pixelY={PixelY})", pixelX, pixelY); + UpdateSystemState(SystemState with + { + HasError = true, + ErrorMessage = "CalibrationMatrix 未设置,无法执行画面联动" + }); + return; + } + + var (x, y, z) = matrix.Transform(pixelX, pixelY); + var newLinkedState = new LinkedViewState( + new PhysicalPosition(x, y, z), + IsExecuting: true, + LastRequestTime: DateTime.UtcNow); + + UpdateLinkedViewState(newLinkedState); + + _dispatcher.BeginInvoke(() => + { + LinkedViewRequested?.Invoke(this, new LinkedViewRequestEventArgs(x, y, z)); + }); + } + + // ── 内部辅助方法 ── + + private void RaiseOnDispatcher(T oldVal, T newVal, + EventHandler> handler, string propertyName) + { + _dispatcher.BeginInvoke(() => + { + RaisePropertyChanged(propertyName); + try + { + handler?.Invoke(this, new StateChangedEventArgs(oldVal, newVal)); + } + catch (Exception ex) + { + _logger.Error(ex, "状态变更事件处理器抛出异常 (property={PropertyName})", propertyName); + } + }); + } + + private void SubscribeToExistingServices() + { + // IRaySourceService 是基于命令的服务(没有直接的状态变更事件), + // 状态同步由外部调用者通过 UpdateRaySourceState 完成。 + // TODO: 当 IRaySourceService 添加状态变更事件时,在此处订阅并同步到 RaySourceState + _logger.Information("AppStateService 已准备好接收外部服务状态更新"); + } + + // ── Dispose ── + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + // 清除所有事件订阅 + MotionStateChanged = null; + RaySourceStateChanged = null; + DetectorStateChanged = null; + SystemStateChanged = null; + CameraStateChanged = null; + LinkedViewStateChanged = null; + RecipeExecutionStateChanged = null; + LinkedViewRequested = null; + + _logger.Information("AppStateService 已释放"); + } + } +} diff --git a/XplorePlane/Services/AppState/IAppStateService.cs b/XplorePlane/Services/AppState/IAppStateService.cs new file mode 100644 index 0000000..aba3b11 --- /dev/null +++ b/XplorePlane/Services/AppState/IAppStateService.cs @@ -0,0 +1,54 @@ +using System; +using System.ComponentModel; +using XplorePlane.Models; + +namespace XplorePlane.Services.AppState +{ + /// + /// 全局应用状态管理服务接口。 + /// 以单例模式注册,聚合所有硬件和系统状态,支持 WPF 数据绑定和类型化事件订阅。 + /// + public interface IAppStateService : INotifyPropertyChanged, IDisposable + { + // ── 状态属性(只读,通过 Update 方法更新)── + + MotionState MotionState { get; } + RaySourceState RaySourceState { get; } + DetectorState DetectorState { get; } + SystemState SystemState { get; } + CameraState CameraState { get; } + CalibrationMatrix CalibrationMatrix { get; } + LinkedViewState LinkedViewState { get; } + RecipeExecutionState RecipeExecutionState { get; } + + // ── 状态更新方法(线程安全,可从任意线程调用)── + + void UpdateMotionState(MotionState newState); + void UpdateRaySourceState(RaySourceState newState); + void UpdateDetectorState(DetectorState newState); + void UpdateSystemState(SystemState newState); + void UpdateCameraState(CameraState newState); + void UpdateCalibrationMatrix(CalibrationMatrix newMatrix); + void UpdateLinkedViewState(LinkedViewState newState); + void UpdateRecipeExecutionState(RecipeExecutionState newState); + + // ── 画面联动 ── + + /// 将像素坐标通过 CalibrationMatrix 转换为物理坐标并发布 LinkedViewRequest + void RequestLinkedView(double pixelX, double pixelY); + + // ── 类型化状态变更事件 ── + + event EventHandler> MotionStateChanged; + event EventHandler> RaySourceStateChanged; + event EventHandler> DetectorStateChanged; + event EventHandler> SystemStateChanged; + event EventHandler> CameraStateChanged; + event EventHandler> LinkedViewStateChanged; + event EventHandler> RecipeExecutionStateChanged; + + // ── 画面联动请求事件 ── + + event EventHandler LinkedViewRequested; + } +} diff --git a/XplorePlane/Services/AppState/StateChangedEventArgs.cs b/XplorePlane/Services/AppState/StateChangedEventArgs.cs new file mode 100644 index 0000000..b8f3084 --- /dev/null +++ b/XplorePlane/Services/AppState/StateChangedEventArgs.cs @@ -0,0 +1,32 @@ +using System; + +namespace XplorePlane.Services.AppState +{ + /// 类型化状态变更事件参数,携带旧值和新值 + public sealed class StateChangedEventArgs : EventArgs + { + public T OldValue { get; } + public T NewValue { get; } + + public StateChangedEventArgs(T oldValue, T newValue) + { + OldValue = oldValue; + NewValue = newValue; + } + } + + /// 画面联动请求事件参数,携带目标物理坐标 + public sealed class LinkedViewRequestEventArgs : EventArgs + { + public double TargetX { get; } + public double TargetY { get; } + public double TargetZ { get; } + + public LinkedViewRequestEventArgs(double targetX, double targetY, double targetZ) + { + TargetX = targetX; + TargetY = targetY; + TargetZ = targetZ; + } + } +} diff --git a/XplorePlane/Services/Recipe/IRecipeService.cs b/XplorePlane/Services/Recipe/IRecipeService.cs new file mode 100644 index 0000000..829fc8d --- /dev/null +++ b/XplorePlane/Services/Recipe/IRecipeService.cs @@ -0,0 +1,31 @@ +using System.Threading; +using System.Threading.Tasks; +using XplorePlane.Models; + +namespace XplorePlane.Services.Recipe +{ + /// 检测配方管理服务接口,负责配方的创建、序列化/反序列化和执行编排 + public interface IRecipeService + { + /// 创建空的检测配方 + InspectionRecipe CreateRecipe(string name); + + /// 从当前全局状态快照 + PipelineModel 记录一个步骤 + RecipeStep RecordCurrentStep(InspectionRecipe recipe, PipelineModel pipeline); + + /// 序列化配方到 JSON 文件 + Task SaveAsync(InspectionRecipe recipe, string filePath); + + /// 从 JSON 文件反序列化配方 + Task LoadAsync(string filePath); + + /// 按步骤顺序执行配方 + Task ExecuteAsync(InspectionRecipe recipe, CancellationToken cancellationToken = default); + + /// 暂停当前配方执行(当前步骤完成后停止) + void Pause(); + + /// 恢复已暂停的配方执行 + void Resume(); + } +} diff --git a/XplorePlane/Services/Recipe/RecipeService.cs b/XplorePlane/Services/Recipe/RecipeService.cs new file mode 100644 index 0000000..9f7e858 --- /dev/null +++ b/XplorePlane/Services/Recipe/RecipeService.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using XplorePlane.Models; +using XplorePlane.Services.AppState; + +namespace XplorePlane.Services.Recipe +{ + /// + /// 检测配方管理服务实现。 + /// 负责配方的创建、序列化/反序列化和执行编排,通过 ManualResetEventSlim 支持暂停/恢复。 + /// + public class RecipeService : IRecipeService + { + private readonly IAppStateService _appStateService; + private readonly IPipelineExecutionService _pipelineExecutionService; + private readonly ILogger _logger; + private readonly ManualResetEventSlim _pauseHandle = new(true); // initially signaled (not paused) + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true + }; + + public RecipeService( + IAppStateService appStateService, + IPipelineExecutionService pipelineExecutionService, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(appStateService); + ArgumentNullException.ThrowIfNull(pipelineExecutionService); + ArgumentNullException.ThrowIfNull(logger); + + _appStateService = appStateService; + _pipelineExecutionService = pipelineExecutionService; + _logger = logger; + + _logger.Information("RecipeService 已初始化"); + } + + /// + public InspectionRecipe CreateRecipe(string name) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("配方名称不能为空", nameof(name)); + + var recipe = new InspectionRecipe( + Id: Guid.NewGuid(), + Name: name, + CreatedAt: DateTime.UtcNow, + UpdatedAt: DateTime.UtcNow, + Steps: new List().AsReadOnly()); + + _logger.Information("已创建配方: {RecipeName}, Id={RecipeId}", name, recipe.Id); + return recipe; + } + + /// + public RecipeStep RecordCurrentStep(InspectionRecipe recipe, PipelineModel pipeline) + { + ArgumentNullException.ThrowIfNull(recipe); + ArgumentNullException.ThrowIfNull(pipeline); + + var motionState = _appStateService.MotionState; + var raySourceState = _appStateService.RaySourceState; + var detectorState = _appStateService.DetectorState; + + var step = new RecipeStep( + StepIndex: recipe.Steps.Count, + MotionState: motionState, + RaySourceState: raySourceState, + DetectorState: detectorState, + Pipeline: pipeline); + + _logger.Information("已记录配方步骤: StepIndex={StepIndex}, Recipe={RecipeName}", + step.StepIndex, recipe.Name); + return step; + } + + /// + public async Task SaveAsync(InspectionRecipe recipe, string filePath) + { + ArgumentNullException.ThrowIfNull(recipe); + ArgumentNullException.ThrowIfNull(filePath); + + // 路径安全性验证:检查路径遍历 + if (filePath.Contains("..", StringComparison.Ordinal)) + throw new UnauthorizedAccessException($"文件路径包含不安全的路径遍历字符: {filePath}"); + + var json = JsonSerializer.Serialize(recipe, _jsonOptions); + await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false); + + _logger.Information("已保存配方到文件: {FilePath}, Recipe={RecipeName}", filePath, recipe.Name); + } + + /// + public async Task LoadAsync(string filePath) + { + ArgumentNullException.ThrowIfNull(filePath); + + if (!File.Exists(filePath)) + throw new FileNotFoundException($"配方文件不存在: {filePath}", filePath); + + string json; + try + { + json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not FileNotFoundException) + { + throw new InvalidDataException($"无法读取配方文件: {filePath}", ex); + } + + InspectionRecipe recipe; + try + { + recipe = JsonSerializer.Deserialize(json, _jsonOptions); + } + catch (JsonException ex) + { + throw new InvalidDataException($"配方文件 JSON 格式无效: {filePath}", ex); + } + + if (recipe is null) + throw new InvalidDataException($"配方文件反序列化结果为 null: {filePath}"); + + if (string.IsNullOrWhiteSpace(recipe.Name)) + throw new InvalidDataException($"配方文件数据不完整,缺少名称: {filePath}"); + + if (recipe.Steps is null) + throw new InvalidDataException($"配方文件数据不完整,缺少步骤列表: {filePath}"); + + _logger.Information("已加载配方: {FilePath}, Recipe={RecipeName}, Steps={StepCount}", + filePath, recipe.Name, recipe.Steps.Count); + return recipe; + } + + /// + public async Task ExecuteAsync(InspectionRecipe recipe, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(recipe); + + _logger.Information("开始执行配方: {RecipeName}, 共 {StepCount} 步", recipe.Name, recipe.Steps.Count); + + // 重置暂停状态 + _pauseHandle.Set(); + + _appStateService.UpdateRecipeExecutionState(new RecipeExecutionState( + CurrentStepIndex: 0, + TotalSteps: recipe.Steps.Count, + Status: RecipeExecutionStatus.Running, + CurrentRecipeName: recipe.Name)); + + try + { + for (int i = 0; i < recipe.Steps.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + // 暂停检查:等待恢复信号 + _pauseHandle.Wait(cancellationToken); + + var step = recipe.Steps[i]; + + _appStateService.UpdateRecipeExecutionState(new RecipeExecutionState( + CurrentStepIndex: i, + TotalSteps: recipe.Steps.Count, + Status: RecipeExecutionStatus.Running, + CurrentRecipeName: recipe.Name)); + + _logger.Information("执行配方步骤 {StepIndex}/{TotalSteps}: Recipe={RecipeName}", + i + 1, recipe.Steps.Count, recipe.Name); + + try + { + await ExecuteStepAsync(step, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; // 让取消异常向上传播 + } + catch (Exception ex) + { + _logger.Error(ex, "配方步骤 {StepIndex} 执行失败: Recipe={RecipeName}", i, recipe.Name); + + _appStateService.UpdateRecipeExecutionState(new RecipeExecutionState( + CurrentStepIndex: i, + TotalSteps: recipe.Steps.Count, + Status: RecipeExecutionStatus.Error, + CurrentRecipeName: recipe.Name)); + + _appStateService.UpdateSystemState(new SystemState( + OperationMode: OperationMode.Idle, + HasError: true, + ErrorMessage: $"配方 '{recipe.Name}' 步骤 {i} 执行失败: {ex.Message}")); + + return; + } + } + + _appStateService.UpdateRecipeExecutionState(new RecipeExecutionState( + CurrentStepIndex: recipe.Steps.Count, + TotalSteps: recipe.Steps.Count, + Status: RecipeExecutionStatus.Completed, + CurrentRecipeName: recipe.Name)); + + _logger.Information("配方执行完成: {RecipeName}", recipe.Name); + } + catch (OperationCanceledException) + { + _logger.Information("配方执行已取消: {RecipeName}", recipe.Name); + + _appStateService.UpdateRecipeExecutionState(new RecipeExecutionState( + CurrentStepIndex: 0, + TotalSteps: recipe.Steps.Count, + Status: RecipeExecutionStatus.Idle, + CurrentRecipeName: recipe.Name)); + + throw; + } + } + + /// + public void Pause() + { + _pauseHandle.Reset(); + + var current = _appStateService.RecipeExecutionState; + if (current.Status == RecipeExecutionStatus.Running) + { + _appStateService.UpdateRecipeExecutionState(current with + { + Status = RecipeExecutionStatus.Paused + }); + } + + _logger.Information("配方执行已暂停"); + } + + /// + public void Resume() + { + var current = _appStateService.RecipeExecutionState; + if (current.Status == RecipeExecutionStatus.Paused) + { + _appStateService.UpdateRecipeExecutionState(current with + { + Status = RecipeExecutionStatus.Running + }); + } + + _pauseHandle.Set(); + _logger.Information("配方执行已恢复"); + } + + // ── 内部辅助方法 ── + + private async Task ExecuteStepAsync(RecipeStep step, CancellationToken cancellationToken) + { + // 更新运动状态 + _appStateService.UpdateMotionState(step.MotionState); + + // 更新射线源状态 + _appStateService.UpdateRaySourceState(step.RaySourceState); + + // 更新探测器状态 + _appStateService.UpdateDetectorState(step.DetectorState); + + // TODO: 通过 IPipelineExecutionService 执行 Pipeline + // IPipelineExecutionService.ExecutePipelineAsync 需要 IEnumerable 和 BitmapSource, + // 而 RecipeStep 持有 PipelineModel。此处记录日志作为占位,待 ViewModel 层适配后完善。 + _logger.Information("步骤 {StepIndex}: 已更新设备状态,Pipeline '{PipelineName}' 执行待实现", + step.StepIndex, step.Pipeline?.Name ?? "N/A"); + + await Task.CompletedTask; + } + } +} diff --git a/XplorePlane/readme.txt b/XplorePlane/readme.txt index 077a576..0eaedc0 100644 --- a/XplorePlane/readme.txt +++ b/XplorePlane/readme.txt @@ -34,8 +34,9 @@ 2026.3.18 ---------------------- -1、全局数据结构的考虑与设计 -2、将计划窗体默认隐藏,只有CNC状态下展开 +1、全局数据结构的考虑与设计(多个窗体可以调用公共的数据,如射线源状态,探测器状态,运动位置,图像等) +2、将计划窗体默认隐藏,只有CNC状态下展开 √ +3、日志该用XP.Common库和多语言的学习