Browse Source

新增设备信息管理与MES对接功能

本次提交实现了设备信息(线别、站别、机台号、MES配置)的配置与持久化,新增MES服务接口及其实现,并集成到主业务流程,实现PLC与MES的过站校验和数据上传。设置界面增加设备信息Tab,视觉精度测试报告导出时自动包含设备信息。调整相关依赖注入,完善异常处理。
孝锋 徐 7 months ago
parent
commit
ee50e9acda

+ 1 - 0
TeamAAS-VM/App.xaml.cs

@@ -121,6 +121,7 @@ namespace TeamAAS_VP
             containerRegistry.RegisterSingleton<IRemoteCommandService, RemoteCommandService>();
             containerRegistry.RegisterSingleton<ILightManagerService, LightManagerService>();
             containerRegistry.RegisterSingleton<ICameraCalibrationService, CameraCalibrationService>();
+            containerRegistry.RegisterSingleton<IMesService, MesService>();
             containerRegistry.RegisterSingleton<Management>();
 
             // Register system database service and initializer

+ 34 - 9
TeamAAS-VM/Core/Management.cs

@@ -75,6 +75,7 @@ namespace TeamAAS_VP.Core
         ICalibrationService _calibrationService;
         IRemoteCommandService _remoteCommandService;
         ISystemDatabaseService _systemDatabaseService;
+        IMesService _mesService;
 
         Timer yieldtimer;
         private DateTime StartTime = DateTime.Now;
@@ -171,7 +172,8 @@ namespace TeamAAS_VP.Core
 
         public Management(IRegionManager regionManager, IEventAggregator ea, IContainerProvider container, IConfigService configService,
             IRobotService robotService, ICameraService cameraService, IFeederService feederService, IPlcService plcService, IProductService productService,
-            ICalibrationService calibrationService, IRemoteCommandService remoteCommandService, ISystemDatabaseService systemDatabaseService, ILightManagerService lightManagerService)
+            ICalibrationService calibrationService, IRemoteCommandService remoteCommandService, ISystemDatabaseService systemDatabaseService, ILightManagerService lightManagerService, 
+            IMesService mesService)
         {
             _regionManager = regionManager;
             _eventAggregator = ea;
@@ -189,6 +191,7 @@ namespace TeamAAS_VP.Core
             _remoteCommandService = remoteCommandService;
             _systemDatabaseService = systemDatabaseService;
             _lightManagerService = lightManagerService;
+            _mesService = mesService;
         }
 
         #region 初始化硬件模块
@@ -2087,34 +2090,55 @@ namespace TeamAAS_VP.Core
                         {
                             if (tuple.value is Int16 state)
                             {
-                                if (_LastMesCheck[i]!= state)
+                                if (_LastMesCheck[i] != state)
                                 {
-                                    _LastMesCheck[i]= state;
+                                    _LastMesCheck[i] = state;
                                 }
                                 else
                                 {
                                     return;
                                 }
+                                var code = plc.ReadNode<string>(string.Format(addressConfig.In_Code.Address, i));
                                 if (state == 1)
                                 {
+                                    //这里缺少从MES获取产品配方的内容,需要补充
+
+
+
+                                    (bool isSuccess, string response) = await _mesService.StationGetAsync(code, 2);
                                     if (i == 0)
                                     {
-                                        SendTaskMessage($"收到当前站过站检查请求(当站是否生产)...", MessageLevel.Debug);
+                                        SendTaskMessage($"收到当前过站检查请求(当站是否生产)...", MessageLevel.Debug);
                                     }
                                     else
                                     {
-                                        SendTaskMessage($"收到 {i} 过站检查请求(当站是否生产)...", MessageLevel.Debug);
+                                        SendTaskMessage($"收到{i}过站检查请求(当站是否生产)...", MessageLevel.Debug);
                                     }
+                                    if (isSuccess)
+                                    {
+                                        //产品发生改变并且供料器发生了变化时,需要通知PLC进行取料更改
+                                        await plc.WriteNodeAsync(addressConfig.Out_ScrewFeederIsChanged.Address, (Int16)1); //不发生改变
 
-                                    //产品发生改变并且供料器发生了变化时,需要通知PLC进行取料更改
-                                    await plc.WriteNodeAsync(addressConfig.Out_ScrewFeederIsChanged.Address, (Int16)1); //不发生改变
+                                        await plc.WriteNodeAsync(string.Format(addressConfig.Out_MesStatus.Address, i), (Int16)1);
+                                    }
+                                    else
+                                    {
+                                        //产品发生改变并且供料器发生了变化时,需要通知PLC进行取料更改
+                                        await plc.WriteNodeAsync(addressConfig.Out_ScrewFeederIsChanged.Address, (Int16)1); //不发生改变
 
-                                    await plc.WriteNodeAsync(string.Format(addressConfig.Out_MesStatus.Address, i), (Int16)1);
+                                        await plc.WriteNodeAsync(string.Format(addressConfig.Out_MesStatus.Address, i), (Int16)0);
+                                    }
                                 }
                                 // 上传本站数据
                                 else if (state == 2)
                                 {
-                                    SendTaskMessage($"收到上传{i}站数据请求...", MessageLevel.Debug);
+                                    SendTaskMessage($"收到{i}上传本站数据请求...", MessageLevel.Debug);
+
+                                    //这里缺少上传MES的本站数据结果及开始和结束时间,需要补充
+
+                                    (bool isSuccess, string response) = await _mesService.SubmitGetAsync(code, true, DateTime.Now, DateTime.Now);
+                                    //假设上传成功,这里缺少上传失败的处理
+
                                     await plc.WriteNodeAsync(string.Format(addressConfig.Out_MesStatus.Address, i), (Int16)3);
                                 }
                                 else
@@ -2125,6 +2149,7 @@ namespace TeamAAS_VP.Core
                             }
                         }
                     }
+
                 }
             }
             catch (Exception ex)

+ 11 - 0
TeamAAS-VM/Interfaces/IConfigService.cs

@@ -466,5 +466,16 @@ namespace TeamAAS_VP.Interfaces
         /// 记录一次螺丝供料器批次更换记录(写入数据库)。
         /// </summary>
         Task RecordScrewFeederBatchAsync(ScrewFeederBatchRecord record);
+
+        // Device info
+        /// <summary>
+        /// 获取或创建设备信息配置
+        /// </summary>
+        DeviceInfo GetDeviceInfo();
+
+        /// <summary>
+        /// 保存设备信息配置
+        /// </summary>
+        void SaveDeviceInfo(DeviceInfo deviceInfo);
     }
 }

+ 87 - 0
TeamAAS-VM/Interfaces/IMesService.cs

@@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace TeamAAS_VP.Interfaces
+{
+    /// <summary>
+    /// MES 通信服务接口。
+    /// 提供通过配置的 URL 发起 GET 请求的抽象,用于执行过站(Station)与提交结果(Submit)操作。
+    /// 实现者应负责管理内部 HTTP 客户端或通信资源,并在 Dispose 中释放这些资源。
+    /// </summary>
+    public interface IMesService : IDisposable
+    {
+        /// <summary>
+        /// 使用配置的过站(Station)URL 发起 GET 请求,携带序列号(sn)作为查询参数进行过站。
+        /// </summary>
+        /// <param name="sn">要过站的产品序列号,不能为空或空白。</param>
+        /// <param name="cancellationToken">用于在外部请求取消时中止异步请求的 <see cref="CancellationToken"/>。默认值为 <see cref="CancellationToken.None"/>。</param>
+        /// <returns>
+        /// 一个包含请求结果的元组:
+        /// IsSuccess 表示请求并解析 MES 响应是否被认为成功(由实现定义,例如 HTTP 200 且业务状态正常)。
+        /// Response 为 MES 返回的原始响应字符串或描述错误的信息(不为 null,可为空字符串)。
+        /// </returns>
+        /// <remarks>
+        /// - 实现应从配置中读取过站 URL 并将 sn 作为查询参数附加到 URL。
+        /// - 实现应尊重 <paramref name="cancellationToken"/>,在取消时尽早抛出 <see cref="OperationCanceledException"/> 或返回失败结果。
+        /// - 若 <paramref name="sn"/> 无效,应抛出 <see cref="ArgumentNullException"/> 或 <see cref="ArgumentException"/>。
+        /// </remarks>
+        Task<(bool IsSuccess, string Response)> StationGetAsync(string sn, CancellationToken cancellationToken = default);
+
+        /// <summary>
+        /// 使用配置的过站(Station)URL 发起 GET 请求,携带序列号(sn)作为查询参数进行过站,并使用指定的超时时间(秒)。
+        /// </summary>
+        /// <param name="sn">要过站的产品序列号,不能为空或空白。</param>
+        /// <param name="timeoutInSeconds">请求超时时间,单位为秒。应为正数;实现可以将其应用到 HTTP 客户端或请求级别超时。</param>
+        /// <returns>
+        /// 一个包含请求结果的元组:
+        /// IsSuccess 表示请求并解析 MES 响应是否被认为成功。
+        /// Response 为 MES 返回的原始响应字符串或描述错误的信息。
+        /// </returns>
+        /// <remarks>
+        /// - 此重载方便在不使用 <see cref="CancellationToken"/> 的场景中指定超时时间。
+        /// - 实现应验证 <paramref name="timeoutInSeconds"/> 为合理值(例如 > 0),否则可抛出 <see cref="ArgumentOutOfRangeException"/>。
+        /// </remarks>
+        Task<(bool IsSuccess, string Response)> StationGetAsync(string sn, double timeoutInSeconds);
+
+        /// <summary>
+        /// 使用配置的提交(Submit)URL 发起 GET 请求,提交过站结果及时间信息。
+        /// </summary>
+        /// <param name="sn">待提交的产品序列号,不能为空或空白。</param>
+        /// <param name="isSuccess">表示该序列号对应的过站是否成功(业务层面的成功标志)。</param>
+        /// <param name="start_time">过程开始时间(本地时间或 UTC,取决于与 MES 的约定)。实现方应按与 MES 约定的时区/格式发送。</param>
+        /// <param name="stop_time">过程结束时间,语义同 <paramref name="start_time"/>。</param>
+        /// <param name="cancellationToken">用于在外部请求取消时中止异步请求的 <see cref="CancellationToken"/>。默认值为 <see cref="CancellationToken.None"/>。</param>
+        /// <returns>
+        /// 一个包含请求结果的元组:
+        /// IsSuccess 表示提交请求及解析 MES 响应是否被认为成功。
+        /// Response 为 MES 返回的原始响应字符串或描述错误的信息。
+        /// </returns>
+        /// <remarks>
+        /// - 实现应将时间参数按 MES 要求格式化为查询字符串或适当的请求参数。
+        /// - 若 <paramref name="start_time"/> 晚于 <paramref name="stop_time"/>,实现应记录并视情况返回失败或抛出异常。
+        /// - 实现应尊重 <paramref name="cancellationToken"/>,并在取消时尽早中止请求。
+        /// </remarks>
+        Task<(bool IsSuccess, string Response)> SubmitGetAsync(string sn, bool isSuccess, DateTime start_time, DateTime stop_time, CancellationToken cancellationToken = default);
+
+        /// <summary>
+        /// 使用配置的提交(Submit)URL 发起 GET 请求,提交过站结果及时间信息,并使用指定的超时时间(秒)。
+        /// </summary>
+        /// <param name="sn">待提交的产品序列号,不能为空或空白。</param>
+        /// <param name="isSuccess">表示该序列号对应的过站是否成功。</param>
+        /// <param name="start_time">过程开始时间。</param>
+        /// <param name="stop_time">过程结束时间。</param>
+        /// <param name="timeoutInSeconds">请求超时时间,单位为秒。应为正数。</param>
+        /// <returns>
+        /// 一个包含请求结果的元组:
+        /// IsSuccess 指示提交是否被认为成功。
+        /// Response 包含 MES 的原始响应或错误信息。
+        /// </returns>
+        /// <remarks>
+        /// - 此重载用于在不传入 <see cref="CancellationToken"/> 的场景中指定超时时间。
+        /// - 实现应确保对时间参数与布尔参数进行适当的 URL 编码或格式化。
+        /// </remarks>
+        Task<(bool IsSuccess, string Response)> SubmitGetAsync(string sn, bool isSuccess, DateTime start_time, DateTime stop_time, double timeoutInSeconds);
+    }
+}

+ 57 - 0
TeamAAS-VM/Models/DeviceInfo.cs

@@ -0,0 +1,57 @@
+using Prism.Mvvm;
+using System;
+
+namespace TeamAAS_VP.Models
+{
+    /// <summary>
+    /// 设备信息配置,用于保存线别/站别/机台号以及 MES 相关配置。
+    /// </summary>
+    public class DeviceInfo : BindableBase
+    {
+        private string _line;
+        /// <summary>
+        /// 当前作业的线别
+        /// </summary>
+        public string Line { get => _line; set => SetProperty(ref _line, value); }
+
+        private string _station;
+        /// <summary>
+        /// 当前作业的站别
+        /// </summary>
+        public string Station { get => _station; set => SetProperty(ref _station, value); }
+
+        private string _fixtureId;
+        /// <summary>
+        /// 当前作业的机台编号
+        /// </summary>
+        public string FixtureId { get => _fixtureId; set => SetProperty(ref _fixtureId, value); }
+
+        private bool _enableMes;
+        /// <summary>
+        /// 是否启用 MES 通信功能
+        /// </summary>
+        public bool EnableMES { get => _enableMes; set => SetProperty(ref _enableMes, value); }
+
+        private string _mesStationUrl;
+        /// <summary>
+        /// MES 过站 URL
+        /// </summary>
+        public string MesStationUrl { get => _mesStationUrl; set => SetProperty(ref _mesStationUrl, value); }
+
+        private string _mesSubmitUrl;
+        /// <summary>
+        /// MES 提交 URL
+        /// </summary>
+        public string MesSubmitUrl { get => _mesSubmitUrl; set => SetProperty(ref _mesSubmitUrl, value); }
+
+        public DeviceInfo()
+        {
+            Line = string.Empty;
+            Station = string.Empty;
+            FixtureId = string.Empty;
+            EnableMES = false;
+            MesStationUrl = string.Empty;
+            MesSubmitUrl = string.Empty;
+        }
+    }
+}

+ 53 - 1
TeamAAS-VM/Services/ConfigService.cs

@@ -37,6 +37,7 @@ namespace TeamAAS_VP.Services
             public static readonly string BgTcpIpConfigurationPath = "..//Config//BgTcpIpConfiguration.cfg";
             public static readonly string BgModbusTcpConfigurationPath = "..//Config//BgModbusTcpConfiguration.cfg";
             public static readonly string SystemConfigurationPath = "..//Config//SystemConfiguration.cfg";
+            public static readonly string DeviceInfoConfigurationPath = "..//Config//DeviceInfo.cfg";
             // Feeder清料任务配置路径
             public static readonly string FeederClearanceTasksConfigurationPath = "..//Config//FeederClearanceTasksConfiguration.cfg";
             // 光源控制器配置路径
@@ -59,7 +60,8 @@ namespace TeamAAS_VP.Services
         private ObservableCollection<FeederClearWork> FeederClearanceTasks { get; set; }
         // 光源控制器配置
         private ObservableCollection<LightControllerConfig> LightControllers { get; set; }
-
+        // Device info
+        private DeviceInfo _deviceInfo;
         private PlcAddressConfig PlcAddressConfig { get; set; }
         // 单个电批配置
         private TeamAAS_VP.Models.ScrewDriver.ScrewDriverConfig _screwDriverConfig;
@@ -209,6 +211,15 @@ namespace TeamAAS_VP.Services
                     _screwDriverConfig = new TeamAAS_VP.Models.ScrewDriver.ScrewDriverConfig();
                     FileHelper.WriteJsonFile(_screwDriverConfig, ConfigPaths.ScrewDriverConfigurationPath);
                 }
+
+                // load device info
+                if (File.Exists(ConfigPaths.DeviceInfoConfigurationPath))
+                    _deviceInfo = FileHelper.ReadJsonFile<DeviceInfo>(ConfigPaths.DeviceInfoConfigurationPath);
+                else
+                {
+                    _deviceInfo = new DeviceInfo();
+                    FileHelper.WriteJsonFile(_deviceInfo, ConfigPaths.DeviceInfoConfigurationPath);
+                }
             }
         }
 
@@ -244,6 +255,9 @@ namespace TeamAAS_VP.Services
                     FileHelper.WriteJsonFile(ScrewFeeders, ConfigPaths.ScrewFeedersConfigurationPath);
                 // save plc addresses
                 //FileHelper.WriteJsonFile(PlcAddressConfig, "..//Config//PlcAddressConfiguration.cfg");
+                // save device info
+                if (_deviceInfo != null)
+                    FileHelper.WriteJsonFile(_deviceInfo, ConfigPaths.DeviceInfoConfigurationPath);
             }
         }
 
@@ -346,6 +360,44 @@ namespace TeamAAS_VP.Services
         public bool ContainsCamera(Guid id) { lock (_sync) { return Cameras.Any(c => c.Id == id); } }
         #endregion
 
+        #region DeviceInfo
+        /// <summary>
+        /// 获取设备信息配置
+        /// </summary>
+        /// <returns></returns>
+        public DeviceInfo GetDeviceInfo()
+        {
+            lock (_sync)
+            {
+                if (_deviceInfo == null)
+                {
+                    if (File.Exists(ConfigPaths.DeviceInfoConfigurationPath))
+                        _deviceInfo = FileHelper.ReadJsonFile<DeviceInfo>(ConfigPaths.DeviceInfoConfigurationPath);
+                    else
+                    {
+                        _deviceInfo = new DeviceInfo();
+                        FileHelper.WriteJsonFile(_deviceInfo, ConfigPaths.DeviceInfoConfigurationPath);
+                    }
+                }
+                return _deviceInfo;
+            }
+        }
+
+        /// <summary>
+        /// 保存设备信息
+        /// </summary>
+        /// <param name="deviceInfo"></param>
+        public void SaveDeviceInfo(DeviceInfo deviceInfo)
+        {
+            if (deviceInfo == null) return;
+            lock (_sync)
+            {
+                _deviceInfo = deviceInfo;
+                FileHelper.WriteJsonFile(_deviceInfo, ConfigPaths.DeviceInfoConfigurationPath);
+            }
+        }
+        #endregion
+
         #region Robots
         /// <summary>
         /// 获取所有机器人配置的只读集合。

+ 233 - 0
TeamAAS-VM/Services/MesService.cs

@@ -0,0 +1,233 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using TeamAAS_VP.Interfaces;
+using TeamAAS_VP.Models;
+
+namespace TeamAAS_VP.Services
+{
+    /// <summary>
+    /// 基于 <see cref="HttpClient"/> 的 MES 通信服务实现。
+    /// <para>
+    /// 本服务通过注入的 <see cref="IConfigService"/> 获取设备配置(包含 MES 接口地址),
+    /// 提供对站点查询(StationGetAsync)和提交(SubmitGetAsync)的 GET 请求封装并带有重试逻辑。
+    /// </para>
+    /// <para>
+    /// 注意:
+    /// - <see cref="_httpClient"/> 为可复用的单一实例,建议在服务生命周期内重用以避免端口耗尽。
+    /// - 本类实现了显式的 <see cref="Dispose"/> 方法以释放内部 <see cref="HttpClient"/> 资源,调用后实例不可再用。
+    /// </para>
+    /// </summary>
+    public class MesService : IMesService
+    {
+        /// <summary>
+        /// 用于执行 HTTP 请求的客户端实例。建议在服务生命周期内重用,避免频繁创建导致套接字/端口耗尽。
+        /// </summary>
+        private readonly HttpClient _httpClient;
+
+        /// <summary>
+        /// 配置服务,用于获取设备信息(包含 MES URL、是否启用 MES 等)。
+        /// </summary>
+        private readonly IConfigService _configService;
+
+        /// <summary>
+        /// 标记实例是否已释放,防止重复释放和在释放后继续使用导致未定义行为。
+        /// </summary>
+        private bool _disposed;
+
+        /// <summary>
+        /// 使用指定的配置服务创建 <see cref="MesService"/> 实例。
+        /// </summary>
+        /// <param name="configService">用于获取设备配置信息的实现,不能为空。</param>
+        /// <exception cref="ArgumentNullException">当 <paramref name="configService"/> 为 null 时抛出。</exception>
+        public MesService(IConfigService configService)
+        {
+            _configService = configService ?? throw new ArgumentNullException(nameof(configService));
+            _httpClient = new HttpClient();
+            _disposed = false;
+        }
+
+
+        /// <summary>
+        /// 向配置中指定的 MES 站点 URL 发起 GET 请求(用于过站查询)。
+        /// </summary>
+        /// <param name="sn">要查询的序列号(SN)。</param>
+        /// <param name="cancellationToken">可选的取消令牌,用于请求超时或取消。</param>
+        /// <returns>
+        /// 返回元组 (IsSuccess, Response):
+        /// - IsSuccess: 请求是否视为成功(即 MES 返回了预期的 "SFC_OK" 且包含 "unit_process_check=OK")。
+        /// - Response: 原始响应文本或错误信息说明(如 "MES功能未启用!"、"Missing MES station URL"、"Canceled" 等)。
+        /// </returns>
+        /// <remarks>
+        /// 行为说明:
+        /// - 从配置获取设备信息,若 MES 未启用则直接返回 IsSuccess=true 且 Response 为提示文本(不视为错误)。
+        /// - 若未配置 MES URL 则返回 IsSuccess=false 与错误文本。
+        /// - 使用配置的 URL(通过 <see cref="string.Format"/> 填充 sn)发起 GET 请求,最多重试 10 次。
+        /// - 只有当响应包含 "SFC_OK" 且包含 "unit_process_check=OK" 时视为成功并立即返回。
+        /// - 捕获 <see cref="OperationCanceledException"/> 返回 "Canceled",捕获其它异常则返回异常消息。
+        /// - 若实例已被释放则抛出 <see cref="ObjectDisposedException"/>。
+        /// </remarks>
+        public async Task<(bool IsSuccess, string Response)> StationGetAsync(string sn, CancellationToken cancellationToken = default)
+        {
+            // 如果已经释放,则抛出异常,防止在已释放资源上继续操作。
+            if (_disposed) throw new ObjectDisposedException(nameof(MesService));
+
+            var device = _configService.GetDeviceInfo();
+            if (device == null || !device.EnableMES)
+            {
+                return (true, "MES功能未启用!");
+            }
+            if (string.IsNullOrWhiteSpace(device.MesStationUrl))
+                return (false, "Missing MES station URL");
+
+            var url = string.Format(device.MesStationUrl, sn);
+
+            (bool IsSuccess, string Response) result = (false, string.Empty);
+            for (int i = 0; i < 5; i++)
+            {
+                try
+                {
+                    using (var rsp = await _httpClient.GetAsync(url, cancellationToken))
+                    {
+                        var text = await rsp.Content.ReadAsStringAsync();
+                        // "SFC_OK" 代表和 MES 连接成功
+                        if (text.Contains("SFC_OK"))
+                        {
+                            if (text.Contains("unit_process_check=OK"))
+                            {
+                                return (true, text);
+                            }
+                        }
+                        result = (false, text);
+                    }
+                }
+                catch (OperationCanceledException)
+                {
+                    result = (false, "Canceled");
+                }
+                catch (Exception ex)
+                {
+                    result = (false, ex.Message);
+                }
+            }
+            return result;
+        }
+
+        /// <summary>
+        /// 向 MES 发起站点查询请求,使用超时(秒)作为便捷重载。
+        /// </summary>
+        /// <param name="sn">序列号(SN)。</param>
+        /// <param name="timeoutInSeconds">请求超时,单位为秒。</param>
+        /// <returns>与 <see cref="StationGetAsync(string, CancellationToken)"/> 相同的返回语义。</returns>
+        public async Task<(bool IsSuccess, string Response)> StationGetAsync(string sn, double timeoutInSeconds)
+        {
+            using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutInSeconds)))
+            {
+                return await StationGetAsync(sn, cts.Token);
+            }
+        }
+
+        /// <summary>
+        /// 向配置中指定的 MES 提交 URL 发起 GET 请求(用于提交测试结果/记录)。
+        /// </summary>
+        /// <param name="sn">要提交的序列号(SN)。</param>
+        /// <param name="isSuccess">表示此次记录是否通过(true => PASS,false => FAIL)。</param>
+        /// <param name="start_time">开始时间,提交时使用 "yyyy-MM-dd HH:mm:ss" 格式。</param>
+        /// <param name="stop_time">结束时间,提交时使用 "yyyy-MM-dd HH:mm:ss" 格式。</param>
+        /// <param name="cancellationToken">可选的取消令牌,用于请求超时或取消。</param>
+        /// <returns>
+        /// 返回元组 (IsSuccess, Response):
+        /// - IsSuccess: 提交是否被 MES 视为成功(收到 "SFC_OK" 且响应中无额外多行信息)。
+        /// - Response: MES 返回文本或错误说明;当 MES 返回 "SFC_OK" 但包含额外多行信息(代表 SN 数据问题)时,IsSuccess 为 false 且 Response 为该文本。
+        /// </returns>
+        /// <remarks>
+        /// 行为说明:
+        /// - 从配置获取设备信息,若 MES 未启用则直接返回 IsSuccess=true 且 Response 为提示文本(不视为错误)。
+        /// - 若未配置 MES 提交 URL 则返回 IsSuccess=false 与错误文本。
+        /// - 使用配置的 URL(通过 <see cref="string.Format"/> 按顺序填充 sn、PASS/FAIL、start_time、stop_time)发起 GET 请求,最多重试 10 次。
+        /// - 在收到包含 "SFC_OK" 的响应时,如果返回文本包含多行(有附加信息)则视为提交失败并返回该文本,否则视为提交成功。
+        /// - 捕获 <see cref="OperationCanceledException"/> 返回 "Canceled",捕获其它异常则返回异常消息。
+        /// - 若实例已被释放则抛出 <see cref="ObjectDisposedException"/>。
+        /// </remarks>
+        public async Task<(bool IsSuccess, string Response)> SubmitGetAsync(string sn, bool isSuccess, DateTime start_time, DateTime stop_time, CancellationToken cancellationToken = default)
+        {
+            // 如果已经释放,则抛出异常,防止在已释放资源上继续操作。
+            if (_disposed) throw new ObjectDisposedException(nameof(MesService));
+
+            var device = _configService.GetDeviceInfo();
+            if (device == null || !device.EnableMES)
+            {
+                return (true, "MES功能未启用!");
+            }
+            if (string.IsNullOrWhiteSpace(device.MesStationUrl))
+                return (false, "Missing MES submit URL");
+
+            var url = string.Format(device.MesStationUrl, sn, isSuccess ? "PASS" : "FAIL", start_time.ToString("yyyy-MM-dd HH:mm:ss"), stop_time.ToString("yyyy-MM-dd HH:mm:ss"));
+
+            (bool IsSuccess, string Response) result = (false, string.Empty);
+            for (int i = 0; i < 5; i++)
+            {
+                try
+                {
+                    using (var rsp = await _httpClient.GetAsync(url, cancellationToken))
+                    {
+                        var text = await rsp.Content.ReadAsStringAsync();
+                        // "SFC_OK" 代表和 MES 连接成功
+                        if (text.Contains("SFC_OK"))
+                        {
+                            // 如果有附加信息(多行),代表此 SN 数据有问题,视为失败并返回该文本
+                            if (text.Split(new[] { '\n' }, StringSplitOptions.RemoveEmptyEntries).Length > 1)
+                            {
+                                return (false, text);
+                            }
+                            return (true, text);
+                        }
+                        result = (false, text);
+                    }
+                }
+                catch (OperationCanceledException)
+                {
+                    result = (false, "Canceled");
+                }
+                catch (Exception ex)
+                {
+                    result = (false, ex.Message);
+                }
+            }
+            return result;
+        }
+
+        /// <summary>
+        /// 向 MES 提交请求的超时重载,使用超时(秒)作为便捷参数。
+        /// </summary>
+        /// <param name="sn">序列号(SN)。</param>
+        /// <param name="isSuccess">是否通过(PASS/FAIL)。</param>
+        /// <param name="start_time">开始时间。</param>
+        /// <param name="stop_time">结束时间。</param>
+        /// <param name="timeoutInSeconds">请求超时,单位为秒。</param>
+        /// <returns>与 <see cref="SubmitGetAsync(string, bool, DateTime, DateTime, CancellationToken)"/> 相同的返回语义。</returns>
+        public async Task<(bool IsSuccess, string Response)> SubmitGetAsync(string sn, bool isSuccess, DateTime start_time, DateTime stop_time, double timeoutInSeconds)
+        {
+            using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutInSeconds)))
+            {
+                return await SubmitGetAsync(sn, isSuccess, start_time, stop_time, cts.Token);
+            }
+        }
+
+        /// <summary>
+        /// 释放服务占用的托管资源(当前仅 <see cref="_httpClient"/>)。
+        /// <para>本方法为幂等操作,重复调用不会产生异常。调用后实例不可再用于发起请求,尝试使用将抛出 <see cref="ObjectDisposedException"/>。</para>
+        /// </summary>
+        public void Dispose()
+        {
+            if (!_disposed)
+            {
+                _httpClient.Dispose();
+                _disposed = true;
+            }
+        }
+    }
+}

+ 3 - 0
TeamAAS-VM/TeamAAS-VP.csproj

@@ -535,8 +535,11 @@
     <Compile Include="Core\RectangleCenterCalculator.cs" />
     <Compile Include="Core\StabilityAnalyzer.cs" />
     <Compile Include="Events\MainTabSwitchNotification.cs" />
+    <Compile Include="Interfaces\IMesService.cs" />
+    <Compile Include="Models\DeviceInfo.cs" />
     <Compile Include="Models\Feeder\ScrewFeederInfo.cs" />
     <Compile Include="Models\ScrewFeederBatchRecord.cs" />
+    <Compile Include="Services\MesService.cs" />
     <Compile Include="ViewModels\Calibration\CalibrationDistortionViewModel.cs" />
     <Compile Include="ViewModels\Calibration\CalibIndependentCameraViewModel.cs" />
     <Compile Include="ViewModels\DebugMod\PlcRobotManualStepViewModel.cs" />

+ 24 - 2
TeamAAS-VM/ViewModels/Product/VisionDynamicAccuracyAnalyzerViewModel.cs

@@ -38,6 +38,7 @@ namespace TeamAAS_VP.ViewModels.Product
         ICameraService _cameraService;
         ISystemDatabaseService _systemDatabaseService;
         ICalibrationService _calibrationService;
+        IConfigService _configService;
 
         //测试过程中控制取消的CTS
         CancellationTokenSource _cts;
@@ -304,7 +305,8 @@ namespace TeamAAS_VP.ViewModels.Product
         #endregion
 
         public VisionDynamicAccuracyAnalyzerViewModel(IRegionManager regionManager, IEventAggregator ea, IContainerProvider container, IDialogService dialogService,
-            IFeederService feederService, IRobotService robotService, ICameraService cameraService, ISystemDatabaseService systemDatabaseService, ICalibrationService calibrationService)
+            IFeederService feederService, IRobotService robotService, ICameraService cameraService, ISystemDatabaseService systemDatabaseService, ICalibrationService calibrationService,
+            IConfigService configService)
         {
             _regionManager = regionManager;
             _eventAggregator = ea;
@@ -316,6 +318,7 @@ namespace TeamAAS_VP.ViewModels.Product
             _systemDatabaseService = systemDatabaseService;
             MessageQueue = new SnackbarMessageQueue(TimeSpan.FromSeconds(1));
             _calibrationService = calibrationService;
+            _configService = configService;
         }
 
         #region 方法
@@ -651,6 +654,15 @@ namespace TeamAAS_VP.ViewModels.Product
                         metaTable.AddCell(cellVal);
                     }
 
+                    // include device info (line, station, fixture id) but exclude MES flags/urls
+                    var device = _configService?.GetDeviceInfo();
+                    if (device != null)
+                    {
+                        AddMeta("线别", device.Line ?? string.Empty);
+                        AddMeta("站别", device.Station ?? string.Empty);
+                        AddMeta("机台号", device.FixtureId ?? string.Empty);
+                    }
+
                     AddMeta("导出时间", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
                     AddMeta("测试次数(目标)", RepeatCount.ToString(System.Globalization.CultureInfo.InvariantCulture));
                     AddMeta("当前进度", $"{CurrentProgress} / {RepeatCount}");
@@ -817,7 +829,7 @@ namespace TeamAAS_VP.ViewModels.Product
                 string path = dlg.FileName;
                 var sb = new System.Text.StringBuilder();
 
-                // CSV 字段转义
+                // 辅助:CSV 字段转义
                 Func<string, string> EscapeCsv = (s) =>
                 {
                     if (s == null) return "";
@@ -825,6 +837,16 @@ namespace TeamAAS_VP.ViewModels.Product
                     string esc = s.Replace("\"", "\"\"");
                     return mustQuote ? $"\"{esc}\"" : esc;
                 };
+                // include device info at top of CSV
+                var device = _configService?.GetDeviceInfo() ?? _configService?.GetDeviceInfo();
+                if (device != null)
+                {
+                    sb.AppendLine("设备信息");
+                    sb.AppendLine($"线别,{EscapeCsv(device.Line)}");
+                    sb.AppendLine($"站别,{EscapeCsv(device.Station)}");
+                    sb.AppendLine($"机台号,{EscapeCsv(device.FixtureId)}");
+                    sb.AppendLine();
+                }
 
                 // 写入表头
                 var headers = new[]

+ 26 - 3
TeamAAS-VM/ViewModels/Product/VisionStaticAccuracyAnalyzerViewModel.cs

@@ -42,6 +42,7 @@ namespace TeamAAS_VP.ViewModels.Product
         ICameraService _cameraService;
         ISystemDatabaseService _systemDatabaseService;
         ICalibrationService _calibrationService;
+        IConfigService _configService;
 
         //测试过程中控制取消的CTS
         CancellationTokenSource _cts;
@@ -252,7 +253,8 @@ namespace TeamAAS_VP.ViewModels.Product
         #endregion
 
         public VisionStaticAccuracyAnalyzerViewModel(IRegionManager regionManager, IEventAggregator ea, IContainerProvider container, IDialogService dialogService,
-            IFeederService feederService, IRobotService robotService, ICameraService cameraService, ISystemDatabaseService systemDatabaseService, ICalibrationService calibrationService)
+            IFeederService feederService, IRobotService robotService, ICameraService cameraService, ISystemDatabaseService systemDatabaseService, ICalibrationService calibrationService,
+            IConfigService configService)
         {
             _regionManager = regionManager;
             _eventAggregator = ea;
@@ -264,6 +266,7 @@ namespace TeamAAS_VP.ViewModels.Product
             _systemDatabaseService = systemDatabaseService;
             MessageQueue = new SnackbarMessageQueue(TimeSpan.FromSeconds(1));
             _calibrationService = calibrationService;
+            _configService = configService;
         }
 
         #region 方法
@@ -474,7 +477,7 @@ namespace TeamAAS_VP.ViewModels.Product
                         titleFont = iTextSharp.text.FontFactory.GetFont(iTextSharp.text.FontFactory.HELVETICA, 16, iTextSharp.text.Font.BOLD, iTextSharp.text.BaseColor.BLACK);
                         headerFont = iTextSharp.text.FontFactory.GetFont(iTextSharp.text.FontFactory.HELVETICA, 10, iTextSharp.text.Font.BOLD, iTextSharp.text.BaseColor.BLACK);
                         normalFont = iTextSharp.text.FontFactory.GetFont(iTextSharp.text.FontFactory.HELVETICA, 10, iTextSharp.text.Font.NORMAL, iTextSharp.text.BaseColor.BLACK);
-                        
+
                     }
 
 
@@ -498,6 +501,15 @@ namespace TeamAAS_VP.ViewModels.Product
                         metaTable.AddCell(cellVal);
                     }
 
+                    // include device info (line, station, fixture id) but exclude MES flags/urls
+                    var device = _configService?.GetDeviceInfo() ?? _configService?.GetDeviceInfo();
+                    if (device != null)
+                    {
+                        AddMeta("线别", device.Line ?? string.Empty);
+                        AddMeta("站别", device.Station ?? string.Empty);
+                        AddMeta("机台号", device.FixtureId ?? string.Empty);
+                    }
+
                     AddMeta("导出时间", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
                     AddMeta("测试次数(目标)", RepeatCount.ToString());
                     AddMeta("当前进度", $"{CurrentProgress} / {RepeatCount}");
@@ -666,6 +678,17 @@ namespace TeamAAS_VP.ViewModels.Product
                     return mustQuote ? $"\"{esc}\"" : esc;
                 };
 
+                // include device info at top of CSV
+                var device = _configService?.GetDeviceInfo();
+                if (device != null)
+                {
+                    sb.AppendLine("设备信息");
+                    sb.AppendLine($"线别,{EscapeCsv(device.Line)}");
+                    sb.AppendLine($"站别,{EscapeCsv(device.Station)}");
+                    sb.AppendLine($"机台号,{EscapeCsv(device.FixtureId)}");
+                    sb.AppendLine();
+                }
+
                 // 1. 写入表头
                 for (int c = 0; c < TestResult.Columns.Count; c++)
                 {
@@ -845,7 +868,7 @@ namespace TeamAAS_VP.ViewModels.Product
                     if (image == null)
                     {
 
-                        SendTaskMessage("图像采集失败");
+                        SendTaskMessage(Lang.图像采集失败);
                         outputCollection = null;
                         return (false, result.ToArray());
                     }

+ 35 - 1
TeamAAS-VM/ViewModels/SettingViewModel.cs

@@ -326,6 +326,12 @@ namespace TeamAAS_VP.ViewModels
             set { SetProperty(ref _SelectLightController, value); }
         }
 
+        private DeviceInfo _DeviceInfo;
+        public DeviceInfo DeviceInfo
+        {
+            get { return _DeviceInfo; }
+            set { SetProperty(ref _DeviceInfo, value); }
+        }
         #endregion
 
         #region 命令
@@ -414,7 +420,8 @@ namespace TeamAAS_VP.ViewModels
         public DelegateCommand SaveScrewFeedersCommand =>
             _SaveScrewFeedersCommand ?? (_SaveScrewFeedersCommand = new DelegateCommand(ExecuteSaveScrewFeedersCommand));
 
-       
+        private DelegateCommand _SaveDeviceInfoCommand;
+        public DelegateCommand SaveDeviceInfoCommand => _SaveDeviceInfoCommand ?? (_SaveDeviceInfoCommand = new DelegateCommand(ExecuteSaveDeviceInfoCommand));
         #endregion
 
         #region 事件
@@ -1305,6 +1312,12 @@ namespace TeamAAS_VP.ViewModels
                 BgModbus = _configService.GetBgModbusTcp();
             }
 
+            if (DeviceInfo == null)
+            {
+                // load device info
+                DeviceInfo = _configService.GetDeviceInfo();
+            }
+
             CurrentLanguage = _configService.GetSystemConfiguration().CurrentLanguage;
 
             // load system parameters into UI
@@ -1517,5 +1530,26 @@ namespace TeamAAS_VP.ViewModels
             }
         }
         #endregion
+
+        #region 设备信息
+        /// <summary>
+        /// 保存设备信息
+        /// </summary>
+        void ExecuteSaveDeviceInfoCommand()
+        {
+            try
+            {
+                if (DeviceInfo != null)
+                {
+                    _configService.SaveDeviceInfo(DeviceInfo);
+                    _eventAggregator.GetEvent<SnackbarMessageNotification>().Publish(new MessageParameter() { Msg = Lang.完成, Duration = 1 });
+                }
+            }
+            catch (Exception ex)
+            {
+                _eventAggregator.GetEvent<SnackbarMessageNotification>().Publish(new MessageParameter() { Msg = ex.Message, Duration = 1 });
+            }
+        }
+        #endregion
     }
 }

+ 101 - 0
TeamAAS-VM/Views/SettingView.xaml

@@ -213,6 +213,107 @@
                 </Grid>
             </TabItem>
 
+            <!--设备信息-->
+            <TabItem>
+                <TabItem.Header>
+                    <StackPanel HorizontalAlignment="Stretch">
+                        <materialDesign:PackIcon Width="24"
+                                                 Height="24"
+                                                 HorizontalAlignment="Center"
+                                                 Kind="RouterNetwork" />
+                        <TextBlock Style="{StaticResource TabHeaderTextBlockStyle}"
+                                   FontSize="{DynamicResource Font.Size.Body2}"
+                                   Text="设备信息"
+                                   HorizontalAlignment="Stretch" />
+                    </StackPanel>
+                </TabItem.Header>
+
+                <Grid Margin="16">
+                    <Grid.RowDefinitions>
+                        <RowDefinition Height="Auto" />
+                        <RowDefinition Height="Auto" />
+                        <RowDefinition Height="Auto" />
+                        <RowDefinition Height="Auto" />
+                        <RowDefinition Height="Auto" />
+                        <RowDefinition Height="Auto" />
+                        <RowDefinition Height="Auto" />
+                    </Grid.RowDefinitions>
+                    <Grid.ColumnDefinitions>
+                        <ColumnDefinition Width="auto" />
+                        <ColumnDefinition Width="*" />
+                    </Grid.ColumnDefinitions>
+
+                    <TextBlock Grid.Row="0"
+                               Grid.Column="0"
+                               Text="线别:"
+                               HorizontalAlignment="Right"
+                               VerticalAlignment="Center" />
+                    <TextBox Grid.Row="0"
+                             Grid.Column="1"
+                             Text="{Binding DeviceInfo.Line, Mode=TwoWay}"
+                             Margin="6" />
+
+                    <TextBlock Grid.Row="1"
+                               Grid.Column="0"
+                               Text="站别:"
+                               HorizontalAlignment="Right"
+                               VerticalAlignment="Center" />
+                    <TextBox Grid.Row="1"
+                             Grid.Column="1"
+                             Text="{Binding DeviceInfo.Station, Mode=TwoWay}"
+                             Margin="6" />
+
+                    <TextBlock Grid.Row="2"
+                               Grid.Column="0"
+                               Text="机台号:"
+                               HorizontalAlignment="Right"
+                               VerticalAlignment="Center" />
+                    <TextBox Grid.Row="2"
+                             Grid.Column="1"
+                             Text="{Binding DeviceInfo.FixtureId, Mode=TwoWay}"
+                             Margin="6" />
+
+                    <TextBlock Grid.Row="3"
+                               Grid.Column="0"
+                               Text="启用 MES:"
+                               HorizontalAlignment="Right"
+                               VerticalAlignment="Center" />
+                    <CheckBox Grid.Row="3"
+                              Grid.Column="1"
+                              IsChecked="{Binding DeviceInfo.EnableMES, Mode=TwoWay}"
+                              Margin="6" />
+
+                    <TextBlock Grid.Row="4"
+                               Grid.Column="0"
+                               Text="MES 过站 URL:"
+                               HorizontalAlignment="Right"
+                               VerticalAlignment="Center" />
+                    <TextBox Grid.Row="4"
+                             Grid.Column="1"
+                             Text="{Binding DeviceInfo.MesStationUrl, Mode=TwoWay}"
+                             Margin="6" />
+
+                    <TextBlock Grid.Row="5"
+                               Grid.Column="0"
+                               Text="MES 提交 URL:"
+                               HorizontalAlignment="Right"
+                               VerticalAlignment="Center" />
+                    <TextBox Grid.Row="5"
+                             Grid.Column="1"
+                             Text="{Binding DeviceInfo.MesSubmitUrl, Mode=TwoWay}"
+                             Margin="6" />
+
+                    <StackPanel Orientation="Horizontal"
+                                HorizontalAlignment="Right"
+                                Grid.Row="6"
+                                Grid.ColumnSpan="2"
+                                Margin="6">
+                        <Button Style="{StaticResource MaterialDesignRaisedButton}"
+                                Command="{Binding SaveDeviceInfoCommand}">保存</Button>
+                    </StackPanel>
+                </Grid>
+            </TabItem>
+
             <!--机器人-->
             <TabItem>
                 <TabItem.Header>