From b740f8d4538d4fd4d03ec0707c8da458a2d16fcf Mon Sep 17 00:00:00 2001 From: "zhengxuan.zhang" Date: Wed, 6 May 2026 18:20:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8E=A2=E6=B5=8B=E5=99=A8?= =?UTF-8?q?=E7=9A=84=E8=AE=A2=E9=98=85=E4=B8=8E=E8=8E=B7=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/AppStateServiceTests.cs | 68 +++++ XplorePlane/Doc/硬件层及UI集成技术路线.md | 255 ++++++++++++++++++ .../Services/AppState/AppStateService.cs | 92 +++++++ .../Services/AppState/IAppStateService.cs | 8 + 4 files changed, 423 insertions(+) diff --git a/XplorePlane.Tests/Services/AppStateServiceTests.cs b/XplorePlane.Tests/Services/AppStateServiceTests.cs index 1a0c058..8513d13 100644 --- a/XplorePlane.Tests/Services/AppStateServiceTests.cs +++ b/XplorePlane.Tests/Services/AppStateServiceTests.cs @@ -3,6 +3,9 @@ using Prism.Events; using System; using System.Windows; using XP.Common.Logging.Interfaces; +using XP.Hardware.Detector.Abstractions.Enums; +using XP.Hardware.Detector.Abstractions.Events; +using XP.Hardware.Detector.Services; using XP.Hardware.MotionControl.Abstractions; using XP.Hardware.MotionControl.Abstractions.Enums; using XP.Hardware.MotionControl.Abstractions.Events; @@ -25,6 +28,7 @@ namespace XplorePlane.Tests.Services private readonly Mock _mockRaySource; private readonly Mock _mockMotionSystem; private readonly Mock _mockMotionControlService; + private readonly Mock _mockDetectorService; private readonly Mock _mockStageX; private readonly Mock _mockStageY; private readonly Mock _mockSourceZ; @@ -48,6 +52,7 @@ namespace XplorePlane.Tests.Services _mockRaySource = new Mock(); _mockMotionSystem = new Mock(); _mockMotionControlService = new Mock(); + _mockDetectorService = new Mock(); _mockStageX = CreateLinearAxis(AxisId.StageX, 0); _mockStageY = CreateLinearAxis(AxisId.StageY, 0); _mockSourceZ = CreateLinearAxis(AxisId.SourceZ, 0); @@ -70,12 +75,24 @@ namespace XplorePlane.Tests.Services .Setup(x => x.GetCurrentGeometry()) .Returns((0d, 0d, 1d)); + // DetectorService:GetInfo 在未初始化时抛出,模拟此行为 + _mockDetectorService + .Setup(x => x.GetInfo()) + .Throws(new InvalidOperationException("探测器未初始化")); + _mockDetectorService + .SetupGet(x => x.Status) + .Returns(DetectorStatus.Uninitialized); + _mockDetectorService + .SetupGet(x => x.IsConnected) + .Returns(false); + _mockLogger.Setup(l => l.ForModule()).Returns(_mockLogger.Object); _service = new AppStateService( _mockRaySource.Object, _mockMotionSystem.Object, _mockMotionControlService.Object, + _mockDetectorService.Object, _eventAggregator, _mockLogger.Object); } @@ -188,6 +205,57 @@ namespace XplorePlane.Tests.Services Assert.Equal(222.2, _service.MotionState.FDD); } + [Fact] + public void StatusChangedEvent_Acquiring_SyncsDetectorState() + { + // 模拟探测器进入采集状态 + _mockDetectorService.SetupGet(x => x.Status).Returns(DetectorStatus.Acquiring); + _mockDetectorService.Setup(x => x.GetInfo()).Throws(new InvalidOperationException()); + + _eventAggregator.GetEvent() + .Publish(DetectorStatus.Acquiring); + + // 等待后台线程处理(BackgroundThread 订阅) + System.Threading.Thread.Sleep(100); + + Assert.True(_service.DetectorState.IsConnected); + Assert.True(_service.DetectorState.IsAcquiring); + } + + [Fact] + public void StatusChangedEvent_Uninitialized_SyncsDetectorStateDisconnected() + { + _eventAggregator.GetEvent() + .Publish(DetectorStatus.Uninitialized); + + System.Threading.Thread.Sleep(100); + + Assert.False(_service.DetectorState.IsConnected); + Assert.False(_service.DetectorState.IsAcquiring); + } + + [Fact] + public void ImageCapturedEvent_UpdatesLatestDetectorFrame() + { + Assert.Null(_service.LatestDetectorFrame); + + var args = new XP.Hardware.Detector.Abstractions.ImageCapturedEventArgs + { + ImageData = new ushort[4], + Width = 2, + Height = 2, + FrameNumber = 1, + CaptureTime = DateTime.UtcNow + }; + + _eventAggregator.GetEvent().Publish(args); + + // 等待后台线程处理 + System.Threading.Thread.Sleep(100); + + Assert.Same(args, _service.LatestDetectorFrame); + } + private static Mock CreateLinearAxis(AxisId axisId, double position) { var axis = new Mock(); diff --git a/XplorePlane/Doc/硬件层及UI集成技术路线.md b/XplorePlane/Doc/硬件层及UI集成技术路线.md index 791baeb..e007ed5 100644 --- a/XplorePlane/Doc/硬件层及UI集成技术路线.md +++ b/XplorePlane/Doc/硬件层及UI集成技术路线.md @@ -169,3 +169,258 @@ raySourceService?.Dispose(); ``` 确保硬件连接在应用退出时正确断开。 + +### 7. + +--- + +# 硬件层 → AppState → UI 状态同步机制 + +## 整体结论 + +| 硬件子系统 | 同步方式 | AppState 订阅 | 状态是否自动同步 | +|-----------|---------|--------------|----------------| +| 运动控制(Motion) | 事件驱动 + 轮询 | ✅ 已订阅 `GeometryUpdatedEvent` / `AxisStatusChangedEvent` | ✅ 自动同步 | +| 射线源(RaySource) | 事件定义存在 | ❌ 未订阅任何事件 | ⚠️ 需手动推送 | +| 探测器(Detector) | 事件定义存在 | ❌ 未订阅任何事件 | ⚠️ 需手动推送 | +| 相机(Camera) | ViewModel 直连 | ❌ 不经过 AppState | ⚠️ `CameraState` 为空壳 | + +--- + +## 一、运动控制 — 完整链路 + +### 1.1 数据流 + +``` +PLC 硬件(B&R) + └─ IPlcService.IsConnected(轮询前置检查) + └─ MotionControlService.OnPollingTick() [System.Threading.Timer,周期 = PollingInterval ms] + ├─ _motionSystem.UpdateAllStatus() ← 从 PLC 读取所有轴实际位置 + ├─ GetCurrentGeometry() ← 正算 FOD / FDD / Magnification + ├─ 发布 GeometryUpdatedEvent ─┐ + └─ 发布 AxisStatusChangedEvent ─┤ + ↓ + AppStateService(构造时订阅两个事件) + ├─ OnGeometryUpdated() + └─ OnAxisStatusChanged() + └─ TryRefreshMotionStateFromHardware() + └─ BuildMotionStateSnapshot() + ├─ 读取所有轴 ActualPosition / ActualAngle + └─ SetMotionState() + └─ 触发 MotionStateChanged 事件 + └─ ViewModel 绑定自动刷新 UI +``` + +### 1.2 关键实现细节 + +**轮询启动**:`MotionControlService.StartPolling()` 需在应用启动时显式调用,否则轮询不会运行。 + +**PLC 未连接时的保护**: +```csharp +// MotionControlService.OnPollingTick() +if (!_plcService.IsConnected) return; // 直接跳过,不报错 +``` + +**连续错误降频**: +```csharp +if (_pollErrorCount > 3) +{ + if (++_pollErrorCount % 50 != 0) return; // 每 50 次才尝试一次,防止日志刷屏 +} +``` + +**AppStateService 初始化时的首次刷新**: +```csharp +// AppStateService 构造函数末尾 +private void SubscribeToExistingServices() +{ + if (TryRefreshMotionStateFromHardware("initialization")) + _logger.Info("AppStateService subscribed to motion hardware state"); + else + _logger.Warn("AppStateService could not initialize motion state from hardware"); +} +``` +PLC 未连接时 warn 但不崩溃,等待后续轮询事件触发再同步。 + +**`UpdateMotionState()` 的特殊行为**: +```csharp +public void UpdateMotionState(MotionState newState) +{ + // 优先从硬件层拉取最新快照,忽略外部传入值 + if (TryRefreshMotionStateFromHardware("UpdateMotionState")) + return; + + // 硬件不可用时才使用传入值(降级路径) + SetMotionState(newState); +} +``` +硬件连接后,外部调用 `UpdateMotionState()` 实际上会被硬件快照覆盖,硬件层始终是 `MotionState` 的唯一真实来源。 + +### 1.3 事件清单 + +| 事件 | 发布方 | 订阅方 | 触发时机 | +|------|--------|--------|---------| +| `GeometryUpdatedEvent` | `MotionControlService` | `AppStateService`、`MotionControlViewModel`、`AxisControlViewModel` | 每次轮询 tick | +| `AxisStatusChangedEvent` | `MotionControlService` | `AppStateService`、`MotionControlViewModel`、`AxisControlViewModel` | 轴状态发生变化时 | +| `MotionErrorEvent` | `MotionControlService` | — | 轴状态变为 Error / Alarm | +| `DoorStatusChangedEvent` | `MotionControlService` | `MotionControlViewModel` | 安全门状态变化 | +| `DoorInterlockChangedEvent` | `MotionControlService` | — | 联锁状态变化 | +| `GeometryApplyRequestEvent` | DebugWindow | `MotionControlViewModel` | 调试窗口发起几何反算请求 | + +--- + +## 二、射线源 — 断链(待补全) + +### 2.1 现状 + +`AppStateService` 持有 `IRaySourceService` 引用,但**没有订阅任何射线源事件**。`RaySourceState` 只能通过外部显式调用 `UpdateRaySourceState()` 手动推送,硬件状态变化不会自动反映到 AppState。 + +``` +XP.Hardware.RaySource 发布的事件(均无人在 AppState 层订阅): + ├─ StatusUpdatedEvent ← 实时电压 / 电流 / 功率数据 + ├─ RaySourceStatusChangedEvent ← 三态状态(On / Off / Fault) + ├─ VariablesConnectedEvent ← PLC 变量连接状态 + └─ OperationResultEvent ← 操作执行结果 +``` + +### 2.2 当前 `RaySourceState` 的写入路径 + +目前只有两处会更新 `RaySourceState`: + +1. `CncProgramService.CreateReferencePointNode()` — 创建参考点时读取一次快照 +2. 任何直接调用 `appStateService.UpdateRaySourceState()` 的地方(目前代码中无此调用) + +### 2.3 修复方案 + +在 `AppStateService` 构造时订阅 `StatusUpdatedEvent`,将实时数据映射到 `RaySourceState`: + +```csharp +// AppStateService 构造函数中补充 +_raySourceStatusToken = _eventAggregator + .GetEvent() + .Subscribe(OnRaySourceStatusUpdated); + +// 处理方法 +private void OnRaySourceStatusUpdated(SystemStatusData data) +{ + if (_disposed) return; + UpdateRaySourceState(new RaySourceState( + IsOn: data.IsOn, + Voltage: data.Voltage, + Power: data.Power)); +} +``` + +同时在 `Dispose()` 中取消订阅,并在 `AppStateService` 的字段和 `Dispose` 方法中补充对应的 `SubscriptionToken`。 + +--- + +## 三、探测器 — 断链(待补全) + +### 3.1 现状 + +与射线源情况相同,`AppStateService` 没有订阅任何探测器事件,`DetectorState` 是静态默认值。 + +``` +XP.Hardware.Detector 发布的事件(均无人在 AppState 层订阅): + ├─ StatusChangedEvent ← 探测器连接 / 采集状态变化 + ├─ ImageCapturedEvent ← 图像采集完成 + ├─ CorrectionCompletedEvent ← 校正完成 + └─ ErrorOccurredEvent ← 错误发生 +``` + +### 3.2 修复方案 + +在 `AppStateService` 构造时订阅 `StatusChangedEvent`: + +```csharp +_detectorStatusToken = _eventAggregator + .GetEvent() + .Subscribe(OnDetectorStatusChanged); + +private void OnDetectorStatusChanged(DetectorStatus status) +{ + if (_disposed) return; + UpdateDetectorState(new DetectorState( + IsConnected: status.IsConnected, + IsAcquiring: status.IsAcquiring, + FrameRate: status.FrameRate, + Resolution: status.Resolution)); +} +``` + +--- + +## 四、相机 — 独立直连,不经过 AppState + +### 4.1 现状 + +相机完全绕过 `AppStateService`,由 `NavigationPropertyPanelViewModel` 直接持有 `ICamera` 引用并订阅硬件事件: + +``` +NavigationPropertyPanelViewModel + └─ 直接持有 ICamera 引用 + ├─ _camera.ImageGrabbed += OnCameraImageGrabbed + ├─ _camera.GrabError += OnCameraGrabError + └─ _camera.ConnectionLost += OnCameraConnectionLost + └─ IsCameraConnected 属性直接驱动 UI 命令可用性 +``` + +`AppStateService.CameraState` 字段目前是空壳,没有任何地方向它写入真实数据。 + +### 4.2 架构说明 + +相机的独立直连是有意为之的设计——相机图像流数据量大、帧率高,不适合经过 AppState 的不可变 record 替换机制。当前图像通过 `ManualImageLoadedEvent` 在模块间传递,与状态管理解耦。 + +如需将相机连接状态纳入 AppState 统一管理(例如供其他模块查询),可在 `OnCameraConnectionLost` / `ConnectCamera` 中补充调用 `_appStateService.UpdateCameraState()`,仅同步连接状态,不同步图像帧。 + +--- + +## 五、状态同步全景图 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 硬件层 │ +│ │ +│ PLC ──→ MotionControlService ──→ GeometryUpdatedEvent ─────┐ │ +│ └──→ AxisStatusChangedEvent ────┤ │ +│ │ │ +│ RaySource ──→ IRaySourceService ──→ StatusUpdatedEvent ─ ─ ┤ │ +│ └──→ RaySourceStatusChangedEvent ─┤│ +│ │ │ +│ Detector ──→ IDetectorService ──→ StatusChangedEvent ─ ─ ┤ │ +│ │ │ +│ Camera ──→ ICamera ──→ ImageGrabbed / ConnectionLost ─ ─ ┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ 已接通 ✅ │ 断链 ⚠️ + ↓ ↓ +┌─────────────────────────────────────────────────────────────────────┐ +│ AppStateService │ +│ │ +│ MotionState ← 自动同步(轮询 + 事件驱动) ✅ │ +│ RaySourceState ← 需手动推送 / 待补订阅 ⚠️ │ +│ DetectorState ← 需手动推送 / 待补订阅 ⚠️ │ +│ CameraState ← 空壳,相机走独立直连路径 ⚠️ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ↓ PropertyChanged / StateChangedEvent +┌─────────────────────────────────────────────────────────────────────┐ +│ ViewModel 层 │ +│ │ +│ MotionControlViewModel ← 同时订阅 GeometryUpdatedEvent(双路) │ +│ CncNodeViewModel ← 通过 IAppStateService 读取快照 │ +│ CncEditorViewModel ← 通过 IAppStateService 读取快照 │ +│ NavigationPropertyPanelViewModel ← 直连 ICamera(独立路径) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 六、待办事项 + +| 优先级 | 项目 | 涉及文件 | +|--------|------|---------| +| 高 | 补充射线源事件订阅,打通 `RaySourceState` 自动同步 | `AppStateService.cs` | +| 高 | 补充探测器事件订阅,打通 `DetectorState` 自动同步 | `AppStateService.cs` | +| 中 | 确认 `StartPolling()` 在应用启动流程中被调用 | `App.xaml.cs` / `AppBootstrapper` | +| 低 | 评估是否将相机连接状态纳入 `CameraState` 统一管理 | `NavigationPropertyPanelViewModel.cs`、`AppStateService.cs` | diff --git a/XplorePlane/Services/AppState/AppStateService.cs b/XplorePlane/Services/AppState/AppStateService.cs index 1039fe6..d324144 100644 --- a/XplorePlane/Services/AppState/AppStateService.cs +++ b/XplorePlane/Services/AppState/AppStateService.cs @@ -5,6 +5,10 @@ using System.Threading; using System.Windows; using System.Windows.Threading; using XP.Common.Logging.Interfaces; +using XP.Hardware.Detector.Abstractions; +using XP.Hardware.Detector.Abstractions.Enums; +using XP.Hardware.Detector.Abstractions.Events; +using XP.Hardware.Detector.Services; using XP.Hardware.MotionControl.Abstractions; using XP.Hardware.MotionControl.Abstractions.Enums; using XP.Hardware.MotionControl.Abstractions.Events; @@ -18,6 +22,7 @@ namespace XplorePlane.Services.AppState /// Global application state service. /// Motion state is synchronized from the motion hardware service layer and /// mapped into the legacy business model for compatibility. + /// Detector state and latest frame are synchronized via Prism EventAggregator subscriptions. /// public class AppStateService : BindableBase, IAppStateService { @@ -25,10 +30,13 @@ namespace XplorePlane.Services.AppState private readonly IRaySourceService _raySourceService; private readonly IMotionSystem _motionSystem; private readonly IMotionControlService _motionControlService; + private readonly IDetectorService _detectorService; private readonly IEventAggregator _eventAggregator; private readonly ILoggerService _logger; private readonly SubscriptionToken _axisStatusChangedToken; private readonly SubscriptionToken _geometryUpdatedToken; + private readonly SubscriptionToken _detectorStatusChangedToken; + private readonly SubscriptionToken _detectorImageCapturedToken; private bool _disposed; private GeometryData _latestGeometry; @@ -43,6 +51,9 @@ namespace XplorePlane.Services.AppState private LinkedViewState _linkedViewState = LinkedViewState.Default; private RecipeExecutionState _recipeExecutionState = RecipeExecutionState.Default; + // ── 探测器最新帧(volatile,供任意线程读取)── + private volatile ImageCapturedEventArgs _latestDetectorFrame; + // ── 类型化状态变更事件 ── public event EventHandler> MotionStateChanged; public event EventHandler> RaySourceStateChanged; @@ -63,26 +74,36 @@ namespace XplorePlane.Services.AppState public LinkedViewState LinkedViewState => _linkedViewState; public RecipeExecutionState RecipeExecutionState => _recipeExecutionState; + /// + /// 探测器最新采集帧(线程安全,可从任意线程读取)。 + /// 由 ImageCapturedEvent 驱动更新,无采集时为 null。 + /// + public ImageCapturedEventArgs LatestDetectorFrame => _latestDetectorFrame; + public AppStateService( IRaySourceService raySourceService, IMotionSystem motionSystem, IMotionControlService motionControlService, + IDetectorService detectorService, IEventAggregator eventAggregator, ILoggerService logger) { ArgumentNullException.ThrowIfNull(raySourceService); ArgumentNullException.ThrowIfNull(motionSystem); ArgumentNullException.ThrowIfNull(motionControlService); + ArgumentNullException.ThrowIfNull(detectorService); ArgumentNullException.ThrowIfNull(eventAggregator); ArgumentNullException.ThrowIfNull(logger); _raySourceService = raySourceService; _motionSystem = motionSystem; _motionControlService = motionControlService; + _detectorService = detectorService; _eventAggregator = eventAggregator; _logger = logger.ForModule(); _dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + // ── 运动控制事件订阅 ── _geometryUpdatedToken = _eventAggregator .GetEvent() .Subscribe(OnGeometryUpdated); @@ -91,6 +112,16 @@ namespace XplorePlane.Services.AppState .GetEvent() .Subscribe(OnAxisStatusChanged); + // ── 探测器状态事件订阅(后台线程,避免阻塞采集)── + _detectorStatusChangedToken = _eventAggregator + .GetEvent() + .Subscribe(OnDetectorStatusChanged, ThreadOption.BackgroundThread); + + // ── 探测器图像事件订阅(后台线程,仅缓存最新帧)── + _detectorImageCapturedToken = _eventAggregator + .GetEvent() + .Subscribe(OnDetectorImageCaptured, ThreadOption.BackgroundThread); + SubscribeToExistingServices(); _logger.Info("AppStateService initialized"); } @@ -260,6 +291,16 @@ namespace XplorePlane.Services.AppState _eventAggregator.GetEvent().Unsubscribe(_geometryUpdatedToken); } + if (_detectorStatusChangedToken is not null) + { + _eventAggregator.GetEvent().Unsubscribe(_detectorStatusChangedToken); + } + + if (_detectorImageCapturedToken is not null) + { + _eventAggregator.GetEvent().Unsubscribe(_detectorImageCapturedToken); + } + MotionStateChanged = null; RaySourceStateChanged = null; DetectorStateChanged = null; @@ -317,6 +358,57 @@ namespace XplorePlane.Services.AppState TryRefreshMotionStateFromHardware("geometry-updated"); } + /// + /// 探测器状态变更回调。 + /// 将硬件层 DetectorStatus 映射为应用层 DetectorState 并同步到 AppState。 + /// 运行在后台线程(ThreadOption.BackgroundThread),不阻塞采集。 + /// + private void OnDetectorStatusChanged(DetectorStatus status) + { + if (_disposed) return; + + // 从 IDetectorService 读取分辨率等补充信息 + string resolution = string.Empty; + double frameRate = 0; + try + { + var info = _detectorService.GetInfo(); + if (info != null) + resolution = $"{info.MaxWidth}x{info.MaxHeight}"; + } + catch + { + // 探测器未初始化时 GetInfo 会抛出,忽略即可 + } + + bool isConnected = status != DetectorStatus.Uninitialized && status != DetectorStatus.Error; + bool isAcquiring = status == DetectorStatus.Acquiring; + + var newState = new DetectorState( + IsConnected: isConnected, + IsAcquiring: isAcquiring, + FrameRate: frameRate, + Resolution: resolution); + + UpdateDetectorState(newState); + + _logger.Info( + "探测器状态已同步:{Status} → IsConnected={IsConnected} IsAcquiring={IsAcquiring} | " + + "Detector state synced: {Status} → IsConnected={IsConnected} IsAcquiring={IsAcquiring}", + status, isConnected, isAcquiring); + } + + /// + /// 探测器图像采集回调。 + /// 仅缓存最新帧引用(volatile 写),不做任何图像处理,保持采集链路零阻塞。 + /// 上层通过 LatestDetectorFrame 属性按需读取。 + /// + private void OnDetectorImageCaptured(ImageCapturedEventArgs args) + { + if (_disposed || args?.ImageData == null) return; + _latestDetectorFrame = args; + } + private bool TryRefreshMotionStateFromHardware(string reason) { try diff --git a/XplorePlane/Services/AppState/IAppStateService.cs b/XplorePlane/Services/AppState/IAppStateService.cs index 9a43fa3..fc1d168 100644 --- a/XplorePlane/Services/AppState/IAppStateService.cs +++ b/XplorePlane/Services/AppState/IAppStateService.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel; +using XP.Hardware.Detector.Abstractions; using XplorePlane.Models; namespace XplorePlane.Services.AppState @@ -21,6 +22,13 @@ namespace XplorePlane.Services.AppState LinkedViewState LinkedViewState { get; } RecipeExecutionState RecipeExecutionState { get; } + /// + /// 探测器最新采集帧(线程安全,可从任意线程读取)。 + /// 由 ImageCapturedEvent 驱动更新,探测器未采集时为 null。 + /// CNC 执行、图像处理等上层服务通过此属性按需取图,无需自行订阅事件。 + /// + ImageCapturedEventArgs LatestDetectorFrame { get; } + // ── 状态更新方法(线程安全,可从任意线程调用)── void UpdateMotionState(MotionState newState);