Compare commits

...

2 Commits

Author SHA1 Message Date
17839a9e76 ✨ feat(*): 更新 PLC 相关配置与结果代码类型
- 修改 appConfig.json 中 PLC 和扫描仪的 IP 地址及字节序配置
- 将多个地方的 ResultCode 类型从 short 更新为 ushort,以确保与 PLC 通信一致
- 更新相关服务和模型以适应新的 ResultCode 类型
- 调整单元测试以验证新配置和类型的正确性
2026-06-02 17:59:04 +08:00
27e2c9d5d0 ✨ feat(*): 更新 PLC 相关功能与配置
- 在 appConfig.json 中添加 EndianFormat 配置项并更新相关输入输出地址
- 修改 ResultCode 类型为 short,确保与 PLC 通信一致
- 新增 DesignTimePlcService 以支持设计时的 PLC 测试写入功能
- 更新 SystemSettingViewModel 和 DashboardPage 以支持手动测试写入
- 调整多个视图模型和服务以适应新的 PLC 写入逻辑
- 更新相关单元测试以验证新功能的正确性
2026-06-02 17:26:24 +08:00
25 changed files with 850 additions and 146 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -5,6 +5,8 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
<InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.Extensions.Configuration.Binder.SourceGeneration</InterceptorsNamespaces>
</PropertyGroup>
<ItemGroup>
@@ -18,6 +20,10 @@
<PackageReference Include="SSH.NET" Version="2020.0.2" />
</ItemGroup>
<ItemGroup>
<Resource Include="Assets\AppIcon.ico" />
</ItemGroup>
<ItemGroup>
<None Update="appConfig.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>

View File

@@ -20,14 +20,15 @@ public sealed class ResultCodeToTagTextConverter : IValueConverter
/// <param name="parameter">扩展参数。</param>
/// <param name="culture">当前区域信息。</param>
/// <returns>简短标签文本。</returns>
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
{
ushort u => u,
int i => (ushort)i,
ushort s => s,
short s when s >= 0 => (ushort)s,
int i when i >= 0 && i <= ushort.MaxValue => (ushort)i,
WorkflowResultCode rc => (ushort)rc,
_ => (ushort)0
_ => 0
};
return code switch
@@ -47,7 +48,7 @@ public sealed class ResultCodeToTagTextConverter : IValueConverter
/// <param name="parameter">扩展参数。</param>
/// <param name="culture">当前区域信息。</param>
/// <returns>抛出不支持异常。</returns>
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();
}

View File

@@ -0,0 +1,64 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.DesignTime;
/// <summary>
/// 为设计器提供 PLC 通信能力的设计时占位服务,所有方法均为无副作用实现。
/// </summary>
public sealed class DesignTimePlcService : IPlcService
{
/// <summary>
/// 返回设计时输入信号快照。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>空的输入信号快照。</returns>
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new PlcSignalSnapshot());
}
/// <summary>
/// 返回设计时监控镜像快照。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>空的监控镜像快照。</returns>
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new PlcMonitorSnapshot());
}
/// <summary>
/// 模拟写入流程状态,不执行实际设备通信。
/// </summary>
/// <param name="state">待写入状态。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>已完成任务。</returns>
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <summary>
/// 模拟 PLC 重连,不执行实际设备通信。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>已完成任务。</returns>
public Task ForceReconnectAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <summary>
/// 返回设计时测试写入成功结果,不执行实际设备通信。
/// </summary>
/// <param name="address">目标地址。</param>
/// <param name="type">变量类型。</param>
/// <param name="value">写入值文本。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>固定成功结果。</returns>
public Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
{
return Task.FromResult(PlcWriteResult.Success(address, type, value));
}
}

View File

@@ -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
/// 获取系统设置设计时视图模型。
/// </summary>
public SystemSettingViewModel SystemSettingViewModel
=> _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService, _appStateStore, _dispatcherService);
=> _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService, _appStateStore, _dispatcherService, _plcService);
/// <summary>
/// 创建首页设计时视图模型。

View File

@@ -10,6 +10,7 @@
Height="950"
MinWidth="1400"
MinHeight="860"
Icon="Assets/AppIcon.ico"
Background="{DynamicResource {x:Static hc:ResourceToken.BackgroundBrush}}"
WindowStartupLocation="CenterScreen">
<Grid>

View File

@@ -0,0 +1,105 @@
namespace AxiOmron.PcbCheck.Models;
/// <summary>
/// 表示 PLC 测试写入支持的变量类型。
/// </summary>
public enum PlcWriteType
{
/// <summary>
/// 布尔位。
/// </summary>
Bool = 0,
/// <summary>
/// 有符号 16 位整数。
/// </summary>
Int16 = 1,
/// <summary>
/// 无符号 16 位整数。
/// </summary>
UInt16 = 2,
/// <summary>
/// 有符号 32 位整数。
/// </summary>
Int32 = 3,
/// <summary>
/// 无符号 32 位整数。
/// </summary>
UInt32 = 4,
/// <summary>
/// 单精度浮点数。
/// </summary>
Float = 5
}
/// <summary>
/// 表示一次 PLC 测试写入的执行结果。
/// </summary>
public sealed record PlcWriteResult
{
/// <summary>
/// 获取一个值,指示写入是否成功。
/// </summary>
public bool IsSuccess { get; init; }
/// <summary>
/// 获取面向用户的结果说明。
/// </summary>
public string Message { get; init; } = string.Empty;
/// <summary>
/// 获取本次写入的目标地址。
/// </summary>
public string Address { get; init; } = string.Empty;
/// <summary>
/// 获取本次写入的变量类型名称。
/// </summary>
public string TypeName { get; init; } = string.Empty;
/// <summary>
/// 获取本次实际写入的文本值。
/// </summary>
public string WrittenText { get; init; } = string.Empty;
/// <summary>
/// 创建一个表示写入成功的结果。
/// </summary>
/// <param name="address">目标地址。</param>
/// <param name="type">变量类型。</param>
/// <param name="writtenText">实际写入的文本值。</param>
/// <returns>成功结果对象。</returns>
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})"
};
}
/// <summary>
/// 创建一个表示写入失败的结果。
/// </summary>
/// <param name="address">目标地址。</param>
/// <param name="type">变量类型。</param>
/// <param name="message">失败说明。</param>
/// <returns>失败结果对象。</returns>
public static PlcWriteResult Failure(string address, PlcWriteType type, string message)
{
return new PlcWriteResult
{
IsSuccess = false,
Address = address,
TypeName = type.ToString(),
Message = message
};
}
}

View File

@@ -129,6 +129,11 @@ public sealed class PlcOptions
/// </summary>
public byte SourceNodeAddress { get; set; } = 25;
/// <summary>
/// 获取或设置 PLC 寄存器字节序,默认使用 ABCD 以避免 UInt16 结果代码发生高低字节交换。
/// </summary>
public string? EndianFormat { get; set; } = "ABCD";
/// <summary>
/// 获取或设置轮询周期,单位为毫秒。
/// </summary>

View File

@@ -1,15 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>bin\Release\net8.0-windows\publish\</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net8.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>false</IncludeNativeLibrariesForSelfExtract>
<EnableCompressionInSingleFile>false</EnableCompressionInSingleFile>
</PropertyGroup>
</Project>
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>bin\Release\net8.0-windows\publish\</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<_TargetId>Folder</_TargetId>
<TargetFramework>net8.0-windows</TargetFramework>
<SelfContained>false</SelfContained>
</PropertyGroup>
</Project>

View File

@@ -1,10 +0,0 @@
{
"profiles": {
"AxiOmron.PcbCheck": {
"commandName": "Project",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -155,6 +155,54 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
}
}
/// <summary>
/// 向指定地址写入一个原始值,供调试界面手动测试使用。
/// 复用与回读相同的 <see cref="_ioLock"/> 串行化,且不修改 <see cref="_lastWrittenState"/>,因此不会干扰后台轮询回读与流程状态机写入。
/// </summary>
/// <param name="address">完整的 FINS 地址字符串,例如 <c>D100</c> 或 <c>D100.0</c>。</param>
/// <param name="type">写入的变量类型。</param>
/// <param name="value">待写入值的文本表示。</param>
/// <param name="cancellationToken">取消令牌。取消后立即终止本次写入并返回已取消任务。</param>
/// <returns>包含成功状态与说明的写入结果。</returns>
public async Task<PlcWriteResult> 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();
}
}
/// <summary>
/// 释放 PLC 通信资源。
/// </summary>
@@ -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);
}
/// <summary>
/// 解析 PLC 字节序配置。
/// </summary>
/// <param name="options">PLC 通信配置。</param>
/// <returns>IoTClient 使用的字节序枚举。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="options"/> 为空时抛出。</exception>
/// <exception cref="InvalidOperationException">当字节序配置为空或无法识别时抛出。</exception>
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;
}
/// <summary>
@@ -206,7 +276,7 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
}
/// <summary>
/// 读取单个 D 区无符号整数。
/// 读取单个 D 区无符号 16 位整数。
/// </summary>
/// <param name="address">FINS 地址。</param>
/// <returns>读取到的寄存器值。</returns>
@@ -225,9 +295,10 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
private void WriteChangedState(PlcProcessState state)
{
var previous = _lastWrittenState;
ushort? 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));
WriteUInt16IfChanged(previousResultCode, state.ResultCode, ToDataMemoryAddress(_options.Registers.ResultCode));
}
/// <summary>
@@ -249,7 +320,7 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
}
/// <summary>
/// 仅当值发生变化时写入单个 D 区无符号整数。
/// 仅当值发生变化时写入单个 D 区无符号 16 位整数。
/// </summary>
/// <param name="previous">上一值。</param>
/// <param name="current">当前值。</param>
@@ -266,6 +337,147 @@ public sealed class OmronFinsPlcService : IPlcService, IDisposable
EnsureSuccess(result.IsSucceed, result.Err, $"写入 FINS UInt16 失败,地址={address},值={current}");
}
/// <summary>
/// 按变量类型将文本值解析为强类型对象。
/// </summary>
/// <param name="type">变量类型。</param>
/// <param name="value">待解析的文本值。</param>
/// <param name="parsedValue">解析成功时返回的强类型值。</param>
/// <param name="error">解析失败时返回的错误说明。</param>
/// <returns>解析成功返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
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;
}
}
/// <summary>
/// 解析布尔文本,接受 1/0 与 true/false(忽略大小写)。
/// </summary>
/// <param name="text">待解析文本。</param>
/// <param name="result">解析结果。</param>
/// <returns>解析成功返回 <see langword="true"/>;否则返回 <see langword="false"/>。</returns>
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;
}
}
/// <summary>
/// 按强类型值调用 IoTClient 对应写入重载。
/// </summary>
/// <param name="address">FINS 地址。</param>
/// <param name="parsedValue">已解析的强类型值。</param>
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}");
}
/// <summary>
/// 将强类型值格式化为用于展示的文本。
/// </summary>
/// <param name="parsedValue">已解析的强类型值。</param>
/// <returns>文本表示。</returns>
private static string FormatWrittenText(object parsedValue)
{
return parsedValue switch
{
float floatValue => floatValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
_ => parsedValue.ToString() ?? string.Empty
};
}
/// <summary>
/// 将数字形式 D 区地址转换为 IoTClient FINS 地址字符串。
/// </summary>

View File

@@ -124,6 +124,17 @@ public interface IPlcService
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>表示重连完成的任务。</returns>
Task ForceReconnectAsync(CancellationToken cancellationToken);
/// <summary>
/// 向指定地址写入一个原始值,供调试界面手动测试使用。
/// 该写入复用与回读相同的 IO 串行化机制,且不会影响流程状态机的写入缓存,因此不会干扰后台轮询回读。
/// </summary>
/// <param name="address">完整的 FINS 地址字符串,例如 <c>D100</c> 或 <c>D100.0</c>。</param>
/// <param name="type">写入的变量类型。</param>
/// <param name="value">待写入值的文本表示,由实现按 <paramref name="type"/> 解析。</param>
/// <param name="cancellationToken">取消令牌。取消后立即终止本次写入并返回已取消任务。</param>
/// <returns>包含成功状态与说明的写入结果。</returns>
Task<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken);
}
/// <summary>

View File

@@ -182,6 +182,12 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty]
private string _lastLogUpdateTime = "-";
/// <summary>
/// 获取或设置运行日志区域是否展开;该状态仅影响首页布局,不参与业务流程。
/// </summary>
[ObservableProperty]
private bool _isLogAreaExpanded;
/// <summary>
/// 获取或设置今日异常日志条数。
/// </summary>
@@ -367,6 +373,15 @@ public partial class MainWindowViewModel : ObservableObject
LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
/// 切换运行日志区域展开状态,收起或恢复上方状态概览区域。
/// </summary>
[RelayCommand]
private void ToggleLogArea()
{
IsLogAreaExpanded = !IsLogAreaExpanded;
}
/// <summary>
/// 打开追踪区命令占位。后续应由导航服务跳转至追踪页面。
/// </summary>
@@ -542,6 +557,7 @@ public partial class MainWindowViewModel : ObservableObject
await AppendLogOnUiAsync(entry).ConfigureAwait(false);
}
/// <summary>
/// 处理管理员解锁倒计时,超时后自动恢复锁定。
/// </summary>
/// <param name="sender">事件源。</param>

View File

@@ -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;
/// <summary>
/// 初始化系统设置视图模型。
@@ -25,20 +25,24 @@ public partial class SystemSettingViewModel : ObservableObject
/// <param name="fileCheckService">文件校验通道测试服务。</param>
/// <param name="appStateStore">运行态快照存储。</param>
/// <param name="dispatcherService">UI 线程调度服务。</param>
/// <param name="plcService">PLC 通信服务,用于调试界面的测试写入。</param>
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<PlcMonitorItem>();
FileCheckProviders = Enum.GetValues<FileCheckProvider>();
PlcWriteTypes = Enum.GetValues<PlcWriteType>();
ApplySnapshot(_appStateStore.GetSnapshot());
_appStateStore.SnapshotChanged += OnSnapshotChanged;
StatusMessage = "已加载配置。";
@@ -54,6 +58,11 @@ public partial class SystemSettingViewModel : ObservableObject
/// </summary>
public IReadOnlyList<FileCheckProvider> FileCheckProviders { get; }
/// <summary>
/// 获取 PLC 测试写入支持的变量类型可选项。
/// </summary>
public IReadOnlyList<PlcWriteType> PlcWriteTypes { get; }
/// <summary>
/// 获取或设置可编辑配置对象。
/// </summary>
@@ -79,6 +88,37 @@ public partial class SystemSettingViewModel : ObservableObject
[ObservableProperty]
private bool _isTestingFileCheckConnection;
/// <summary>
/// 获取或设置测试写入的目标地址,例如 D100、D100.0。
/// </summary>
[ObservableProperty]
private string _plcWriteAddress = string.Empty;
/// <summary>
/// 获取或设置测试写入的变量类型。
/// </summary>
[ObservableProperty]
private PlcWriteType _selectedPlcWriteType = PlcWriteType.Int16;
/// <summary>
/// 获取或设置测试写入的值文本。
/// </summary>
[ObservableProperty]
private string _plcWriteValue = string.Empty;
/// <summary>
/// 获取或设置测试写入的状态提示文本。
/// </summary>
[ObservableProperty]
private string _plcWriteStatusMessage = string.Empty;
/// <summary>
/// 获取或设置当前是否正在执行 PLC 测试写入。
/// </summary>
[NotifyCanExecuteChangedFor(nameof(WritePlcValueCommand))]
[ObservableProperty]
private bool _isWritingPlc;
/// <summary>
/// 保存当前配置。
/// </summary>
@@ -143,6 +183,44 @@ public partial class SystemSettingViewModel : ObservableObject
/// </summary>
private bool CanTestFileCheckConnection => !IsTestingFileCheckConnection;
/// <summary>
/// 执行一次 PLC 测试写入。该写入复用回读相同的 IO 串行化,不会干扰后台轮询回读。
/// </summary>
/// <returns>表示写入完成的任务。</returns>
[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;
}
}
/// <summary>
/// 判断当前是否允许执行 PLC 测试写入。
/// </summary>
private bool CanWritePlcValue => !IsWritingPlc;
/// <summary>
/// 将运行态快照中的 PLC 监控数据同步到当前视图模型。
/// </summary>

View File

@@ -639,11 +639,12 @@
<Grid Grid.Row="2" Grid.Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="16" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid Grid.Row="0"
Visibility="{Binding IsLogAreaExpanded, Converter={StaticResource BooleanToVisibilityConverter}, ConverterParameter=Invert}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1.12*" />
<ColumnDefinition Width="12" />
@@ -775,6 +776,10 @@
</Border>
</Grid>
<Border Grid.Row="1"
Height="16"
Visibility="{Binding IsLogAreaExpanded, Converter={StaticResource BooleanToVisibilityConverter}, ConverterParameter=Invert}" />
<Border Grid.Row="2" Style="{StaticResource ModernCardStyle}">
<Grid>
<Grid.RowDefinitions>
@@ -809,17 +814,29 @@
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="8" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="8" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Style="{StaticResource ToolbarButtonStyle}"
Command="{Binding ToggleLogAreaCommand}">
<Grid>
<TextBlock Text="展开日志"
Visibility="{Binding IsLogAreaExpanded, Converter={StaticResource BooleanToVisibilityConverter}, ConverterParameter=Invert}" />
<TextBlock Text="恢复概览"
Visibility="{Binding IsLogAreaExpanded, Converter={StaticResource BooleanToVisibilityConverter}}" />
</Grid>
</Button>
<Button Grid.Column="2"
Content="全部"
Style="{StaticResource ToolbarButtonStyle}"
Command="{Binding ShowAllLogsCommand}" />
<Button Grid.Column="2"
<Button Grid.Column="4"
Content="仅错误"
Style="{StaticResource ToolbarButtonStyle}"
Command="{Binding ShowErrorLogsCommand}" />
<Button Grid.Column="4"
<Button Grid.Column="6"
Content="导出"
Style="{StaticResource ToolbarButtonStyle}"
Command="{Binding ExportLogsCommand}" />

View File

@@ -140,6 +140,8 @@
<TabItem Header="PLC监控">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="12" />
<RowDefinition Height="Auto" />
<RowDefinition Height="12" />
<RowDefinition Height="*" />
@@ -153,6 +155,60 @@
</StackPanel>
<Border Grid.Row="2"
Background="White"
BorderBrush="#E8ECF3"
BorderThickness="1"
CornerRadius="10"
Padding="16,14">
<StackPanel>
<TextBlock Text="测试写入" FontSize="15" FontWeight="SemiBold" Foreground="#1F2937" />
<TextBlock Text="手动向指定地址写入测试值,仅用于调试,不影响实时回读。"
Margin="0,4,0,0"
Foreground="#6B7280" />
<Grid Margin="0,12,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="1.4*" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Style="{StaticResource TextBoxExtend}"
hc:InfoElement.Title="地址"
hc:InfoElement.TitlePlacement="Top"
hc:InfoElement.Placeholder="如 D100、D100.0"
Text="{Binding PlcWriteAddress, UpdateSourceTrigger=PropertyChanged}" />
<ComboBox Grid.Column="2"
Style="{StaticResource ComboBoxExtend}"
hc:InfoElement.Title="变量类型"
hc:InfoElement.TitlePlacement="Top"
ItemsSource="{Binding PlcWriteTypes}"
SelectedItem="{Binding SelectedPlcWriteType}" />
<TextBox Grid.Column="4"
Style="{StaticResource TextBoxExtend}"
hc:InfoElement.Title="写入值"
hc:InfoElement.TitlePlacement="Top"
hc:InfoElement.Placeholder="Bool 用 1/0 或 true/false"
Text="{Binding PlcWriteValue, UpdateSourceTrigger=PropertyChanged}" />
<Button Grid.Column="6"
VerticalAlignment="Bottom"
Style="{StaticResource ButtonPrimary}"
Padding="18,8"
MinHeight="36"
Content="写入"
Command="{Binding WritePlcValueCommand}" />
</Grid>
<TextBlock Text="{Binding PlcWriteStatusMessage}"
Margin="0,10,0,0"
TextWrapping="Wrap"
Foreground="#2563EB" />
</StackPanel>
</Border>
<Border Grid.Row="4"
Background="White"
BorderBrush="#E8ECF3"
BorderThickness="1"

View File

@@ -1,91 +1,92 @@
{
"Plc": {
"Host": "127.0.0.1",
"Port": 9600,
"UnitAddress": 0,
"SourceNodeAddress": 25,
"PollIntervalMs": 200,
"ConnectTimeoutMs": 3000,
"ReleasePulseMs": 500,
"ReleaseAckTimeoutMs": 2000,
"Inputs": {
"PcbArrived": 1001,
"PlcReset": 1002,
"PlcAckRelease": 1003
},
"Outputs": {
"PcBusy": 1051,
"ReleasePermit": 1052
},
"Registers": {
"ResultCode": 1100
}
},
"Scanner": {
"Scanner1": {
"Name": "扫码枪1",
"Host": "127.0.0.1",
"Port": 9001
},
"Scanner2": {
"Name": "扫码枪2",
"Host": "127.0.0.1",
"Port": 9002
},
"ConnectTimeoutMs": 3000,
"ReadTimeoutMs": 3000,
"TriggerCommand": "T",
"ResponseTerminator": "\\r",
"TriggerInParallel": true
},
"FileCheck": {
"Provider": "Sftp",
"FileNamePattern": "${barcode}.txt",
"RetryIntervalSeconds": 2,
"MaxRetryCount": 3,
"Local": {
"RootPath": "D:\\PcbCheck\\Files"
}
},
"Sftp": {
"Host": "127.0.0.1",
"Port": 22,
"Username": "uaesadmin",
"Password": "uaes,123",
"PrivateKeyPath": "",
"PrivateKeyPassphrase": "",
"RootPath": "/home/uaesadmin/",
"FileNamePattern": "${barcode}.txt",
"RetryIntervalSeconds": 2,
"MaxRetryCount": 3,
"ConnectTimeoutMs": 3000
},
"Andon": {
"Enable": true,
"Url": "https://shp.prod.aliyun.mit.uaes.com/api/mit/MAE/Station/Status/alarm",
"Method": "POST",
"TimeoutMs": 3000,
"AndonTheme": "SS7_Auto_Andon",
"Eid": "PR1965269806334783488",
"FromSign": "IOT",
"ServiceId": "4dccd822-86b2-47dd-89bd-ca472ba0d14d",
"EnableScanFailAlarm": true,
"EnableFileNotFoundAlarm": false,
"Headers": {
"Accept": "application/json"
}
},
"Workflow": {
"RequireManualResetAfterFault": true,
"MaxUiLogEntries": 200,
"MaxBoardRecords": 100
},
"RecordPersistence": {
"DirectoryPath": "Data\\Records",
"DayChangeCheckIntervalSeconds": 30
},
"Security": {
"AdminPassword": "AxiOmron@123",
"AdminSessionTimeoutMinutes": 15
}
}
{
"Plc": {
"Host": "192.168.2.100",
"Port": 9600,
"UnitAddress": 0,
"SourceNodeAddress": 0,
"EndianFormat": "ABCD",
"PollIntervalMs": 200,
"ConnectTimeoutMs": 3000,
"ReleasePulseMs": 500,
"ReleaseAckTimeoutMs": 2000,
"Inputs": {
"PcbArrived": 101,
"PlcReset": 102,
"PlcAckRelease": 103
},
"Outputs": {
"PcBusy": 51,
"ReleasePermit": 52
},
"Registers": {
"ResultCode": 401
}
},
"Scanner": {
"Scanner1": {
"Name": "\u626B\u7801\u67AA1",
"Host": "192.168.2.13",
"Port": 2001
},
"Scanner2": {
"Name": "\u626B\u7801\u67AA2",
"Host": "192.168.2.14",
"Port": 2001
},
"ConnectTimeoutMs": 3000,
"TriggerCommand": "T",
"ResponseTerminator": "",
"TriggerInParallel": true,
"ReadTimeoutMs": 5000
},
"FileCheck": {
"Provider": "Local",
"FileNamePattern": "${barcode}.txt",
"RetryIntervalSeconds": 2,
"MaxRetryCount": 3,
"Local": {
"RootPath": "D:\\test"
}
},
"Sftp": {
"Host": "127.0.0.1",
"Port": 22,
"Username": "uaesadmin",
"Password": "uaes,123",
"PrivateKeyPath": "",
"PrivateKeyPassphrase": "",
"RootPath": "/home/uaesadmin/",
"FileNamePattern": "${barcode}.txt",
"RetryIntervalSeconds": 2,
"MaxRetryCount": 3,
"ConnectTimeoutMs": 3000
},
"Andon": {
"Enable": false,
"Url": "https://shp.prod.aliyun.mit.uaes.com/api/mit/MAE/Station/Status/alarm",
"Method": "POST",
"TimeoutMs": 3000,
"AndonTheme": "SS7_Auto_Andon",
"Eid": "PR1965269806334783488",
"FromSign": "IOT",
"ServiceId": "4dccd822-86b2-47dd-89bd-ca472ba0d14d",
"EnableScanFailAlarm": false,
"EnableFileNotFoundAlarm": false,
"Headers": {
"Accept": "application/json"
}
},
"Workflow": {
"RequireManualResetAfterFault": true,
"MaxUiLogEntries": 200,
"MaxBoardRecords": 100
},
"RecordPersistence": {
"DirectoryPath": "Data\\Records",
"DayChangeCheckIntervalSeconds": 30
},
"Security": {
"AdminPassword": "AxiOmron@123",
"AdminSessionTimeoutMinutes": 15
}
}

View File

@@ -89,20 +89,20 @@ public sealed class LocalFileCheckProviderTests : IDisposable
}
/// <summary>
/// 文件名包含条码时,应返回命中文件路径。
/// 文件名只包含条码但不等于模板生成文件名时,不应命中。
/// </summary>
/// <returns>异步测试任务。</returns>
[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);
}
/// <summary>

View File

@@ -131,10 +131,31 @@ public sealed class MainWindowViewModelTests
}
}
/// <summary>
/// 日志区域展开命令应只切换界面折叠状态,不影响运行数据。
/// </summary>
[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);
}
/// <summary>
/// 创建测试用首页视图模型。
/// </summary>
/// <param name="stateStore">运行态存储。</param>
/// <param name="dispatcherService">可选的 UI 调度服务。</param>
/// <returns>首页视图模型。</returns>
private static MainWindowViewModel CreateViewModel(IAppStateStore stateStore, IDispatcherService? dispatcherService = null)
{

View File

@@ -0,0 +1,41 @@
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Implementations;
using IoTClient.Enums;
using Xunit;
namespace AxiOmron.PcbCheck.Tests;
/// <summary>
/// 验证欧姆龙 FINS PLC 服务的通信参数解析行为。
/// </summary>
public sealed class OmronFinsPlcServiceTests
{
/// <summary>
/// 默认字节序应使用 ABCD,避免 UInt16 结果代码发生高低字节交换。
/// </summary>
[Fact]
public void ResolveEndianFormat_ShouldUseAbcd_WhenConfigurationIsDefault()
{
var options = new PlcOptions();
EndianFormat result = OmronFinsPlcService.ResolveEndianFormat(options);
Assert.Equal(EndianFormat.ABCD, result);
}
/// <summary>
/// 非法字节序配置应抛出明确异常,避免使用错误端序静默读写 PLC 寄存器。
/// </summary>
[Fact]
public void ResolveEndianFormat_ShouldThrow_WhenConfigurationIsInvalid()
{
var options = new PlcOptions
{
EndianFormat = "XYZ"
};
var exception = Assert.Throws<InvalidOperationException>(() => OmronFinsPlcService.ResolveEndianFormat(options));
Assert.Contains("PLC 字节序配置无效", exception.Message);
}
}

View File

@@ -0,0 +1,33 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.ViewModels;
using Xunit;
namespace AxiOmron.PcbCheck.Tests;
/// <summary>
/// 验证结果代码在模型与界面层统一使用 16 位无符号整数。
/// </summary>
public sealed class ResultCodeTypeTests
{
/// <summary>
/// 结果代码枚举应以 UInt16 作为底层类型,匹配 PLC 的 ResultCode 定义。
/// </summary>
[Fact]
public void WorkflowResultCode_ShouldUseUInt16UnderlyingType()
{
Assert.Equal(typeof(ushort), Enum.GetUnderlyingType(typeof(WorkflowResultCode)));
}
/// <summary>
/// 结果代码属性应统一使用 UInt16,避免经过界面或快照时被误建模为 Int16 或 Int32。
/// </summary>
[Theory]
[InlineData(typeof(RuntimeSnapshot))]
[InlineData(typeof(PlcProcessState))]
[InlineData(typeof(BoardProcessRecord))]
[InlineData(typeof(MainWindowViewModel))]
public void ResultCodeProperties_ShouldUseUInt16(Type ownerType)
{
Assert.Equal(typeof(ushort), ownerType.GetProperty("ResultCode")?.PropertyType);
}
}

View File

@@ -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
}
}
/// <summary>
/// 提供系统设置页测试用 PLC 服务替身。
/// </summary>
private sealed class FakePlcService : IPlcService
{
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new PlcSignalSnapshot());
}
public Task<PlcMonitorSnapshot> 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<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
{
return Task.FromResult(PlcWriteResult.Success(address, type, value));
}
}
/// <summary>
/// 提供立即执行的测试用 Dispatcher,实现与设计时调度一致的同步行为。
/// </summary>

View File

@@ -418,6 +418,7 @@ public sealed class WorkflowHostedServiceTests
public Task<PlcMonitorSnapshot> ReadMonitorSnapshotAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcMonitorSnapshot());
public Task<PlcSignalSnapshot> ReadSignalsAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcSignalSnapshot());
public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken) => Task.CompletedTask;
public Task<PlcWriteResult> 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<PlcWriteResult> 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<PlcWriteResult> 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<PlcWriteResult> WriteRawValueAsync(string address, PlcWriteType type, string value, CancellationToken cancellationToken)
{
return Task.FromResult(PlcWriteResult.Success(address, type, value));
}
}
private sealed class FakeScannerService : IScannerService