diff --git a/XplorePlane/Models/Main/StateModels.cs b/XplorePlane/Models/Main/StateModels.cs
index 08d60366..88e5ce16 100644
--- a/XplorePlane/Models/Main/StateModels.cs
+++ b/XplorePlane/Models/Main/StateModels.cs
@@ -3,13 +3,22 @@ using System;
namespace XplorePlane.Models.Main
{
/// 系统操作模式
- public enum OperationMode
+ public enum OperationMode
{
Idle, // 空闲
Scanning, // 扫描
CTAcquire, // CT 采集
- RecipeRun // 配方执行中
- }
+ RecipeRun // 配方执行中
+ }
+
+ /// 统一硬件故障来源。
+ public enum HardwareFaultSource
+ {
+ None,
+ Motion,
+ Detector,
+ RaySource
+ }
/// 配方执行状态
public enum RecipeExecutionStatus
@@ -74,10 +83,12 @@ namespace XplorePlane.Models.Main
}
/// 系统整体状态(不可变)
- public record SystemState(
- OperationMode OperationMode, // 当前操作模式
- bool HasError, // 是否存在错误
- string ErrorMessage) // 错误信息
+ public record SystemState(
+ OperationMode OperationMode, // 当前操作模式
+ bool HasError, // 是否存在错误
+ string ErrorMessage, // 错误信息
+ HardwareFaultSource FaultSource = HardwareFaultSource.None,
+ DateTime? FaultedAtUtc = null) // 错误信息
{
public static readonly SystemState Default = new(OperationMode.Idle, false, string.Empty);
}
diff --git a/XplorePlane/Services/AppState/AppStateService.cs b/XplorePlane/Services/AppState/AppStateService.cs
index 90970861..72cbd1b1 100644
--- a/XplorePlane/Services/AppState/AppStateService.cs
+++ b/XplorePlane/Services/AppState/AppStateService.cs
@@ -1,7 +1,9 @@
using Prism.Events;
-using Prism.Mvvm;
-using System;
-using System.ComponentModel;
+using Prism.Mvvm;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
using System.Threading;
using System.Windows;
using System.Windows.Threading;
@@ -16,8 +18,11 @@ using XP.Hardware.MotionControl.Abstractions.Events;
using XP.Hardware.MotionControl.Services;
using XP.Hardware.RaySource.Abstractions.Enums;
using XP.Hardware.RaySource.Abstractions.Events;
-using XP.Hardware.RaySource.Services;
-using XplorePlane.Events;
+using XP.Hardware.RaySource.Services;
+using XplorePlane.Events;
+using XplorePlane.Models.Main;
+using DetectorErrorOccurredEvent = XP.Hardware.Detector.Abstractions.Events.ErrorOccurredEvent;
+using RaySourceErrorOccurredEvent = XP.Hardware.RaySource.Abstractions.Events.ErrorOccurredEvent;
namespace XplorePlane.Services.AppState
{
@@ -155,10 +160,15 @@ namespace XplorePlane.Services.AppState
private readonly SubscriptionToken _detectorImageCapturedToken; // 探测器图像采集订阅
private readonly SubscriptionToken _raySourceStatusUpdatedToken; // 射线源全量状态更新订阅
private readonly SubscriptionToken _raySourceStatusChangedToken; // 射线源三态变更订阅
- private readonly SubscriptionToken _raySourceVariablesConnectedToken; // 射线源 PVI 变量连接状态订阅
+ private readonly SubscriptionToken _raySourceVariablesConnectedToken; // 射线源 PVI 变量连接状态订阅
+ private readonly SubscriptionToken _motionErrorToken;
+ private readonly SubscriptionToken _detectorErrorToken;
+ private readonly SubscriptionToken _raySourceErrorToken;
- private bool _disposed; // 是否已释放
- private GeometryData _latestGeometry; // 最新几何参数缓存
+ private bool _disposed; // 是否已释放
+ private GeometryData _latestGeometry; // 最新几何参数缓存
+ private readonly object _hardwareFaultGate = new();
+ private readonly System.Collections.Generic.Dictionary _hardwareFaults = new();
// ── 状态字段(通过 Interlocked.Exchange 原子替换,线程安全)──
private MotionState _motionState = MotionState.Default; // 运动轴状态
@@ -272,9 +282,16 @@ namespace XplorePlane.Services.AppState
.GetEvent()
.Subscribe(OnRaySourceStatusChanged, ThreadOption.BackgroundThread);
- _raySourceVariablesConnectedToken = _eventAggregator
- .GetEvent()
- .Subscribe(OnRaySourceVariablesConnected, ThreadOption.BackgroundThread);
+ _raySourceVariablesConnectedToken = _eventAggregator
+ .GetEvent()
+ .Subscribe(OnRaySourceVariablesConnected, ThreadOption.BackgroundThread);
+
+ _motionErrorToken = _eventAggregator.GetEvent()
+ .Subscribe(OnMotionError, ThreadOption.BackgroundThread);
+ _detectorErrorToken = _eventAggregator.GetEvent()
+ .Subscribe(OnDetectorError, ThreadOption.BackgroundThread);
+ _raySourceErrorToken = _eventAggregator.GetEvent()
+ .Subscribe(OnRaySourceError, ThreadOption.BackgroundThread);
SubscribeToExistingServices();
_logger.Info("AppStateService initialized");
@@ -501,10 +518,17 @@ namespace XplorePlane.Services.AppState
_eventAggregator.GetEvent().Unsubscribe(_raySourceStatusChangedToken);
}
- if (_raySourceVariablesConnectedToken is not null)
- {
- _eventAggregator.GetEvent().Unsubscribe(_raySourceVariablesConnectedToken);
- }
+ if (_raySourceVariablesConnectedToken is not null)
+ {
+ _eventAggregator.GetEvent().Unsubscribe(_raySourceVariablesConnectedToken);
+ }
+
+ if (_motionErrorToken is not null)
+ _eventAggregator.GetEvent().Unsubscribe(_motionErrorToken);
+ if (_detectorErrorToken is not null)
+ _eventAggregator.GetEvent().Unsubscribe(_detectorErrorToken);
+ if (_raySourceErrorToken is not null)
+ _eventAggregator.GetEvent().Unsubscribe(_raySourceErrorToken);
MotionStateChanged = null;
RaySourceStateChanged = null;
@@ -556,11 +580,48 @@ namespace XplorePlane.Services.AppState
}
/// 轴状态变更事件回调:尝试从硬件刷新完整运动状态快照。
- private void OnAxisStatusChanged(AxisStatusChangedData _)
- {
- if (_disposed) return;
- TryRefreshMotionStateFromHardware("axis-status-changed");
- }
+ private void OnAxisStatusChanged(AxisStatusChangedData data)
+ {
+ if (_disposed) return;
+
+ if (data.Status is AxisStatus.Error or AxisStatus.Alarm)
+ {
+ PublishHardwareFault(
+ HardwareFaultSource.Motion,
+ $"轴 {data.AxisId} 进入 {data.Status} 状态");
+ }
+ else
+ {
+ ClearHardwareFault(HardwareFaultSource.Motion);
+ }
+
+ TryRefreshMotionStateFromHardware("axis-status-changed");
+ }
+
+ private void OnMotionError(MotionErrorData data)
+ {
+ if (_disposed || data is null) return;
+ PublishHardwareFault(
+ HardwareFaultSource.Motion,
+ $"轴 {data.AxisId}:{data.ErrorMessage}");
+ }
+
+ private void OnDetectorError(DetectorResult result)
+ {
+ if (_disposed || result is null || result.IsSuccess) return;
+ PublishHardwareFault(
+ HardwareFaultSource.Detector,
+ result.ErrorMessage ?? "探测器发生未知错误",
+ result.Exception);
+ }
+
+ private void OnRaySourceError(string message)
+ {
+ if (_disposed) return;
+ PublishHardwareFault(
+ HardwareFaultSource.RaySource,
+ string.IsNullOrWhiteSpace(message) ? "射线源发生未知错误" : message);
+ }
/// 几何参数更新事件回调:缓存几何数据并刷新运动状态。
private void OnGeometryUpdated(GeometryData geometry)
@@ -631,17 +692,26 @@ namespace XplorePlane.Services.AppState
bool isConnected = status != DetectorStatus.Uninitialized && status != DetectorStatus.Error;
bool isAcquiring = status == DetectorStatus.Acquiring;
- var newState = new DetectorState(
- IsConnected: isConnected,
+ var newState = new DetectorState(
+ IsConnected: isConnected,
IsAcquiring: isAcquiring,
FrameRate: frameRate,
Resolution: resolution,
Binning: binning,
FrameAvg: frameAvg,
WorkMode: workMode,
- Sensitivity: sensitivity);
-
- UpdateDetectorState(newState);
+ Sensitivity: sensitivity);
+
+ UpdateDetectorState(newState);
+
+ if (status == DetectorStatus.Error)
+ {
+ PublishHardwareFault(HardwareFaultSource.Detector, "探测器状态为 Error");
+ }
+ else if (isConnected)
+ {
+ ClearHardwareFault(HardwareFaultSource.Detector);
+ }
_logger.Info(
"探测器状态已同步:{Status} → IsConnected={IsConnected} IsAcquiring={IsAcquiring} | " +
@@ -716,16 +786,108 @@ namespace XplorePlane.Services.AppState
{
if (_disposed) return;
- if (!isConnected)
- {
- UpdateRaySourceState(RaySourceState.Default);
- _logger.Warn("射线源 PVI 变量已断开,RaySourceState 已重置 | RaySource PVI variables disconnected, RaySourceState reset");
- }
- else
- {
- _logger.Info("射线源 PVI 变量已连接 | RaySource PVI variables connected");
- }
- }
+ if (!isConnected)
+ {
+ UpdateRaySourceState(RaySourceState.Default);
+ PublishHardwareFault(HardwareFaultSource.RaySource, "射线源变量连接已断开");
+ _logger.Warn("射线源 PVI 变量已断开,RaySourceState 已重置 | RaySource PVI variables disconnected, RaySourceState reset");
+ }
+ else
+ {
+ ClearHardwareFault(HardwareFaultSource.RaySource);
+ _logger.Info("射线源 PVI 变量已连接 | RaySource PVI variables connected");
+ }
+ }
+
+ ///
+ /// 将硬件故障统一写入 AppState、日志和主界面状态栏。
+ /// 同一来源同一消息只提示一次,避免硬件轮询造成告警洪泛。
+ ///
+ private void PublishHardwareFault(
+ HardwareFaultSource source,
+ string message,
+ Exception exception = null)
+ {
+ if (_disposed || source == HardwareFaultSource.None) return;
+
+ var normalized = string.IsNullOrWhiteSpace(message) ? "未知硬件故障" : message.Trim();
+ bool shouldNotify;
+ lock (_hardwareFaultGate)
+ {
+ shouldNotify = !_hardwareFaults.TryGetValue(source, out var previous) ||
+ !string.Equals(previous, normalized, StringComparison.Ordinal);
+ _hardwareFaults[source] = normalized;
+ }
+
+ var displayMessage = $"{GetHardwareFaultName(source)}:{normalized}";
+ var state = _systemState;
+ if (shouldNotify || !state.HasError || state.FaultSource != source || state.ErrorMessage != displayMessage)
+ {
+ UpdateSystemState(state with
+ {
+ HasError = true,
+ ErrorMessage = displayMessage,
+ FaultSource = source,
+ FaultedAtUtc = DateTime.UtcNow
+ });
+ }
+
+ if (shouldNotify)
+ {
+ if (exception is null)
+ _logger.Error(null, $"硬件故障 [{source}] {normalized}");
+ else
+ _logger.Error(exception, $"硬件故障 [{source}] {normalized}");
+ }
+
+ if (shouldNotify)
+ {
+ _eventAggregator.GetEvent().Publish(
+ new StatusBarMessagePayload(displayMessage, IsError: true, DurationMs: 0));
+ }
+ }
+
+ private void ClearHardwareFault(HardwareFaultSource source)
+ {
+ if (_disposed || source == HardwareFaultSource.None) return;
+
+ bool cleared;
+ KeyValuePair[] remaining;
+ lock (_hardwareFaultGate)
+ {
+ cleared = _hardwareFaults.Remove(source);
+ remaining = _hardwareFaults.ToArray();
+ }
+ if (!cleared) return;
+
+ var next = remaining.Length == 0
+ ? _systemState with
+ {
+ HasError = false,
+ ErrorMessage = string.Empty,
+ FaultSource = HardwareFaultSource.None,
+ FaultedAtUtc = null
+ }
+ : _systemState with
+ {
+ HasError = true,
+ ErrorMessage = $"{GetHardwareFaultName(remaining[0].Key)}:{remaining[0].Value}",
+ FaultSource = remaining[0].Key
+ };
+
+ UpdateSystemState(next);
+ _logger.Info("硬件故障已恢复 [{Source}] | Hardware fault cleared: {Source}", source, source);
+ _eventAggregator.GetEvent().Publish(
+ new StatusBarMessagePayload($"{GetHardwareFaultName(source)}故障已恢复", IsError: false, DurationMs: 5000));
+ }
+
+ private static string GetHardwareFaultName(HardwareFaultSource source) => source switch
+ {
+ HardwareFaultSource.Motion => "运动控制",
+ HardwareFaultSource.Detector => "探测器",
+ HardwareFaultSource.RaySource => "射线源",
+ _ => "硬件"
+ };
///
/// 从硬件层采集各轴实际位置和几何参数,生成完整 MotionState 快照。
@@ -794,4 +956,4 @@ namespace XplorePlane.Services.AppState
RaiseOnDispatcher(old, newState, MotionStateChanged, nameof(MotionState));
}
}
-}
\ No newline at end of file
+}
diff --git a/doc/fix/AppState与事件流问题审计.md b/doc/fix/AppState与事件流问题审计.md
index a43b8639..22de1147 100644
--- a/doc/fix/AppState与事件流问题审计.md
+++ b/doc/fix/AppState与事件流问题审计.md
@@ -1,6 +1,6 @@
# AppState 与事件流问题审计
-> 审计日期:2026-08-03
+> 审计日期:2026-08-03;P1-6 修复完成:2026-08-04
> 审计范围:硬件事件、`AppStateService`、调试面板、探测器帧队列、BGA 向导及窗口生命周期。
> 本文最初用于记录代码审阅结论;2026-08-04 更新:第一、二阶段(P0-1、P0-2、P1-1、P1-2)已修复并通过编译验证,其余问题仍待处理,详见第 4、5 节。
@@ -19,7 +19,7 @@
| P1-3 | `_latestGeometry` 跨线程读写缺少明确同步 | **确认存在,风险待量化** | [`AppStateService.cs:161`](../../XplorePlane/Services/AppState/AppStateService.cs#L161)、[`AppStateService.cs:566-571`](../../XplorePlane/Services/AppState/AppStateService.cs#L566) |
| P1-4 | 探测器断连状态判断不是单一原子事务 | **部分确认** | [`AppStateService.cs:579-585`](../../XplorePlane/Services/AppState/AppStateService.cs#L579) 在后台线程读取 `_detectorState`;最终状态更新仍通过原子替换,但 `wasConnected` 与后续更新之间可能交错 |
| P1-5 | `CncExecutionStateChanged` 没有主项目订阅方 | **确认存在** | `AppStateService` 有发布事件,但当前主项目未发现 `+=` 订阅;部分界面改读 `MainViewportService.IsCncRunning`,形成双来源风险 |
-| P1-6 | 硬件错误事件没有进入统一 `SystemState` | **确认存在** | `MotionErrorEvent`、探测器 `ErrorOccurredEvent`、射线源 `ErrorOccurredEvent` 在硬件层有发布,但主项目未发现对应统一订阅;射线源自身 ViewModel 的局部订阅不等于全局故障状态闭环 |
+| P1-6 | 硬件错误事件没有进入统一 `SystemState` | **已修复(2026-08-04)** | `AppStateService` 统一订阅运动、探测器和射线源错误事件,更新 `SystemState`,记录日志,并发布 `StatusBarMessageEvent`;同源同消息去重,恢复事件清除对应故障 |
| P1-7 | `OperationMode` 始终为 `Idle`,权限守卫可能失效 | **确认存在** | 当前唯一写入点 [`RecipeService.cs:225`](../../XplorePlane/Services/Recipe/RecipeService.cs#L225) 仍写入 `OperationMode.Idle`;[`PermissionService.cs:159`](../../XplorePlane/Services/Security/PermissionService.cs#L159) 依赖该字段 |
| P2-1 | 标定矩阵/画面联动没有外部调用方 | **基本确认** | `UpdateCalibrationMatrix`、`RequestLinkedView`、`UpdateLinkedViewState` 的调用主要位于 `AppStateService` 自身,未发现业务入口;需结合实际 UI 需求决定是接入还是删除 |
| P2-2 | `WindowLauncherService.ShowOrActivateVisible` 关闭后保留窗口引用 | **确认存在** | [`WindowLauncherService.cs:91-106`](../../XplorePlane/Services/Main/WindowLauncherService.cs#L91) 没有像 `ShowOrActivate` 一样挂载 `Closed` 清理逻辑;接口注释也明确记录了该历史行为 |
@@ -72,7 +72,7 @@
- [x] `_acquireQueue` 有明确消费者或明确的淘汰/禁用策略,运行时内存曲线可解释。(P1-1 已修复:移除队列本体,改为纯计数)
- [x] BGA 流水线输出缺失时有可见错误,不再静默返回;编译器不再报告该方法的不可达代码。(P0-1 已修复)
- [ ] CNC 执行中权限判断、主界面状态和执行服务使用同一个状态来源。(P1-5/P1-7,第三阶段,未开始)
-- [ ] 硬件故障能够进入统一状态、日志和 UI 告警链路。(P1-6,第三阶段,未开始)
+- [x] 硬件故障能够进入统一状态、日志和 UI 告警链路。(P1-6 已修复)
第一、二阶段的四项已完成并通过 `dotnet build` 主项目编译验证;`XplorePlane.Tests` 因历史遗留的命名空间引用问题(`XplorePlane.Services.Logging`/`Diagnostics`/`Dialogs` 缺失)当前无法编译运行,与本次改动无关(改动前 `git stash` 验证同样报错),因此本轮未新增/运行自动化测试,也未验证 P1-3/P1-4/P2-3 等标注为“待验证”的问题。
@@ -81,7 +81,7 @@
- P1-3 `_latestGeometry` 跨线程同步风险待量化。
- P1-4 探测器断连状态判断的原子性待确认。
- P1-5 `CncExecutionStateChanged` 统一事实源。
-- P1-6 硬件错误事件接入统一 `SystemState`。
+- ~~P1-6 硬件错误事件接入统一 `SystemState`。~~ 已完成:运动轴 Error/Alarm、探测器错误/错误状态、射线源错误/变量断连均已接入统一状态、日志和状态栏告警链路。
- P1-7 `OperationMode` 恒为 `Idle` 的权限守卫失效风险。
- P2-1 标定矩阵/画面联动子系统去留决策。
- P2-2 `WindowLauncherService.ShowOrActivateVisible` 窗口生命周期清理。