diff --git a/src/AxiOmron.PcbCheck/Assets/AppIcon.ico b/src/AxiOmron.PcbCheck/Assets/AppIcon.ico new file mode 100644 index 0000000..1d88bbf Binary files /dev/null and b/src/AxiOmron.PcbCheck/Assets/AppIcon.ico differ diff --git a/src/AxiOmron.PcbCheck/Assets/AppIcon.png b/src/AxiOmron.PcbCheck/Assets/AppIcon.png new file mode 100644 index 0000000..cf2649b Binary files /dev/null and b/src/AxiOmron.PcbCheck/Assets/AppIcon.png differ diff --git a/src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj b/src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj index 496e887..acccc63 100644 --- a/src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj +++ b/src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj @@ -5,6 +5,8 @@ enable enable true + Assets\AppIcon.ico + $(InterceptorsNamespaces);Microsoft.Extensions.Configuration.Binder.SourceGeneration @@ -18,6 +20,10 @@ + + + + Always diff --git a/src/AxiOmron.PcbCheck/Converters/ResultCodeToTagTextConverter.cs b/src/AxiOmron.PcbCheck/Converters/ResultCodeToTagTextConverter.cs index 30970a2..28cf4f1 100644 --- a/src/AxiOmron.PcbCheck/Converters/ResultCodeToTagTextConverter.cs +++ b/src/AxiOmron.PcbCheck/Converters/ResultCodeToTagTextConverter.cs @@ -5,7 +5,7 @@ using AxiOmron.PcbCheck.Models; namespace AxiOmron.PcbCheck.Converters; /// -/// 将 (以 存储)映射为简短 Tag 文本。 +/// 将 (以 存储)映射为简短 Tag 文本。 /// /// /// 映射规则:Passed → "OK";Processing → "处理中";None → "-";其余视为 "NG"。 @@ -20,21 +20,21 @@ public sealed class ResultCodeToTagTextConverter : IValueConverter /// 扩展参数。 /// 当前区域信息。 /// 简短标签文本。 - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { - ushort code = value switch + short code = value switch { - ushort u => u, - int i => (ushort)i, - WorkflowResultCode rc => (ushort)rc, - _ => (ushort)0 + short s => s, + int i => (short)i, + WorkflowResultCode rc => (short)rc, + _ => 0 }; return code switch { - (ushort)WorkflowResultCode.Passed => "OK", - (ushort)WorkflowResultCode.Processing => "处理中", - (ushort)WorkflowResultCode.None => "-", + (short)WorkflowResultCode.Passed => "OK", + (short)WorkflowResultCode.Processing => "处理中", + (short)WorkflowResultCode.None => "-", _ => "NG" }; } @@ -47,7 +47,7 @@ public sealed class ResultCodeToTagTextConverter : IValueConverter /// 扩展参数。 /// 当前区域信息。 /// 抛出不支持异常。 - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) { throw new NotSupportedException(); } diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs index 063cb9d..fac8ace 100644 --- a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs +++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppStateStore.cs @@ -32,7 +32,7 @@ public sealed class DesignTimeAppStateStore : IAppStateStore CurrentBarcode = "PCB240417000128", ResultDescription = "已扫码,等待 SFTP 文件确认", FaultMessage = string.Empty, - ResultCode = (ushort)WorkflowResultCode.Processing, + ResultCode = (short)WorkflowResultCode.Processing, LastTriggeredAt = now.AddSeconds(-18), LastCompletedAt = now.AddMinutes(-2), IsBusy = true, @@ -44,7 +44,7 @@ public sealed class DesignTimeAppStateStore : IAppStateStore _snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Inputs", Name = "PlcAckRelease", Address = "10003", CurrentValue = "False", LastUpdatedAt = now.AddSeconds(-1) }); _snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Outputs", Name = "PcBusy", Address = "51", CurrentValue = "True", LastUpdatedAt = now.AddSeconds(-1) }); _snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Outputs", Name = "ReleasePermit", Address = "52", CurrentValue = "False", LastUpdatedAt = now.AddSeconds(-1) }); - _snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Registers", Name = "ResultCode", Address = "40001", CurrentValue = ((ushort)WorkflowResultCode.Processing).ToString(), LastUpdatedAt = now.AddSeconds(-1) }); + _snapshot.PlcMonitorItems.Add(new PlcMonitorItem { GroupName = "Registers", Name = "ResultCode", Address = "40001", CurrentValue = ((short)WorkflowResultCode.Processing).ToString(), LastUpdatedAt = now.AddSeconds(-1) }); _logs = new List { @@ -64,7 +64,7 @@ public sealed class DesignTimeAppStateStore : IAppStateStore Barcode = "PCB240417000125", ScanTryCount = 1, SftpTryCount = 1, - ResultCode = (ushort)WorkflowResultCode.Passed, + ResultCode = (short)WorkflowResultCode.Passed, ResultDescription = "OK 放行", ReleaseSent = true, ExceptionSummary = string.Empty @@ -76,7 +76,7 @@ public sealed class DesignTimeAppStateStore : IAppStateStore Barcode = "PCB240417000126", ScanTryCount = 3, SftpTryCount = 0, - ResultCode = (ushort)WorkflowResultCode.NgReleased, + ResultCode = (short)WorkflowResultCode.NgReleased, ResultDescription = "扫码失败后放行", ReleaseSent = true, ExceptionSummary = "扫码连续失败三次" @@ -88,7 +88,7 @@ public sealed class DesignTimeAppStateStore : IAppStateStore Barcode = "PCB240417000127", ScanTryCount = 1, SftpTryCount = 3, - ResultCode = (ushort)WorkflowResultCode.NgReleased, + ResultCode = (short)WorkflowResultCode.NgReleased, ResultDescription = "处理结果 NG,随后放行", ReleaseSent = true, ExceptionSummary = "SFTP 文件查询超时" @@ -97,8 +97,8 @@ public sealed class DesignTimeAppStateStore : IAppStateStore _snapshot.RecordStatisticDate = DateOnly.FromDateTime(now.LocalDateTime.Date); _snapshot.TodayProcessCount = _records.Count; - _snapshot.TodayOkCount = _records.Count(record => record.ResultCode == (ushort)WorkflowResultCode.Passed); - _snapshot.TodayNgCount = _records.Count(record => record.ResultCode != (ushort)WorkflowResultCode.Passed); + _snapshot.TodayOkCount = _records.Count(record => record.ResultCode == (short)WorkflowResultCode.Passed); + _snapshot.TodayNgCount = _records.Count(record => record.ResultCode != (short)WorkflowResultCode.Passed); _snapshot.RecordsLastUpdatedAt = now; foreach (BoardProcessRecord record in _records) { diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimePlcService.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimePlcService.cs new file mode 100644 index 0000000..0635212 --- /dev/null +++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimePlcService.cs @@ -0,0 +1,64 @@ +using AxiOmron.PcbCheck.Models; +using AxiOmron.PcbCheck.Services.Interfaces; + +namespace AxiOmron.PcbCheck.DesignTime; + +/// +/// 为设计器提供 PLC 通信能力的设计时占位服务,所有方法均为无副作用实现。 +/// +public sealed class DesignTimePlcService : IPlcService +{ + /// + /// 返回设计时输入信号快照。 + /// + /// 取消令牌。 + /// 空的输入信号快照。 + public Task ReadSignalsAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new PlcSignalSnapshot()); + } + + /// + /// 返回设计时监控镜像快照。 + /// + /// 取消令牌。 + /// 空的监控镜像快照。 + public Task ReadMonitorSnapshotAsync(CancellationToken cancellationToken) + { + return Task.FromResult(new PlcMonitorSnapshot()); + } + + /// + /// 模拟写入流程状态,不执行实际设备通信。 + /// + /// 待写入状态。 + /// 取消令牌。 + /// 已完成任务。 + public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + /// 模拟 PLC 重连,不执行实际设备通信。 + /// + /// 取消令牌。 + /// 已完成任务。 + public Task ForceReconnectAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + /// 返回设计时测试写入成功结果,不执行实际设备通信。 + /// + /// 目标地址。 + /// 变量类型。 + /// 写入值文本。 + /// 取消令牌。 + /// 固定成功结果。 + public Task WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken) + { + return Task.FromResult(PlcWriteResult.Success(address, type, value)); + } +} diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs index c8b56c4..27b896a 100644 --- a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs +++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs @@ -15,6 +15,7 @@ public sealed class DesignTimeViewModelLocator private readonly DesignTimeAppConfigService _appConfigService = new(); private readonly DesignTimeAdminUnlockDialogService _adminUnlockDialogService = new(); private readonly DesignTimeSftpLookupService _sftpLookupService = new(); + private readonly DesignTimePlcService _plcService = new(); private MainWindowViewModel? _mainWindowViewModel; private SystemSettingViewModel? _systemSettingViewModel; @@ -28,7 +29,7 @@ public sealed class DesignTimeViewModelLocator /// 获取系统设置设计时视图模型。 /// public SystemSettingViewModel SystemSettingViewModel - => _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService, _appStateStore, _dispatcherService); + => _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService, _appStateStore, _dispatcherService, _plcService); /// /// 创建首页设计时视图模型。 diff --git a/src/AxiOmron.PcbCheck/MainWindow.xaml b/src/AxiOmron.PcbCheck/MainWindow.xaml index 14538d0..d4d9ae3 100644 --- a/src/AxiOmron.PcbCheck/MainWindow.xaml +++ b/src/AxiOmron.PcbCheck/MainWindow.xaml @@ -10,6 +10,7 @@ Height="950" MinWidth="1400" MinHeight="860" + Icon="Assets/AppIcon.ico" Background="{DynamicResource {x:Static hc:ResourceToken.BackgroundBrush}}" WindowStartupLocation="CenterScreen"> diff --git a/src/AxiOmron.PcbCheck/Models/PlcWriteModels.cs b/src/AxiOmron.PcbCheck/Models/PlcWriteModels.cs new file mode 100644 index 0000000..566a2e9 --- /dev/null +++ b/src/AxiOmron.PcbCheck/Models/PlcWriteModels.cs @@ -0,0 +1,105 @@ +namespace AxiOmron.PcbCheck.Models; + +/// +/// 表示 PLC 测试写入支持的变量类型。 +/// +public enum PlcWriteType +{ + /// + /// 布尔位。 + /// + Bool = 0, + + /// + /// 有符号 16 位整数。 + /// + Int16 = 1, + + /// + /// 无符号 16 位整数。 + /// + UInt16 = 2, + + /// + /// 有符号 32 位整数。 + /// + Int32 = 3, + + /// + /// 无符号 32 位整数。 + /// + UInt32 = 4, + + /// + /// 单精度浮点数。 + /// + Float = 5 +} + +/// +/// 表示一次 PLC 测试写入的执行结果。 +/// +public sealed record PlcWriteResult +{ + /// + /// 获取一个值,指示写入是否成功。 + /// + public bool IsSuccess { get; init; } + + /// + /// 获取面向用户的结果说明。 + /// + public string Message { get; init; } = string.Empty; + + /// + /// 获取本次写入的目标地址。 + /// + public string Address { get; init; } = string.Empty; + + /// + /// 获取本次写入的变量类型名称。 + /// + public string TypeName { get; init; } = string.Empty; + + /// + /// 获取本次实际写入的文本值。 + /// + public string WrittenText { get; init; } = string.Empty; + + /// + /// 创建一个表示写入成功的结果。 + /// + /// 目标地址。 + /// 变量类型。 + /// 实际写入的文本值。 + /// 成功结果对象。 + public static PlcWriteResult Success(string address, PlcWriteType type, string writtenText) + { + return new PlcWriteResult + { + IsSuccess = true, + Address = address, + TypeName = type.ToString(), + WrittenText = writtenText, + Message = $"写入成功:{address} = {writtenText} ({type})" + }; + } + + /// + /// 创建一个表示写入失败的结果。 + /// + /// 目标地址。 + /// 变量类型。 + /// 失败说明。 + /// 失败结果对象。 + public static PlcWriteResult Failure(string address, PlcWriteType type, string message) + { + return new PlcWriteResult + { + IsSuccess = false, + Address = address, + TypeName = type.ToString(), + Message = message + }; + } +} diff --git a/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs b/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs index 3b1c1f5..1911f6f 100644 --- a/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs +++ b/src/AxiOmron.PcbCheck/Models/RuntimeSnapshot.cs @@ -63,7 +63,7 @@ public sealed class RuntimeSnapshot /// /// 获取或设置结果代码。 /// - public ushort ResultCode { get; set; } + public short ResultCode { get; set; } /// /// 获取或设置上次触发时间。 diff --git a/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs b/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs index a4fb15a..06e9128 100644 --- a/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs +++ b/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs @@ -69,7 +69,7 @@ public enum WorkflowState /// /// 表示最终结果代码定义。 /// -public enum WorkflowResultCode : ushort +public enum WorkflowResultCode : short { /// /// 无结果。 @@ -182,7 +182,7 @@ public sealed class PlcProcessState /// /// 获取或设置结果代码寄存器值。 /// - public ushort ResultCode { get; set; } + public short ResultCode { get; set; } /// /// 创建当前状态对象的浅拷贝。 @@ -496,7 +496,7 @@ public sealed class BoardProcessRecord /// /// 获取或设置结果代码。 /// - public ushort ResultCode { get; set; } + public short ResultCode { get; set; } /// /// 获取或设置结果描述。 diff --git a/src/AxiOmron.PcbCheck/Options/AppConfig.cs b/src/AxiOmron.PcbCheck/Options/AppConfig.cs index c995fad..87713c7 100644 --- a/src/AxiOmron.PcbCheck/Options/AppConfig.cs +++ b/src/AxiOmron.PcbCheck/Options/AppConfig.cs @@ -129,6 +129,11 @@ public sealed class PlcOptions /// public byte SourceNodeAddress { get; set; } = 25; + /// + /// 获取或设置 PLC 寄存器字节序,默认使用 BADC 以匹配现场 D 区 Int16 高低字节顺序。 + /// + public string? EndianFormat { get; set; } = "BADC"; + /// /// 获取或设置轮询周期,单位为毫秒。 /// diff --git a/src/AxiOmron.PcbCheck/Properties/PublishProfiles/FolderProfile.pubxml b/src/AxiOmron.PcbCheck/Properties/PublishProfiles/FolderProfile.pubxml index 1e728be..6af5f01 100644 --- a/src/AxiOmron.PcbCheck/Properties/PublishProfiles/FolderProfile.pubxml +++ b/src/AxiOmron.PcbCheck/Properties/PublishProfiles/FolderProfile.pubxml @@ -1,15 +1,13 @@ - - - - Release - Any CPU - bin\Release\net8.0-windows\publish\ - FileSystem - net8.0-windows - win-x64 - false - true - false - false - - + + + + + Release + Any CPU + bin\Release\net8.0-windows\publish\ + FileSystem + <_TargetId>Folder + net8.0-windows + false + + \ No newline at end of file diff --git a/src/AxiOmron.PcbCheck/Properties/launchSettings.json b/src/AxiOmron.PcbCheck/Properties/launchSettings.json deleted file mode 100644 index 714055d..0000000 --- a/src/AxiOmron.PcbCheck/Properties/launchSettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "profiles": { - "AxiOmron.PcbCheck": { - "commandName": "Project", - "environmentVariables": { - "DOTNET_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/CsvBoardRecordRepository.cs b/src/AxiOmron.PcbCheck/Services/Implementations/CsvBoardRecordRepository.cs index 1f786de..ea680eb 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/CsvBoardRecordRepository.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/CsvBoardRecordRepository.cs @@ -247,10 +247,10 @@ public sealed class CsvBoardRecordRepository : IBoardRecordRepository { StatisticDate = date, TotalCount = records.Count, - OkCount = records.Count(record => record.ResultCode == (ushort)WorkflowResultCode.Passed), - NgCount = records.Count(record => record.ResultCode != (ushort)WorkflowResultCode.Passed - && record.ResultCode != (ushort)WorkflowResultCode.Processing - && record.ResultCode != (ushort)WorkflowResultCode.None), + OkCount = records.Count(record => record.ResultCode == (short)WorkflowResultCode.Passed), + NgCount = records.Count(record => record.ResultCode != (short)WorkflowResultCode.Passed + && record.ResultCode != (short)WorkflowResultCode.Processing + && record.ResultCode != (short)WorkflowResultCode.None), LastUpdatedAt = _timeProvider.GetLocalNow() }; @@ -400,7 +400,7 @@ public sealed class CsvBoardRecordRepository : IBoardRecordRepository || !DateTimeOffset.TryParse(fields[1], out DateTimeOffset completedAt) || !int.TryParse(fields[3], out int scanTryCount) || !int.TryParse(fields[4], out int sftpTryCount) - || !ushort.TryParse(fields[5], out ushort resultCode) + || !short.TryParse(fields[5], out short resultCode) || !bool.TryParse(fields[7], out bool releaseSent) || !bool.TryParse(fields[8], out bool alarmRaised)) { diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/FileCheckPathMatcher.cs b/src/AxiOmron.PcbCheck/Services/Implementations/FileCheckPathMatcher.cs index 98fb0ad..5194942 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/FileCheckPathMatcher.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/FileCheckPathMatcher.cs @@ -29,7 +29,6 @@ internal static class FileCheckPathMatcher public static bool IsMatch(string fileName, string candidateName, string barcode) { return fileName.Equals(candidateName, StringComparison.OrdinalIgnoreCase) - || WildcardMatcher.IsMatch(fileName, candidateName) - || fileName.Contains(barcode, StringComparison.OrdinalIgnoreCase); + || WildcardMatcher.IsMatch(fileName, candidateName); } } diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/OmronFinsPlcService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/OmronFinsPlcService.cs index f22c7fc..4e50ea2 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/OmronFinsPlcService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/OmronFinsPlcService.cs @@ -89,7 +89,7 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable { PcBusy = ReadBoolean(ToDataMemoryAddress(_options.Outputs.PcBusy)), ReleasePermit = ReadBoolean(ToDataMemoryAddress(_options.Outputs.ReleasePermit)), - ResultCode = ReadUInt16(ToDataMemoryAddress(_options.Registers.ResultCode)) + ResultCode = ReadInt16(ToDataMemoryAddress(_options.Registers.ResultCode)) }, CapturedAt = capturedAt }; @@ -155,6 +155,54 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable } } + /// + /// 向指定地址写入一个原始值,供调试界面手动测试使用。 + /// 复用与回读相同的 串行化,且不修改 ,因此不会干扰后台轮询回读与流程状态机写入。 + /// + /// 完整的 FINS 地址字符串,例如 D100 或 D100.0。 + /// 写入的变量类型。 + /// 待写入值的文本表示。 + /// 取消令牌。取消后立即终止本次写入并返回已取消任务。 + /// 包含成功状态与说明的写入结果。 + public async Task WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken) + { + string trimmedAddress = address.Trim(); + if (string.IsNullOrEmpty(trimmedAddress)) + { + return PlcWriteResult.Failure(trimmedAddress, type, "写入失败:地址不能为空。"); + } + + if (!TryParseWriteValue(type, value, out object parsedValue, out string parseError)) + { + return PlcWriteResult.Failure(trimmedAddress, type, $"写入失败:{parseError}"); + } + + await _ioLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + EnsureConnected(); + WriteRawValueUnsafe(trimmedAddress, parsedValue); + string writtenText = FormatWrittenText(parsedValue); + _logger.LogInformation("测试写入 PLC 成功,地址={Address},类型={Type},值={Value}", true, trimmedAddress, type, writtenText); + return PlcWriteResult.Success(trimmedAddress, type, writtenText); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // 仅记录并返回失败结果,不断开连接,避免干扰后台轮询回读循环。 + _logger.LogWarning(ex, "测试写入 PLC 失败,地址={Address},类型={Type}", true, trimmedAddress, type); + return PlcWriteResult.Failure(trimmedAddress, type, $"写入失败:{ex.Message}"); + } + finally + { + _ioLock.Release(); + } + } + /// /// 释放 PLC 通信资源。 /// @@ -181,7 +229,8 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable } DisconnectUnsafe(); - var client = new OmronFinsClient(_options.Host, _options.Port, _options.ConnectTimeoutMs, EndianFormat.ABCD) + EndianFormat endianFormat = ResolveEndianFormat(_options); + var client = new OmronFinsClient(_options.Host, _options.Port, _options.ConnectTimeoutMs, endianFormat) { UnitAddress = _options.UnitAddress, SA1 = _options.SourceNodeAddress @@ -189,7 +238,28 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable var openResult = client.Open(); EnsureSuccess(openResult.IsSucceed, openResult.Err, "连接 OMRON FINS PLC 失败"); _client = client; - _logger.LogInformation("已通过 IoTClient FINS 连接 PLC {Host}:{Port}", false, _options.Host, _options.Port); + _logger.LogInformation("已通过 IoTClient FINS 连接 PLC {Host}:{Port},字节序={EndianFormat}", false, _options.Host, _options.Port, endianFormat); + } + + /// + /// 解析 PLC 字节序配置。 + /// + /// PLC 通信配置。 + /// IoTClient 使用的字节序枚举。 + /// 当 为空时抛出。 + /// 当字节序配置为空或无法识别时抛出。 + internal static EndianFormat ResolveEndianFormat(PlcOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + string? configuredValue = options.EndianFormat?.Trim(); + if (string.IsNullOrWhiteSpace(configuredValue) + || !Enum.TryParse(configuredValue, ignoreCase: true, out EndianFormat endianFormat)) + { + throw new InvalidOperationException($"PLC 字节序配置无效:{options.EndianFormat}。支持值:ABCD、BADC、CDAB、DCBA。"); + } + + return endianFormat; } /// @@ -206,15 +276,15 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable } /// - /// 读取单个 D 区无符号整数。 + /// 读取单个 D 区有符号 16 位整数。 /// /// FINS 地址。 /// 读取到的寄存器值。 - private ushort ReadUInt16(string address) + private short ReadInt16(string address) { var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。"); - var result = client.ReadUInt16(address); - EnsureSuccess(result.IsSucceed, result.Err, $"读取 FINS UInt16 失败,地址={address}"); + var result = client.ReadInt16(address); + EnsureSuccess(result.IsSucceed, result.Err, $"读取 FINS Int16 失败,地址={address}"); return result.Value; } @@ -225,9 +295,10 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable private void WriteChangedState(PlcProcessState state) { var previous = _lastWrittenState; + short? previousResultCode = previous is null ? null : previous.ResultCode; WriteBooleanIfChanged(previous?.PcBusy, state.PcBusy, ToDataMemoryAddress(_options.Outputs.PcBusy)); WriteBooleanIfChanged(previous?.ReleasePermit, state.ReleasePermit, ToDataMemoryAddress(_options.Outputs.ReleasePermit)); - WriteUInt16IfChanged(previous?.ResultCode, state.ResultCode, ToDataMemoryAddress(_options.Registers.ResultCode)); + WriteInt16IfChanged(previousResultCode, state.ResultCode, ToDataMemoryAddress(_options.Registers.ResultCode)); } /// @@ -249,12 +320,12 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable } /// - /// 仅当值发生变化时写入单个 D 区无符号整数。 + /// 仅当值发生变化时写入单个 D 区有符号 16 位整数。 /// /// 上一值。 /// 当前值。 /// FINS 地址。 - private void WriteUInt16IfChanged(ushort? previous, ushort current, string address) + private void WriteInt16IfChanged(short? previous, short current, string address) { if (previous.HasValue && previous.Value == current) { @@ -263,7 +334,148 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。"); var result = client.Write(address, current); - EnsureSuccess(result.IsSucceed, result.Err, $"写入 FINS UInt16 失败,地址={address},值={current}"); + EnsureSuccess(result.IsSucceed, result.Err, $"写入 FINS Int16 失败,地址={address},值={current}"); + } + + /// + /// 按变量类型将文本值解析为强类型对象。 + /// + /// 变量类型。 + /// 待解析的文本值。 + /// 解析成功时返回的强类型值。 + /// 解析失败时返回的错误说明。 + /// 解析成功返回 ;否则返回 。 + private static bool TryParseWriteValue(PlcWriteType type, string value, out object parsedValue, out string error) + { + parsedValue = default!; + error = string.Empty; + string text = value.Trim(); + if (string.IsNullOrEmpty(text)) + { + error = "写入值不能为空。"; + return false; + } + + switch (type) + { + case PlcWriteType.Bool: + if (TryParseBoolean(text, out bool boolValue)) + { + parsedValue = boolValue; + return true; + } + + error = "布尔值仅支持 1/0、true/false。"; + return false; + case PlcWriteType.Int16: + if (short.TryParse(text, out short shortValue)) + { + parsedValue = shortValue; + return true; + } + + error = $"无法将 '{text}' 解析为 Int16。"; + return false; + case PlcWriteType.UInt16: + if (ushort.TryParse(text, out ushort ushortValue)) + { + parsedValue = ushortValue; + return true; + } + + error = $"无法将 '{text}' 解析为 UInt16。"; + return false; + case PlcWriteType.Int32: + if (int.TryParse(text, out int intValue)) + { + parsedValue = intValue; + return true; + } + + error = $"无法将 '{text}' 解析为 Int32。"; + return false; + case PlcWriteType.UInt32: + if (uint.TryParse(text, out uint uintValue)) + { + parsedValue = uintValue; + return true; + } + + error = $"无法将 '{text}' 解析为 UInt32。"; + return false; + case PlcWriteType.Float: + if (float.TryParse(text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float floatValue)) + { + parsedValue = floatValue; + return true; + } + + error = $"无法将 '{text}' 解析为 Float。"; + return false; + default: + error = $"不支持的变量类型:{type}。"; + return false; + } + } + + /// + /// 解析布尔文本,接受 1/0 与 true/false(忽略大小写)。 + /// + /// 待解析文本。 + /// 解析结果。 + /// 解析成功返回 ;否则返回 。 + private static bool TryParseBoolean(string text, out bool result) + { + switch (text.ToLowerInvariant()) + { + case "1": + case "true": + result = true; + return true; + case "0": + case "false": + result = false; + return true; + default: + result = false; + return false; + } + } + + /// + /// 按强类型值调用 IoTClient 对应写入重载。 + /// + /// FINS 地址。 + /// 已解析的强类型值。 + private void WriteRawValueUnsafe(string address, object parsedValue) + { + var client = _client ?? throw new InvalidOperationException("PLC 客户端尚未初始化。"); + var result = parsedValue switch + { + bool boolValue => client.Write(address, boolValue), + short shortValue => client.Write(address, shortValue), + ushort ushortValue => client.Write(address, ushortValue), + int intValue => client.Write(address, intValue), + uint uintValue => client.Write(address, uintValue), + float floatValue => client.Write(address, floatValue), + _ => throw new InvalidOperationException($"不支持的写入值类型:{parsedValue.GetType().Name}。") + }; + + EnsureSuccess(result.IsSucceed, result.Err, $"写入 FINS 值失败,地址={address}"); + } + + /// + /// 将强类型值格式化为用于展示的文本。 + /// + /// 已解析的强类型值。 + /// 文本表示。 + private static string FormatWrittenText(object parsedValue) + { + return parsedValue switch + { + float floatValue => floatValue.ToString(System.Globalization.CultureInfo.InvariantCulture), + _ => parsedValue.ToString() ?? string.Empty + }; } /// diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs index f34f8a7..25c8d8f 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs @@ -848,7 +848,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS Barcode = _currentBarcode, ScanTryCount = _scanTryCount, SftpTryCount = _sftpTryCount, - ResultCode = (ushort)_resultCode, + ResultCode = (short)_resultCode, ResultDescription = GetResultDescription(_resultCode), ReleaseSent = _releaseSent, AlarmRaised = false, @@ -1082,7 +1082,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS lock (_stateSyncRoot) { updateAction(_plcState); - _plcState.ResultCode = (ushort)_resultCode; + _plcState.ResultCode = (short)_resultCode; snapshot = _plcState.Clone(); } @@ -1103,7 +1103,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS { DateTimeOffset? startedAt; string barcode; - ushort resultCode; + short resultCode; bool isBusy; string faultMessage; @@ -1111,7 +1111,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS { startedAt = _currentBoardStartedAt; barcode = _currentBarcode; - resultCode = (ushort)_resultCode; + resultCode = (short)_resultCode; isBusy = _plcState.PcBusy; faultMessage = _faultLatched ? description : string.Empty; } diff --git a/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs b/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs index e5a1bd2..a48befc 100644 --- a/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs +++ b/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs @@ -124,6 +124,17 @@ public interface IPlcService /// 取消令牌。 /// 表示重连完成的任务。 Task ForceReconnectAsync(CancellationToken cancellationToken); + + /// + /// 向指定地址写入一个原始值,供调试界面手动测试使用。 + /// 该写入复用与回读相同的 IO 串行化机制,且不会影响流程状态机的写入缓存,因此不会干扰后台轮询回读。 + /// + /// 完整的 FINS 地址字符串,例如 D100 或 D100.0。 + /// 写入的变量类型。 + /// 待写入值的文本表示,由实现按 解析。 + /// 取消令牌。取消后立即终止本次写入并返回已取消任务。 + /// 包含成功状态与说明的写入结果。 + Task WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken); } /// diff --git a/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs b/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs index 3f7d94b..1102cf1 100644 --- a/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs +++ b/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs @@ -150,7 +150,7 @@ public partial class MainWindowViewModel : ObservableObject /// 获取或设置结果代码。 /// [ObservableProperty] - private ushort _resultCode; + private short _resultCode; /// /// 获取或设置最近触发时间。 @@ -182,6 +182,12 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] private string _lastLogUpdateTime = "-"; + /// + /// 获取或设置运行日志区域是否展开;该状态仅影响首页布局,不参与业务流程。 + /// + [ObservableProperty] + private bool _isLogAreaExpanded; + /// /// 获取或设置今日异常日志条数。 /// @@ -367,6 +373,15 @@ public partial class MainWindowViewModel : ObservableObject LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss"); } + /// + /// 切换运行日志区域展开状态,收起或恢复上方状态概览区域。 + /// + [RelayCommand] + private void ToggleLogArea() + { + IsLogAreaExpanded = !IsLogAreaExpanded; + } + /// /// 打开追踪区命令占位。后续应由导航服务跳转至追踪页面。 /// diff --git a/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs b/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs index 332ec42..b7a3c32 100644 --- a/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs +++ b/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs @@ -4,7 +4,6 @@ using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Interfaces; using AxiOmron.PcbCheck.Models; using System.Collections.ObjectModel; -using System.Collections.Generic; namespace AxiOmron.PcbCheck.ViewModels; @@ -17,6 +16,7 @@ public partial class SystemSettingViewModel : ObservableObject private readonly IFileCheckService _fileCheckService; private readonly IAppStateStore _appStateStore; private readonly IDispatcherService _dispatcherService; + private readonly IPlcService _plcService; /// /// 初始化系统设置视图模型。 @@ -25,20 +25,24 @@ public partial class SystemSettingViewModel : ObservableObject /// 文件校验通道测试服务。 /// 运行态快照存储。 /// UI 线程调度服务。 + /// PLC 通信服务,用于调试界面的测试写入。 public SystemSettingViewModel( IAppConfigService appConfigService, IFileCheckService fileCheckService, IAppStateStore appStateStore, - IDispatcherService dispatcherService) + IDispatcherService dispatcherService, + IPlcService plcService) { _appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService)); _fileCheckService = fileCheckService ?? throw new ArgumentNullException(nameof(fileCheckService)); _appStateStore = appStateStore ?? throw new ArgumentNullException(nameof(appStateStore)); _dispatcherService = dispatcherService ?? throw new ArgumentNullException(nameof(dispatcherService)); + _plcService = plcService ?? throw new ArgumentNullException(nameof(plcService)); EditableConfig = _appConfigService.Load(); ConfigPath = _appConfigService.GetConfigPath(); PlcMonitorItems = new ObservableCollection(); FileCheckProviders = Enum.GetValues(); + PlcWriteTypes = Enum.GetValues(); ApplySnapshot(_appStateStore.GetSnapshot()); _appStateStore.SnapshotChanged += OnSnapshotChanged; StatusMessage = "已加载配置。"; @@ -54,6 +58,11 @@ public partial class SystemSettingViewModel : ObservableObject /// public IReadOnlyList FileCheckProviders { get; } + /// + /// 获取 PLC 测试写入支持的变量类型可选项。 + /// + public IReadOnlyList PlcWriteTypes { get; } + /// /// 获取或设置可编辑配置对象。 /// @@ -79,6 +88,37 @@ public partial class SystemSettingViewModel : ObservableObject [ObservableProperty] private bool _isTestingFileCheckConnection; + /// + /// 获取或设置测试写入的目标地址,例如 D100、D100.0。 + /// + [ObservableProperty] + private string _plcWriteAddress = string.Empty; + + /// + /// 获取或设置测试写入的变量类型。 + /// + [ObservableProperty] + private PlcWriteType _selectedPlcWriteType = PlcWriteType.Int16; + + /// + /// 获取或设置测试写入的值文本。 + /// + [ObservableProperty] + private string _plcWriteValue = string.Empty; + + /// + /// 获取或设置测试写入的状态提示文本。 + /// + [ObservableProperty] + private string _plcWriteStatusMessage = string.Empty; + + /// + /// 获取或设置当前是否正在执行 PLC 测试写入。 + /// + [NotifyCanExecuteChangedFor(nameof(WritePlcValueCommand))] + [ObservableProperty] + private bool _isWritingPlc; + /// /// 保存当前配置。 /// @@ -143,6 +183,44 @@ public partial class SystemSettingViewModel : ObservableObject /// private bool CanTestFileCheckConnection => !IsTestingFileCheckConnection; + /// + /// 执行一次 PLC 测试写入。该写入复用回读相同的 IO 串行化,不会干扰后台轮询回读。 + /// + /// 表示写入完成的任务。 + [RelayCommand(CanExecute = nameof(CanWritePlcValue))] + private async Task WritePlcValueAsync() + { + if (string.IsNullOrWhiteSpace(PlcWriteAddress)) + { + PlcWriteStatusMessage = "请先输入写入地址。"; + return; + } + + IsWritingPlc = true; + PlcWriteStatusMessage = "正在写入..."; + + try + { + PlcWriteResult result = await _plcService + .WriteRawValueAsync(PlcWriteAddress, SelectedPlcWriteType, PlcWriteValue, CancellationToken.None); + + PlcWriteStatusMessage = result.Message; + } + catch (Exception ex) + { + PlcWriteStatusMessage = $"写入失败:{ex.Message}"; + } + finally + { + IsWritingPlc = false; + } + } + + /// + /// 判断当前是否允许执行 PLC 测试写入。 + /// + private bool CanWritePlcValue => !IsWritingPlc; + /// /// 将运行态快照中的 PLC 监控数据同步到当前视图模型。 /// diff --git a/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml b/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml index bec7176..6073d05 100644 --- a/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml +++ b/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml @@ -639,11 +639,12 @@ - + - + @@ -775,6 +776,10 @@ + + @@ -809,17 +814,29 @@ + + +