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 @@
+
+
+
-
-
diff --git a/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml b/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml
index 174075f..218e74e 100644
--- a/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml
+++ b/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml
@@ -140,6 +140,8 @@
+
+
@@ -153,6 +155,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 完成时间。
/// 结果代码。
/// 测试记录。
- private static BoardProcessRecord CreateRecord(string barcode, DateTimeOffset completedAt, ushort resultCode)
+ private static BoardProcessRecord CreateRecord(string barcode, DateTimeOffset completedAt, short resultCode)
{
return new BoardProcessRecord
{
@@ -141,7 +141,7 @@ public sealed class CsvBoardRecordRepositoryTests : IDisposable
ScanTryCount = 1,
SftpTryCount = 1,
ResultCode = resultCode,
- ResultDescription = resultCode == (ushort)WorkflowResultCode.Passed ? "OK" : "NG",
+ ResultDescription = resultCode == (short)WorkflowResultCode.Passed ? "OK" : "NG",
ReleaseSent = true,
AlarmRaised = false,
ExceptionSummary = string.Empty
diff --git a/tests/AxiOmron.PcbCheck.Tests/LocalFileCheckProviderTests.cs b/tests/AxiOmron.PcbCheck.Tests/LocalFileCheckProviderTests.cs
index 31bd8fa..4b7e972 100644
--- a/tests/AxiOmron.PcbCheck.Tests/LocalFileCheckProviderTests.cs
+++ b/tests/AxiOmron.PcbCheck.Tests/LocalFileCheckProviderTests.cs
@@ -89,20 +89,20 @@ public sealed class LocalFileCheckProviderTests : IDisposable
}
///
- /// 文件名包含条码时,应返回命中文件路径。
+ /// 文件名只包含条码但不等于模板生成文件名时,不应命中。
///
/// 异步测试任务。
[Fact]
- public async Task CheckFileAsync_ShouldMatchFileNameContainingBarcode()
+ public async Task CheckFileAsync_ShouldNotMatchFileNameContainingBarcodeOnly()
{
- string filePath = Path.Combine(_rootPath, "prefix-PCB-002-result.dat");
+ string filePath = Path.Combine(_rootPath, "abcd10.txt");
await File.WriteAllTextAsync(filePath, "ok");
var provider = CreateProvider();
- FileCheckOutcome outcome = await provider.CheckFileAsync("PCB-002", CreateConfig(_rootPath), CancellationToken.None);
+ FileCheckOutcome outcome = await provider.CheckFileAsync("abcd1", CreateConfig(_rootPath), CancellationToken.None);
- Assert.True(outcome.Exists);
- Assert.Equal(filePath, outcome.MatchedFilePath);
+ Assert.False(outcome.Exists);
+ Assert.Empty(outcome.MatchedFilePath);
}
///
diff --git a/tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs b/tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs
index 7c7b6f0..85eecdf 100644
--- a/tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs
+++ b/tests/AxiOmron.PcbCheck.Tests/MainWindowViewModelTests.cs
@@ -50,7 +50,7 @@ public sealed class MainWindowViewModelTests
Barcode = $"PCB-{index:D3}",
ScanTryCount = 1,
SftpTryCount = 1,
- ResultCode = index % 2 == 0 ? (ushort)WorkflowResultCode.Passed : (ushort)WorkflowResultCode.NgReleased,
+ ResultCode = index % 2 == 0 ? (short)WorkflowResultCode.Passed : (short)WorkflowResultCode.NgReleased,
ResultDescription = index % 2 == 0 ? "OK" : "NG",
ReleaseSent = true,
AlarmRaised = false
@@ -131,10 +131,31 @@ public sealed class MainWindowViewModelTests
}
}
+ ///
+ /// 日志区域展开命令应只切换界面折叠状态,不影响运行数据。
+ ///
+ [Fact]
+ public void ToggleLogArea_ShouldSwitchExpandedState()
+ {
+ var stateStore = new AppStateStore();
+ var viewModel = CreateViewModel(stateStore);
+
+ Assert.False(viewModel.IsLogAreaExpanded);
+
+ viewModel.ToggleLogAreaCommand.Execute(null);
+
+ Assert.True(viewModel.IsLogAreaExpanded);
+
+ viewModel.ToggleLogAreaCommand.Execute(null);
+
+ Assert.False(viewModel.IsLogAreaExpanded);
+ }
+
///
/// 创建测试用首页视图模型。
///
/// 运行态存储。
+ /// 可选的 UI 调度服务。
/// 首页视图模型。
private static MainWindowViewModel CreateViewModel(IAppStateStore stateStore, IDispatcherService? dispatcherService = null)
{
diff --git a/tests/AxiOmron.PcbCheck.Tests/OmronFinsPlcServiceTests.cs b/tests/AxiOmron.PcbCheck.Tests/OmronFinsPlcServiceTests.cs
new file mode 100644
index 0000000..5016fc4
--- /dev/null
+++ b/tests/AxiOmron.PcbCheck.Tests/OmronFinsPlcServiceTests.cs
@@ -0,0 +1,41 @@
+using AxiOmron.PcbCheck.Options;
+using AxiOmron.PcbCheck.Services.Implementations;
+using IoTClient.Enums;
+using Xunit;
+
+namespace AxiOmron.PcbCheck.Tests;
+
+///
+/// 验证欧姆龙 FINS PLC 服务的通信参数解析行为。
+///
+public sealed class OmronFinsPlcServiceTests
+{
+ ///
+ /// 默认字节序应使用 BADC,以匹配现场 D 区 Int16 的高低字节顺序。
+ ///
+ [Fact]
+ public void ResolveEndianFormat_ShouldUseBadc_WhenConfigurationIsDefault()
+ {
+ var options = new PlcOptions();
+
+ EndianFormat result = OmronFinsPlcService.ResolveEndianFormat(options);
+
+ Assert.Equal(EndianFormat.BADC, result);
+ }
+
+ ///
+ /// 非法字节序配置应抛出明确异常,避免使用错误端序静默读写 PLC 寄存器。
+ ///
+ [Fact]
+ public void ResolveEndianFormat_ShouldThrow_WhenConfigurationIsInvalid()
+ {
+ var options = new PlcOptions
+ {
+ EndianFormat = "XYZ"
+ };
+
+ var exception = Assert.Throws(() => OmronFinsPlcService.ResolveEndianFormat(options));
+
+ Assert.Contains("PLC 字节序配置无效", exception.Message);
+ }
+}
diff --git a/tests/AxiOmron.PcbCheck.Tests/ResultCodeTypeTests.cs b/tests/AxiOmron.PcbCheck.Tests/ResultCodeTypeTests.cs
new file mode 100644
index 0000000..ca67826
--- /dev/null
+++ b/tests/AxiOmron.PcbCheck.Tests/ResultCodeTypeTests.cs
@@ -0,0 +1,33 @@
+using AxiOmron.PcbCheck.Models;
+using AxiOmron.PcbCheck.ViewModels;
+using Xunit;
+
+namespace AxiOmron.PcbCheck.Tests;
+
+///
+/// 验证结果代码在模型与界面层统一使用 16 位有符号整数。
+///
+public sealed class ResultCodeTypeTests
+{
+ ///
+ /// 结果代码枚举应以 Int16 作为底层类型,匹配 PLC 的 ResultCode 定义。
+ ///
+ [Fact]
+ public void WorkflowResultCode_ShouldUseInt16UnderlyingType()
+ {
+ Assert.Equal(typeof(short), Enum.GetUnderlyingType(typeof(WorkflowResultCode)));
+ }
+
+ ///
+ /// 结果代码属性应统一使用 Int16,避免经过界面或快照时被误建模为 UInt16 或 Int32。
+ ///
+ [Theory]
+ [InlineData(typeof(RuntimeSnapshot))]
+ [InlineData(typeof(PlcProcessState))]
+ [InlineData(typeof(BoardProcessRecord))]
+ [InlineData(typeof(MainWindowViewModel))]
+ public void ResultCodeProperties_ShouldUseInt16(Type ownerType)
+ {
+ Assert.Equal(typeof(short), ownerType.GetProperty("ResultCode")?.PropertyType);
+ }
+}
diff --git a/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs b/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs
index faf1810..b5f9746 100644
--- a/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs
+++ b/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs
@@ -34,7 +34,7 @@ public sealed class SystemSettingViewModelTests
StatusMessage = "SFTP 连接成功,根目录可访问。"
}
};
- var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService);
+ var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService, new FakePlcService());
await viewModel.TestFileCheckConnectionCommand.ExecuteAsync(null);
@@ -60,7 +60,7 @@ public sealed class SystemSettingViewModelTests
StatusMessage = "SFTP 连接失败: timeout"
}
};
- var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService);
+ var viewModel = new SystemSettingViewModel(configService, fileCheckService, stateStore, dispatcherService, new FakePlcService());
await viewModel.TestFileCheckConnectionCommand.ExecuteAsync(null);
@@ -87,7 +87,7 @@ public sealed class SystemSettingViewModelTests
});
});
- var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService);
+ var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService, new FakePlcService());
Assert.Single(viewModel.PlcMonitorItems);
Assert.Equal("PcbArrived", viewModel.PlcMonitorItems[0].Name);
@@ -104,7 +104,7 @@ public sealed class SystemSettingViewModelTests
var configService = new FakeAppConfigService();
var stateStore = new AppStateStore();
var dispatcherService = new CapturedDispatcherService(Dispatcher.CurrentDispatcher);
- var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService);
+ var viewModel = new SystemSettingViewModel(configService, new FakeFileCheckService(), stateStore, dispatcherService, new FakePlcService());
_ = CollectionViewSource.GetDefaultView(viewModel.PlcMonitorItems);
Exception? capturedException = null;
@@ -160,7 +160,8 @@ public sealed class SystemSettingViewModelTests
configService,
new FakeFileCheckService(),
stateStore,
- new CanceledDispatcherService());
+ new CanceledDispatcherService(),
+ new FakePlcService());
var synchronizationContext = new ExceptionCapturingSynchronizationContext();
SynchronizationContext? originalContext = SynchronizationContext.Current;
@@ -296,6 +297,37 @@ public sealed class SystemSettingViewModelTests
}
}
+ ///
+ /// 提供系统设置页测试用 PLC 服务替身。
+ ///
+ private sealed class FakePlcService : 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;
+ }
+
+ 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));
+ }
+ }
+
///
/// 提供立即执行的测试用 Dispatcher,实现与设计时调度一致的同步行为。
///
diff --git a/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs b/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs
index 78c284d..e88a761 100644
--- a/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs
+++ b/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs
@@ -148,12 +148,12 @@ public sealed class WorkflowHostedServiceTests
Assert.True(scannerService.TriggerCount > 0);
Assert.Contains(plcService.WrittenStates, state => state.ReleasePermit);
- Assert.Contains(plcService.WrittenStates, state => state.ResultCode == (ushort)WorkflowResultCode.Passed);
+ Assert.Contains(plcService.WrittenStates, state => state.ResultCode == (short)WorkflowResultCode.Passed);
RuntimeSnapshot snapshot = stateStore.GetSnapshot();
Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "PcbArrived" && item.CurrentValue == "False");
Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "PcBusy" && item.CurrentValue == "False");
- Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "ResultCode" && item.CurrentValue == ((ushort)WorkflowResultCode.Passed).ToString());
+ Assert.Contains(snapshot.PlcMonitorItems, item => item.Name == "ResultCode" && item.CurrentValue == ((short)WorkflowResultCode.Passed).ToString());
Assert.All(snapshot.PlcMonitorItems, item => Assert.NotEqual(default, item.LastUpdatedAt));
}
@@ -217,7 +217,7 @@ public sealed class WorkflowHostedServiceTests
Assert.Equal(1, scannerService.TriggerCount);
Assert.Equal("PCB-MERGED", fileCheckService.LastBarcode);
- Assert.Contains(plcService.WrittenStates, state => state.ResultCode == (ushort)WorkflowResultCode.Passed);
+ Assert.Contains(plcService.WrittenStates, state => state.ResultCode == (short)WorkflowResultCode.Passed);
}
///
@@ -304,7 +304,7 @@ public sealed class WorkflowHostedServiceTests
Barcode = "PCB-RESTORED",
ScanTryCount = 1,
SftpTryCount = 1,
- ResultCode = (ushort)WorkflowResultCode.Passed,
+ ResultCode = (short)WorkflowResultCode.Passed,
ResultDescription = "OK",
ReleaseSent = true
}
@@ -409,7 +409,7 @@ public sealed class WorkflowHostedServiceTests
RuntimeSnapshot snapshot = stateStore.GetSnapshot();
Assert.NotEqual(WorkflowState.Faulted, snapshot.WorkflowState);
- Assert.Equal((ushort)WorkflowResultCode.Passed, snapshot.ResultCode);
+ Assert.Equal((short)WorkflowResultCode.Passed, snapshot.ResultCode);
}
private sealed class FakePlcService : IPlcService
@@ -418,6 +418,7 @@ public sealed class WorkflowHostedServiceTests
public Task ReadMonitorSnapshotAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcMonitorSnapshot());
public Task ReadSignalsAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcSignalSnapshot());
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken) => Task.FromResult(PlcWriteResult.Success(address, type, value));
}
private sealed class ProbingPlcService : IPlcService
@@ -460,6 +461,11 @@ public sealed class WorkflowHostedServiceTests
{
return Task.CompletedTask;
}
+
+ public Task WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(PlcWriteResult.Success(address, type, value));
+ }
}
private sealed class FailingPlcService : IPlcService
@@ -483,6 +489,11 @@ public sealed class WorkflowHostedServiceTests
{
return Task.CompletedTask;
}
+
+ public Task WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
+ {
+ throw new InvalidOperationException("PLC unreachable");
+ }
}
private sealed class SequencedPlcService : IPlcService
@@ -529,6 +540,11 @@ public sealed class WorkflowHostedServiceTests
WrittenStates.Add(state.Clone());
return Task.CompletedTask;
}
+
+ public Task WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(PlcWriteResult.Success(address, type, value));
+ }
}
private sealed class FakeScannerService : IScannerService