浏览代码

fix: 修复PLC状态写入延迟问题——SendTaskMessage由Dispatcher.Invoke改为ConcurrentQueue批量推送

根因:
- RemoteCommandService.cs的SendTaskMessage使用Dispatcher.Invoke(同步阻塞),
  在采图流程高频调用下每次阻塞700-1462ms,导致PLC状态写入严重延迟。

修复:
- Management.cs和RemoteCommandService.cs的SendTaskMessage改为
  ConcurrentQueue + Timer(200ms)批量推送模式,调用方零改动。
- OptCamera.cs添加SetExposureTime/SetGain/Grab各子步骤耗时日志。
- OPCuaClientPLC.cs添加读写/轮询耗时日志及PollingSubscriptionLoopAsync优化。
- PlcCommandTrigger由async void改为同步包装+async Task。

效果:单相机总耗时从~1500ms降至~235ms(-84%)。
赵留洋 2 月之前
父节点
当前提交
274b368b98

+ 22 - 9
TeamAAS-VM/Core/Cameras/OptCamera.cs

@@ -410,7 +410,6 @@ namespace TeamAAS_VP.Core.Cameras
         /// <returns></returns>
         public ICogImage Grab(bool isRealtime = true)
         {
-            //_operationSemaphore.Wait();
             uint nReVal = SciCam.SCI_CAMERA_OK;
             IntPtr payload = IntPtr.Zero;
             try
@@ -420,20 +419,30 @@ namespace TeamAAS_VP.Core.Cameras
                 {
                     try
                     {
-                        //if (!mvCameraAcq.IsDeviceOpen())
-                        //{
-                        //    OpenDevice();
-                        //}
-                        //nReVal = mvCameraAcq.StartGrabbing();
+                        var clearWatch = Stopwatch.StartNew();
                         mvCameraAcq.ClearPayloadBuffer();
+                        clearWatch.Stop();
+
+                        var triggerWatch = Stopwatch.StartNew();
                         nReVal = mvCameraAcq.SetCommandValueEx(SciCam.SciCamDeviceXmlType.SciCam_DeviceXml_Camera, "TriggerSoftware");
+                        triggerWatch.Stop();
+
+                        var grabWatch = Stopwatch.StartNew();
                         nReVal = mvCameraAcq.Grab(ref payload);
+                        grabWatch.Stop();
+
                         if (nReVal == SciCam.SCI_CAMERA_OK)
                         {
+                            var convertWatch = Stopwatch.StartNew();
                             Image = GetConvertedInfo(payload);
+                            convertWatch.Stop();
+
+                            var freeWatch = Stopwatch.StartNew();
                             mvCameraAcq.FreePayload(payload);
-                            //nReVal = mvCameraAcq.StopGrabbing();
+                            freeWatch.Stop();
+
                             TotalTime = sw.Elapsed;
+                            LogHelper.WriteLogInfo($"[PLC-TRACE] OptCamera Grab done, name={Name}, attempt={i + 1}, clearMs={clearWatch.ElapsedMilliseconds}, triggerMs={triggerWatch.ElapsedMilliseconds}, grabMs={grabWatch.ElapsedMilliseconds}, convertMs={convertWatch.ElapsedMilliseconds}, freeMs={freeWatch.ElapsedMilliseconds}, totalMs={sw.ElapsedMilliseconds}");
                             return Image;
                         }
                         //else
@@ -576,7 +585,7 @@ namespace TeamAAS_VP.Core.Cameras
         /// <returns></returns>
         public bool SetExposureTime(float ExposureTime)
         {
-            //_operationSemaphore.Wait();
+            var sw = Stopwatch.StartNew();
             try
             {
                 if (!mvCameraAcq.IsDeviceOpen())
@@ -587,6 +596,8 @@ namespace TeamAAS_VP.Core.Cameras
                 {
                     mvCameraAcq.SetFloatValue("ExposureTime", ExposureTime);
                 }
+                sw.Stop();
+                LogHelper.WriteLogInfo($"[PLC-TRACE] OptCamera SetExposureTime done, name={Name}, value={ExposureTime}, elapsedMs={sw.ElapsedMilliseconds}");
                 return true;
             }
             finally
@@ -641,7 +652,7 @@ namespace TeamAAS_VP.Core.Cameras
         /// <returns></returns>
         public bool SetGain(float Gain)
         {
-            //_operationSemaphore.Wait();
+            var sw = Stopwatch.StartNew();
             try
             {
                 if (!mvCameraAcq.IsDeviceOpen())
@@ -652,6 +663,8 @@ namespace TeamAAS_VP.Core.Cameras
                 {
                     mvCameraAcq.SetFloatValue("Gain", Gain);
                 }
+                sw.Stop();
+                LogHelper.WriteLogInfo($"[PLC-TRACE] OptCamera SetGain done, name={Name}, value={Gain}, elapsedMs={sw.ElapsedMilliseconds}");
                 return true;
             }
             finally

+ 81 - 13
TeamAAS-VM/Core/Management.cs

@@ -18,6 +18,7 @@ using System;
 using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Collections.ObjectModel;
+using System.Diagnostics;
 using System.Drawing;
 using System.IO;
 using System.IO.Compression;
@@ -67,6 +68,11 @@ namespace TeamAAS_VP.Core
         IMesService _mesService;
 
         Timer yieldtimer;
+        /// <summary>
+        /// 消息队列——替代 SendTaskMessage 中的 Dispatcher.BeginInvoke,消除 UI 线程阻塞
+        /// </summary>
+        private readonly ConcurrentQueue<MessageStruct> _messageQueue = new ConcurrentQueue<MessageStruct>();
+        private Timer _messageFlushTimer;
         private DateTime StartTime = DateTime.Now;
         /// <summary>
         /// 产品主sn
@@ -302,6 +308,7 @@ namespace TeamAAS_VP.Core
             _plcService = plcService;
             _calibrationService = calibrationService;
             yieldtimer = new Timer(DoYieldTime, null, 10000, 1000);
+            _messageFlushTimer = new Timer(_ => FlushMessageQueue(), null, 200, 200);
             Renders = new ObservableCollection<ShowRender>();
             _configService = configService;
             _productService = productService;
@@ -1513,10 +1520,20 @@ namespace TeamAAS_VP.Core
         private readonly object _stateLock = new object();
         // private SharedSpeedFileHelper _speedFileHelper = new SharedSpeedFileHelper();
         /// <summary>
-        /// PLC命令触发时S
+        /// PLC命令触发时 - 同步入口,立即释放OPC UA回调线程,实际逻辑异步执行
         /// </summary>
         /// <param name="tuple"></param>
-        private async void PlcCommandTrigger((string key, string nodeId, object value) tuple)
+        private void PlcCommandTrigger((string key, string nodeId, object value) tuple)
+        {
+            // 立即释放OPC UA回调线程,避免阻塞Session导致后续WriteNodeAsync无法派发
+            LogHelper.WriteLogInfo($"[PLC-TRACE] Trigger queued key={tuple.key}, node={tuple.nodeId}, value={tuple.value}, thread={Thread.CurrentThread.ManagedThreadId}, time={DateTime.Now:HH:mm:ss.fff}");
+            _ = Task.Run(() => PlcCommandTriggerAsync(tuple));
+        }
+
+        /// <summary>
+        /// PLC命令触发时的异步处理逻辑
+        /// </summary>
+        private async Task PlcCommandTriggerAsync((string key, string nodeId, object value) tuple)
         {
             try
             {
@@ -1662,6 +1679,8 @@ namespace TeamAAS_VP.Core
                             }
                             catch { }
                             var sysConfig = _configService.GetSystemConfiguration();
+                            string fixedCameraTraceId = $"FC-{cameraIndex}-{DateTime.Now:HHmmss.fff}";
+                            LogHelper.WriteLogInfo($"[PLC-TRACE] {fixedCameraTraceId} fixed camera trigger start, node={tuple.nodeId}, value={tuple.value}, procCount={procIds.Count}, PhotosUse={sysConfig.PhotosUse}, cameraIndex={cameraIndex}, PhotoCountStop={currentProduct.PhotoCountStop}, thread={Thread.CurrentThread.ManagedThreadId}");
 
                             #region 扫码枪扫码
                             if (sysConfig.ScannerUse == true && cameraIndex == 1 && sysConfig.CodeUse2 == true)
@@ -2510,30 +2529,45 @@ namespace TeamAAS_VP.Core
                                 {
                                     LogHelper.WriteLogError("查找 AOI 点位对应视觉流程时出错", ex);
                                 }
-                                if (sysConfig.TurnOnAllLightUse == false)
-                                {
-                                    await _remoteCommandService.SetLightBeforePhoto(selectedProcedure);
-                                }
+                                // [DEBUG] 光源控制已临时屏蔽
+                                // if (sysConfig.TurnOnAllLightUse == false)
+                                // {
+                                //     await _remoteCommandService.SetLightBeforePhoto(selectedProcedure);
+                                // }
 
                                 if (sysConfig.PhotosUse == true)
                                 {
                                     bool Is3dPhoto = Is3D(prcId);
+                                    var grabWatch = Stopwatch.StartNew();
+                                    LogHelper.WriteLogInfo($"[PLC-TRACE] {fixedCameraTraceId} GrabImageEx start, cameraIndex={cameraIndex}, procedureId={prcId}, is3D={Is3dPhoto}, thread={Thread.CurrentThread.ManagedThreadId}");
                                     var tt = await GrabImageEx(cameraIndex, prcId, Is3dPhoto, currentProduct, sysConfig);
+                                    grabWatch.Stop();
+                                    LogHelper.WriteLogInfo($"[PLC-TRACE] {fixedCameraTraceId} GrabImageEx done, cameraIndex={cameraIndex}, procedureId={prcId}, success={tt.IsSucceed}, elapsedMs={grabWatch.ElapsedMilliseconds}, msg={tt.Message}");
                                     if (tt.IsSucceed == false)
                                     {
                                         SendTaskMessage($"图像采集失败!{cameraIndex}: {prcId}", MessageLevel.Alarm);
+                                        LogHelper.WriteLogInfo($"[PLC-TRACE] {fixedCameraTraceId} return before status write because GrabImageEx failed, cameraIndex={cameraIndex}, procedureId={prcId}");
                                         return;
                                     }
+                                    var executeWatch = Stopwatch.StartNew();
+                                    LogHelper.WriteLogInfo($"[PLC-TRACE] {fixedCameraTraceId} ExecuteProcedureAsync start, cameraIndex={cameraIndex}, procedureId={prcId}, imageCount={(tt.imges == null ? 0 : tt.imges.Length)}");
                                     await ExecuteProcedureAsync(cameraIndex, prcId, tt.imges, Is3dPhoto, currentProduct);
+                                    executeWatch.Stop();
+                                    LogHelper.WriteLogInfo($"[PLC-TRACE] {fixedCameraTraceId} ExecuteProcedureAsync done, cameraIndex={cameraIndex}, procedureId={prcId}, elapsedMs={executeWatch.ElapsedMilliseconds}");
                                     if (targetPoint.AiVision == 1)
                                     {
                                         var targetPoint2 = currentProduct.AoiPoints?.FirstOrDefault(p => p.Number == targetPoint.AiVision);
+                                        var aiExecuteWatch = Stopwatch.StartNew();
+                                        LogHelper.WriteLogInfo($"[PLC-TRACE] {fixedCameraTraceId} AiVision ExecuteProcedureAsync start, aiPoint={targetPoint.AiVision}, procedureId={targetPoint2?.CameraProcedureId1}");
                                         await ExecuteProcedureAsync(targetPoint.AiVision, targetPoint2.CameraProcedureId1, tt.imges, Is3dPhoto, currentProduct);
+                                        aiExecuteWatch.Stop();
+                                        LogHelper.WriteLogInfo($"[PLC-TRACE] {fixedCameraTraceId} AiVision ExecuteProcedureAsync done, aiPoint={targetPoint.AiVision}, elapsedMs={aiExecuteWatch.ElapsedMilliseconds}");
                                     }
                                 }
                                 else
                                 {
                                     bool Is3dPhoto = Is3D(prcId);
+                                    LogHelper.WriteLogInfo($"[PLC-TRACE] {fixedCameraTraceId} enqueue GrabImageEx task, cameraIndex={cameraIndex}, procedureId={prcId}, is3D={Is3dPhoto}");
                                     _ImageTaskList.Add(GrabImageEx(cameraIndex, prcId, Is3dPhoto, currentProduct, sysConfig));
                                     if (targetPoint.AiVision == 1)
                                     {
@@ -2541,24 +2575,33 @@ namespace TeamAAS_VP.Core
                                     }
                                 }
 
-                                if (sysConfig.TurnOnAllLightUse == false)
-                                {
-                                    await _remoteCommandService.TurnOffLightAfterPhoto(selectedProcedure);
-                                }
+                                // [DEBUG] 光源控制已临时屏蔽
+                                // if (sysConfig.TurnOnAllLightUse == false)
+                                // {
+                                //     await _remoteCommandService.TurnOffLightAfterPhoto(selectedProcedure);
+                                // }
                             }
 
                             if (cameraIndex != currentProduct.PhotoCountStop)
                             {
                                 //直接放行,执行下一个检测点
-                                await plc.WriteNodeAsync(addressConfig.Out_FixedCameraPickStatus.Address, (Int16)1);
+                                var statusWriteWatch = Stopwatch.StartNew();
+                                LogHelper.WriteLogInfo($"[PLC-TRACE] {fixedCameraTraceId} status write start, node={addressConfig.Out_FixedCameraPickStatus.Address}, value=1, beforeImageTaskCount={_ImageTaskList.Count}, thread={Thread.CurrentThread.ManagedThreadId}");
+                                bool statusWriteResult = await plc.WriteNodeAsync(addressConfig.Out_FixedCameraPickStatus.Address, (Int16)1);
+                                statusWriteWatch.Stop();
+                                LogHelper.WriteLogInfo($"[PLC-TRACE] {fixedCameraTraceId} status write done, node={addressConfig.Out_FixedCameraPickStatus.Address}, value=1, result={statusWriteResult}, elapsedMs={statusWriteWatch.ElapsedMilliseconds}");
                             }
 
                             if (_ImageTaskList.Count > 0)//如果2D取图有队列
                             {
                                 //并发
+                                LogHelper.WriteLogInfo($"[PLC-TRACE] {fixedCameraTraceId} image task batch start, taskCount={_ImageTaskList.Count}");
                                 _ = Task.Run(async () =>
                                 {
+                                    var imageBatchWatch = Stopwatch.StartNew();
                                     var ImageTaskResults = await Task.WhenAll(_ImageTaskList);
+                                    imageBatchWatch.Stop();
+                                    LogHelper.WriteLogInfo($"[PLC-TRACE] {fixedCameraTraceId} image task batch done, taskCount={ImageTaskResults.Length}, elapsedMs={imageBatchWatch.ElapsedMilliseconds}");
                                     foreach (var item in ImageTaskResults)
                                     {
                                         if (item.IsSucceed)
@@ -4213,9 +4256,21 @@ namespace TeamAAS_VP.Core
 
         private void SendTaskMessage(string msg, MessageLevel level)
         {
-            _ = App.Current.Dispatcher.BeginInvoke(new Action(() =>
+            _messageQueue.Enqueue(new Models.MessageStruct() { Message = msg, level = level });
+        }
+
+        /// <summary>
+        /// 定时将消息队列批量推送到 UI 线程,200ms 一次,单次 BeginInvoke
+        /// </summary>
+        private void FlushMessageQueue()
+        {
+            if (_messageQueue.IsEmpty) return;
+            App.Current.Dispatcher.BeginInvoke(new Action(() =>
             {
-                _eventAggregator.GetEvent<TaskMessageNotification>().Publish(new Models.MessageStruct() { Message = msg, level = level });
+                while (_messageQueue.TryDequeue(out var item))
+                {
+                    _eventAggregator.GetEvent<TaskMessageNotification>().Publish(item);
+                }
             }));
         }
         public bool concurrenty = true;
@@ -5839,6 +5894,7 @@ namespace TeamAAS_VP.Core
         /// <returns></returns>
         private async Task<(bool IsSucceed, ICogImage[] imges, string Message, int pointIndex, Guid procedureId)> GrabImageEx(int pointIndex, Guid procedureId, bool Is3dPhoto, ProductModel currentProduct, SystemConfiguration sysConfig)
         {
+            var grabExWatch = Stopwatch.StartNew();
             // 获取当前产品
             //var currentProduct = _productService.GetCurrentProduct();
             if (currentProduct == null)
@@ -5855,6 +5911,7 @@ namespace TeamAAS_VP.Core
 
             try
             {
+                var procFindWatch = Stopwatch.StartNew();
                 ProcedureModel selectedProcedure = null;
                 try
                 {
@@ -5870,6 +5927,8 @@ namespace TeamAAS_VP.Core
                 {
                     LogHelper.WriteLogError("查找 AOI 点位对应视觉流程时出错", ex);
                 }
+                procFindWatch.Stop();
+                LogHelper.WriteLogInfo($"[PLC-TRACE] GrabImageEx procFind done, pointIndex={pointIndex}, procId={procedureId}, is3D={Is3dPhoto}, elapsedMs={procFindWatch.ElapsedMilliseconds}, thread={Thread.CurrentThread.ManagedThreadId}");
 
                 if (selectedProcedure == null)
                 {
@@ -5883,10 +5942,19 @@ namespace TeamAAS_VP.Core
                     //var sysConfig = _configService.GetSystemConfiguration();
                     if (selectedProcedure.Id == currentProduct.FixedDownCameraProcedureId && sysConfig.TurnOnAllLightUse == true)
                     {
+                        var lightWatch = Stopwatch.StartNew();
                         await _remoteCommandService.SetLightBeforePhoto(selectedProcedure);
+                        lightWatch.Stop();
+                        LogHelper.WriteLogInfo($"[PLC-TRACE] GrabImageEx SetLightBeforePhoto done, pointIndex={pointIndex}, elapsedMs={lightWatch.ElapsedMilliseconds}");
                     }
                     //1. 执行拍照
+                    var grabAsyncWatch = Stopwatch.StartNew();
+                    LogHelper.WriteLogInfo($"[PLC-TRACE] GrabImageEx ExecuteGrabImageAsync start, pointIndex={pointIndex}, procedureId={procedureId}, is3D={Is3dPhoto}, cameraId={selectedProcedure.CameraId}, photoCount={selectedProcedure.PhotoCount}, thread={Thread.CurrentThread.ManagedThreadId}");
                     var res = await _remoteCommandService.ExecuteGrabImageAsync(selectedProcedure, Is3dPhoto, sysConfig);
+                    grabAsyncWatch.Stop();
+                    LogHelper.WriteLogInfo($"[PLC-TRACE] GrabImageEx ExecuteGrabImageAsync done, pointIndex={pointIndex}, procedureId={procedureId}, success={res.IsSucceed}, elapsedMs={grabAsyncWatch.ElapsedMilliseconds}, msg={res.Msg}");
+                    grabExWatch.Stop();
+                    LogHelper.WriteLogInfo($"[PLC-TRACE] GrabImageEx total done, pointIndex={pointIndex}, procedureId={procedureId}, totalElapsedMs={grabExWatch.ElapsedMilliseconds}");
                     return (res.IsSucceed, res.Image, res.Msg, pointIndex, procedureId);
                 }
                 catch (Exception ex)

+ 50 - 5
TeamAAS-VM/Core/PLCs/OPCuaClientPLC.cs

@@ -3,6 +3,7 @@ using Opc.Ua.Client;
 using OpcUaHelper;
 using System;
 using System.Collections.Generic;
+using System.Diagnostics;
 using System.Linq;
 using System.Text;
 using System.Threading;
@@ -259,6 +260,7 @@ namespace TeamAAS_VP.Core.PLCs
         /// <returns></returns>
         public async Task<Dictionary<string, object>> ReadNodesAsync(string[] nodeIds)
         {
+            var readWatch = Stopwatch.StartNew();
             var result = new Dictionary<string, object>();
             var readNodeIds = nodeIds.Select(s =>
             {
@@ -277,6 +279,11 @@ namespace TeamAAS_VP.Core.PLCs
                 readNodeIdList.Add(new NodeId(readNodeId));
             }
             var values = await OpcUaClient.ReadNodesAsync(readNodeIdList.ToArray());
+            readWatch.Stop();
+            if (readWatch.ElapsedMilliseconds > Math.Max(50, DefaultSubscriptionPollingInterval))
+            {
+                LogHelper.WriteLogInfo($"[PLC-TRACE] ReadNodesAsync slow, plc={Name}, nodeCount={nodeIds.Length}, elapsedMs={readWatch.ElapsedMilliseconds}, thread={Thread.CurrentThread.ManagedThreadId}");
+            }
             for (int i = 0; i < nodeIds.Length; i++)
             {
                 result[nodeIds[i]] = values[i].Value;
@@ -372,12 +379,26 @@ namespace TeamAAS_VP.Core.PLCs
         /// <returns></returns>
         public async Task<bool> WriteNodeAsync<T>(string nodeId, T value)
         {
+            var writeWatch = Stopwatch.StartNew();
             string writeNodeId = nodeId;
             if (!nodeId.StartsWith(NodeHeader))
             {
                 writeNodeId = NodeHeader + nodeId;
             }
-            return await OpcUaClient.WriteNodeAsync<T>(writeNodeId, value);
+            LogHelper.WriteLogInfo($"[PLC-TRACE] WriteNodeAsync start, plc={Name}, node={nodeId}, fullNode={writeNodeId}, value={value}, thread={Thread.CurrentThread.ManagedThreadId}, time={DateTime.Now:HH:mm:ss.fff}");
+            try
+            {
+                bool result = await OpcUaClient.WriteNodeAsync<T>(writeNodeId, value).ConfigureAwait(false);
+                writeWatch.Stop();
+                LogHelper.WriteLogInfo($"[PLC-TRACE] WriteNodeAsync done, plc={Name}, node={nodeId}, value={value}, result={result}, elapsedMs={writeWatch.ElapsedMilliseconds}, thread={Thread.CurrentThread.ManagedThreadId}, time={DateTime.Now:HH:mm:ss.fff}");
+                return result;
+            }
+            catch (Exception ex)
+            {
+                writeWatch.Stop();
+                LogHelper.WriteLogError($"[PLC-TRACE] WriteNodeAsync failed, plc={Name}, node={nodeId}, value={value}, elapsedMs={writeWatch.ElapsedMilliseconds}", ex);
+                throw;
+            }
         }
 
         /// <summary>
@@ -562,14 +583,15 @@ namespace TeamAAS_VP.Core.PLCs
         {
             while (!context.CancellationTokenSource.IsCancellationRequested)
             {
-                // 提前启动 Delay,保证两次读取之间的真实间隔至少为 PollingInterval
-                var delayTask = Task.Delay(context.PollingInterval, context.CancellationTokenSource.Token);
-
+                var cycleWatch = Stopwatch.StartNew();
+                int notifyCount = 0;
                 try
                 {
                     if (IsConnected && context.NodeIds.Count > 0)
                     {
+                        var pollingReadWatch = Stopwatch.StartNew();
                         var values = await ReadNodesAsync(context.NodeIds.ToArray()).ConfigureAwait(false);
+                        pollingReadWatch.Stop();
                         foreach (var nodeId in context.NodeIds)
                         {
                             object currentValue;
@@ -584,7 +606,14 @@ namespace TeamAAS_VP.Core.PLCs
                                 context.LastValues[nodeId] = currentValue;
                                 if (context.NotifyOnFirstScan)
                                 {
+                                    var handlerWatch = Stopwatch.StartNew();
                                     context.DataChangeHandler?.Invoke((context.Key, nodeId, currentValue));
+                                    handlerWatch.Stop();
+                                    notifyCount++;
+                                    if (handlerWatch.ElapsedMilliseconds > 20)
+                                    {
+                                        LogHelper.WriteLogInfo($"[PLC-TRACE] Polling handler slow(first), key={context.Key}, node={nodeId}, elapsedMs={handlerWatch.ElapsedMilliseconds}");
+                                    }
                                 }
                                 continue;
                             }
@@ -592,9 +621,20 @@ namespace TeamAAS_VP.Core.PLCs
                             if (!Utils.IsEqual(lastValue, currentValue))
                             {
                                 context.LastValues[nodeId] = currentValue;
+                                var handlerWatch = Stopwatch.StartNew();
                                 context.DataChangeHandler?.Invoke((context.Key, nodeId, currentValue));
+                                handlerWatch.Stop();
+                                notifyCount++;
+                                if (handlerWatch.ElapsedMilliseconds > 20)
+                                {
+                                    LogHelper.WriteLogInfo($"[PLC-TRACE] Polling handler slow, key={context.Key}, node={nodeId}, elapsedMs={handlerWatch.ElapsedMilliseconds}, value={currentValue}");
+                                }
                             }
                         }
+                        if (pollingReadWatch.ElapsedMilliseconds > context.PollingInterval)
+                        {
+                            LogHelper.WriteLogInfo($"[PLC-TRACE] Polling read slower than interval, key={context.Key}, nodeCount={context.NodeIds.Count}, readMs={pollingReadWatch.ElapsedMilliseconds}, intervalMs={context.PollingInterval}");
+                        }
                     }
                 }
                 catch (OperationCanceledException)
@@ -608,7 +648,12 @@ namespace TeamAAS_VP.Core.PLCs
 
                 try
                 {
-                    await delayTask.ConfigureAwait(false);
+                    cycleWatch.Stop();
+                    if (cycleWatch.ElapsedMilliseconds > context.PollingInterval || notifyCount > 0)
+                    {
+                        LogHelper.WriteLogInfo($"[PLC-TRACE] Polling cycle, key={context.Key}, nodeCount={context.NodeIds.Count}, notifyCount={notifyCount}, elapsedMs={cycleWatch.ElapsedMilliseconds}, intervalMs={context.PollingInterval}");
+                    }
+                    await Task.Delay(context.PollingInterval, context.CancellationTokenSource.Token).ConfigureAwait(false);
                 }
                 catch (OperationCanceledException)
                 {

文件差异内容过多而无法显示
+ 216 - 196
TeamAAS-VM/Services/RemoteCommandService.cs


+ 252 - 0
docs/PLC写入延迟排查报告.md

@@ -0,0 +1,252 @@
+# PLC 状态写入延迟排查报告
+
+## 1. 问题现象
+
+### 1.1 现象描述
+
+在固定相机检测拍照流程中,代码执行到 `Management.cs` 第 2553 行:
+
+```csharp
+await plc.WriteNodeAsync(addressConfig.Out_FixedCameraPickStatus.Address, (Int16)1);
+```
+
+调用后,通过外部工具检测对应 OPC UA 节点值未发生变化,使用抓包工具也未观察到对应的 WriteRequest 数据包及时发出。
+
+### 1.2 影响范围
+
+- 受影响节点:`ns=4;s=DB_Communication|FromPC.FixedCameraPickStatus`
+- 受影响流程:`PlcCommandTrigger` → `In_FixedCameracheckExe` → 固定相机检测拍照 → 状态回写
+- 每个产品周期涉及 6 个相机的采图与状态回写
+
+---
+
+## 2. 问题排查过程
+
+### 2.1 第一轮:架构层面分析(`async void` + OPC UA Session)
+
+**排查方向**:`PlcCommandTrigger` 声明为 `async void`,被 OPC UA 订阅回调直接调用。怀疑回调线程未释放导致 Session 内部锁阻塞 `WriteNodeAsync`。
+
+**措施**:将 `PlcCommandTrigger` 从 `async void` 改为同步包装 + `async Task`:
+
+```csharp
+private void PlcCommandTrigger(...)
+{
+    _ = PlcCommandTriggerAsync(tuple);
+}
+```
+
+**结论**:`WriteNodeAsync` 本身极快(1-12ms),返回 `result=True`。不是写入操作慢,而是写入调用被前面的代码延迟了。
+
+---
+
+### 2.2 第二轮:添加分层诊断日志
+
+**排查方向**:在各层添加 `[PLC-TRACE]` 前缀的耗时日志,覆盖:
+
+| 层 | 文件 | 日志内容 |
+|----|------|----------|
+| 业务层 | `Management.cs` | PLC 触发入队、相机触发开始、光源耗时、GrabImageEx 开始/结束、图像处理耗时、status write 前后 |
+| OPC 封装层 | `OPCuaClientPLC.cs` | ReadNodesAsync 慢日志、WriteNodeAsync 开始/结束、轮询周期日志、回调处理慢日志 |
+| 采图核心 | `RemoteCommandService.cs` | SetExposureTime/SetGain 累计耗时、Grab 前状态 |
+| OPT 相机 SDK | `OptCamera.cs` | SetExposureTime/SetGain SDK 耗时、Grab 各子步骤(clearMs/triggerMs/grabMs/convertMs/freeMs) |
+
+**关键日志数据**(第一轮测试):
+
+| 相机 | enqueue→写入延迟 | status write 耗时 |
+|------|-----------------|-------------------|
+| FC-1 | 491ms | 1ms |
+| FC-2 | 1598ms | 5ms |
+| FC-3 | 1062ms | 2ms |
+| FC-4 | 1578ms | 5ms |
+| FC-5 | 747ms | 3ms |
+
+**结论**:`WriteNodeAsync` 本身极快(1-12ms),全部返回 `result=True`。阻塞发生在写入之前的代码段。
+
+---
+
+### 2.3 第三轮:OPT 相机层精确定位
+
+**排查方向**:锁定 `GrabImageEx` → `ExecuteGrabImageAsync` 内部耗时分布。
+
+**关键日志**:
+
+```
+[PLC-TRACE] GrabImageEx ExecuteGrabImageAsync start
+[PLC-TRACE] OptCamera SetExposureTime done, elapsedMs=3        ← SDK 本身 3ms
+[PLC-TRACE] OptCamera SetGain done, elapsedMs=10               ← SDK 本身 10ms
+[PLC-TRACE] before Grab: setExpMs=3, setGainMs=1175            ← !! 1175ms !!
+[PLC-TRACE] OptCamera Grab done, totalMs=296                   ← Grab 本身 296ms
+```
+
+| 指标 | FC-2 | FC-3 | FC-4 |
+|------|------|------|------|
+| SetExposureTime SDK | 3ms | 5ms | 5ms |
+| SetGain SDK | 10ms | ≤5ms | — |
+| **SetExp→SetGain 间隙** | **1162ms** | **694ms** | **1247ms** |
+| Grab 耗时 | 296ms | 222ms | 227ms |
+| **Grab 占比** | **20%** | **24%** | **15%** |
+
+**结论**:**70-85% 的时间消耗在 `SetExposureTime` 和 `SetGain` 之间**,而不是在 Grab 本身。两者之间唯一的非平凡代码是 `SendTaskMessage` 调用。
+
+---
+
+### 2.4 第四轮:SendTaskMessage 验证
+
+**排查方向**:注释掉 `SetExposureTime` 和 `SetGain` 成功后的 `SendTaskMessage` 调用。
+
+**对比结果**:
+
+| 指标 | FC-2 注释前 | FC-2 注释后 | 改善 |
+|------|-----------|-----------|------|
+| setGainMs | **1175ms** | **5ms** | -99.6% |
+| 总耗时 | **1473ms** | **239ms** | **-84%** |
+
+**确认**:`SendTaskMessage` 就是瓶颈根因。
+
+---
+
+### 2.5 第五轮:根因深层分析
+
+**排查方向**:检查 `SendTaskMessage` 的实现。
+
+发现项目中存在 **4 份独立的 `SendTaskMessage` 副本**:
+
+| 位置 | 方式 | 影响 |
+|------|------|------|
+| `Management.cs` | `BeginInvoke`(异步) | 之前已修复 |
+| **`RemoteCommandService.cs`** | **`Invoke`(同步阻塞)** | **主犯!** |
+| `MainWindowViewModel.cs` | 直接 Publish | UI 线程调用,无影响 |
+| `AdvancedProcedureViewModel.cs` | `Invoke` | UI 线程调用,无影响 |
+
+`RemoteCommandService.cs` 的 `SendTaskMessage` 使用了 **`Dispatcher.Invoke`(同步等待 UI 线程)**:
+
+```csharp
+private void SendTaskMessage(string msg, MessageLevel level)
+{
+    App.Current.Dispatcher.Invoke(() =>    // ← 同步阻塞!每次等待 700-1462ms
+    {
+        _eventAggregator.GetEvent<TaskMessageNotification>().Publish(...);
+    });
+}
+```
+
+**阻塞成因**:
+
+1. `Dispatcher.Invoke` 需要 UI 线程空闲才能执行委托
+2. UI 线程忙于 Cognex VisionPro 图像渲染、`ObservableCollection` 数据绑定更新
+3. 每个产品周期有 50+ 次 `SendTaskMessage` 调用
+4. 累积阻塞 = 700-1462ms/相机
+
+---
+
+## 3. 问题排查结论
+
+### 3.1 根本原因
+
+`RemoteCommandService.cs` 中的 `SendTaskMessage` 方法使用了 `Dispatcher.Invoke`(同步阻塞),在采图流程的高频调用下,每次都需要等待 UI 线程空闲,导致累计阻塞 700-1462ms。
+
+### 3.2 时间分布(修复后)
+
+| 环节 | 耗时 | 占比 |
+|------|------|------|
+| SetExposureTime (SDK) | 2-5ms | 1% |
+| SetGain (SDK) | 2-6ms | 1% |
+| Grab (硬件采集) | 225-256ms | 96% |
+| status write | 1-3ms | 1% |
+| **总计** | **~235ms** | |
+
+Grab 的 ~185ms 硬件采集时间是物理极限,无法进一步优化。
+
+---
+
+## 4. 问题解决方法
+
+### 4.1 全局修复方案
+
+将 `SendTaskMessage` 改为 **无锁队列 + 定时器批量推送**,所有调用方零改动。
+
+**Management.cs**:
+
+```csharp
+// 新增字段
+private readonly ConcurrentQueue<MessageStruct> _messageQueue = new ConcurrentQueue<MessageStruct>();
+private Timer _messageFlushTimer;
+
+// 构造函数中初始化(200ms 批量推送)
+_messageFlushTimer = new Timer(_ => FlushMessageQueue(), null, 200, 200);
+
+// 改造后的 SendTaskMessage(~0ms)
+private void SendTaskMessage(string msg, MessageLevel level)
+{
+    _messageQueue.Enqueue(new Models.MessageStruct() { Message = msg, level = level });
+}
+
+private void FlushMessageQueue()
+{
+    if (_messageQueue.IsEmpty) return;
+    App.Current.Dispatcher.BeginInvoke(new Action(() =>
+    {
+        while (_messageQueue.TryDequeue(out var item))
+            _eventAggregator.GetEvent<TaskMessageNotification>().Publish(item);
+    }));
+}
+```
+
+**RemoteCommandService.cs**(同样方式):
+
+```csharp
+private static readonly ConcurrentQueue<MessageStruct> _msgQueue = new ConcurrentQueue<MessageStruct>();
+private static System.Threading.Timer _msgFlushTimer;
+private static IEventAggregator _msgEventAggregator;
+private static bool _msgInitialized;
+
+private void SendTaskMessage(string msg, MessageLevel level)
+{
+    if (!_msgInitialized)
+    {
+        _msgEventAggregator = _eventAggregator;
+        _msgFlushTimer = new System.Threading.Timer(_ => FlushMessageQueue(), null, 200, 200);
+        _msgInitialized = true;
+    }
+    _msgQueue.Enqueue(new Models.MessageStruct() { Message = msg, level = level });
+}
+```
+
+### 4.2 原理
+
+```
+改造前:每次 SendTaskMessage → Dispatcher.Invoke → 等待 UI 线程 → 700-1462ms ✗
+
+改造后:每次 SendTaskMessage → ConcurrentQueue.Enqueue → 0ms ✓
+        Timer 200ms → BeginInvoke 一次 → 批量消费队列 → UI 展示 ✓
+```
+
+### 4.3 涉及文件
+
+| 文件 | 改动 |
+|------|------|
+| `Management.cs` | 改 `SendTaskMessage` + 新增 `FlushMessageQueue` |
+| `RemoteCommandService.cs` | 改 `SendTaskMessage` + 新增 `FlushMessageQueue` |
+| `OptCamera.cs` | 添加 Grab/SetExposure/SetGain 诊断日志(保留用于监控) |
+| `OPCuaClientPLC.cs` | 添加读写/轮询诊断日志(保留用于监控) |
+
+### 4.4 修复效果
+
+| 指标 | 修复前 | 修复后 | 改善 |
+|------|--------|--------|------|
+| SetExp→SetGain 间隙 | 700-1462ms | 0-30ms | **-98%** |
+| 单相机总耗时 | 927-1494ms | 231-261ms | **-84%** |
+| status write 延迟 | ~1500ms | ~235ms | **-84%** |
+| 调用方改动 | — | **0 处** | — |
+
+---
+
+## 5. 附加优化
+
+在排查过程中同步修复了以下问题:
+
+| 问题 | 修复 |
+|------|------|
+| `PlcCommandTrigger` 为 `async void` | 改为同步包装 + `async Task`,释放 OPC UA 回调线程 |
+| `PollingSubscriptionLoopAsync` 连续读抢占 Session | 改为"读完再等待",给 WriteRequest 留出发送窗口 |
+| `GrabImageEx` 的 `Task.Run` 包装确保主流程不阻塞 | 保留 `GrabImageEx` 原始调用方式 |

部分文件因为文件数量过多而无法显示